diff --git a/changelogs/fragments/7953.yml b/changelogs/fragments/7953.yml new file mode 100644 index 000000000000..0b0782f6dafb --- /dev/null +++ b/changelogs/fragments/7953.yml @@ -0,0 +1,2 @@ +feat: +- [Experimental] Support user personal settings ([#7953](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7953)) \ No newline at end of file diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 2398b16e7c03..0f177f672837 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -131,6 +131,8 @@ export { NavGroupType, NavGroupStatus, WorkspaceAttributeWithPermission, + UiSettingScope, + PermissionModeId, } from '../types'; export { diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 67c8c6645f9f..85d353425186 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -358,6 +358,8 @@ export { StringValidation, StringValidationRegex, StringValidationRegexString, + CURRENT_USER_PLACEHOLDER, + UiSettingScope, } from './ui_settings'; export { @@ -369,7 +371,7 @@ export { MetricsServiceStart, } from './metrics'; -export { AppCategory, WorkspaceAttribute } from '../types'; +export { AppCategory, WorkspaceAttribute, PermissionModeId } from '../types'; export { DEFAULT_APP_CATEGORIES, WORKSPACE_TYPE, DEFAULT_NAV_GROUPS } from '../utils'; export { diff --git a/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts b/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts index eb23e78b1756..bbfb04cff90a 100644 --- a/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts +++ b/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.ts @@ -155,6 +155,7 @@ describe('uiSettings/createOrUpgradeSavedConfig', function () { Object { "newVersion": "4.0.1", "prevVersion": "4.0.0", + "scope": undefined, }, ], ] diff --git a/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.ts b/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.ts index 639dd09249ff..fc8c9452163b 100644 --- a/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.ts +++ b/src/core/server/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.ts @@ -35,6 +35,8 @@ import { SavedObjectsErrorHelpers } from '../../saved_objects/'; import { Logger } from '../../logging'; import { getUpgradeableConfig } from './get_upgradeable_config'; +import { UiSettingScope } from '../types'; +import { buildDocIdWithScope } from '../utils'; interface Options { savedObjectsClient: SavedObjectsClientContract; @@ -42,18 +44,24 @@ interface Options { buildNum: number; log: Logger; handleWriteErrors: boolean; + scope?: UiSettingScope; } export async function createOrUpgradeSavedConfig( options: Options ): Promise | undefined> { - const { savedObjectsClient, version, buildNum, log, handleWriteErrors } = options; + const { savedObjectsClient, version, buildNum, log, handleWriteErrors, scope } = options; // try to find an older config we can upgrade - const upgradeableConfig = await getUpgradeableConfig({ - savedObjectsClient, - version, - }); + let upgradeableConfig; + if (scope === UiSettingScope.USER) { + upgradeableConfig = undefined; + } else { + upgradeableConfig = await getUpgradeableConfig({ + savedObjectsClient, + version, + }); + } // default to the attributes of the upgradeableConfig if available const attributes = defaults( @@ -62,8 +70,9 @@ export async function createOrUpgradeSavedConfig( ); try { + const docId = buildDocIdWithScope(version, scope); // create the new SavedConfig - await savedObjectsClient.create('config', attributes, { id: version }); + await savedObjectsClient.create('config', attributes, { id: docId }); } catch (error) { if (handleWriteErrors) { if (SavedObjectsErrorHelpers.isConflictError(error)) { @@ -85,6 +94,7 @@ export async function createOrUpgradeSavedConfig( log.debug(`Upgrade config from ${upgradeableConfig.id} to ${version}`, { prevVersion: upgradeableConfig.id, newVersion: version, + scope, }); } } diff --git a/src/core/server/ui_settings/index.ts b/src/core/server/ui_settings/index.ts index 7912c0af84af..0f98342125b5 100644 --- a/src/core/server/ui_settings/index.ts +++ b/src/core/server/ui_settings/index.ts @@ -48,4 +48,7 @@ export { StringValidation, StringValidationRegex, StringValidationRegexString, + UiSettingScope, } from './types'; + +export { CURRENT_USER_PLACEHOLDER } from './utils'; diff --git a/src/core/server/ui_settings/routes/delete.ts b/src/core/server/ui_settings/routes/delete.ts index d42eb948259e..eb3d167edfd5 100644 --- a/src/core/server/ui_settings/routes/delete.ts +++ b/src/core/server/ui_settings/routes/delete.ts @@ -33,11 +33,17 @@ import { schema } from '@osd/config-schema'; import { IRouter } from '../../http'; import { SavedObjectsErrorHelpers } from '../../saved_objects'; import { CannotOverrideError } from '../ui_settings_errors'; +import { UiSettingScope } from '../types'; const validate = { params: schema.object({ key: schema.string(), }), + query: schema.object({ + scope: schema.maybe( + schema.oneOf([schema.literal(UiSettingScope.GLOBAL), schema.literal(UiSettingScope.USER)]) + ), + }), }; export function registerDeleteRoute(router: IRouter) { @@ -47,7 +53,9 @@ export function registerDeleteRoute(router: IRouter) { try { const uiSettingsClient = context.core.uiSettings.client; - await uiSettingsClient.remove(request.params.key); + const { scope } = request.query; + + await uiSettingsClient.remove(request.params.key, scope); return response.ok({ body: { diff --git a/src/core/server/ui_settings/routes/get.ts b/src/core/server/ui_settings/routes/get.ts index 000d16a37358..5d4571f93a1a 100644 --- a/src/core/server/ui_settings/routes/get.ts +++ b/src/core/server/ui_settings/routes/get.ts @@ -28,18 +28,30 @@ * under the License. */ +import { schema } from '@osd/config-schema'; + import { IRouter } from '../../http'; import { SavedObjectsErrorHelpers } from '../../saved_objects'; +import { UiSettingScope } from '../types'; + +const validate = { + query: schema.object({ + scope: schema.maybe( + schema.oneOf([schema.literal(UiSettingScope.GLOBAL), schema.literal(UiSettingScope.USER)]) + ), + }), +}; export function registerGetRoute(router: IRouter) { router.get( - { path: '/api/opensearch-dashboards/settings', validate: false }, + { path: '/api/opensearch-dashboards/settings', validate }, async (context, request, response) => { try { const uiSettingsClient = context.core.uiSettings.client; + const { scope } = request.query; return response.ok({ body: { - settings: await uiSettingsClient.getUserProvided(), + settings: await uiSettingsClient.getUserProvided(scope), }, }); } catch (error) { diff --git a/src/core/server/ui_settings/routes/set.ts b/src/core/server/ui_settings/routes/set.ts index d30b7d705d6a..abf49f4218e3 100644 --- a/src/core/server/ui_settings/routes/set.ts +++ b/src/core/server/ui_settings/routes/set.ts @@ -33,6 +33,7 @@ import { schema, ValidationError } from '@osd/config-schema'; import { IRouter } from '../../http'; import { SavedObjectsErrorHelpers } from '../../saved_objects'; import { CannotOverrideError } from '../ui_settings_errors'; +import { UiSettingScope } from '../types'; const validate = { params: schema.object({ @@ -41,6 +42,11 @@ const validate = { body: schema.object({ value: schema.any(), }), + query: schema.object({ + scope: schema.maybe( + schema.oneOf([schema.literal(UiSettingScope.GLOBAL), schema.literal(UiSettingScope.USER)]) + ), + }), }; export function registerSetRoute(router: IRouter) { @@ -52,12 +58,13 @@ export function registerSetRoute(router: IRouter) { const { key } = request.params; const { value } = request.body; + const { scope } = request.query; - await uiSettingsClient.set(key, value); + await uiSettingsClient.set(key, value, scope); return response.ok({ body: { - settings: await uiSettingsClient.getUserProvided(), + settings: await uiSettingsClient.getUserProvided(scope), }, }); } catch (error) { diff --git a/src/core/server/ui_settings/routes/set_many.ts b/src/core/server/ui_settings/routes/set_many.ts index 8698445a2c5b..b83d2bbef20e 100644 --- a/src/core/server/ui_settings/routes/set_many.ts +++ b/src/core/server/ui_settings/routes/set_many.ts @@ -33,11 +33,17 @@ import { schema, ValidationError } from '@osd/config-schema'; import { IRouter } from '../../http'; import { SavedObjectsErrorHelpers } from '../../saved_objects'; import { CannotOverrideError } from '../ui_settings_errors'; +import { UiSettingScope } from '../types'; const validate = { body: schema.object({ changes: schema.object({}, { unknowns: 'allow' }), }), + query: schema.object({ + scope: schema.maybe( + schema.oneOf([schema.literal(UiSettingScope.GLOBAL), schema.literal(UiSettingScope.USER)]) + ), + }), }; export function registerSetManyRoute(router: IRouter) { @@ -48,12 +54,13 @@ export function registerSetManyRoute(router: IRouter) { const uiSettingsClient = context.core.uiSettings.client; const { changes } = request.body; + const { scope } = request.query; - await uiSettingsClient.setMany(changes); + await uiSettingsClient.setMany(changes, scope); return response.ok({ body: { - settings: await uiSettingsClient.getUserProvided(), + settings: await uiSettingsClient.getUserProvided(scope), }, }); } catch (error) { diff --git a/src/core/server/ui_settings/types.ts b/src/core/server/ui_settings/types.ts index 399349f33f9e..1ca67c9ca5b9 100644 --- a/src/core/server/ui_settings/types.ts +++ b/src/core/server/ui_settings/types.ts @@ -29,7 +29,12 @@ */ import { SavedObjectsClientContract } from '../saved_objects/types'; -import { UiSettingsParams, UserProvidedValues, PublicUiSettingsParams } from '../../types'; +import { + UiSettingsParams, + UserProvidedValues, + PublicUiSettingsParams, + UiSettingScope, +} from '../../types'; export { UiSettingsParams, PublicUiSettingsParams, @@ -40,6 +45,7 @@ export { ImageValidation, UiSettingsType, UserProvidedValues, + UiSettingScope, } from '../../types'; /** @@ -66,31 +72,33 @@ export interface IUiSettingsClient { /** * Retrieves uiSettings values set by the user with fallbacks to default values if not specified. */ - get: (key: string) => Promise; + get: (key: string, scope?: UiSettingScope) => Promise; /** * Retrieves a set of all uiSettings values set by the user with fallbacks to default values if not specified. */ - getAll: () => Promise>; + getAll: (scope?: UiSettingScope) => Promise>; /** * Retrieves a set of all uiSettings values set by the user. */ - getUserProvided: () => Promise>>; + getUserProvided: ( + scope?: UiSettingScope + ) => Promise>>; /** * Writes multiple uiSettings values and marks them as set by the user. */ - setMany: (changes: Record) => Promise; + setMany: (changes: Record, scope?: UiSettingScope) => Promise; /** * Writes uiSettings value and marks it as set by the user. */ - set: (key: string, value: any) => Promise; + set: (key: string, value: any, scope?: UiSettingScope) => Promise; /** * Removes uiSettings value by key. */ - remove: (key: string) => Promise; + remove: (key: string, scope?: UiSettingScope) => Promise; /** * Removes multiple uiSettings values by keys. */ - removeMany: (keys: string[]) => Promise; + removeMany: (keys: string[], scope?: UiSettingScope) => Promise; /** * Shows whether the uiSettings value set by the user. */ diff --git a/src/core/server/ui_settings/ui_settings_client.test.ts b/src/core/server/ui_settings/ui_settings_client.test.ts index e30ff651e91b..c63fb1c472cf 100644 --- a/src/core/server/ui_settings/ui_settings_client.test.ts +++ b/src/core/server/ui_settings/ui_settings_client.test.ts @@ -38,6 +38,7 @@ import { SavedObjectsClient } from '../saved_objects'; import { savedObjectsClientMock } from '../saved_objects/service/saved_objects_client.mock'; import { UiSettingsClient } from './ui_settings_client'; import { CannotOverrideError } from './ui_settings_errors'; +import { CURRENT_USER_PLACEHOLDER } from './utils'; const logger = loggingSystemMock.create().get(); @@ -105,6 +106,26 @@ describe('ui settings', () => { }); }); + it('updates several values in one operation includes user level settings', async () => { + const value = chance.word(); + const defaults = { key: { value, scope: 'user' } }; + const { uiSettings, savedObjectsClient } = setup({ defaults }); + await uiSettings.setMany({ one: 'value', another: 'val', key: 'value' }); + + expect(savedObjectsClient.update).toHaveBeenCalledTimes(2); + expect(savedObjectsClient.update).toHaveBeenCalledWith(TYPE, ID, { + one: 'value', + another: 'val', + }); + expect(savedObjectsClient.update).toHaveBeenCalledWith( + TYPE, + `${CURRENT_USER_PLACEHOLDER}_${ID}`, + { + key: 'value', + } + ); + }); + it('automatically creates the savedConfig if it is missing', async () => { const { uiSettings, savedObjectsClient, createOrUpgradeSavedConfig } = setup(); savedObjectsClient.update @@ -357,8 +378,12 @@ describe('ui settings', () => { const { uiSettings, savedObjectsClient } = setup(); await uiSettings.getUserProvided(); - expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.get).toHaveBeenCalledTimes(2); expect(savedObjectsClient.get).toHaveBeenCalledWith(TYPE, ID); + expect(savedObjectsClient.get).toHaveBeenCalledWith( + TYPE, + `${CURRENT_USER_PLACEHOLDER}_${ID}` + ); }); it('returns user configuration', async () => { @@ -410,6 +435,9 @@ describe('ui settings', () => { Array [ "Ignore invalid UiSettings value. ValidationError: [validation [id]]: expected value of type [number] but got [string].", ], + Array [ + "Ignore invalid UiSettings value. ValidationError: [validation [id]]: expected value of type [number] but got [string].", + ], ] `); }); @@ -419,11 +447,13 @@ describe('ui settings', () => { savedObjectsClient.get = jest .fn() .mockRejectedValueOnce(SavedObjectsClient.errors.createGenericNotFoundError()) + .mockResolvedValueOnce({ attributes: {} }) + .mockRejectedValueOnce(SavedObjectsClient.errors.createGenericNotFoundError()) .mockResolvedValueOnce({ attributes: {} }); expect(await uiSettings.getUserProvided()).toStrictEqual({}); - expect(savedObjectsClient.get).toHaveBeenCalledTimes(2); + expect(savedObjectsClient.get).toHaveBeenCalledTimes(3); expect(createOrUpgradeSavedConfig).toHaveBeenCalledTimes(1); expect(createOrUpgradeSavedConfig).toHaveBeenCalledWith( @@ -518,8 +548,12 @@ describe('ui settings', () => { const opensearchDocSource = {}; const { uiSettings, savedObjectsClient } = setup({ opensearchDocSource }); await uiSettings.getAll(); - expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.get).toHaveBeenCalledTimes(2); expect(savedObjectsClient.get).toHaveBeenCalledWith(TYPE, ID); + expect(savedObjectsClient.get).toHaveBeenCalledWith( + TYPE, + `${CURRENT_USER_PLACEHOLDER}_${ID}` + ); }); it('returns defaults when opensearch doc is empty', async () => { @@ -552,6 +586,9 @@ describe('ui settings', () => { Array [ "Ignore invalid UiSettings value. ValidationError: [validation [id]]: expected value of type [number] but got [string].", ], + Array [ + "Ignore invalid UiSettings value. ValidationError: [validation [id]]: expected value of type [number] but got [string].", + ], ] `); }); @@ -599,6 +636,43 @@ describe('ui settings', () => { bar: 'user-provided', }); }); + + // verify user level settings override global + it(`user level values will override global settings`, async () => { + const defaults = { + foo: { + value: 'default', + }, + bar: { + value: 'default', + scope: 'user', + }, + }; + + const { uiSettings, savedObjectsClient } = setup({ defaults }); + + savedObjectsClient.get.mockImplementation((_type, id, _options) => { + if (id === `${CURRENT_USER_PLACEHOLDER}_${ID}`) { + return Promise.resolve({ + attributes: { + bar: 'my personal value', + }, + } as any); + } else { + return Promise.resolve({ + attributes: { + foo: 'default1', + bar: 'global value', + }, + } as any); + } + }); + + expect(await uiSettings.getAll()).toStrictEqual({ + foo: 'default1', + bar: 'my personal value', + }); + }); }); describe('#getDefault()', () => { @@ -626,8 +700,12 @@ describe('ui settings', () => { const { uiSettings, savedObjectsClient } = setup({ opensearchDocSource }); await uiSettings.get('any'); - expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.get).toHaveBeenCalledTimes(2); expect(savedObjectsClient.get).toHaveBeenCalledWith(TYPE, ID); + expect(savedObjectsClient.get).toHaveBeenCalledWith( + TYPE, + `${CURRENT_USER_PLACEHOLDER}_${ID}` + ); }); it(`returns the promised value for a key`, async () => { @@ -722,6 +800,9 @@ describe('ui settings', () => { Array [ "Ignore invalid UiSettings value. ValidationError: [validation [id]]: expected value of type [number] but got [string].", ], + Array [ + "Ignore invalid UiSettings value. ValidationError: [validation [id]]: expected value of type [number] but got [string].", + ], ] `); }); diff --git a/src/core/server/ui_settings/ui_settings_client.ts b/src/core/server/ui_settings/ui_settings_client.ts index 8744cb3b80da..68b1ac6688e0 100644 --- a/src/core/server/ui_settings/ui_settings_client.ts +++ b/src/core/server/ui_settings/ui_settings_client.ts @@ -34,8 +34,14 @@ import { SavedObjectsErrorHelpers } from '../saved_objects'; import { SavedObjectsClientContract } from '../saved_objects/types'; import { Logger } from '../logging'; import { createOrUpgradeSavedConfig } from './create_or_upgrade_saved_config'; -import { IUiSettingsClient, UiSettingsParams, PublicUiSettingsParams } from './types'; +import { + IUiSettingsClient, + UiSettingsParams, + PublicUiSettingsParams, + UiSettingScope, +} from './types'; import { CannotOverrideError } from './ui_settings_errors'; +import { buildDocIdWithScope } from './utils'; export interface UiSettingsServiceOptions { type: string; @@ -50,6 +56,8 @@ export interface UiSettingsServiceOptions { interface ReadOptions { ignore401Errors?: boolean; autoCreateOrUpgradeIfMissing?: boolean; + scope?: UiSettingScope; + ignore404Errors?: boolean; } interface UserProvidedValue { @@ -62,6 +70,26 @@ type UiSettingsRawValue = UiSettingsParams & UserProvidedValue; type UserProvided = Record>; type UiSettingsRaw = Record; +/** + * default scope read options, order matters + * for user setting, we don't want to create the record when user visit the dashboard that's get, + * that will have too much records without any actual setting in it, instead do create when write at first time + */ +const UiSettingScopeReadOptions = [ + { + scope: UiSettingScope.GLOBAL, + ignore401Errors: false, + autoCreateOrUpgradeIfMissing: true, + ignore404Errors: false, + }, + { + scope: UiSettingScope.USER, + ignore401Errors: true, + autoCreateOrUpgradeIfMissing: false, + ignore404Errors: true, + }, +] as ReadOptions[]; + export class UiSettingsClient implements IUiSettingsClient { private readonly type: UiSettingsServiceOptions['type']; private readonly id: UiSettingsServiceOptions['id']; @@ -99,13 +127,13 @@ export class UiSettingsClient implements IUiSettingsClient { return this.defaults[key]?.value; } - async get(key: string): Promise { - const all = await this.getAll(); + async get(key: string, scope?: UiSettingScope): Promise { + const all = await this.getAll(scope); return all[key]; } - async getAll() { - const raw = await this.getRaw(); + async getAll(scope?: UiSettingScope) { + const raw = await this.getRaw(scope); return Object.keys(raw).reduce((all, key) => { const item = raw[key]; @@ -114,8 +142,18 @@ export class UiSettingsClient implements IUiSettingsClient { }, {} as Record); } - async getUserProvided(): Promise> { - const userProvided: UserProvided = this.onReadHook(await this.read()); + async getUserProvided(scope?: UiSettingScope): Promise> { + let userProvided: UserProvided = {}; + if (scope) { + const readOptions = UiSettingScopeReadOptions.find((option) => option.scope === scope); + userProvided = this.onReadHook(await this.read(readOptions)); + } else { + // default will get from all scope and merge + // loop UiSettingScopeReadOptions + for (const readOptions of UiSettingScopeReadOptions) { + userProvided = { ...userProvided, ...this.onReadHook(await this.read(readOptions)) }; + } + } // write all overridden keys, dropping the userValue is override is null and // adding keys for overrides that are not in saved object @@ -127,25 +165,37 @@ export class UiSettingsClient implements IUiSettingsClient { return userProvided; } - async setMany(changes: Record) { - this.onWriteHook(changes); - await this.write({ changes }); + async setMany(changes: Record, scope?: UiSettingScope) { + this.onWriteHook(changes, scope); + + if (scope) { + await this.write({ changes, scope }); + } else { + // group changes into different scope + const [global, personal] = this.groupChanges(changes); + if (global && Object.keys(global).length > 0) { + await this.write({ changes: global }); + } + if (personal && Object.keys(personal).length > 0) { + await this.write({ changes: personal, scope: UiSettingScope.USER }); + } + } } - async set(key: string, value: any) { - await this.setMany({ [key]: value }); + async set(key: string, value: any, scope?: UiSettingScope) { + await this.setMany({ [key]: value }, scope); } - async remove(key: string) { - await this.set(key, null); + async remove(key: string, scope?: UiSettingScope) { + await this.set(key, null, scope); } - async removeMany(keys: string[]) { + async removeMany(keys: string[], scope?: UiSettingScope) { const changes: Record = {}; keys.forEach((key) => { changes[key] = null; }); - await this.setMany(changes); + await this.setMany(changes, scope); } isOverridden(key: string) { @@ -158,8 +208,8 @@ export class UiSettingsClient implements IUiSettingsClient { } } - private async getRaw(): Promise { - const userProvided = await this.getUserProvided(); + private async getRaw(scope?: UiSettingScope): Promise { + const userProvided = await this.getUserProvided(scope); return defaultsDeep(userProvided, this.defaults); } @@ -171,7 +221,20 @@ export class UiSettingsClient implements IUiSettingsClient { } } - private onWriteHook(changes: Record) { + private validateScope(key: string, value: unknown, scope?: UiSettingScope) { + const definition = this.defaults[key]; + if (value === null || definition === undefined) return; + const validScopes = Array.isArray(definition.scope) + ? definition.scope + : [definition.scope || UiSettingScope.GLOBAL]; + if (scope && !validScopes.includes(scope)) { + throw new Error( + `Unable to update "${key}" with "${scope}" because the valid scopes are "${validScopes}"` + ); + } + } + + private onWriteHook(changes: Record, scope?: UiSettingScope) { for (const key of Object.keys(changes)) { this.assertUpdateAllowed(key); } @@ -179,6 +242,10 @@ export class UiSettingsClient implements IUiSettingsClient { for (const [key, value] of Object.entries(changes)) { this.validateKey(key, value); } + + for (const [key, value] of Object.entries(changes)) { + this.validateScope(key, value, scope); + } } private onReadHook(values: Record) { @@ -200,16 +267,48 @@ export class UiSettingsClient implements IUiSettingsClient { return filteredValues; } + /** + * group change into different scopes + * @param changes ui setting changes + * @returns [global, user] + */ + private groupChanges(changes: Record) { + const userLevelKeys = [] as string[]; + Object.entries(this.defaults).forEach(([key, value]) => { + if ( + value.scope === UiSettingScope.USER || + (Array.isArray(value.scope) && value.scope.includes(UiSettingScope.USER)) + ) { + userLevelKeys.push(key); + } + }); + const userChanges = {} as Record; + const globalChanges = {} as Record; + + Object.entries(changes).forEach(([key, val]) => { + if (userLevelKeys.includes(key)) { + userChanges[key] = val; + } else { + globalChanges[key] = val; + } + }); + + return [globalChanges, userChanges]; + } + private async write({ changes, autoCreateOrUpgradeIfMissing = true, + scope, }: { changes: Record; autoCreateOrUpgradeIfMissing?: boolean; + scope?: UiSettingScope; }) { changes = this.translateChanges(changes, 'timeline', 'timelion'); try { - await this.savedObjectsClient.update(this.type, this.id, changes); + const docId = buildDocIdWithScope(this.id, scope); + await this.savedObjectsClient.update(this.type, docId, changes); } catch (error) { if (!SavedObjectsErrorHelpers.isNotFoundError(error) || !autoCreateOrUpgradeIfMissing) { throw error; @@ -221,11 +320,13 @@ export class UiSettingsClient implements IUiSettingsClient { buildNum: this.buildNum, log: this.log, handleWriteErrors: false, + scope, }); await this.write({ changes, autoCreateOrUpgradeIfMissing: false, + scope, }); } } @@ -233,9 +334,12 @@ export class UiSettingsClient implements IUiSettingsClient { private async read({ ignore401Errors = false, autoCreateOrUpgradeIfMissing = true, + ignore404Errors = false, + scope, }: ReadOptions = {}): Promise> { try { - const resp = await this.savedObjectsClient.get>(this.type, this.id); + const docId = buildDocIdWithScope(this.id, scope); + const resp = await this.savedObjectsClient.get>(this.type, docId); return this.translateChanges(resp.attributes, 'timelion', 'timeline'); } catch (error) { if (SavedObjectsErrorHelpers.isNotFoundError(error) && autoCreateOrUpgradeIfMissing) { @@ -245,18 +349,25 @@ export class UiSettingsClient implements IUiSettingsClient { buildNum: this.buildNum, log: this.log, handleWriteErrors: true, + scope, }); if (!failedUpgradeAttributes) { return await this.read({ ignore401Errors, autoCreateOrUpgradeIfMissing: false, + scope, }); } return failedUpgradeAttributes; } + // ignore 404 and return an empty object + if (ignore404Errors && SavedObjectsErrorHelpers.isNotFoundError(error)) { + return {}; + } + if (this.isIgnorableError(error, ignore401Errors)) { return {}; } diff --git a/src/core/server/ui_settings/utils.ts b/src/core/server/ui_settings/utils.ts new file mode 100644 index 000000000000..da6f1ee64d96 --- /dev/null +++ b/src/core/server/ui_settings/utils.ts @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { UiSettingScope } from './types'; + +export const CURRENT_USER_PLACEHOLDER = ''; + +export const buildDocIdWithScope = (id: string, scope?: UiSettingScope) => { + if (scope === UiSettingScope.USER) { + return `${CURRENT_USER_PLACEHOLDER}_${id}`; + } + return id; +}; diff --git a/src/core/types/ui_settings.ts b/src/core/types/ui_settings.ts index 010288166e2d..39c15959aeac 100644 --- a/src/core/types/ui_settings.ts +++ b/src/core/types/ui_settings.ts @@ -56,6 +56,15 @@ export interface DeprecationSettings { docLinksKey: string; } +/** + * UiSettings scope options. + * @experimental + */ +export enum UiSettingScope { + GLOBAL = 'global', + USER = 'user', +} + /** * UiSettings parameters defined by the plugins. * @public @@ -63,6 +72,10 @@ export interface DeprecationSettings { export interface UiSettingsParams { /** title in the UI */ name?: string; + /** + * scope of the setting item + */ + scope?: UiSettingScope | UiSettingScope[]; /** default value to fall back to if a user doesn't provide any */ value?: T; /** description provided to a user in UI */ diff --git a/src/core/types/workspace.ts b/src/core/types/workspace.ts index c00d3576d567..aa7a3073f377 100644 --- a/src/core/types/workspace.ts +++ b/src/core/types/workspace.ts @@ -5,6 +5,12 @@ import { Permissions } from '../server/saved_objects'; +export enum PermissionModeId { + Read = 'read', + ReadAndWrite = 'read+write', + Owner = 'owner', +} + export interface WorkspaceAttribute { id: string; name: string; @@ -19,4 +25,5 @@ export interface WorkspaceAttribute { export interface WorkspaceAttributeWithPermission extends WorkspaceAttribute { permissions?: Permissions; + permissionMode?: PermissionModeId; } diff --git a/src/plugins/advanced_settings/opensearch_dashboards.json b/src/plugins/advanced_settings/opensearch_dashboards.json index 97848475efca..406fb402de79 100644 --- a/src/plugins/advanced_settings/opensearch_dashboards.json +++ b/src/plugins/advanced_settings/opensearch_dashboards.json @@ -3,7 +3,7 @@ "version": "opensearchDashboards", "server": true, "ui": true, - "requiredPlugins": ["management","navigation"], + "requiredPlugins": ["management","navigation", "contentManagement"], "optionalPlugins": ["home"], "requiredBundles": ["opensearchDashboardsReact", "home"] } diff --git a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx index b85732ddafad..8ad927c66f03 100644 --- a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx +++ b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx @@ -49,6 +49,7 @@ import { getAriaName, toEditableConfig, DEFAULT_CATEGORY } from './lib'; import { FieldSetting, SettingsChanges } from './types'; import { NavigationPublicPluginStart } from '../../../../plugins/navigation/public'; +import { UiSettingScope } from '../../../../core/public'; interface AdvancedSettingsProps { enableSaving: boolean; @@ -171,6 +172,7 @@ export class AdvancedSettingsComponent extends Component< mapConfig(config: IUiSettingsClient) { const all = config.getAll(); return Object.entries(all) + .filter(([, setting]) => setting.scope !== UiSettingScope.USER) .map((setting) => { return toEditableConfig({ def: setting[1], diff --git a/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx b/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx index 608324d0ecb2..c5f3db562a59 100644 --- a/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx +++ b/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx @@ -34,15 +34,18 @@ import { Router, Switch, Route } from 'react-router-dom'; import { i18n } from '@osd/i18n'; import { I18nProvider } from '@osd/i18n/react'; -import { StartServicesAccessor } from 'src/core/public'; +import { AppMountParameters, CoreStart, StartServicesAccessor } from 'src/core/public'; import { EuiPageContent } from '@elastic/eui'; +import { ContentManagementPluginStart } from '../../../content_management/public'; import { AdvancedSettings } from './advanced_settings'; import { ManagementAppMountParams } from '../../../management/public'; import { ComponentRegistry } from '../types'; -import { NavigationPublicPluginStart } from '../../../../plugins/navigation/public'; +import { NavigationPublicPluginStart } from '../../../navigation/public'; +import { OpenSearchDashboardsContextProvider } from '../../../opensearch_dashboards_react/public'; import './index.scss'; +import { UserSettingsApp } from './user_settings'; const readOnlyBadge = { text: i18n.translate('advancedSettings.badge.readOnly.text', { @@ -131,3 +134,22 @@ export async function mountManagementSection( ReactDOM.unmountComponentAtNode(params.element); }; } + +export const renderUserSettingsApp = async ( + { element }: AppMountParameters, + services: CoreStart & { + contentManagement: ContentManagementPluginStart; + navigation: NavigationPublicPluginStart; + } +) => { + ReactDOM.render( + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; diff --git a/src/plugins/advanced_settings/public/management_app/user_settings.test.tsx b/src/plugins/advanced_settings/public/management_app/user_settings.test.tsx new file mode 100644 index 000000000000..56889915247e --- /dev/null +++ b/src/plugins/advanced_settings/public/management_app/user_settings.test.tsx @@ -0,0 +1,103 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { contentManagementPluginMocks } from '../../../content_management/public'; +import { setupUserSettingsPage, UserSettingsApp } from './user_settings'; +import { I18nProvider } from '@osd/i18n/react'; +import { coreMock } from '../../../../core/public/mocks'; +import { OpenSearchDashboardsContextProvider } from '../../../opensearch_dashboards_react/public'; +import { render } from '@testing-library/react'; + +describe('UserSettings', () => { + const registerPageMock = jest.fn(); + const contentManagementSetupMock = { + ...contentManagementPluginMocks.createSetupContract(), + registerPage: registerPageMock, + }; + + const coreStartMock = coreMock.createStart(); + const renderPageMock = jest.fn(); + + renderPageMock.mockReturnValue('
page content
'); + + const contentManagementStartMock = { + ...contentManagementPluginMocks.createStartContract(), + renderPage: renderPageMock, + }; + + const mockHeaderControl = ({ controls }) => { + return controls?.[0].description ?? controls?.[0].renderComponent ?? null; + }; + + function renderUserSettingsApp() { + const services = { + ...coreStartMock, + navigation: { + ui: { + HeaderControl: mockHeaderControl, + }, + }, + contentManagement: contentManagementStartMock, + }; + + return ( + + + + + + ); + } + + it('setupUserSettingsPage', () => { + setupUserSettingsPage(contentManagementSetupMock); + + const calls = registerPageMock.mock.calls[0]; + expect(calls[0]).toMatchInlineSnapshot(` + Object { + "id": "user_settings", + "sections": Array [ + Object { + "id": "user_profile", + "kind": "custom", + "order": 1000, + "render": [Function], + "title": "User's profile", + }, + Object { + "id": "default_workspace", + "kind": "custom", + "order": 2000, + "render": [Function], + }, + Object { + "id": "user_identity_role", + "kind": "custom", + "order": 3000, + "render": [Function], + }, + ], + "title": "User Settings", + } + `); + }); + + it('renders', () => { + const { container } = render(renderUserSettingsApp()); + expect(container).toMatchInlineSnapshot(` +
+
+ Configure your personal preferences. + <div>page content</div> +
+
+ `); + expect(renderPageMock).toHaveBeenCalledWith('user_settings', { fragmentOnly: true }); + expect(coreStartMock.chrome.setBreadcrumbs).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/advanced_settings/public/management_app/user_settings.tsx b/src/plugins/advanced_settings/public/management_app/user_settings.tsx new file mode 100644 index 000000000000..bb3d1118f59a --- /dev/null +++ b/src/plugins/advanced_settings/public/management_app/user_settings.tsx @@ -0,0 +1,105 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiBreadcrumb, EuiPage } from '@elastic/eui'; +import { CoreStart } from 'opensearch-dashboards/public'; +import React, { useEffect } from 'react'; +import { i18n } from '@osd/i18n'; +import { + ContentManagementPluginSetup, + ContentManagementPluginStart, + Content, +} from '../../../content_management/public'; +import { NavigationPublicPluginStart } from '../../../navigation/public'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; + +const sectionRender = (contents: Content[]) => { + return ( + <> + {contents.map((content) => { + if (content.kind === 'custom') { + return {content.render()}; + } + + return null; + })} + + ); +}; + +export const setupUserSettingsPage = (contentManagement?: ContentManagementPluginSetup) => { + contentManagement?.registerPage({ + id: 'user_settings', + title: 'User Settings', + sections: [ + { + id: 'user_profile', + order: 1000, + title: `User's profile`, + kind: 'custom', + render: sectionRender, + }, + { + id: 'default_workspace', + order: 2000, + kind: 'custom', + render: sectionRender, + }, + { + id: 'user_identity_role', + order: 3000, + kind: 'custom', + render: sectionRender, + }, + ], + }); +}; + +export const UserSettingsApp = () => { + const { + services: { + contentManagement, + application, + chrome, + navigation: { + ui: { HeaderControl }, + }, + }, + } = useOpenSearchDashboards< + CoreStart & { + contentManagement: ContentManagementPluginStart; + navigation: NavigationPublicPluginStart; + } + >(); + + useEffect(() => { + const breadcrumbs: EuiBreadcrumb[] = [ + { + text: i18n.translate('advancedSettings.userSettingsLabel', { + defaultMessage: 'User settings', + }), + }, + ]; + chrome.setBreadcrumbs(breadcrumbs); + }, [chrome]); + + return ( + + + {contentManagement + ? contentManagement.renderPage('user_settings', { fragmentOnly: true }) + : null} + + ); +}; diff --git a/src/plugins/advanced_settings/public/plugin.test.ts b/src/plugins/advanced_settings/public/plugin.test.ts index 2ff2a08b8077..b018e960f383 100644 --- a/src/plugins/advanced_settings/public/plugin.test.ts +++ b/src/plugins/advanced_settings/public/plugin.test.ts @@ -21,3 +21,26 @@ describe('AdvancedSettingsPlugin', () => { expect(setupMock.application.register).toBeCalledTimes(1); }); }); + +describe('UserSettingsPlugin', () => { + it('setup successfully', () => { + const pluginInstance = new AdvancedSettingsPlugin(); + const setupMock = { + ...coreMock.createSetup(), + chrome: { + ...coreMock.createSetup().chrome, + navGroup: { + ...coreMock.createSetup().chrome.navGroup, + getNavGroupEnabled: () => true, + }, + }, + }; + expect(() => + pluginInstance.setup(setupMock, { + management: managementPluginMock.createSetupContract(), + home: homePluginMock.createSetupContract(), + }) + ).not.toThrow(); + expect(setupMock.application.register).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/plugins/advanced_settings/public/plugin.ts b/src/plugins/advanced_settings/public/plugin.ts index 9bdab5da8c7d..40657c893867 100644 --- a/src/plugins/advanced_settings/public/plugin.ts +++ b/src/plugins/advanced_settings/public/plugin.ts @@ -29,7 +29,14 @@ */ import { i18n } from '@osd/i18n'; -import { AppMountParameters, CoreSetup, Plugin } from 'opensearch-dashboards/public'; +import { + AppMountParameters, + AppUpdater, + CoreSetup, + CoreStart, + Plugin, +} from 'opensearch-dashboards/public'; +import { BehaviorSubject } from 'rxjs'; import { FeatureCatalogueCategory } from '../../home/public'; import { ComponentRegistry } from './component_registry'; import { @@ -40,6 +47,7 @@ import { } from './types'; import { DEFAULT_NAV_GROUPS, AppNavLinkStatus, WorkspaceAvailability } from '../../../core/public'; import { getScopedBreadcrumbs } from '../../opensearch_dashboards_react/public'; +import { setupUserSettingsPage } from './management_app/user_settings'; const component = new ComponentRegistry(); @@ -51,6 +59,7 @@ const titleInGroup = i18n.translate('advancedSettings.applicationSettingsLabel', defaultMessage: 'Application settings', }); +const USER_SETTINGS_APPID = 'user_settings'; export class AdvancedSettingsPlugin implements Plugin< @@ -59,9 +68,11 @@ export class AdvancedSettingsPlugin AdvancedSettingsPluginSetup, AdvancedSettingsPluginStart > { + private appUpdater$ = new BehaviorSubject(() => undefined); + public setup( core: CoreSetup, - { management, home }: AdvancedSettingsPluginSetup + { management, home, contentManagement: contentManagementSetup }: AdvancedSettingsPluginSetup ) { const opensearchDashboardsSection = management.sections.section.opensearchDashboards; @@ -115,6 +126,42 @@ export class AdvancedSettingsPlugin }, ]); + if (core.chrome.navGroup.getNavGroupEnabled()) { + setupUserSettingsPage(contentManagementSetup); + + const userSettingTitle = i18n.translate('advancedSettings.userSettingsLabel', { + defaultMessage: 'User settings', + }); + + core.application.register({ + id: USER_SETTINGS_APPID, + title: userSettingTitle, + updater$: this.appUpdater$, + navLinkStatus: core.chrome.navGroup.getNavGroupEnabled() + ? AppNavLinkStatus.visible + : AppNavLinkStatus.hidden, + workspaceAvailability: WorkspaceAvailability.outsideWorkspace, + description: i18n.translate('advancedSettings.userSettings.description', { + defaultMessage: 'Configure your personal preferences.', + }), + mount: async (params: AppMountParameters) => { + const { renderUserSettingsApp } = await import( + './management_app/mount_management_section' + ); + const [coreStart, { contentManagement, navigation }] = await core.getStartServices(); + + return renderUserSettingsApp(params, { ...coreStart, contentManagement, navigation }); + }, + }); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.settingsAndSetup, [ + { + id: USER_SETTINGS_APPID, + order: 101, // just right after application settings which order is 100 + }, + ]); + } + if (home) { home.featureCatalogue.register({ id: 'advanced_settings', @@ -135,7 +182,16 @@ export class AdvancedSettingsPlugin }; } - public start() { + public start(core: CoreStart) { + this.appUpdater$.next((app) => { + const userSettingsEnabled = core.application.capabilities.userSettings?.enabled; + if (app.id === USER_SETTINGS_APPID) { + return { + navLinkStatus: userSettingsEnabled ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden, + }; + } + }); + return { component: component.start, }; diff --git a/src/plugins/advanced_settings/public/types.ts b/src/plugins/advanced_settings/public/types.ts index 68d92a2b856f..435c1a3b5cee 100644 --- a/src/plugins/advanced_settings/public/types.ts +++ b/src/plugins/advanced_settings/public/types.ts @@ -28,6 +28,10 @@ * under the License. */ +import { + ContentManagementPluginStart, + ContentManagementPluginSetup, +} from '../../content_management/public'; import { ComponentRegistry } from './component_registry'; import { HomePublicPluginSetup } from '../../home/public'; @@ -44,10 +48,12 @@ export interface AdvancedSettingsStart { export interface AdvancedSettingsPluginSetup { management: ManagementSetup; home?: HomePublicPluginSetup; + contentManagement: ContentManagementPluginSetup; } export interface AdvancedSettingsPluginStart { navigation: NavigationPublicPluginStart; + contentManagement: ContentManagementPluginStart; } export { ComponentRegistry }; diff --git a/src/plugins/advanced_settings/server/capabilities_provider.ts b/src/plugins/advanced_settings/server/capabilities_provider.ts index be87d687d4b9..c8210c029faa 100644 --- a/src/plugins/advanced_settings/server/capabilities_provider.ts +++ b/src/plugins/advanced_settings/server/capabilities_provider.ts @@ -33,4 +33,7 @@ export const capabilitiesProvider = () => ({ show: true, save: true, }, + userSettings: { + enabled: false, + }, }); diff --git a/src/plugins/advanced_settings/server/plugin.ts b/src/plugins/advanced_settings/server/plugin.ts index 094d975acf91..42f60d9eea97 100644 --- a/src/plugins/advanced_settings/server/plugin.ts +++ b/src/plugins/advanced_settings/server/plugin.ts @@ -34,34 +34,73 @@ import { CoreStart, Plugin, Logger, + SharedGlobalConfig, } from 'opensearch-dashboards/server'; +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; import { capabilitiesProvider } from './capabilities_provider'; +import { UserUISettingsClientWrapper } from './saved_objects/user_ui_settings_client_wrapper'; +import { extractUserName } from './utils'; export class AdvancedSettingsServerPlugin implements Plugin { private readonly logger: Logger; + private userUiSettingsClientWrapper?: UserUISettingsClientWrapper; + private coreStart: CoreStart | undefined; + private readonly globalConfig$: Observable; constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); + this.globalConfig$ = initializerContext.config.legacy.globalConfig$; } - public setup(core: CoreSetup) { + public async setup(core: CoreSetup) { this.logger.debug('advancedSettings: Setup'); core.capabilities.registerProvider(capabilitiesProvider); - core.capabilities.registerSwitcher(async (request, capabilites) => { - return await core.security.readonlyService().hideForReadonly(request, capabilites, { + core.capabilities.registerSwitcher(async (request, capabilities) => { + return await core.security.readonlyService().hideForReadonly(request, capabilities, { advancedSettings: { save: false, }, }); }); + const globalConfig = await this.globalConfig$.pipe(first()).toPromise(); + const isPermissionControlEnabled = globalConfig.savedObjects.permission.enabled === true; + + const userUiSettingsClientWrapper = new UserUISettingsClientWrapper( + this.logger, + isPermissionControlEnabled + ); + this.userUiSettingsClientWrapper = userUiSettingsClientWrapper; + core.savedObjects.addClientWrapper( + 3, // The wrapper should be triggered after workspace_id_consumer wrapper which id is -3 to avoid creating user settings within any workspace. + 'user_ui_settings', + userUiSettingsClientWrapper.wrapperFactory + ); + + core.capabilities.registerSwitcher(async (request, capabilities) => { + const userName = extractUserName(request, this.coreStart); + if (userName) { + return { + ...capabilities, + userSettings: { + enabled: true, + }, + }; + } + return capabilities; + }); + return {}; } public start(core: CoreStart) { this.logger.debug('advancedSettings: Started'); + this.coreStart = core; + this.userUiSettingsClientWrapper?.setCore(core); + return {}; } diff --git a/src/plugins/advanced_settings/server/saved_objects/user_ui_settings_client_wrapper.test.ts b/src/plugins/advanced_settings/server/saved_objects/user_ui_settings_client_wrapper.test.ts new file mode 100644 index 000000000000..84fc47fd94b3 --- /dev/null +++ b/src/plugins/advanced_settings/server/saved_objects/user_ui_settings_client_wrapper.test.ts @@ -0,0 +1,206 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { httpServerMock, savedObjectsClientMock, coreMock } from '../../../../core/server/mocks'; +import { UserUISettingsClientWrapper } from './user_ui_settings_client_wrapper'; +// eslint-disable-next-line @osd/eslint/no-restricted-paths +import { loggerMock } from '../../../../core/server/logging/logger.mock'; +import { CURRENT_USER_PLACEHOLDER } from '../../../../core/server'; + +jest.mock('../utils', () => { + return { + extractUserName: jest.fn().mockReturnValue('test_user'), + }; +}); + +describe('UserUISettingsClientWrapper', () => { + const requestHandlerContext = coreMock.createRequestHandlerContext(); + const mockedClient = savedObjectsClientMock.create(); + const requestMock = httpServerMock.createOpenSearchDashboardsRequest(); + + const buildWrapperInstance = (permissionEnabled: boolean) => { + const wrapperInstance = new UserUISettingsClientWrapper(loggerMock.create(), permissionEnabled); + const wrapperClient = wrapperInstance.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: requestMock, + }); + return wrapperClient; + }; + + const wrapperClient = buildWrapperInstance(false); + + describe('#get', () => { + // beforeEach + beforeEach(() => { + jest.clearAllMocks(); + }); + + // test getUiSettings + it('should skip replacing user id if not user level config', async () => { + await wrapperClient.get('config', '3.0.0'); + + expect(mockedClient.get).toBeCalledWith('config', '3.0.0', {}); + }); + + it('should skip replacing user id if type is not config', async () => { + await wrapperClient.get('config1', `${CURRENT_USER_PLACEHOLDER}_3.0.0`); + + expect(mockedClient.get).toBeCalledWith('config1', `${CURRENT_USER_PLACEHOLDER}_3.0.0`, {}); + }); + + it('should replace user id placeholder with real user id', async () => { + await wrapperClient.get('config', `${CURRENT_USER_PLACEHOLDER}_3.0.0`); + + expect(mockedClient.get).toBeCalledWith('config', 'test_user', {}); + }); + }); + + describe('#update', () => { + // beforeEach + beforeEach(() => { + jest.clearAllMocks(); + }); + + // test getUiSettings + it('should skip replacing user id if not user level config', async () => { + await wrapperClient.update('config', '3.0.0', {}); + + expect(mockedClient.update).toBeCalledWith('config', '3.0.0', {}, {}); + }); + + it('should skip replacing user id if type is not config', async () => { + await wrapperClient.update('config1', `${CURRENT_USER_PLACEHOLDER}_3.0.0`, {}); + + expect(mockedClient.update).toBeCalledWith( + 'config1', + `${CURRENT_USER_PLACEHOLDER}_3.0.0`, + {}, + {} + ); + }); + + it('should replace user id placeholder with real user id', async () => { + await wrapperClient.update('config', `${CURRENT_USER_PLACEHOLDER}_3.0.0`, {}); + + expect(mockedClient.update).toBeCalledWith('config', 'test_user', {}, {}); + }); + }); + + describe('#create', () => { + // beforeEach + beforeEach(() => { + jest.clearAllMocks(); + }); + + // test getUiSettings + it('should skip replacing user id if not user level config', async () => { + await wrapperClient.create('config', {}, { id: '3.0.0' }); + + expect(mockedClient.create).toBeCalledWith('config', {}, { id: '3.0.0' }); + }); + + it('should skip replacing user id if type is not config', async () => { + await wrapperClient.create('config1', {}, { id: `${CURRENT_USER_PLACEHOLDER}_3.0.0` }); + + expect(mockedClient.create).toBeCalledWith( + 'config1', + {}, + { id: `${CURRENT_USER_PLACEHOLDER}_3.0.0` } + ); + }); + + it('should replace user id placeholder with real user id', async () => { + await wrapperClient.create('config', {}, { id: `${CURRENT_USER_PLACEHOLDER}_3.0.0` }); + + expect(mockedClient.create).toBeCalledWith( + 'config', + {}, + { + id: 'test_user', + } + ); + }); + + it('should replace user id placeholder with real user id and permission enabled', async () => { + const wrapperClientWithPermission = buildWrapperInstance(true); + await wrapperClientWithPermission.create( + 'config', + {}, + { id: `${CURRENT_USER_PLACEHOLDER}_3.0.0` } + ); + + expect(mockedClient.create).toBeCalledWith( + 'config', + {}, + { + id: 'test_user', + permissions: { + write: { + users: ['test_user'], + }, + }, + } + ); + }); + }); +}); + +describe('UserUISettingsClientWrapper - security not enabled', () => { + // security not enabled + beforeEach(() => { + jest.mock('../utils'); + }); + + const requestHandlerContext = coreMock.createRequestHandlerContext(); + const mockedClient = savedObjectsClientMock.create(); + const requestMock = httpServerMock.createOpenSearchDashboardsRequest(); + + const buildWrapperInstance = (permissionEnabled: boolean) => { + const wrapperInstance = new UserUISettingsClientWrapper(loggerMock.create(), permissionEnabled); + const wrapperClient = wrapperInstance.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: requestMock, + }); + return wrapperClient; + }; + + const wrapperClient = buildWrapperInstance(false); + + describe('#get', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should replace user id placeholder with version', async () => { + await wrapperClient.get('config', `${CURRENT_USER_PLACEHOLDER}_3.0.0`); + + expect(mockedClient.get).toBeCalledWith('config', '3.0.0', {}); + }); + }); + + describe('#update', () => { + it('should replace user id placeholder with version', async () => { + await wrapperClient.update('config', `${CURRENT_USER_PLACEHOLDER}_3.0.0`, {}); + + expect(mockedClient.update).toBeCalledWith('config', '3.0.0', {}, {}); + }); + }); + + describe('#create', () => { + it('should replace user id placeholder with version', async () => { + await wrapperClient.create('config', {}, { id: `${CURRENT_USER_PLACEHOLDER}_3.0.0` }); + + expect(mockedClient.create).toBeCalledWith( + 'config', + {}, + { + id: '3.0.0', + } + ); + }); + }); +}); diff --git a/src/plugins/advanced_settings/server/saved_objects/user_ui_settings_client_wrapper.ts b/src/plugins/advanced_settings/server/saved_objects/user_ui_settings_client_wrapper.ts new file mode 100644 index 000000000000..1b5e44eac18b --- /dev/null +++ b/src/plugins/advanced_settings/server/saved_objects/user_ui_settings_client_wrapper.ts @@ -0,0 +1,137 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + SavedObject, + SavedObjectsBaseOptions, + SavedObjectsClientWrapperFactory, + SavedObjectsUpdateOptions, + SavedObjectsUpdateResponse, + CoreStart, + ACL, + SavedObjectsCreateOptions, + OpenSearchDashboardsRequest, +} from '../../../../core/server'; +import { Logger, CURRENT_USER_PLACEHOLDER } from '../../../../core/server'; +import { extractUserName } from '../utils'; + +/** + * This saved object client wrapper offers methods to get and update UI settings considering + * the context of the current user + */ +export class UserUISettingsClientWrapper { + constructor( + private readonly logger: Logger, + private readonly savedObjectsPermissionEnabled: boolean + ) {} + private core?: CoreStart; + + public setCore(core: CoreStart) { + this.core = core; + } + + private isUserLevelSetting(id: string | undefined): boolean { + return id ? id.startsWith(CURRENT_USER_PLACEHOLDER) : false; + } + + private normalizeDocId(id: string, request: OpenSearchDashboardsRequest, core?: CoreStart) { + const userName = extractUserName(request, core); + if (this.isUserLevelSetting(id)) { + if (userName) { + // return id.replace(CURRENT_USER, userName); // uncomment this to support version for user personal setting + return userName; + } else { + // security is not enabled, using global setting id + return id.replace(`${CURRENT_USER_PLACEHOLDER}_`, ''); + } + } + return id; + } + + public wrapperFactory: SavedObjectsClientWrapperFactory = (wrapperOptions) => { + const getUiSettings = async ( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ): Promise> => { + if (type === 'config') { + const docId = this.normalizeDocId(id, wrapperOptions.request, this.core); + return wrapperOptions.client.get(type, docId, options); + } + + return wrapperOptions.client.get(type, id, options); + }; + + const updateUiSettings = async ( + type: string, + id: string, + attributes: Partial, + options: SavedObjectsUpdateOptions = {} + ): Promise> => { + if (type === 'config') { + const docId = this.normalizeDocId(id, wrapperOptions.request, this.core); + // update user level settings + return await wrapperOptions.client.update(type, docId, attributes, options); + } + return wrapperOptions.client.update(type, id, attributes, options); + }; + + const createUiSettings = async ( + type: string, + attributes: T, + options?: SavedObjectsCreateOptions + ) => { + const userName = extractUserName(wrapperOptions.request, this.core); + const { id } = options || {}; + const userLevel = this.isUserLevelSetting(id); + + if (type === 'config' && id) { + const docId = this.normalizeDocId(id, wrapperOptions.request, this.core); + + if (userLevel && userName) { + const permissions = { + permissions: new ACL() + .addPermission(['write'], { + users: [userName], + }) + .getPermissions()!, + }; + + // remove buildNum from attributes + const { buildNum, ...others } = attributes as any; + + return await wrapperOptions.client.create(type, others, { + ...options, + id: docId, + ...(this.savedObjectsPermissionEnabled ? permissions : {}), + }); + } else { + return wrapperOptions.client.create(type, attributes, { + ...options, + id: docId, + }); + } + } + return wrapperOptions.client.create(type, 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: createUiSettings, + bulkCreate: wrapperOptions.client.bulkCreate, + delete: wrapperOptions.client.delete, + bulkUpdate: wrapperOptions.client.bulkUpdate, + deleteByWorkspace: wrapperOptions.client.deleteByWorkspace, + get: getUiSettings, + update: updateUiSettings, + }; + }; +} diff --git a/src/plugins/advanced_settings/server/utils.test.ts b/src/plugins/advanced_settings/server/utils.test.ts new file mode 100644 index 000000000000..f0089afe51af --- /dev/null +++ b/src/plugins/advanced_settings/server/utils.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { coreMock, httpServerMock } from '../../../core/server/mocks'; +import { extractUserName } from './utils'; +import { AuthStatus } from '../../../core/server'; + +// help on write unit test on utils.ts with jest +describe('utils', () => { + const isAuthenticatedMock = jest.fn().mockReturnValue(true); + const getMock = jest.fn().mockReturnValue({ + status: AuthStatus.authenticated, + state: { + authInfo: { + user_name: 'test_user', + }, + }, + }); + const coreStartMock = { + ...coreMock.createStart(), + http: { + ...coreMock.createStart().http, + auth: { + ...coreMock.createStart().http.auth, + get: getMock, + isAuthenticated: isAuthenticatedMock, + }, + }, + }; + const requestMock = httpServerMock.createOpenSearchDashboardsRequest(); + + // test extractUserName + it('extractUserName when authenticated', () => { + const result = extractUserName(requestMock, coreStartMock); + expect(result).toBe('test_user'); + }); + + it('extractUserName when not authenticated', () => { + isAuthenticatedMock.mockReturnValue(false); + getMock.mockReturnValue({ status: AuthStatus.unauthenticated }); + const result = extractUserName(requestMock, coreStartMock); + expect(result).toBeFalsy(); + }); + + it('extractUserName when auth status is unknown', () => { + isAuthenticatedMock.mockReturnValue(false); + getMock.mockReturnValue({ status: AuthStatus.unknown }); + const result = extractUserName(requestMock, coreStartMock); + expect(result).toBeFalsy(); + }); +}); diff --git a/src/plugins/advanced_settings/server/utils.ts b/src/plugins/advanced_settings/server/utils.ts new file mode 100644 index 000000000000..7ebbb3598159 --- /dev/null +++ b/src/plugins/advanced_settings/server/utils.ts @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CoreStart, OpenSearchDashboardsRequest } from '../../../core/server'; +import { getPrincipalsFromRequest } from '../../../core/server/utils'; + +export const extractUserName = (request: OpenSearchDashboardsRequest, core?: CoreStart) => { + try { + const principals = getPrincipalsFromRequest(request, core?.http.auth); + if (principals && principals.users?.length) { + return principals.users[0]; + } + } catch (error) { + return undefined; + } +}; diff --git a/src/plugins/content_management/public/app.tsx b/src/plugins/content_management/public/app.tsx index 794d36d7e946..0abe07f707e0 100644 --- a/src/plugins/content_management/public/app.tsx +++ b/src/plugins/content_management/public/app.tsx @@ -9,15 +9,25 @@ import { SavedObjectsClientContract } from 'opensearch-dashboards/public'; import { PageRender } from './components/page_render'; import { Page } from './services'; import { EmbeddableStart } from '../../embeddable/public'; +import { RenderOptions } from './types'; export const renderPage = ({ page, embeddable, savedObjectsClient, + renderOptions, }: { page: Page; embeddable: EmbeddableStart; savedObjectsClient: SavedObjectsClientContract; + renderOptions?: RenderOptions; }) => { - return ; + return ( + + ); }; diff --git a/src/plugins/content_management/public/components/page_render.tsx b/src/plugins/content_management/public/components/page_render.tsx index f0cdca8bd900..101624aa3289 100644 --- a/src/plugins/content_management/public/components/page_render.tsx +++ b/src/plugins/content_management/public/components/page_render.tsx @@ -11,19 +11,27 @@ import { EuiFlexItem, EuiPage, EuiSpacer } from '@elastic/eui'; import { Page } from '../services'; import { SectionRender } from './section_render'; import { EmbeddableStart } from '../../../embeddable/public'; +import { RenderOptions } from '../types'; export interface Props { page: Page; embeddable: EmbeddableStart; savedObjectsClient: SavedObjectsClientContract; + renderOptions?: RenderOptions; } -export const PageRender = ({ page, embeddable, savedObjectsClient }: Props) => { +export const PageRender = ({ page, embeddable, savedObjectsClient, renderOptions }: Props) => { const sections = useObservable(page.getSections$()) || []; - return ( - - {sections.map((section, i) => ( + let finalRenderSections = sections; + const { sectionId, fragmentOnly } = renderOptions || {}; + if (sectionId) { + finalRenderSections = sections.filter((section) => section.id === sectionId); + } + + const sectionRenderResult = ( + <> + {finalRenderSections.map((section, i) => ( { {i < sections.length - 1 && } ))} + + ); + + if (fragmentOnly) { + return sectionRenderResult; + } + + return ( + + {sectionRenderResult} ); }; diff --git a/src/plugins/content_management/public/plugin.ts b/src/plugins/content_management/public/plugin.ts index 6b5feeaeb9a1..91c3b1a979ff 100644 --- a/src/plugins/content_management/public/plugin.ts +++ b/src/plugins/content_management/public/plugin.ts @@ -11,6 +11,7 @@ import { ContentManagementPluginSetupDependencies, ContentManagementPluginStart, ContentManagementPluginStartDependencies, + RenderOptions, } from './types'; import { CUSTOM_CONTENT_EMBEDDABLE } from './components/custom_content_embeddable'; import { CustomContentEmbeddableFactoryDefinition } from './components/custom_content_embeddable_factory'; @@ -65,13 +66,14 @@ export class ContentManagementPublicPlugin return { registerContentProvider: this.contentManagementService.registerContentProvider, updatePageSection: this.contentManagementService.updatePageSection, - renderPage: (id: string) => { + renderPage: (id: string, renderOptions?: RenderOptions) => { const page = this.contentManagementService.getPage(id); if (page) { return renderPage({ page, embeddable: depsStart.embeddable, savedObjectsClient: core.savedObjects.client, + renderOptions, }); } }, diff --git a/src/plugins/content_management/public/types.ts b/src/plugins/content_management/public/types.ts index a2c1f506b9c0..e925408a7823 100644 --- a/src/plugins/content_management/public/types.ts +++ b/src/plugins/content_management/public/types.ts @@ -12,6 +12,17 @@ import { EmbeddableSetup, EmbeddableStart } from '../../embeddable/public'; export interface ContentManagementPluginSetup { registerPage: ContentManagementService['registerPage']; } +export interface RenderOptions { + /** + * show as fragment not a full page + */ + fragmentOnly?: boolean; + /** + * only render specific section when specified + */ + sectionId?: string; +} + export interface ContentManagementPluginStart { /** * @experimental this API is experimental and might change in future releases @@ -25,7 +36,7 @@ export interface ContentManagementPluginStart { targetArea: string, callback: (section: Section | null, err?: Error) => Section | null ) => void; - renderPage: (id: string) => React.ReactNode; + renderPage: (id: string, options?: RenderOptions) => React.ReactNode; } export interface ContentManagementPluginStartDependencies { diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 22c746c0dbed..4f6326b500fb 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -16,6 +16,10 @@ 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'; +/** + * UI setting for user default workspace + */ +export const DEFAULT_WORKSPACE = 'defaultWorkspace'; export enum WorkspacePermissionMode { Read = 'read', diff --git a/src/plugins/workspace/public/components/workspace_form/constants.ts b/src/plugins/workspace/public/components/workspace_form/constants.ts index c09ac11f5489..eb1aa1737592 100644 --- a/src/plugins/workspace/public/components/workspace_form/constants.ts +++ b/src/plugins/workspace/public/components/workspace_form/constants.ts @@ -5,6 +5,7 @@ import { i18n } from '@osd/i18n'; import { WorkspacePermissionMode } from '../../../common/constants'; +import { PermissionModeId } from '../../../../../core/public'; export enum WorkspaceOperationType { Create = 'create', @@ -16,12 +17,6 @@ export enum WorkspacePermissionItemType { Group = 'group', } -export enum PermissionModeId { - Read = 'read', - ReadAndWrite = 'read+write', - Owner = 'owner', -} - export const optionIdToWorkspacePermissionModesMap: { [key: string]: WorkspacePermissionMode[]; } = { diff --git a/src/plugins/workspace/public/components/workspace_form/utils.test.ts b/src/plugins/workspace/public/components/workspace_form/utils.test.ts index 03cea502f573..7671f08ee706 100644 --- a/src/plugins/workspace/public/components/workspace_form/utils.test.ts +++ b/src/plugins/workspace/public/components/workspace_form/utils.test.ts @@ -12,13 +12,10 @@ import { isWorkspacePermissionSetting, } from './utils'; import { WorkspacePermissionMode } from '../../../common/constants'; -import { - WorkspacePermissionItemType, - optionIdToWorkspacePermissionModesMap, - PermissionModeId, -} from './constants'; +import { WorkspacePermissionItemType, optionIdToWorkspacePermissionModesMap } from './constants'; import { DataSourceConnectionType } from '../../../common/types'; import { WorkspaceFormErrorCode } from './types'; +import { PermissionModeId } from '../../../../../core/public'; describe('convertPermissionSettingsToPermissions', () => { it('should return undefined if permission items not provided', () => { diff --git a/src/plugins/workspace/public/components/workspace_form/utils.ts b/src/plugins/workspace/public/components/workspace_form/utils.ts index 5e03c724889a..79c5501e9102 100644 --- a/src/plugins/workspace/public/components/workspace_form/utils.ts +++ b/src/plugins/workspace/public/components/workspace_form/utils.ts @@ -10,7 +10,6 @@ import { CURRENT_USER_PLACEHOLDER, WorkspacePermissionMode } from '../../../comm import { isUseCaseFeatureConfig } from '../../utils'; import { optionIdToWorkspacePermissionModesMap, - PermissionModeId, WorkspaceOperationType, WorkspacePermissionItemType, } from './constants'; @@ -27,6 +26,7 @@ import { } from './types'; import { DataSourceConnection } from '../../../common/types'; import { validateWorkspaceColor } from '../../../common/utils'; +import { PermissionModeId } from '../../../../../core/public'; export const isValidFormTextInput = (input?: string) => { /** diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx index 86f0d0688714..bbbc5e2b393d 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx @@ -17,12 +17,12 @@ import { WorkspacePermissionMode } from '../../../common/constants'; import { WorkspacePermissionItemType, optionIdToWorkspacePermissionModesMap, - PermissionModeId, PERMISSION_TYPE_LABEL_ID, PERMISSION_COLLABORATOR_LABEL_ID, PERMISSION_ACCESS_LEVEL_LABEL_ID, } from './constants'; import { getPermissionModeId } from './utils'; +import { PermissionModeId } from '../../../../../core/public'; const permissionModeOptions: Array> = [ { diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx index 845708d7ecbf..6b10b88dc5f5 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx @@ -17,7 +17,6 @@ import { WorkspaceFormError, WorkspacePermissionSetting } from './types'; import { WorkspacePermissionItemType, optionIdToWorkspacePermissionModesMap, - PermissionModeId, PERMISSION_TYPE_LABEL_ID, PERMISSION_COLLABORATOR_LABEL_ID, PERMISSION_ACCESS_LEVEL_LABEL_ID, @@ -27,6 +26,7 @@ import { WorkspacePermissionSettingInputProps, } from './workspace_permission_setting_input'; import { generateNextPermissionSettingsId } from './utils'; +import { PermissionModeId } from '../../../../../core/public'; export interface WorkspacePermissionSettingPanelProps { errors?: { [key: number]: WorkspaceFormError }; diff --git a/src/plugins/workspace/public/components/workspace_list/__snapshots__/default_workspace.test.tsx.snap b/src/plugins/workspace/public/components/workspace_list/__snapshots__/default_workspace.test.tsx.snap new file mode 100644 index 000000000000..0bb3f51ead3d --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_list/__snapshots__/default_workspace.test.tsx.snap @@ -0,0 +1,813 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UserDefaultWorkspace should render title and table normally 1`] = ` +
+
+
+
+
+

+ Workspaces ( + 3 + ) +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + Use case + + + + + + Description + + + + + + Permissions + + + + + + Actions + + +
+
+ Name +
+
+ + + +
+
+
+ Use case +
+
+ + Analytics (All) + +
+
+
+ Description +
+
+ +
+ should be able to see the description tooltip when hovering over the description +
+
+
+
+
+ Permissions +
+
+ +
+ Admin +
+
+
+
+
+ + + +
+ Copy ID +
+
+
+ + + +
+ Set as my default +
+
+
+
+
+
+ Name +
+
+ + + +
+
+
+ Use case +
+
+ + Observability + +
+
+
+ Description +
+
+ +
+ should be able to see the description tooltip when hovering over the description +
+
+
+
+
+ Permissions +
+
+ +
+ Admin +
+
+
+
+
+ + + +
+ Copy ID +
+
+
+ + + +
+ Set as my default +
+
+
+
+
+
+ Name +
+
+ + + +
+
+
+ Use case +
+
+ + Search + +
+
+
+ Description +
+
+ +
+ +
+
+
+ Permissions +
+
+ +
+ Admin +
+
+
+
+
+ + + +
+ Copy ID +
+
+
+ + + +
+ Set as my default +
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+`; diff --git a/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap b/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap index 2728a6740982..b32655bafd98 100644 --- a/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap +++ b/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap @@ -202,7 +202,7 @@ exports[`WorkspaceList should render title and table normally 1`] = ` data-test-subj="tableHeaderCell_name_0" role="columnheader" scope="col" - style="width: 15%;" + style="width: 18%;" > +
+
+ name1 +
+
+
- +
+
+ name2 +
+
+
- +
+
+ name3 +
+
+
Rows per page : - 5 + 10 diff --git a/src/plugins/workspace/public/components/workspace_list/default_workspace.test.tsx b/src/plugins/workspace/public/components/workspace_list/default_workspace.test.tsx new file mode 100644 index 000000000000..f6a50b1c1350 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_list/default_workspace.test.tsx @@ -0,0 +1,152 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { BehaviorSubject } from 'rxjs'; +import { render } from '@testing-library/react'; +import { I18nProvider } from '@osd/i18n/react'; +import { coreMock } from '../../../../../core/public/mocks'; +import { createMockedRegisteredUseCases$ } from '../../mocks'; +import { UserDefaultWorkspace } from './default_workspace'; +import { WorkspaceClient } from '../../workspace_client'; +import { WorkspaceObject } from '../../../../../core/public'; + +jest.mock('../utils/workspace'); +jest.mock('../../utils', () => { + const original = jest.requireActual('../../utils'); + return { + ...original, + getDataSourcesList: jest.fn().mockResolvedValue(() => [ + { + id: 'ds_id1', + title: 'ds_title1', + workspaces: 'id1', + }, + { + id: 'ds_id2', + title: 'ds_title2', + workspaces: 'id1', + }, + { + id: 'ds_id3', + title: 'ds_title3', + workspaces: 'id1', + }, + ]), + }; +}); + +function getWrapUserDefaultWorkspaceList( + workspaceList = [ + { + id: 'id1', + name: 'name1', + features: ['use-case-all'], + description: + 'should be able to see the description tooltip when hovering over the description', + lastUpdatedTime: '1999-08-06T02:00:00.00Z', + permissions: { + write: { + users: ['admin', 'nonadmin'], + }, + }, + }, + { + id: 'id2', + name: 'name2', + features: ['use-case-observability'], + description: + 'should be able to see the description tooltip when hovering over the description', + lastUpdatedTime: '1999-08-06T00:00:00.00Z', + }, + { + id: 'id3', + name: 'name3', + features: ['use-case-search'], + description: '', + lastUpdatedTime: '1999-08-06T01:00:00.00Z', + }, + ], + isDashboardAdmin = true +) { + const coreStartMock = coreMock.createStart(); + const coreSetupMock = coreMock.createSetup(); + coreStartMock.application.capabilities = { + ...coreStartMock.application.capabilities, + dashboards: { + isDashboardAdmin, + }, + }; + + const mockHeaderControl = ({ controls }) => { + return controls?.[0].description ?? controls?.[0].renderComponent ?? null; + }; + + const workspaceClientMock = new WorkspaceClient(coreSetupMock.http, coreSetupMock.workspaces); + + const services = { + ...coreStartMock, + workspaces: { + ...coreStartMock.workspaces, + workspaceList$: new BehaviorSubject(workspaceList), + }, + uiSettings: { + ...coreStartMock.uiSettings, + get: jest.fn().mockImplementation((key) => { + if (key === 'dateFormat') { + return 'MMM D, YYYY @ HH:mm:ss.SSS'; + } + return null; + }), + }, + navigationUI: { + HeaderControl: mockHeaderControl, + }, + workspaceClient: workspaceClientMock, + }; + + return ( + + + + ); +} + +describe('UserDefaultWorkspace', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render title and table normally', () => { + const { getByText, getByRole, container } = render(getWrapUserDefaultWorkspaceList()); + expect(getByText('Workspaces (3)')).toBeInTheDocument(); + expect(getByRole('table')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + + it('should render data in table based on workspace list data', async () => { + const { getByText, queryByText, queryAllByText } = render(getWrapUserDefaultWorkspaceList()); + + // should display workspace names + expect(getByText('name1')).toBeInTheDocument(); + expect(getByText('name2')).toBeInTheDocument(); + + // should display use case + expect(getByText('Analytics (All)')).toBeInTheDocument(); + expect(getByText('Observability')).toBeInTheDocument(); + + // owner column not display + expect(queryByText('admin')).not.toBeInTheDocument(); + + // euiTableRow-isSelectable + expect(document.querySelectorAll('.euiTableRow-isSelectable').length).toBe(0); + + // action button Set as default in document + expect(queryAllByText('Set as my default')).toHaveLength(3); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_list/default_workspace.tsx b/src/plugins/workspace/public/components/workspace_list/default_workspace.tsx new file mode 100644 index 000000000000..126190332065 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_list/default_workspace.tsx @@ -0,0 +1,54 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { BehaviorSubject, of } from 'rxjs'; +import { useObservable } from 'react-use'; +import { EuiFlexItem, EuiPanel, EuiFlexGroup, EuiText, EuiHorizontalRule } from '@elastic/eui'; +import { OpenSearchDashboardsContextProvider } from '../../../../opensearch_dashboards_react/public'; +import { WorkspaceListInner } from '.'; +import { Services, WorkspaceUseCase } from '../../types'; + +interface Props { + services: Services; + registeredUseCases$: BehaviorSubject; +} + +export const UserDefaultWorkspace = ({ services, registeredUseCases$ }: Props) => { + const { workspaces } = services; + const workspaceList = useObservable(workspaces?.workspaceList$ ?? of([]), []); + + return ( + + + + + +

Workspaces ({workspaceList?.length})

+
+
+ + + + + + +
+
+
+ ); +}; diff --git a/src/plugins/workspace/public/components/workspace_list/index.test.tsx b/src/plugins/workspace/public/components/workspace_list/index.test.tsx index bec37c5bb160..a945866fd7bf 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.test.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.test.tsx @@ -239,57 +239,24 @@ describe('WorkspaceList', () => { }); it('should be able to pagination when clicking pagination button', async () => { - const list = [ - { - id: 'id1', - name: 'name1', - features: ['use-case-all'], - description: '', - lastUpdatedTime: '2024-08-06T00:00:00.00Z', - }, - { - id: 'id2', - name: 'name2', - features: ['use-case-observability'], - description: '', - lastUpdatedTime: '2024-08-06T00:00:00.00Z', - }, - { - id: 'id3', - name: 'name3', - features: ['use-case-search'], - description: '', - lastUpdatedTime: '2024-08-06T00:00:00.00Z', - }, - { - id: 'id4', - name: 'name4', + const list = []; + // add 15 items into list + for (let i = 100; i < 115; i++) { + list.push({ + id: `id${i}`, + name: `name${i}`, features: ['use-case-all'], description: '', - lastUpdatedTime: '2024-08-05T00:00:00.00Z', - }, - { - id: 'id5', - name: 'name5', - features: ['use-case-observability'], - description: '', - lastUpdatedTime: '2024-08-06T00:00:00.00Z', - }, - { - id: 'id6', - name: 'name6', - features: ['use-case-search'], - description: '', lastUpdatedTime: '2024-08-06T00:00:00.00Z', - }, - ]; + }); + } const { getByTestId, getByText, queryByText } = render(getWrapWorkspaceListInContext(list)); - expect(getByText('name1')).toBeInTheDocument(); - expect(queryByText('name6')).not.toBeInTheDocument(); + expect(getByText('name100')).toBeInTheDocument(); + expect(queryByText('name110')).not.toBeInTheDocument(); const paginationButton = getByTestId('pagination-button-next'); fireEvent.click(paginationButton); - expect(queryByText('name1')).not.toBeInTheDocument(); - expect(getByText('name6')).toBeInTheDocument(); + expect(queryByText('name100')).not.toBeInTheDocument(); + expect(queryByText('name110')).toBeInTheDocument(); }); it('should display create workspace button for dashboard admin', async () => { diff --git a/src/plugins/workspace/public/components/workspace_list/index.tsx b/src/plugins/workspace/public/components/workspace_list/index.tsx index 176f0dfdcc39..8366c240cca3 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.tsx @@ -16,14 +16,18 @@ import { EuiSearchBarProps, copyToClipboard, EuiTableSelectionType, - EuiButtonEmpty, EuiButton, EuiEmptyPrompt, EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, } from '@elastic/eui'; import useObservable from 'react-use/lib/useObservable'; import { BehaviorSubject, of } from 'rxjs'; import { i18n } from '@osd/i18n'; +import { isString } from 'lodash'; +import { startCase } from 'lodash'; import { DEFAULT_NAV_GROUPS, WorkspaceAttribute, @@ -33,7 +37,7 @@ import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashb import { navigateToWorkspaceDetail } from '../utils/workspace'; import { DetailTab } from '../workspace_form/constants'; -import { WORKSPACE_CREATE_APP_ID } from '../../../common/constants'; +import { DEFAULT_WORKSPACE, WORKSPACE_CREATE_APP_ID } from '../../../common/constants'; import { DeleteWorkspaceModal } from '../delete_workspace_modal'; import { getFirstUseCaseOfFeatureConfigs, getDataSourcesList } from '../../utils'; @@ -46,12 +50,39 @@ export interface WorkspaceListProps { registeredUseCases$: BehaviorSubject; } +export interface WorkspaceListInnerProps extends WorkspaceListProps { + fullPage?: boolean; + selectable?: boolean; + searchable?: boolean; + excludedActionNames?: string[]; + includedColumns?: Array<{ field: string; width?: string }>; + excludedColumns?: string[]; +} + interface WorkspaceAttributeWithUseCaseIDAndDataSources extends WorkspaceAttribute { useCase?: string; dataSources?: string[]; } -export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { +export const WorkspaceList = (props: WorkspaceListProps) => { + return ( + + ); +}; + +export const WorkspaceListInner = ({ + registeredUseCases$, + fullPage = true, + selectable = true, + searchable = true, + excludedActionNames = [], + includedColumns = [], + excludedColumns = [], +}: WorkspaceListInnerProps) => { const { services: { workspaces, @@ -60,6 +91,7 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { navigationUI: { HeaderControl }, uiSettings, savedObjects, + notifications, }, } = useOpenSearchDashboards<{ navigationUI: NavigationPublicPluginStart['ui']; @@ -72,12 +104,14 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { const [pagination, setPagination] = useState({ pageIndex: 0, - pageSize: 5, + pageSize: 10, pageSizeOptions: [5, 10, 20], }); const [deletedWorkspaces, setDeletedWorkspaces] = useState([]); const [selection, setSelection] = useState([]); const [allDataSources, setAllDataSources] = useState([]); + // default workspace state + const [defaultWorkspaceId, setDefaultWorkspaceId] = useState(undefined); const dateFormat = uiSettings?.get('dateFormat'); @@ -99,12 +133,13 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { ); useEffect(() => { + setDefaultWorkspaceId(uiSettings?.get(DEFAULT_WORKSPACE)); if (savedObjects) { getDataSourcesList(savedObjects.client, ['*']).then((data) => { setAllDataSources(data); }); } - }, [savedObjects]); + }, [savedObjects, uiSettings]); const newWorkspaceList: WorkspaceAttributeWithUseCaseIDAndDataSources[] = useMemo(() => { return workspaceList.map( @@ -136,7 +171,7 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { const emptyStateMessage = useMemo(() => { return ( {i18n.translate('workspace.workspaceList.emptyState.title', { @@ -187,6 +222,9 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { const handleCopyId = (id: string) => { copyToClipboard(id); + notifications?.toasts.addSuccess( + i18n.translate('workspace.copyWorkspaceId.message', { defaultMessage: 'Workspace ID copied' }) + ); }; const handleSwitchWorkspace = useCallback( @@ -198,6 +236,30 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { [application, http] ); + const handleSetDefaultWorkspace = useCallback( + async (item: WorkspaceAttribute) => { + const set = await uiSettings?.set(DEFAULT_WORKSPACE, item.id); + if (set) { + setDefaultWorkspaceId(item.id); + notifications?.toasts.addSuccess( + i18n.translate('workspace.setDefaultWorkspace.success.message', { + defaultMessage: 'Default workspace been set to {name}', + values: { name: item.name }, + }) + ); + } else { + // toast + notifications?.toasts.addWarning( + i18n.translate('workspace.setDefaultWorkspace.error.message', { + defaultMessage: 'Failed to set workspace {name} as default workspace.', + values: { name: item.name }, + }) + ); + } + }, + [notifications?.toasts, uiSettings] + ); + const renderDataWithMoreBadge = ( data: string[], maxDisplayedAmount: number, @@ -294,16 +356,29 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { toolsLeft: renderToolsLeft(), }; - const columns = [ + const columnsWithoutActions = [ { field: 'name', - name: 'Name', - width: '15%', + name: i18n.translate('workspace.list.columns.name.title', { defaultMessage: 'Name' }), + width: '18%', sortable: true, render: (name: string, item: WorkspaceAttributeWithPermission) => ( handleSwitchWorkspace(item.id)}> - {name} + + + {name} + + {item.id === defaultWorkspaceId && ( + + + {i18n.translate('workspace.defaultWorkspace.title', { + defaultMessage: 'Default workspace', + })} + + + )} + ), @@ -311,13 +386,15 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { { field: 'useCase', - name: 'Use case', - width: '15%', + name: i18n.translate('workspace.list.columns.useCase.title', { defaultMessage: 'Use case' }), + width: '12%', }, { field: 'description', - name: 'Description', + name: i18n.translate('workspace.list.columns.description.title', { + defaultMessage: 'Description', + }), width: '15%', render: (description: string) => ( { content={description} data-test-subj="workspaceList-hover-description" > - {/* Here I need to set width mannuly as the tooltip will ineffect the property : truncateText ', */} + {/* Here I need to set width manually as the tooltip will ineffect the property : truncateText ', */} {description} @@ -334,7 +411,7 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { }, { field: 'permissions', - name: 'Owners', + name: i18n.translate('workspace.list.columns.owners.title', { defaultMessage: 'Owners' }), width: '15%', render: ( permissions: WorkspaceAttributeWithPermission['permissions'], @@ -344,9 +421,34 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { return renderDataWithMoreBadge(owners, 1, item.id, DetailTab.Collaborators); }, }, + { + field: 'permissionMode', + name: i18n.translate('workspace.list.columns.permissions.title', { + defaultMessage: 'Permissions', + }), + width: '6%', + render: (permissionMode: WorkspaceAttributeWithPermission['permissionMode']) => { + return isDashboardAdmin ? ( + + + {i18n.translate('workspace.role.admin.name', { defaultMessage: 'Admin' })} + + + ) : ( + startCase(permissionMode) + ); + }, + }, { field: 'lastUpdatedTime', - name: 'Last updated', + name: i18n.translate('workspace.list.columns.lastUpdated.title', { + defaultMessage: 'Last updated', + }), width: '15%', truncateText: false, render: (lastUpdatedTime: string) => { @@ -356,130 +458,189 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { { field: 'dataSources', width: '15%', - name: 'Data sources', + name: i18n.translate('workspace.list.columns.dataSources.title', { + defaultMessage: 'Data sources', + }), render: (dataSources: string[], item: WorkspaceAttributeWithPermission) => { return renderDataWithMoreBadge(dataSources, 2, item.id, DetailTab.DataSources); }, }, + ]; + const allActions = [ + { + name: ( + + {i18n.translate('workspace.list.actions.copyId.name', { defaultMessage: 'Copy ID' })} + + ), + icon: 'copy', + type: 'icon', + description: i18n.translate('workspace.list.actions.copyId.description', { + defaultMessage: 'Copy workspace id', + }), + 'data-test-subj': 'workspace-list-copy-id-icon', + onClick: ({ id }: WorkspaceAttribute) => handleCopyId(id), + }, + { + name: ( + + {i18n.translate('workspace.list.actions.edit.name', { defaultMessage: 'Edit' })} + + ), + icon: 'pencil', + type: 'icon', + description: i18n.translate('workspace.list.actions.edit.description', { + defaultMessage: 'Edit workspace', + }), + 'data-test-subj': 'workspace-list-edit-icon', + onClick: ({ id }: WorkspaceAttribute) => handleSwitchWorkspace(id), + }, + { + name: ( + + {i18n.translate('workspace.list.actions.setDefault.name', { + defaultMessage: 'Set as my default', + })} + + ), + icon: 'flag', + type: 'icon', + description: i18n.translate('workspace.list.actions.setDefault.description', { + defaultMessage: 'Set as my default workspace', + }), + 'data-test-subj': 'workspace-list-set-default-icon', + onClick: (item: WorkspaceAttribute) => handleSetDefaultWorkspace(item), + }, + { + name: ( + + {i18n.translate('workspace.list.actions.leave.name', { defaultMessage: 'Leave' })} + + ), + icon: 'exit', + type: 'icon', + description: i18n.translate('workspace.list.actions.leave.description', { + defaultMessage: 'Leave workspace', + }), + 'data-test-subj': 'workspace-list-leave-icon', + available: () => false, + }, + { + name: ( + + {i18n.translate('workspace.list.actions.delete.name', { defaultMessage: 'Delete' })} + + ), + icon: () => , + type: 'icon', + isPrimary: false, + description: i18n.translate('workspace.list.actions.delete.description', { + defaultMessage: 'Delete workspace', + }), + 'data-test-subj': 'workspace-list-delete-icon', + available: () => isDashboardAdmin, + onClick: (item: WorkspaceAttribute) => { + setDeletedWorkspaces([item]); + }, + }, + ]; + + const availableActions = allActions.filter( + (action) => + !excludedActionNames?.includes(isString(action.name) ? action.name : action.name.key || '') + ); + + const includedColumnsFields = includedColumns.map((column) => { + return column.field; + }); + const availableColumns = columnsWithoutActions + .filter((column) => { + return ( + (!includedColumnsFields || + includedColumnsFields.length === 0 || + includedColumnsFields.includes(column.field)) && + !excludedColumns.includes(column.field) + ); + }) + .map((column) => { + const customizedCol = includedColumns.find((col) => col.field === column.field); + return { + ...column, + ...(customizedCol ? { ...customizedCol } : {}), + }; + }); + + const actionColumns = [ { - name: 'Actions', + name: i18n.translate('workspace.list.columns.actions.title', { + defaultMessage: 'Actions', + }), field: '', - actions: [ - { - name: 'Copy ID', - type: 'button', - description: 'Copy id', - 'data-test-subj': 'workspace-list-copy-id-icon', - render: ({ id }: WorkspaceAttribute) => { - return ( - handleCopyId(id)} - size="xs" - iconType="copy" - color="text" - > - Copy ID - - ); - }, - }, - { - name: 'Edit', - type: 'icon', - icon: 'edit', - color: 'danger', - description: 'Edit workspace', - 'data-test-subj': 'workspace-list-edit-icon', - onClick: ({ id }: WorkspaceAttribute) => handleSwitchWorkspace(id), - render: ({ id }: WorkspaceAttribute) => { - return ( - handleSwitchWorkspace(id)} - iconType="pencil" - size="xs" - color="text" - > - Edit - - ); - }, - }, - { - name: 'Delete', - type: 'button', - description: 'Delete workspace', - 'data-test-subj': 'workspace-list-delete-icon', - available: () => isDashboardAdmin, - render: (item: WorkspaceAttribute) => { - return ( - { - setDeletedWorkspaces([item]); - }} - size="s" - iconType="trash" - color="danger" - style={{ padding: 0 }} - > - Delete - - ); - }, - }, - ], + actions: availableActions, }, ]; - return ( - - - {isDashboardAdmin && renderCreateWorkspaceButton()} - - - setPagination((prev) => { - return { ...prev, pageIndex: index, pageSize: size }; - }) - } - pagination={pagination} - sorting={{ - sort: { - field: initialSortField, - direction: initialSortDirection, + const columns = [...availableColumns, ...actionColumns]; + + const workspaceListTable = ( + + setPagination((prev) => { + return { ...prev, pageIndex: index, pageSize: size }; + }) + } + pagination={pagination} + sorting={{ + sort: { + field: initialSortField, + direction: initialSortDirection, + }, + }} + isSelectable={selectable} + search={searchable ? search : undefined} + selection={selectable ? selectionValue : undefined} + /> + ); + + return fullPage ? ( + <> + + - + {isDashboardAdmin && renderCreateWorkspaceButton()} + + {workspaceListTable} + - {deletedWorkspaces.length > 0 && ( - setDeletedWorkspaces([])} - /> - )} - + {deletedWorkspaces.length > 0 && ( + setDeletedWorkspaces([])} + /> + )} + + + ) : ( + workspaceListTable ); }; diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index d4026560aae5..ab390b71733a 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -69,6 +69,7 @@ import { registerAnalyticsAllOverviewContent, setAnalyticsAllOverviewSection, } from './components/use_case_overview/setup_overview'; +import { UserDefaultWorkspace } from './components/workspace_list/default_workspace'; import { registerGetStartedCardToNewHome } from './components/home_get_start_card'; type WorkspaceAppType = ( @@ -103,6 +104,7 @@ export class WorkspacePlugin private registeredUseCasesUpdaterSubscription?: Subscription; private workspaceAndUseCasesCombineSubscription?: Subscription; private useCase = new UseCaseService(); + private workspaceClient?: WorkspaceClient; private _changeSavedObjectCurrentWorkspace() { if (this.coreStart) { @@ -260,6 +262,7 @@ export class WorkspacePlugin ) { const workspaceClient = new WorkspaceClient(core.http, core.workspaces); await workspaceClient.init(); + this.workspaceClient = workspaceClient; core.workspaces.setClient(workspaceClient); this.useCase.setup({ @@ -594,6 +597,9 @@ export class WorkspacePlugin // register get started card in new home page registerGetStartedCardToNewHome(core, contentManagement, this.registeredUseCases$); + // register workspace list to user settings page + this.registerWorkspaceListToUserSettings(core, contentManagement, navigation); + // set breadcrumbs enricher for workspace this.breadcrumbsSubscription = enrichBreadcrumbsWithWorkspace(core); @@ -625,6 +631,34 @@ export class WorkspacePlugin } } + private async registerWorkspaceListToUserSettings( + coreStart: CoreStart, + contentManagement: ContentManagementPluginStart, + navigation: NavigationPublicPluginStart + ) { + if (contentManagement) { + const services: Services = { + ...coreStart, + workspaceClient: this.workspaceClient!, + navigationUI: navigation.ui, + }; + contentManagement.registerContentProvider({ + id: 'default_workspace_list', + getContent: () => ({ + id: 'default_workspace_list', + kind: 'custom', + order: 0, + render: () => + React.createElement(UserDefaultWorkspace, { + services, + registeredUseCases$: this.registeredUseCases$, + }), + }), + getTargetArea: () => 'user_settings/default_workspace', + }); + } + } + public stop() { this.currentWorkspaceSubscription?.unsubscribe(); this.currentWorkspaceIdSubscription?.unsubscribe(); diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index ad45b1f7fa08..7b0702b41f71 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -24,6 +24,7 @@ import { PRIORITY_FOR_WORKSPACE_UI_SETTINGS_WRAPPER, WORKSPACE_INITIAL_APP_ID, WORKSPACE_NAVIGATION_APP_ID, + DEFAULT_WORKSPACE, } from '../common/constants'; import { IWorkspaceClientImpl, WorkspacePluginSetup, WorkspacePluginStart } from './types'; import { WorkspaceClient } from './workspace_client'; @@ -43,6 +44,7 @@ import { import { getOSDAdminConfigFromYMLConfig, updateDashboardAdminStateForRequest } from './utils'; import { WorkspaceIdConsumerWrapper } from './saved_objects/workspace_id_consumer_wrapper'; import { WorkspaceUiSettingsClientWrapper } from './saved_objects/workspace_ui_settings_client_wrapper'; +import { uiSettings } from './ui_settings'; export class WorkspacePlugin implements Plugin { private readonly logger: Logger; @@ -131,11 +133,10 @@ export class WorkspacePlugin implements Plugin workspace.id === defaultWorkspaceId ); @@ -172,6 +173,9 @@ export class WorkspacePlugin implements Plugin { + const permissionMode = translatePermissionsToRole( + isPermissionControlEnabled, + workspace.permissions, + principals + ); + workspace.permissionMode = permissionMode; + }); + return res.ok({ body: result, }); diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts index a52ab42183fb..ed056bedf4e3 100644 --- a/src/plugins/workspace/server/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -6,7 +6,6 @@ import { Logger, OpenSearchDashboardsRequest, - RequestHandlerContext, SavedObjectsFindResponse, CoreSetup, WorkspaceAttribute, @@ -14,9 +13,10 @@ import { Permissions, UiSettingsServiceStart, } from '../../../core/server'; - +import { PermissionModeId } from '../../../core/server'; export interface WorkspaceAttributeWithPermission extends WorkspaceAttribute { permissions?: Permissions; + permissionMode?: PermissionModeId; } import { WorkspacePermissionMode } from '../common/constants'; @@ -82,7 +82,7 @@ export interface IWorkspaceClientImpl { ): Promise< IResponse< { - workspaces: WorkspaceAttribute[]; + workspaces: WorkspaceAttributeWithPermission[]; } & Pick > >; diff --git a/src/plugins/workspace/server/ui_settings.ts b/src/plugins/workspace/server/ui_settings.ts new file mode 100644 index 000000000000..a5aceccc0386 --- /dev/null +++ b/src/plugins/workspace/server/ui_settings.ts @@ -0,0 +1,20 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; + +import { UiSettingsParams } from 'opensearch-dashboards/server'; +import { UiSettingScope } from '../../../core/server'; +import { DEFAULT_WORKSPACE } from '../common/constants'; + +export const uiSettings: Record = { + [DEFAULT_WORKSPACE]: { + name: 'Default workspace', + scope: UiSettingScope.USER, + value: null, + type: 'string', + schema: schema.nullable(schema.string()), + }, +}; diff --git a/src/plugins/workspace/server/utils.ts b/src/plugins/workspace/server/utils.ts index b8c2b7613839..118cb9d8909d 100644 --- a/src/plugins/workspace/server/utils.ts +++ b/src/plugins/workspace/server/utils.ts @@ -12,10 +12,12 @@ import { Permissions, SavedObjectsClientContract, IUiSettingsClient, + Principals, } from '../../../core/server'; 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'; +import { CURRENT_USER_PLACEHOLDER, WorkspacePermissionMode } from '../common/constants'; +import { PermissionModeId } from '../../../core/server'; /** * Generate URL friendly random ID @@ -124,3 +126,51 @@ export const checkAndSetDefaultDataSource = async ( await uiSettingsClient.set(DEFAULT_DATA_SOURCE_UI_SETTINGS_ID, undefined); } }; + +/** + * translate workspace permission object into PermissionModeId + * @param permissions workspace permissions object + * @param isPermissionControlEnabled permission control flag + * @param principals + * @returns PermissionModeId + */ +export const translatePermissionsToRole = ( + isPermissionControlEnabled: boolean, + permissions?: Permissions, + principals?: Principals +): PermissionModeId => { + let permissionMode = PermissionModeId.Owner; + if (isPermissionControlEnabled && permissions) { + const modes = [] as WorkspacePermissionMode[]; + const currentUserId = principals?.users?.[0] || ''; + const currentGroupId = principals?.groups?.[0] || ''; + [ + WorkspacePermissionMode.Write, + WorkspacePermissionMode.LibraryWrite, + WorkspacePermissionMode.LibraryRead, + WorkspacePermissionMode.Read, + ].forEach((mode) => { + if ( + permissions[mode] && + (permissions[mode].users?.includes(currentUserId) || + permissions[mode].groups?.includes(currentGroupId)) + ) { + modes.push(mode); + } + }); + + if ( + modes.includes(WorkspacePermissionMode.LibraryWrite) && + modes.includes(WorkspacePermissionMode.Write) + ) { + permissionMode = PermissionModeId.Owner; + } else if (modes.includes(WorkspacePermissionMode.LibraryWrite)) { + permissionMode = PermissionModeId.ReadAndWrite; + } else { + permissionMode = PermissionModeId.Read; + } + } else { + permissionMode = PermissionModeId.Read; + } + return permissionMode; +};