From 074d23be87eb6d5952b7d3119181f26d5c87cb39 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Mon, 11 Sep 2023 10:49:44 +0800 Subject: [PATCH 01/21] [Feature] Setup workspace skeleton and implement basic CRUD API (#130) * feature: setup workspace skeleton and implement basic CRUD API on workspace Signed-off-by: Zhou Su * feat: remove useless required plugins and logger typo Signed-off-by: SuZhou-Joe * feat: setup public side skeleton Signed-off-by: SuZhou-Joe --------- Signed-off-by: Zhou Su Signed-off-by: SuZhou-Joe Co-authored-by: Zhou Su --- src/core/server/index.ts | 4 +- src/core/types/index.ts | 1 + src/core/types/workspace.ts | 14 ++ src/core/utils/constants.ts | 6 + src/core/utils/index.ts | 1 + .../workspace/opensearch_dashboards.json | 9 + src/plugins/workspace/public/index.ts | 10 + src/plugins/workspace/public/plugin.ts | 17 ++ src/plugins/workspace/server/index.ts | 13 ++ src/plugins/workspace/server/plugin.ts | 45 ++++ src/plugins/workspace/server/routes/index.ts | 181 ++++++++++++++++ .../workspace/server/saved_objects/index.ts | 6 + .../server/saved_objects/workspace.ts | 41 ++++ src/plugins/workspace/server/types.ts | 65 ++++++ src/plugins/workspace/server/utils.test.ts | 21 ++ src/plugins/workspace/server/utils.ts | 13 ++ .../workspace/server/workspace_client.ts | 193 ++++++++++++++++++ 17 files changed, 638 insertions(+), 2 deletions(-) create mode 100644 src/core/types/workspace.ts create mode 100644 src/core/utils/constants.ts create mode 100644 src/plugins/workspace/opensearch_dashboards.json create mode 100644 src/plugins/workspace/public/index.ts create mode 100644 src/plugins/workspace/public/plugin.ts create mode 100644 src/plugins/workspace/server/index.ts create mode 100644 src/plugins/workspace/server/plugin.ts create mode 100644 src/plugins/workspace/server/routes/index.ts create mode 100644 src/plugins/workspace/server/saved_objects/index.ts create mode 100644 src/plugins/workspace/server/saved_objects/workspace.ts create mode 100644 src/plugins/workspace/server/types.ts create mode 100644 src/plugins/workspace/server/utils.test.ts create mode 100644 src/plugins/workspace/server/utils.ts create mode 100644 src/plugins/workspace/server/workspace_client.ts diff --git a/src/core/server/index.ts b/src/core/server/index.ts index ca55cc8dc1d5..379411398fca 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -345,8 +345,8 @@ export { MetricsServiceStart, } from './metrics'; -export { AppCategory } from '../types'; -export { DEFAULT_APP_CATEGORIES } from '../utils'; +export { AppCategory, WorkspaceAttribute } from '../types'; +export { DEFAULT_APP_CATEGORIES, WORKSPACE_TYPE } from '../utils'; export { SavedObject, diff --git a/src/core/types/index.ts b/src/core/types/index.ts index 9f620273e3b2..4afe9c537f75 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -39,3 +39,4 @@ export * from './ui_settings'; export * from './saved_objects'; export * from './serializable'; export * from './custom_branding'; +export * from './workspace'; diff --git a/src/core/types/workspace.ts b/src/core/types/workspace.ts new file mode 100644 index 000000000000..23c3b2038ff2 --- /dev/null +++ b/src/core/types/workspace.ts @@ -0,0 +1,14 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface WorkspaceAttribute { + id: string; + name: string; + description?: string; + features?: string[]; + color?: string; + icon?: string; + defaultVISTheme?: string; +} diff --git a/src/core/utils/constants.ts b/src/core/utils/constants.ts new file mode 100644 index 000000000000..73c2d6010846 --- /dev/null +++ b/src/core/utils/constants.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const WORKSPACE_TYPE = 'workspace'; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index b3b6ce4aab02..af4f9a17ae58 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -37,3 +37,4 @@ export { IContextProvider, } from './context'; export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; +export { WORKSPACE_TYPE } from './constants'; diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json new file mode 100644 index 000000000000..ea2fe1cbed49 --- /dev/null +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -0,0 +1,9 @@ +{ + "id": "workspace", + "version": "opensearchDashboards", + "server": true, + "ui": true, + "requiredPlugins": [], + "optionalPlugins": [], + "requiredBundles": [] +} diff --git a/src/plugins/workspace/public/index.ts b/src/plugins/workspace/public/index.ts new file mode 100644 index 000000000000..99161a7edbd7 --- /dev/null +++ b/src/plugins/workspace/public/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WorkspacePlugin } from './plugin'; + +export function plugin() { + return new WorkspacePlugin(); +} diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts new file mode 100644 index 000000000000..933afa5858be --- /dev/null +++ b/src/plugins/workspace/public/plugin.ts @@ -0,0 +1,17 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { Plugin } from '../../../core/public'; + +export class WorkspacePlugin implements Plugin<{}, {}, {}> { + public async setup() { + return {}; + } + + public start() { + return {}; + } + + public stop() {} +} diff --git a/src/plugins/workspace/server/index.ts b/src/plugins/workspace/server/index.ts new file mode 100644 index 000000000000..936d3cf3ecec --- /dev/null +++ b/src/plugins/workspace/server/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { PluginInitializerContext } from '../../../core/server'; +import { WorkspacePlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, OpenSearch Dashboards Platform `plugin()` initializer. + +export function plugin(initializerContext: PluginInitializerContext) { + return new WorkspacePlugin(initializerContext); +} diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts new file mode 100644 index 000000000000..515ccc399f8e --- /dev/null +++ b/src/plugins/workspace/server/plugin.ts @@ -0,0 +1,45 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { PluginInitializerContext, CoreSetup, Plugin, Logger } from '../../../core/server'; +import { IWorkspaceDBImpl } from './types'; +import { WorkspaceClientWithSavedObject } from './workspace_client'; +import { registerRoutes } from './routes'; + +export class WorkspacePlugin implements Plugin<{}, {}> { + private readonly logger: Logger; + private client?: IWorkspaceDBImpl; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get('plugins', 'workspace'); + } + + public async setup(core: CoreSetup) { + this.logger.debug('Setting up Workspaces service'); + + this.client = new WorkspaceClientWithSavedObject(core); + + await this.client.setup(core); + + registerRoutes({ + http: core.http, + logger: this.logger, + client: this.client as IWorkspaceDBImpl, + }); + + return { + client: this.client, + }; + } + + public start() { + this.logger.debug('Starting Workspace service'); + + return { + client: this.client as IWorkspaceDBImpl, + }; + } + + public stop() {} +} diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts new file mode 100644 index 000000000000..39cc5877b469 --- /dev/null +++ b/src/plugins/workspace/server/routes/index.ts @@ -0,0 +1,181 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { schema } from '@osd/config-schema'; + +import { CoreSetup, Logger } from '../../../../core/server'; +import { IWorkspaceDBImpl } from '../types'; + +const WORKSPACES_API_BASE_URL = '/api/workspaces'; + +const workspaceAttributesSchema = schema.object({ + description: schema.maybe(schema.string()), + name: schema.string(), + features: schema.maybe(schema.arrayOf(schema.string())), + color: schema.maybe(schema.string()), + icon: schema.maybe(schema.string()), + defaultVISTheme: schema.maybe(schema.string()), +}); + +export function registerRoutes({ + client, + logger, + http, +}: { + client: IWorkspaceDBImpl; + logger: Logger; + http: CoreSetup['http']; +}) { + const router = http.createRouter(); + router.post( + { + path: `${WORKSPACES_API_BASE_URL}/_list`, + validate: { + body: schema.object({ + search: schema.maybe(schema.string()), + sortOrder: schema.maybe(schema.string()), + perPage: schema.number({ min: 0, defaultValue: 20 }), + page: schema.number({ min: 0, defaultValue: 1 }), + sortField: schema.maybe(schema.string()), + searchFields: schema.maybe(schema.arrayOf(schema.string())), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const result = await client.list( + { + context, + request: req, + logger, + }, + req.body + ); + if (!result.success) { + return res.ok({ body: result }); + } + return res.ok({ + body: { + ...result, + result: { + ...result.result, + workspaces: result.result.workspaces.map((workspace) => ({ + ...workspace, + })), + }, + }, + }); + }) + ); + router.get( + { + path: `${WORKSPACES_API_BASE_URL}/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { id } = req.params; + const result = await client.get( + { + context, + request: req, + logger, + }, + id + ); + if (!result.success) { + return res.ok({ body: result }); + } + + return res.ok({ + body: { + ...result, + result: { + ...result.result, + }, + }, + }); + }) + ); + router.post( + { + path: `${WORKSPACES_API_BASE_URL}`, + validate: { + body: schema.object({ + attributes: workspaceAttributesSchema, + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { attributes } = req.body; + + const result = await client.create( + { + context, + request: req, + logger, + }, + { + ...attributes, + } + ); + return res.ok({ body: result }); + }) + ); + router.put( + { + path: `${WORKSPACES_API_BASE_URL}/{id?}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + attributes: workspaceAttributesSchema, + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { id } = req.params; + const { attributes } = req.body; + + const result = await client.update( + { + context, + request: req, + logger, + }, + id, + { + ...attributes, + } + ); + return res.ok({ body: result }); + }) + ); + router.delete( + { + path: `${WORKSPACES_API_BASE_URL}/{id?}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { id } = req.params; + + const result = await client.delete( + { + context, + request: req, + logger, + }, + id + ); + return res.ok({ body: result }); + }) + ); +} diff --git a/src/plugins/workspace/server/saved_objects/index.ts b/src/plugins/workspace/server/saved_objects/index.ts new file mode 100644 index 000000000000..51653c50681e --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { workspace } from './workspace'; diff --git a/src/plugins/workspace/server/saved_objects/workspace.ts b/src/plugins/workspace/server/saved_objects/workspace.ts new file mode 100644 index 000000000000..5142185b0c2d --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/workspace.ts @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsType, WORKSPACE_TYPE } from '../../../../core/server'; + +export const workspace: SavedObjectsType = { + name: WORKSPACE_TYPE, + namespaceType: 'agnostic', + hidden: false, + /** + * workspace won't appear in management page. + */ + mappings: { + dynamic: false, + properties: { + name: { + type: 'keyword', + }, + description: { + type: 'text', + }, + /** + * In opensearch, string[] is also mapped to text + */ + features: { + type: 'text', + }, + color: { + type: 'text', + }, + icon: { + type: 'text', + }, + defaultVISTheme: { + type: 'text', + }, + }, + }, +}; diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts new file mode 100644 index 000000000000..d57cecd4a3c9 --- /dev/null +++ b/src/plugins/workspace/server/types.ts @@ -0,0 +1,65 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { + Logger, + OpenSearchDashboardsRequest, + RequestHandlerContext, + SavedObjectsFindResponse, + CoreSetup, + WorkspaceAttribute, + ISavedObjectsRepository, +} from '../../../core/server'; + +export interface WorkspaceFindOptions { + page?: number; + perPage?: number; + search?: string; + searchFields?: string[]; + sortField?: string; + sortOrder?: string; +} + +export interface IRequestDetail { + request: OpenSearchDashboardsRequest; + context: RequestHandlerContext; + logger: Logger; +} + +export interface IWorkspaceDBImpl { + setup(dep: CoreSetup): Promise>; + setInternalRepository(repository: ISavedObjectsRepository): void; + create( + requestDetail: IRequestDetail, + payload: Omit + ): Promise>; + list( + requestDetail: IRequestDetail, + options: WorkspaceFindOptions + ): Promise< + IResponse< + { + workspaces: WorkspaceAttribute[]; + } & Pick + > + >; + get(requestDetail: IRequestDetail, id: string): Promise>; + update( + requestDetail: IRequestDetail, + id: string, + payload: Omit + ): Promise>; + delete(requestDetail: IRequestDetail, id: string): Promise>; + destroy(): Promise>; +} + +export type IResponse = + | { + result: T; + success: true; + } + | { + success: false; + error?: string; + }; diff --git a/src/plugins/workspace/server/utils.test.ts b/src/plugins/workspace/server/utils.test.ts new file mode 100644 index 000000000000..119b8889f715 --- /dev/null +++ b/src/plugins/workspace/server/utils.test.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { generateRandomId } from './utils'; + +describe('workspace utils', () => { + it('should generate id with the specified size', () => { + expect(generateRandomId(6)).toHaveLength(6); + }); + + it('should generate random IDs', () => { + const NUM_OF_ID = 10000; + const ids = new Set(); + for (let i = 0; i < NUM_OF_ID; i++) { + ids.add(generateRandomId(6)); + } + expect(ids.size).toBe(NUM_OF_ID); + }); +}); diff --git a/src/plugins/workspace/server/utils.ts b/src/plugins/workspace/server/utils.ts new file mode 100644 index 000000000000..89bfabd52657 --- /dev/null +++ b/src/plugins/workspace/server/utils.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import crypto from 'crypto'; + +/** + * Generate URL friendly random ID + */ +export const generateRandomId = (size: number) => { + return crypto.randomBytes(size).toString('base64url').slice(0, size); +}; diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts new file mode 100644 index 000000000000..ec49daf83b6a --- /dev/null +++ b/src/plugins/workspace/server/workspace_client.ts @@ -0,0 +1,193 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { i18n } from '@osd/i18n'; +import type { + SavedObject, + SavedObjectsClientContract, + CoreSetup, + WorkspaceAttribute, + ISavedObjectsRepository, +} from '../../../core/server'; +import { WORKSPACE_TYPE } from '../../../core/server'; +import { IWorkspaceDBImpl, WorkspaceFindOptions, IResponse, IRequestDetail } from './types'; +import { workspace } from './saved_objects'; +import { generateRandomId } from './utils'; + +const WORKSPACE_ID_SIZE = 6; + +const DUPLICATE_WORKSPACE_NAME_ERROR = i18n.translate('workspace.duplicate.name.error', { + defaultMessage: 'workspace name has already been used, try with a different name', +}); + +export class WorkspaceClientWithSavedObject implements IWorkspaceDBImpl { + private setupDep: CoreSetup; + + private internalSavedObjectsRepository?: ISavedObjectsRepository; + setInternalRepository(repository: ISavedObjectsRepository) { + this.internalSavedObjectsRepository = repository; + } + + constructor(core: CoreSetup) { + this.setupDep = core; + } + private getSavedObjectClientsFromRequestDetail( + requestDetail: IRequestDetail + ): SavedObjectsClientContract { + return requestDetail.context.core.savedObjects.client; + } + private getFlattenedResultWithSavedObject( + savedObject: SavedObject + ): WorkspaceAttribute { + return { + ...savedObject.attributes, + id: savedObject.id, + }; + } + private formatError(error: Error | any): string { + return error.message || error.error || 'Error'; + } + public async setup(core: CoreSetup): Promise> { + this.setupDep.savedObjects.registerType(workspace); + return { + success: true, + result: true, + }; + } + public async create( + requestDetail: IRequestDetail, + payload: Omit + ): ReturnType { + try { + const attributes = payload; + const id = generateRandomId(WORKSPACE_ID_SIZE); + const client = this.getSavedObjectClientsFromRequestDetail(requestDetail); + const existingWorkspaceRes = await this.internalSavedObjectsRepository?.find({ + type: WORKSPACE_TYPE, + search: attributes.name, + searchFields: ['name'], + }); + if (existingWorkspaceRes && existingWorkspaceRes.total > 0) { + throw new Error(DUPLICATE_WORKSPACE_NAME_ERROR); + } + const result = await client.create>( + WORKSPACE_TYPE, + attributes, + { + id, + } + ); + return { + success: true, + result: { + id: result.id, + }, + }; + } catch (e: unknown) { + return { + success: false, + error: this.formatError(e), + }; + } + } + public async list( + requestDetail: IRequestDetail, + options: WorkspaceFindOptions + ): ReturnType { + try { + const { + saved_objects: savedObjects, + ...others + } = await this.getSavedObjectClientsFromRequestDetail(requestDetail).find( + { + ...options, + type: WORKSPACE_TYPE, + } + ); + return { + success: true, + result: { + ...others, + workspaces: savedObjects.map((item) => this.getFlattenedResultWithSavedObject(item)), + }, + }; + } catch (e: unknown) { + return { + success: false, + error: this.formatError(e), + }; + } + } + public async get( + requestDetail: IRequestDetail, + id: string + ): Promise> { + try { + const result = await this.getSavedObjectClientsFromRequestDetail(requestDetail).get< + WorkspaceAttribute + >(WORKSPACE_TYPE, id); + return { + success: true, + result: this.getFlattenedResultWithSavedObject(result), + }; + } catch (e: unknown) { + return { + success: false, + error: this.formatError(e), + }; + } + } + public async update( + requestDetail: IRequestDetail, + id: string, + payload: Omit + ): Promise> { + const attributes = payload; + try { + const client = this.getSavedObjectClientsFromRequestDetail(requestDetail); + const workspaceInDB: SavedObject = await client.get(WORKSPACE_TYPE, id); + if (workspaceInDB.attributes.name !== attributes.name) { + const existingWorkspaceRes = await this.internalSavedObjectsRepository?.find({ + type: WORKSPACE_TYPE, + search: attributes.name, + searchFields: ['name'], + fields: ['_id'], + }); + if (existingWorkspaceRes && existingWorkspaceRes.total > 0) { + throw new Error(DUPLICATE_WORKSPACE_NAME_ERROR); + } + } + await client.update>(WORKSPACE_TYPE, id, attributes, {}); + return { + success: true, + result: true, + }; + } catch (e: unknown) { + return { + success: false, + error: this.formatError(e), + }; + } + } + public async delete(requestDetail: IRequestDetail, id: string): Promise> { + try { + await this.getSavedObjectClientsFromRequestDetail(requestDetail).delete(WORKSPACE_TYPE, id); + return { + success: true, + result: true, + }; + } catch (e: unknown) { + return { + success: false, + error: this.formatError(e), + }; + } + } + public async destroy(): Promise> { + return { + success: true, + result: true, + }; + } +} From f1eb76281e8fc101c9c47c35ba896f3550113cfd Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Thu, 14 Sep 2023 17:46:53 +0800 Subject: [PATCH 02/21] [Workspace] Add ACL related functions for workspace (#146) * [Workspace] Add acl related functions for workspace Signed-off-by: gaobinlong * Minor change Signed-off-by: gaobinlong --------- Signed-off-by: gaobinlong --- .../build_active_mappings.test.ts.snap | 110 ++++++++ .../migrations/core/build_active_mappings.ts | 20 ++ .../migrations/core/index_migrator.test.ts | 105 ++++++++ ...pensearch_dashboards_migrator.test.ts.snap | 55 ++++ .../permission_control/acl.test.ts | 166 ++++++++++++ .../saved_objects/permission_control/acl.ts | 249 ++++++++++++++++++ 6 files changed, 705 insertions(+) create mode 100644 src/core/server/saved_objects/permission_control/acl.test.ts create mode 100644 src/core/server/saved_objects/permission_control/acl.ts diff --git a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap index f8ef47cae894..6f67893104e7 100644 --- a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap +++ b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap @@ -10,6 +10,7 @@ Object { "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", + "permissions": "07c04cdd060494956fdddaa7ef86e8ac", "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", @@ -36,6 +37,60 @@ Object { "originId": Object { "type": "keyword", }, + "permissions": Object { + "properties": Object { + "library_read": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "library_write": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "management": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "read": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "write": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + }, + }, "references": Object { "properties": Object { "id": Object { @@ -69,6 +124,7 @@ Object { "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", + "permissions": "07c04cdd060494956fdddaa7ef86e8ac", "references": "7997cf5a56cc02bdc9c93361bde732b0", "secondType": "72d57924f415fbadb3ee293b67d233ab", "thirdType": "510f1f0adb69830cf8a1c5ce2923ed82", @@ -99,6 +155,60 @@ Object { "originId": Object { "type": "keyword", }, + "permissions": Object { + "properties": Object { + "library_read": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "library_write": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "management": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "read": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "write": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + }, + }, "references": Object { "properties": Object { "id": Object { diff --git a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts index bf377a13a42e..02dc13b2cd3f 100644 --- a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts +++ b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts @@ -36,6 +36,7 @@ import crypto from 'crypto'; import { cloneDeep, mapValues } from 'lodash'; import { IndexMapping, + SavedObjectsFieldMapping, SavedObjectsMappingProperties, SavedObjectsTypeMappingDefinitions, } from './../../mappings'; @@ -137,6 +138,16 @@ function findChangedProp(actual: any, expected: any) { * @returns {IndexMapping} */ function defaultMapping(): IndexMapping { + const principals: SavedObjectsFieldMapping = { + properties: { + users: { + type: 'keyword', + }, + groups: { + type: 'keyword', + }, + }, + }; return { dynamic: 'strict', properties: { @@ -175,6 +186,15 @@ function defaultMapping(): IndexMapping { }, }, }, + permissions: { + properties: { + read: principals, + write: principals, + management: principals, + library_read: principals, + library_write: principals, + }, + }, }, }; } diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index 4bacfda3bd5a..70f96c2e4daf 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -79,6 +79,7 @@ describe('IndexMigrator', () => { namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', + permissions: '07c04cdd060494956fdddaa7ef86e8ac', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -92,6 +93,40 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + permissions: { + properties: { + library_read: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + library_write: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + management: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + read: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + write: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + }, + }, references: { type: 'nested', properties: { @@ -196,6 +231,7 @@ describe('IndexMigrator', () => { namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', + permissions: '07c04cdd060494956fdddaa7ef86e8ac', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -210,6 +246,40 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + permissions: { + properties: { + library_read: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + library_write: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + management: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + read: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + write: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + }, + }, references: { type: 'nested', properties: { @@ -257,6 +327,7 @@ describe('IndexMigrator', () => { namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', + permissions: '07c04cdd060494956fdddaa7ef86e8ac', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -271,6 +342,40 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + permissions: { + properties: { + library_read: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + library_write: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + management: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + read: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + write: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + }, + }, references: { type: 'nested', properties: { diff --git a/src/core/server/saved_objects/migrations/opensearch_dashboards/__snapshots__/opensearch_dashboards_migrator.test.ts.snap b/src/core/server/saved_objects/migrations/opensearch_dashboards/__snapshots__/opensearch_dashboards_migrator.test.ts.snap index baebb7848798..5e39af788d79 100644 --- a/src/core/server/saved_objects/migrations/opensearch_dashboards/__snapshots__/opensearch_dashboards_migrator.test.ts.snap +++ b/src/core/server/saved_objects/migrations/opensearch_dashboards/__snapshots__/opensearch_dashboards_migrator.test.ts.snap @@ -10,6 +10,7 @@ Object { "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", + "permissions": "07c04cdd060494956fdddaa7ef86e8ac", "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", @@ -44,6 +45,60 @@ Object { "originId": Object { "type": "keyword", }, + "permissions": Object { + "properties": Object { + "library_read": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "library_write": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "management": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "read": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "write": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + }, + }, "references": Object { "properties": Object { "id": Object { diff --git a/src/core/server/saved_objects/permission_control/acl.test.ts b/src/core/server/saved_objects/permission_control/acl.test.ts new file mode 100644 index 000000000000..057d294c3637 --- /dev/null +++ b/src/core/server/saved_objects/permission_control/acl.test.ts @@ -0,0 +1,166 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Principals, Permissions, ACL } from './acl'; + +describe('SavedObjectTypeRegistry', () => { + it('test has permission', () => { + const principals: Principals = { + users: ['user1'], + groups: [], + }; + const permissions: Permissions = { + read: principals, + }; + const acl = new ACL(permissions); + expect( + acl.hasPermission(['read'], { + users: ['user1'], + groups: [], + }) + ).toEqual(true); + expect( + acl.hasPermission(['read'], { + users: ['user2'], + groups: [], + }) + ).toEqual(false); + }); + + it('test add permission', () => { + const acl = new ACL(); + const result1 = acl + .addPermission(['read'], { + users: ['user1'], + groups: [], + }) + .getPermissions(); + expect(result1?.read?.users).toEqual(['user1']); + + acl.resetPermissions(); + const result2 = acl + .addPermission(['write', 'management'], { + users: ['user2'], + groups: ['group1', 'group2'], + }) + .getPermissions(); + expect(result2?.write?.users).toEqual(['user2']); + expect(result2?.management?.groups).toEqual(['group1', 'group2']); + }); + + it('test remove permission', () => { + const principals1: Principals = { + users: ['user1'], + groups: ['group1', 'group2'], + }; + const permissions1 = { + read: principals1, + write: principals1, + }; + const acl1 = new ACL(permissions1); + const result1 = acl1 + .removePermission(['read'], { + users: ['user1'], + groups: [], + }) + .removePermission(['write'], { + users: [], + groups: ['group2'], + }) + .getPermissions(); + expect(result1?.read?.users).toEqual([]); + expect(result1?.write?.groups).toEqual(['group1']); + + const principals2: Principals = { + users: ['*'], + groups: ['*'], + }; + + const permissions2 = { + read: principals2, + write: principals2, + }; + + const acl2 = new ACL(permissions2); + const result2 = acl2 + .removePermission(['read', 'write'], { + users: ['user1'], + groups: ['group1'], + }) + .getPermissions(); + expect(result2?.read?.users).toEqual(['*']); + expect(result2?.write?.groups).toEqual(['*']); + }); + + it('test transform permission', () => { + const principals: Principals = { + users: ['user1'], + groups: ['group1', 'group2'], + }; + const permissions = { + read: principals, + write: principals, + }; + const acl = new ACL(permissions); + const result = acl.toFlatList(); + expect(result).toHaveLength(3); + expect(result).toEqual( + expect.arrayContaining([{ type: 'users', name: 'user1', permissions: ['read', 'write'] }]) + ); + expect(result).toEqual( + expect.arrayContaining([{ type: 'groups', name: 'group1', permissions: ['read', 'write'] }]) + ); + expect(result).toEqual( + expect.arrayContaining([{ type: 'groups', name: 'group2', permissions: ['read', 'write'] }]) + ); + }); + + it('test generate query DSL', () => { + const principals = { + users: ['user1'], + groups: ['group1'], + }; + const result = ACL.generateGetPermittedSavedObjectsQueryDSL(['read'], principals, 'workspace'); + expect(result).toEqual({ + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + terms: { + 'permissions.read.users': ['user1'], + }, + }, + { + term: { + 'permissions.read.users': '*', + }, + }, + { + terms: { + 'permissions.read.groups': ['group1'], + }, + }, + { + term: { + 'permissions.read.groups': '*', + }, + }, + ], + }, + }, + { + terms: { + type: ['workspace'], + }, + }, + ], + }, + }, + }); + }); +}); diff --git a/src/core/server/saved_objects/permission_control/acl.ts b/src/core/server/saved_objects/permission_control/acl.ts new file mode 100644 index 000000000000..1631b0cbef46 --- /dev/null +++ b/src/core/server/saved_objects/permission_control/acl.ts @@ -0,0 +1,249 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum PrincipalType { + Users = 'users', + Groups = 'groups', +} + +export interface Principals { + users?: string[]; + groups?: string[]; +} + +export type Permissions = Record; + +export interface TransformedPermission { + type: string; + name: string; + permissions: string[]; +} + +const addToPrincipals = (principals?: Principals, users?: string[], groups?: string[]) => { + if (!principals) { + principals = {}; + } + if (!!users) { + if (!principals.users) { + principals.users = []; + } + principals.users = Array.from(new Set([...principals.users, ...users])); + } + if (!!groups) { + if (!principals.groups) { + principals.groups = []; + } + principals.groups = Array.from(new Set([...principals.groups, ...groups])); + } + return principals; +}; + +const deleteFromPrincipals = (principals?: Principals, users?: string[], groups?: string[]) => { + if (!principals) { + return principals; + } + if (!!users && !!principals.users) { + principals.users = principals.users.filter((item) => !users.includes(item)); + } + if (!!groups && !!principals.groups) { + principals.groups = principals.groups.filter((item) => !groups.includes(item)); + } + return principals; +}; + +const checkPermission = (currentPrincipals: Principals | undefined, principals: Principals) => { + return ( + (currentPrincipals?.users && + principals?.users && + checkPermissionForSinglePrincipalType(currentPrincipals.users, principals.users)) || + (currentPrincipals?.groups && + principals.groups && + checkPermissionForSinglePrincipalType(currentPrincipals.groups, principals.groups)) + ); +}; + +const checkPermissionForSinglePrincipalType = ( + currentPrincipalArray: string[], + principalArray: string[] +) => { + return ( + currentPrincipalArray && + principalArray && + (currentPrincipalArray.includes('*') || + principalArray.some((item) => currentPrincipalArray.includes(item))) + ); +}; + +export class ACL { + private permissions?: Permissions; + constructor(initialPermissions?: Permissions) { + this.permissions = initialPermissions || {}; + } + + // parse the permissions object to check whether the specific principal has the specific permission types or not + public hasPermission(permissionTypes: string[], principals: Principals) { + if (!permissionTypes || permissionTypes.length === 0 || !this.permissions || !principals) { + return false; + } + + const currentPermissions = this.permissions; + return permissionTypes.some((permissionType) => + checkPermission(currentPermissions[permissionType], principals) + ); + } + + // permissions object build function, add principal with specific permission to the object + public addPermission(permissionTypes: string[], principals: Principals) { + if (!permissionTypes || !principals) { + return this; + } + if (!this.permissions) { + this.permissions = {}; + } + + for (const permissionType of permissionTypes) { + this.permissions[permissionType] = addToPrincipals( + this.permissions[permissionType], + principals.users, + principals.groups + ); + } + + return this; + } + + // permissions object build function, remove specific permission of specific principal from the object + public removePermission(permissionTypes: string[], principals: Principals) { + if (!permissionTypes || !principals) { + return this; + } + if (!this.permissions) { + this.permissions = {}; + } + + for (const permissionType of permissionTypes) { + const result = deleteFromPrincipals( + this.permissions![permissionType], + principals.users, + principals.groups + ); + if (result) { + this.permissions[permissionType] = result; + } + } + + return this; + } + + /** + * transform permissions format + * original permissions: { + * read: { + * users:['user1'] + * }, + * write:{ + * groups:['group1'] + * } + * } + * + * transformed permissions: [ + * {type:'users',name:'user1',permissions:['read']}, + * {type:'groups',name:'group1',permissions:['write']}, + * ] + */ + public toFlatList(): TransformedPermission[] { + const result: TransformedPermission[] = []; + if (!this.permissions) { + return result; + } + + for (const permissionType in this.permissions) { + if (Object.prototype.hasOwnProperty.call(this.permissions, permissionType)) { + const { users = [], groups = [] } = this.permissions[permissionType] ?? {}; + users.forEach((user) => { + const found = result.find((r) => r.type === PrincipalType.Users && r.name === user); + if (found) { + found.permissions.push(permissionType); + } else { + result.push({ type: PrincipalType.Users, name: user, permissions: [permissionType] }); + } + }); + groups.forEach((group) => { + const found = result.find((r) => r.type === PrincipalType.Groups && r.name === group); + if (found) { + found.permissions.push(permissionType); + } else { + result.push({ type: PrincipalType.Groups, name: group, permissions: [permissionType] }); + } + }); + } + } + + return result; + } + + public resetPermissions() { + // reset permissions + this.permissions = {}; + } + + // return the permissions object + public getPermissions() { + return this.permissions; + } + + /** + * generate query DSL by the specific conditions, used for fetching saved objects from the saved objects index + */ + public static generateGetPermittedSavedObjectsQueryDSL( + permissionTypes: string[], + principals: Principals, + savedObjectType?: string | string[] + ) { + if (!principals || !permissionTypes) { + return { + query: { + match_none: {}, + }, + }; + } + + const bool: any = { + filter: [], + }; + const subBool: any = { + should: [], + }; + + permissionTypes.forEach((permissionType) => { + Object.entries(principals).forEach(([principalType, principalsInCurrentType]) => { + subBool.should.push({ + terms: { + ['permissions.' + permissionType + `.${principalType}`]: principalsInCurrentType, + }, + }); + subBool.should.push({ + term: { + ['permissions.' + permissionType + `.${principalType}`]: '*', + }, + }); + }); + }); + + bool.filter.push({ + bool: subBool, + }); + + if (!!savedObjectType) { + bool.filter.push({ + terms: { + type: Array.isArray(savedObjectType) ? savedObjectType : [savedObjectType], + }, + }); + } + + return { query: { bool } }; + } +} From d7d712e350922e00a9efbe309bbed02f4463374c Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Tue, 19 Sep 2023 17:21:04 +0800 Subject: [PATCH 03/21] feat: add core workspace module (#145) The core workspace module(WorkspaceService) is a foundational component that enables the implementation of workspace features within OSD plugins. The purpose of the core workspace module is to provide a framework for workspace implementations. This module does not implement specific workspace functionality but provides the essential infrastructure for plugins to extend and customize workspace features, it maintains a shared workspace state(observables) across the entire application to ensure a consistent and up-to-date view of workspace-related information to all parts of the application. --------- Signed-off-by: Yulong Ruan --- src/core/public/application/types.ts | 3 + src/core/public/core_system.ts | 8 + src/core/public/index.ts | 13 + src/core/public/mocks.ts | 4 + src/core/public/plugins/plugin_context.ts | 2 + .../public/plugins/plugins_service.test.ts | 3 + src/core/public/workspace/index.ts | 10 + .../workspace/workspaces_service.mock.ts | 36 +++ .../public/workspace/workspaces_service.ts | 134 ++++++++ src/core/types/workspace.ts | 1 + .../dashboard_listing.test.tsx.snap | 240 +++++++++++++++ .../dashboard_top_nav.test.tsx.snap | 288 ++++++++++++++++++ 12 files changed, 742 insertions(+) create mode 100644 src/core/public/workspace/index.ts create mode 100644 src/core/public/workspace/workspaces_service.mock.ts create mode 100644 src/core/public/workspace/workspaces_service.ts diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 7398aad65009..4744ab34cfd3 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -47,6 +47,7 @@ import { IUiSettingsClient } from '../ui_settings'; import { SavedObjectsStart } from '../saved_objects'; import { AppCategory } from '../../types'; import { ScopedHistory } from './scoped_history'; +import { WorkspacesStart } from '../workspace'; /** * Accessibility status of an application. @@ -334,6 +335,8 @@ export interface AppMountContext { injectedMetadata: { getInjectedVar: (name: string, defaultValue?: any) => unknown; }; + /** {@link WorkspacesService} */ + workspaces: WorkspacesStart; }; } diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 8fe5a36ebb55..d7de8b4595d5 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -54,6 +54,7 @@ import { ContextService } from './context'; import { IntegrationsService } from './integrations'; import { CoreApp } from './core_app'; import type { InternalApplicationSetup, InternalApplicationStart } from './application/types'; +import { WorkspacesService } from './workspace'; interface Params { rootDomElement: HTMLElement; @@ -110,6 +111,7 @@ export class CoreSystem { private readonly rootDomElement: HTMLElement; private readonly coreContext: CoreContext; + private readonly workspaces: WorkspacesService; private fatalErrorsSetup: FatalErrorsSetup | null = null; constructor(params: Params) { @@ -138,6 +140,7 @@ export class CoreSystem { this.rendering = new RenderingService(); this.application = new ApplicationService(); this.integrations = new IntegrationsService(); + this.workspaces = new WorkspacesService(); this.coreContext = { coreId: Symbol('core'), env: injectedMetadata.env }; @@ -160,6 +163,7 @@ export class CoreSystem { const http = this.http.setup({ injectedMetadata, fatalErrors: this.fatalErrorsSetup }); const uiSettings = this.uiSettings.setup({ http, injectedMetadata }); const notifications = this.notifications.setup({ uiSettings }); + const workspaces = this.workspaces.setup(); const pluginDependencies = this.plugins.getOpaqueIds(); const context = this.context.setup({ @@ -176,6 +180,7 @@ export class CoreSystem { injectedMetadata, notifications, uiSettings, + workspaces, }; // Services that do not expose contracts at setup @@ -220,6 +225,7 @@ export class CoreSystem { targetDomElement: notificationsTargetDomElement, }); const application = await this.application.start({ http, overlays }); + const workspaces = this.workspaces.start({ application, http }); const chrome = await this.chrome.start({ application, docLinks, @@ -242,6 +248,7 @@ export class CoreSystem { overlays, savedObjects, uiSettings, + workspaces, })); const core: InternalCoreStart = { @@ -256,6 +263,7 @@ export class CoreSystem { overlays, uiSettings, fatalErrors, + workspaces, }; await this.plugins.start(core); diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 03ef6b6392f9..14ab91e1cb13 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -87,6 +87,7 @@ import { HandlerParameters, } from './context'; import { Branding } from '../types'; +import { WorkspacesStart, WorkspacesSetup } from './workspace'; export type { Logos } from '../common'; export { PackageInfo, EnvironmentMode } from '../server/types'; @@ -102,6 +103,7 @@ export { StringValidation, StringValidationRegex, StringValidationRegexString, + WorkspaceAttribute, } from '../types'; export { @@ -239,6 +241,8 @@ export interface CoreSetup; + /** {@link WorkspacesSetup} */ + workspaces: WorkspacesSetup; } /** @@ -293,6 +297,8 @@ export interface CoreStart { getInjectedVar: (name: string, defaultValue?: any) => unknown; getBranding: () => Branding; }; + /** {@link WorkspacesStart} */ + workspaces: WorkspacesStart; } export { @@ -341,3 +347,10 @@ export { }; export { __osdBootstrap__ } from './osd_bootstrap'; + +export { + WorkspacesStart, + WorkspacesSetup, + WorkspacesService, + WorkspaceObservables, +} from './workspace'; diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index e863d627c801..722070d5a9ea 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -47,6 +47,7 @@ import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock'; import { contextServiceMock } from './context/context_service.mock'; import { injectedMetadataServiceMock } from './injected_metadata/injected_metadata_service.mock'; +import { workspacesServiceMock } from './workspace/workspaces_service.mock'; export { chromeServiceMock } from './chrome/chrome_service.mock'; export { docLinksServiceMock } from './doc_links/doc_links_service.mock'; @@ -60,6 +61,7 @@ export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; export { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock'; export { scopedHistoryMock } from './application/scoped_history.mock'; export { applicationServiceMock } from './application/application_service.mock'; +export { workspacesServiceMock } from './workspace/workspaces_service.mock'; function createCoreSetupMock({ basePath = '', @@ -85,6 +87,7 @@ function createCoreSetupMock({ getInjectedVar: injectedMetadataServiceMock.createSetupContract().getInjectedVar, getBranding: injectedMetadataServiceMock.createSetupContract().getBranding, }, + workspaces: workspacesServiceMock.createSetupContractMock(), }; return mock; @@ -106,6 +109,7 @@ function createCoreStartMock({ basePath = '' } = {}) { getBranding: injectedMetadataServiceMock.createStartContract().getBranding, }, fatalErrors: fatalErrorsServiceMock.createStartContract(), + workspaces: workspacesServiceMock.createStartContract(), }; return mock; diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index 42c40e91183f..87738fc7e57a 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -121,6 +121,7 @@ export function createPluginSetupContext< getBranding: deps.injectedMetadata.getBranding, }, getStartServices: () => plugin.startDependencies, + workspaces: deps.workspaces, }; } @@ -168,5 +169,6 @@ export function createPluginStartContext< getBranding: deps.injectedMetadata.getBranding, }, fatalErrors: deps.fatalErrors, + workspaces: deps.workspaces, }; } diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts index b2cf4e8880cf..7e8c96c1f9d0 100644 --- a/src/core/public/plugins/plugins_service.test.ts +++ b/src/core/public/plugins/plugins_service.test.ts @@ -58,6 +58,7 @@ import { CoreSetup, CoreStart, PluginInitializerContext } from '..'; import { docLinksServiceMock } from '../doc_links/doc_links_service.mock'; import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service.mock'; import { contextServiceMock } from '../context/context_service.mock'; +import { workspacesServiceMock } from '../workspace/workspaces_service.mock'; export let mockPluginInitializers: Map; @@ -108,6 +109,7 @@ describe('PluginsService', () => { injectedMetadata: injectedMetadataServiceMock.createStartContract(), notifications: notificationServiceMock.createSetupContract(), uiSettings: uiSettingsServiceMock.createSetupContract(), + workspaces: workspacesServiceMock.createSetupContractMock(), }; mockSetupContext = { ...mockSetupDeps, @@ -127,6 +129,7 @@ describe('PluginsService', () => { uiSettings: uiSettingsServiceMock.createStartContract(), savedObjects: savedObjectsServiceMock.createStartContract(), fatalErrors: fatalErrorsServiceMock.createStartContract(), + workspaces: workspacesServiceMock.createStartContract(), }; mockStartContext = { ...mockStartDeps, diff --git a/src/core/public/workspace/index.ts b/src/core/public/workspace/index.ts new file mode 100644 index 000000000000..4ef6aaae7fd4 --- /dev/null +++ b/src/core/public/workspace/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +export { + WorkspacesStart, + WorkspacesService, + WorkspacesSetup, + WorkspaceObservables, +} from './workspaces_service'; diff --git a/src/core/public/workspace/workspaces_service.mock.ts b/src/core/public/workspace/workspaces_service.mock.ts new file mode 100644 index 000000000000..3c35315aa850 --- /dev/null +++ b/src/core/public/workspace/workspaces_service.mock.ts @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject } from 'rxjs'; +import { WorkspaceAttribute } from '..'; + +const currentWorkspaceId$ = new BehaviorSubject(''); +const workspaceList$ = new BehaviorSubject([]); +const currentWorkspace$ = new BehaviorSubject(null); +const initialized$ = new BehaviorSubject(false); +const workspaceEnabled$ = new BehaviorSubject(false); + +const createWorkspacesSetupContractMock = () => ({ + currentWorkspaceId$, + workspaceList$, + currentWorkspace$, + initialized$, + workspaceEnabled$, + registerWorkspaceMenuRender: jest.fn(), +}); + +const createWorkspacesStartContractMock = () => ({ + currentWorkspaceId$, + workspaceList$, + currentWorkspace$, + initialized$, + workspaceEnabled$, + renderWorkspaceMenu: jest.fn(), +}); + +export const workspacesServiceMock = { + createSetupContractMock: createWorkspacesSetupContractMock, + createStartContract: createWorkspacesStartContractMock, +}; diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts new file mode 100644 index 000000000000..39519ccdddbe --- /dev/null +++ b/src/core/public/workspace/workspaces_service.ts @@ -0,0 +1,134 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject, combineLatest } from 'rxjs'; +import { isEqual } from 'lodash'; + +import { CoreService, WorkspaceAttribute } from '../../types'; +import { InternalApplicationStart } from '../application'; +import { HttpSetup } from '../http'; + +type WorkspaceMenuRenderFn = ({ + basePath, + getUrlForApp, + observables, +}: { + getUrlForApp: InternalApplicationStart['getUrlForApp']; + basePath: HttpSetup['basePath']; + observables: WorkspaceObservables; +}) => JSX.Element | null; + +type WorkspaceObject = WorkspaceAttribute & { readonly?: boolean }; + +export interface WorkspaceObservables { + currentWorkspaceId$: BehaviorSubject; + currentWorkspace$: BehaviorSubject; + workspaceList$: BehaviorSubject; + workspaceEnabled$: BehaviorSubject; + initialized$: BehaviorSubject; +} + +enum WORKSPACE_ERROR { + WORKSPACE_STALED = 'WORKSPACE_STALED', +} + +/** + * @public + */ +export interface WorkspacesSetup extends WorkspaceObservables { + registerWorkspaceMenuRender: (render: WorkspaceMenuRenderFn) => void; +} + +export interface WorkspacesStart extends WorkspaceObservables { + renderWorkspaceMenu: () => JSX.Element | null; +} + +export class WorkspacesService implements CoreService { + private currentWorkspaceId$ = new BehaviorSubject(''); + private workspaceList$ = new BehaviorSubject([]); + private currentWorkspace$ = new BehaviorSubject(null); + private initialized$ = new BehaviorSubject(false); + private workspaceEnabled$ = new BehaviorSubject(false); + private _renderWorkspaceMenu: WorkspaceMenuRenderFn | null = null; + + constructor() { + combineLatest([this.initialized$, this.workspaceList$, this.currentWorkspaceId$]).subscribe( + ([workspaceInitialized, workspaceList, currentWorkspaceId]) => { + if (workspaceInitialized) { + const currentWorkspace = workspaceList.find((w) => w && w.id === currentWorkspaceId); + + /** + * Do a simple idempotent verification here + */ + if (!isEqual(currentWorkspace, this.currentWorkspace$.getValue())) { + this.currentWorkspace$.next(currentWorkspace ?? null); + } + + if (currentWorkspaceId && !currentWorkspace?.id) { + /** + * Current workspace is staled + */ + this.currentWorkspaceId$.error({ + reason: WORKSPACE_ERROR.WORKSPACE_STALED, + }); + this.currentWorkspace$.error({ + reason: WORKSPACE_ERROR.WORKSPACE_STALED, + }); + } + } + } + ); + } + + public setup(): WorkspacesSetup { + return { + currentWorkspaceId$: this.currentWorkspaceId$, + currentWorkspace$: this.currentWorkspace$, + workspaceList$: this.workspaceList$, + initialized$: this.initialized$, + workspaceEnabled$: this.workspaceEnabled$, + registerWorkspaceMenuRender: (render: WorkspaceMenuRenderFn) => + (this._renderWorkspaceMenu = render), + }; + } + + public start({ + http, + application, + }: { + application: InternalApplicationStart; + http: HttpSetup; + }): WorkspacesStart { + const observables = { + currentWorkspaceId$: this.currentWorkspaceId$, + currentWorkspace$: this.currentWorkspace$, + workspaceList$: this.workspaceList$, + initialized$: this.initialized$, + workspaceEnabled$: this.workspaceEnabled$, + }; + return { + ...observables, + renderWorkspaceMenu: () => { + if (this._renderWorkspaceMenu) { + return this._renderWorkspaceMenu({ + basePath: http.basePath, + getUrlForApp: application.getUrlForApp, + observables, + }); + } + return null; + }, + }; + } + + public async stop() { + this.currentWorkspace$.unsubscribe(); + this.currentWorkspaceId$.unsubscribe(); + this.workspaceList$.unsubscribe(); + this.workspaceEnabled$.unsubscribe(); + this.initialized$.unsubscribe(); + this._renderWorkspaceMenu = null; + } +} diff --git a/src/core/types/workspace.ts b/src/core/types/workspace.ts index 23c3b2038ff2..e99744183cac 100644 --- a/src/core/types/workspace.ts +++ b/src/core/types/workspace.ts @@ -11,4 +11,5 @@ export interface WorkspaceAttribute { color?: string; icon?: string; defaultVISTheme?: string; + reserved?: boolean; } diff --git a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap index 1b3c486615c8..d3d0fe94fb68 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -993,6 +993,54 @@ exports[`dashboard listing hideWriteControls 1`] = ` "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "renderWorkspaceMenu": [MockFunction], + "workspaceEnabled$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > @@ -2085,6 +2133,54 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "renderWorkspaceMenu": [MockFunction], + "workspaceEnabled$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > @@ -3238,6 +3334,54 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "renderWorkspaceMenu": [MockFunction], + "workspaceEnabled$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > @@ -4391,6 +4535,54 @@ exports[`dashboard listing renders table rows 1`] = ` "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "renderWorkspaceMenu": [MockFunction], + "workspaceEnabled$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > @@ -5544,6 +5736,54 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "renderWorkspaceMenu": [MockFunction], + "workspaceEnabled$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap index 101e0e520304..0c48ffdc474a 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap @@ -859,6 +859,54 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "renderWorkspaceMenu": [MockFunction], + "workspaceEnabled$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > @@ -1776,6 +1824,54 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "renderWorkspaceMenu": [MockFunction], + "workspaceEnabled$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > @@ -2693,6 +2789,54 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "renderWorkspaceMenu": [MockFunction], + "workspaceEnabled$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > @@ -3610,6 +3754,54 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "renderWorkspaceMenu": [MockFunction], + "workspaceEnabled$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > @@ -4527,6 +4719,54 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "renderWorkspaceMenu": [MockFunction], + "workspaceEnabled$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > @@ -5444,6 +5684,54 @@ exports[`Dashboard top nav render with all components 1`] = ` "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "renderWorkspaceMenu": [MockFunction], + "workspaceEnabled$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > From f630277ee6b5822cc5caa9469f75802bcda15e47 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Wed, 20 Sep 2023 17:37:45 +0800 Subject: [PATCH 04/21] use self hosted runner and disable windows workflow (#182) disable github workflows running on windows for development (#161) --------- Signed-off-by: Yulong Ruan Signed-off-by: SuZhou-Joe Co-authored-by: SuZhou-Joe --- .github/workflows/build_and_test_workflow.yml | 13 ++----------- .github/workflows/cypress_workflow.yml | 8 ++++---- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build_and_test_workflow.yml b/.github/workflows/build_and_test_workflow.yml index a9175ad23f8b..540d771e84bd 100644 --- a/.github/workflows/build_and_test_workflow.yml +++ b/.github/workflows/build_and_test_workflow.yml @@ -33,13 +33,11 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [arc-runner-set] group: [1, 2, 3, 4] include: - os: ubuntu-latest name: Linux - - os: windows-latest - name: Windows runs-on: ${{ matrix.os }} steps: - name: Configure git's autocrlf (Windows only) @@ -130,13 +128,11 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [arc-runner-set] group: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13] include: - os: ubuntu-latest name: Linux - - os: windows-latest - name: Windows runs-on: ${{ matrix.os }} steps: - run: echo Running functional tests for ciGroup${{ matrix.group }} @@ -227,11 +223,6 @@ jobs: ext: tar.gz suffix: linux-arm64 script: build-platform --linux-arm --skip-os-packages - - os: windows-latest - name: Windows x64 - ext: zip - suffix: windows-x64 - script: build-platform --windows --skip-os-packages runs-on: ${{ matrix.os }} defaults: run: diff --git a/.github/workflows/cypress_workflow.yml b/.github/workflows/cypress_workflow.yml index 5e78785f9b88..cb5af78fcfc5 100644 --- a/.github/workflows/cypress_workflow.yml +++ b/.github/workflows/cypress_workflow.yml @@ -19,7 +19,7 @@ env: jobs: cypress-tests: - runs-on: ubuntu-latest + runs-on: arc-runner-set container: image: docker://opensearchstaging/ci-runner:ci-runner-rockylinux8-opensearch-dashboards-integtest-v2 options: --user 1001 @@ -54,7 +54,7 @@ jobs: uses: actions/checkout@v2 with: path: ${{ env.FTR_PATH }} - repository: opensearch-project/opensearch-dashboards-functional-test + repository: ruanyl/opensearch-dashboards-functional-test ref: '${{ github.base_ref }}' - name: Get Cypress version @@ -88,7 +88,7 @@ jobs: name: ftr-cypress-screenshots path: ${{ env.FTR_PATH }}/cypress/screenshots retention-days: 1 - + - uses: actions/upload-artifact@v3 if: always() with: @@ -101,4 +101,4 @@ jobs: with: name: ftr-cypress-results path: ${{ env.FTR_PATH }}/cypress/results - retention-days: 1 \ No newline at end of file + retention-days: 1 From 855c3a8bfb2124a30684939e2b58128e912d62b0 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Fri, 22 Sep 2023 12:30:25 +0800 Subject: [PATCH 05/21] Patch/first pr (#194) * temp: add unit test Signed-off-by: SuZhou-Joe * feat: add function test for workspace CRUD routes Signed-off-by: SuZhou-Joe * feat: use saved objects client instead of internal repository Signed-off-by: SuZhou-Joe * feat: update CHANGELOG Signed-off-by: SuZhou-Joe * feat: exclude permission check wrapper Signed-off-by: SuZhou-Joe * feat: add integration test Signed-off-by: SuZhou-Joe * feat: add configuration Signed-off-by: SuZhou-Joe * feat: enable workspace flag when run workspace related test Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe --- CHANGELOG.md | 1 + src/plugins/workspace/common/constants.ts | 6 + src/plugins/workspace/config.ts | 12 ++ src/plugins/workspace/server/index.ts | 9 +- .../server/integration_tests/routes.test.ts | 146 ++++++++++++++++++ src/plugins/workspace/server/plugin.ts | 11 +- src/plugins/workspace/server/routes/index.ts | 10 +- .../server/saved_objects/workspace.ts | 11 +- src/plugins/workspace/server/types.ts | 4 +- .../workspace/server/workspace_client.ts | 37 +++-- test/api_integration/apis/index.js | 1 + test/api_integration/apis/workspace/index.ts | 132 ++++++++++++++++ 12 files changed, 350 insertions(+), 30 deletions(-) create mode 100644 src/plugins/workspace/common/constants.ts create mode 100644 src/plugins/workspace/config.ts create mode 100644 src/plugins/workspace/server/integration_tests/routes.test.ts create mode 100644 test/api_integration/apis/workspace/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 93c8227d9765..8abc72aeb779 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Adds Data explorer framework and implements Discover using it ([#4806](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4806)) - [Theme] Use themes' definitions to render the initial view ([#4936](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4936/)) - [Theme] Make `next` theme the default ([#4854](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4854/)) +- [Workspace] Setup workspace skeleton and implement basic CRUD API ([#5075](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5075/)) ### 🐛 Bug Fixes diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts new file mode 100644 index 000000000000..b6bd7b00f676 --- /dev/null +++ b/src/plugins/workspace/common/constants.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; diff --git a/src/plugins/workspace/config.ts b/src/plugins/workspace/config.ts new file mode 100644 index 000000000000..79412f5c02ee --- /dev/null +++ b/src/plugins/workspace/config.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema, TypeOf } from '@osd/config-schema'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), +}); + +export type ConfigSchema = TypeOf; diff --git a/src/plugins/workspace/server/index.ts b/src/plugins/workspace/server/index.ts index 936d3cf3ecec..9447b7c6dc8c 100644 --- a/src/plugins/workspace/server/index.ts +++ b/src/plugins/workspace/server/index.ts @@ -2,8 +2,9 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -import { PluginInitializerContext } from '../../../core/server'; +import { PluginConfigDescriptor, PluginInitializerContext } from '../../../core/server'; import { WorkspacePlugin } from './plugin'; +import { configSchema } from '../config'; // This exports static code and TypeScript types, // as well as, OpenSearch Dashboards Platform `plugin()` initializer. @@ -11,3 +12,9 @@ import { WorkspacePlugin } from './plugin'; export function plugin(initializerContext: PluginInitializerContext) { return new WorkspacePlugin(initializerContext); } + +export const config: PluginConfigDescriptor = { + schema: configSchema, +}; + +export { WorkspaceFindOptions } from './types'; diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts new file mode 100644 index 000000000000..e4d29b86ac55 --- /dev/null +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -0,0 +1,146 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WorkspaceAttribute } from 'src/core/types'; +import { omit } from 'lodash'; +import * as osdTestServer from '../../../../core/test_helpers/osd_server'; + +const testWorkspace: WorkspaceAttribute = { + id: 'fake_id', + name: 'test_workspace', + description: 'test_workspace_description', +}; + +describe('workspace service', () => { + let root: ReturnType; + let opensearchServer: osdTestServer.TestOpenSearchUtils; + beforeAll(async () => { + const { startOpenSearch, startOpenSearchDashboards } = osdTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + osd: { + workspace: { + enabled: true, + }, + }, + }, + }); + opensearchServer = await startOpenSearch(); + const startOSDResp = await startOpenSearchDashboards(); + root = startOSDResp.root; + }, 30000); + afterAll(async () => { + await root.shutdown(); + await opensearchServer.stop(); + }); + describe('Workspace CRUD apis', () => { + afterEach(async () => { + const listResult = await osdTestServer.request + .post(root, `/api/workspaces/_list`) + .send({ + page: 1, + }) + .expect(200); + await Promise.all( + listResult.body.result.workspaces.map((item: WorkspaceAttribute) => + osdTestServer.request.delete(root, `/api/workspaces/${item.id}`).expect(200) + ) + ); + }); + it('create', async () => { + await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: testWorkspace, + }) + .expect(400); + + const result: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .expect(200); + + expect(result.body.success).toEqual(true); + expect(typeof result.body.result.id).toBe('string'); + }); + it('get', async () => { + const result = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .expect(200); + + const getResult = await osdTestServer.request.get( + root, + `/api/workspaces/${result.body.result.id}` + ); + expect(getResult.body.result.name).toEqual(testWorkspace.name); + }); + it('update', async () => { + const result: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .expect(200); + + await osdTestServer.request + .put(root, `/api/workspaces/${result.body.result.id}`) + .send({ + attributes: { + ...omit(testWorkspace, 'id'), + name: 'updated', + }, + }) + .expect(200); + + const getResult = await osdTestServer.request.get( + root, + `/api/workspaces/${result.body.result.id}` + ); + + expect(getResult.body.success).toEqual(true); + expect(getResult.body.result.name).toEqual('updated'); + }); + it('delete', async () => { + const result: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .expect(200); + + await osdTestServer.request + .delete(root, `/api/workspaces/${result.body.result.id}`) + .expect(200); + + const getResult = await osdTestServer.request.get( + root, + `/api/workspaces/${result.body.result.id}` + ); + + expect(getResult.body.success).toEqual(false); + }); + it('list', async () => { + await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .expect(200); + + const listResult = await osdTestServer.request + .post(root, `/api/workspaces/_list`) + .send({ + page: 1, + }) + .expect(200); + expect(listResult.body.result.total).toEqual(1); + }); + }); +}); diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index 515ccc399f8e..568f536d65e8 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -2,7 +2,13 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -import { PluginInitializerContext, CoreSetup, Plugin, Logger } from '../../../core/server'; +import { + PluginInitializerContext, + CoreSetup, + Plugin, + Logger, + CoreStart, +} from '../../../core/server'; import { IWorkspaceDBImpl } from './types'; import { WorkspaceClientWithSavedObject } from './workspace_client'; import { registerRoutes } from './routes'; @@ -33,8 +39,9 @@ export class WorkspacePlugin implements Plugin<{}, {}> { }; } - public start() { + public start(core: CoreStart) { this.logger.debug('Starting Workspace service'); + this.client?.setSavedObjects(core.savedObjects); return { client: this.client as IWorkspaceDBImpl, diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts index 39cc5877b469..f968c853fc90 100644 --- a/src/plugins/workspace/server/routes/index.ts +++ b/src/plugins/workspace/server/routes/index.ts @@ -55,15 +55,7 @@ export function registerRoutes({ return res.ok({ body: result }); } return res.ok({ - body: { - ...result, - result: { - ...result.result, - workspaces: result.result.workspaces.map((workspace) => ({ - ...workspace, - })), - }, - }, + body: result, }); }) ); diff --git a/src/plugins/workspace/server/saved_objects/workspace.ts b/src/plugins/workspace/server/saved_objects/workspace.ts index 5142185b0c2d..a26e695d83cb 100644 --- a/src/plugins/workspace/server/saved_objects/workspace.ts +++ b/src/plugins/workspace/server/saved_objects/workspace.ts @@ -25,16 +25,19 @@ export const workspace: SavedObjectsType = { * In opensearch, string[] is also mapped to text */ features: { - type: 'text', + type: 'keyword', }, color: { - type: 'text', + type: 'keyword', }, icon: { - type: 'text', + type: 'keyword', }, defaultVISTheme: { - type: 'text', + type: 'keyword', + }, + reserved: { + type: 'boolean', }, }, }, diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts index d57cecd4a3c9..28d8c25fd3b0 100644 --- a/src/plugins/workspace/server/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -9,7 +9,7 @@ import { SavedObjectsFindResponse, CoreSetup, WorkspaceAttribute, - ISavedObjectsRepository, + SavedObjectsServiceStart, } from '../../../core/server'; export interface WorkspaceFindOptions { @@ -29,7 +29,7 @@ export interface IRequestDetail { export interface IWorkspaceDBImpl { setup(dep: CoreSetup): Promise>; - setInternalRepository(repository: ISavedObjectsRepository): void; + setSavedObjects(savedObjects: SavedObjectsServiceStart): void; create( requestDetail: IRequestDetail, payload: Omit diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index ec49daf83b6a..9dcbc2906d43 100644 --- a/src/plugins/workspace/server/workspace_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -8,12 +8,13 @@ import type { SavedObjectsClientContract, CoreSetup, WorkspaceAttribute, - ISavedObjectsRepository, + SavedObjectsServiceStart, } from '../../../core/server'; import { WORKSPACE_TYPE } from '../../../core/server'; import { IWorkspaceDBImpl, WorkspaceFindOptions, IResponse, IRequestDetail } from './types'; import { workspace } from './saved_objects'; import { generateRandomId } from './utils'; +import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../common/constants'; const WORKSPACE_ID_SIZE = 6; @@ -23,15 +24,20 @@ const DUPLICATE_WORKSPACE_NAME_ERROR = i18n.translate('workspace.duplicate.name. export class WorkspaceClientWithSavedObject implements IWorkspaceDBImpl { private setupDep: CoreSetup; - - private internalSavedObjectsRepository?: ISavedObjectsRepository; - setInternalRepository(repository: ISavedObjectsRepository) { - this.internalSavedObjectsRepository = repository; - } + private savedObjects?: SavedObjectsServiceStart; constructor(core: CoreSetup) { this.setupDep = core; } + + private getScopedClientWithoutPermission( + requestDetail: IRequestDetail + ): SavedObjectsClientContract | undefined { + return this.savedObjects?.getScopedClient(requestDetail.request, { + excludedWrappers: [WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID], + }); + } + private getSavedObjectClientsFromRequestDetail( requestDetail: IRequestDetail ): SavedObjectsClientContract { @@ -63,11 +69,13 @@ export class WorkspaceClientWithSavedObject implements IWorkspaceDBImpl { const attributes = payload; const id = generateRandomId(WORKSPACE_ID_SIZE); const client = this.getSavedObjectClientsFromRequestDetail(requestDetail); - const existingWorkspaceRes = await this.internalSavedObjectsRepository?.find({ - type: WORKSPACE_TYPE, - search: attributes.name, - searchFields: ['name'], - }); + const existingWorkspaceRes = await this.getScopedClientWithoutPermission(requestDetail)?.find( + { + type: WORKSPACE_TYPE, + search: attributes.name, + searchFields: ['name'], + } + ); if (existingWorkspaceRes && existingWorkspaceRes.total > 0) { throw new Error(DUPLICATE_WORKSPACE_NAME_ERROR); } @@ -148,7 +156,9 @@ export class WorkspaceClientWithSavedObject implements IWorkspaceDBImpl { const client = this.getSavedObjectClientsFromRequestDetail(requestDetail); const workspaceInDB: SavedObject = await client.get(WORKSPACE_TYPE, id); if (workspaceInDB.attributes.name !== attributes.name) { - const existingWorkspaceRes = await this.internalSavedObjectsRepository?.find({ + const existingWorkspaceRes = await this.getScopedClientWithoutPermission( + requestDetail + )?.find({ type: WORKSPACE_TYPE, search: attributes.name, searchFields: ['name'], @@ -184,6 +194,9 @@ export class WorkspaceClientWithSavedObject implements IWorkspaceDBImpl { }; } } + public setSavedObjects(savedObjects: SavedObjectsServiceStart) { + this.savedObjects = savedObjects; + } public async destroy(): Promise> { return { success: true, diff --git a/test/api_integration/apis/index.js b/test/api_integration/apis/index.js index 54ffe6e774a5..2d870d88251d 100644 --- a/test/api_integration/apis/index.js +++ b/test/api_integration/apis/index.js @@ -45,5 +45,6 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./stats')); loadTestFile(require.resolve('./ui_metric')); loadTestFile(require.resolve('./telemetry')); + loadTestFile(require.resolve('./workspace')); }); } diff --git a/test/api_integration/apis/workspace/index.ts b/test/api_integration/apis/workspace/index.ts new file mode 100644 index 000000000000..553ee0dce1af --- /dev/null +++ b/test/api_integration/apis/workspace/index.ts @@ -0,0 +1,132 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from '@osd/expect'; +import { WorkspaceAttribute } from 'opensearch-dashboards/server'; +import { omit } from 'lodash'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +const testWorkspace: WorkspaceAttribute = { + id: 'fake_id', + name: 'test_workspace', + description: 'test_workspace_description', +}; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('Workspace CRUD apis', () => { + afterEach(async () => { + const listResult = await supertest + .post(`/api/workspaces/_list`) + .send({ + page: 1, + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + await Promise.all( + listResult.body.result.workspaces.map((item: WorkspaceAttribute) => + supertest + .delete(`/api/workspaces/${item.id}`) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200) + ) + ); + }); + it('create', async () => { + await supertest + .post(`/api/workspaces`) + .send({ + attributes: testWorkspace, + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(400); + + const result: any = await supertest + .post(`/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + expect(result.body.success).equal(true); + expect(result.body.result.id).to.be.a('string'); + }); + it('get', async () => { + const result = await supertest + .post(`/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + const getResult = await supertest.get(`/api/workspaces/${result.body.result.id}`); + expect(getResult.body.result.name).equal(testWorkspace.name); + }); + it('update', async () => { + const result: any = await supertest + .post(`/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + await supertest + .put(`/api/workspaces/${result.body.result.id}`) + .send({ + attributes: { + ...omit(testWorkspace, 'id'), + name: 'updated', + }, + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + const getResult = await supertest.get(`/api/workspaces/${result.body.result.id}`); + + expect(getResult.body.success).equal(true); + expect(getResult.body.result.name).equal('updated'); + }); + it('delete', async () => { + const result: any = await supertest + .post(`/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + await supertest + .delete(`/api/workspaces/${result.body.result.id}`) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + const getResult = await supertest.get(`/api/workspaces/${result.body.result.id}`); + + expect(getResult.body.success).equal(false); + }); + it('list', async () => { + await supertest + .post(`/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + const listResult = await supertest + .post(`/api/workspaces/_list`) + .send({ + page: 1, + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + expect(listResult.body.result.total).equal(1); + }); + }).tags('is:workspace'); +} From 3dcea509e9aee45d38e5f1f3392f8007f20ca07e Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Fri, 22 Sep 2023 17:29:52 +0800 Subject: [PATCH 06/21] add unit tests for workspace core service (#191) Signed-off-by: Yulong Ruan --- src/core/public/core_system.test.mocks.ts | 9 ++ src/core/public/core_system.test.ts | 25 +++++ src/core/public/core_system.ts | 1 + src/core/public/mocks.ts | 2 +- .../public/plugins/plugins_service.test.ts | 2 +- .../workspace/workspaces_service.mock.ts | 13 ++- .../workspace/workspaces_service.test.ts | 94 +++++++++++++++++++ .../public/workspace/workspaces_service.ts | 6 +- 8 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 src/core/public/workspace/workspaces_service.test.ts diff --git a/src/core/public/core_system.test.mocks.ts b/src/core/public/core_system.test.mocks.ts index da09de341bc4..4025361e150d 100644 --- a/src/core/public/core_system.test.mocks.ts +++ b/src/core/public/core_system.test.mocks.ts @@ -42,6 +42,7 @@ import { docLinksServiceMock } from './doc_links/doc_links_service.mock'; import { renderingServiceMock } from './rendering/rendering_service.mock'; import { contextServiceMock } from './context/context_service.mock'; import { integrationsServiceMock } from './integrations/integrations_service.mock'; +import { workspacesServiceMock } from './workspace/workspaces_service.mock'; import { coreAppMock } from './core_app/core_app.mock'; export const MockInjectedMetadataService = injectedMetadataServiceMock.create(); @@ -145,3 +146,11 @@ export const CoreAppConstructor = jest.fn().mockImplementation(() => MockCoreApp jest.doMock('./core_app', () => ({ CoreApp: CoreAppConstructor, })); + +export const MockWorkspacesService = workspacesServiceMock.create(); +export const WorkspacesServiceConstructor = jest + .fn() + .mockImplementation(() => MockWorkspacesService); +jest.doMock('./workspace', () => ({ + WorkspacesService: WorkspacesServiceConstructor, +})); diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index b3acd696bc3d..81dfa97b9afa 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -55,6 +55,8 @@ import { MockIntegrationsService, CoreAppConstructor, MockCoreApp, + WorkspacesServiceConstructor, + MockWorkspacesService, } from './core_system.test.mocks'; import { CoreSystem } from './core_system'; @@ -99,6 +101,7 @@ describe('constructor', () => { expect(RenderingServiceConstructor).toHaveBeenCalledTimes(1); expect(IntegrationsServiceConstructor).toHaveBeenCalledTimes(1); expect(CoreAppConstructor).toHaveBeenCalledTimes(1); + expect(WorkspacesServiceConstructor).toHaveBeenCalledTimes(1); }); it('passes injectedMetadata param to InjectedMetadataService', () => { @@ -223,6 +226,11 @@ describe('#setup()', () => { expect(MockIntegrationsService.setup).toHaveBeenCalledTimes(1); }); + it('calls workspaces#setup()', async () => { + await setupCore(); + expect(MockWorkspacesService.setup).toHaveBeenCalledTimes(1); + }); + it('calls coreApp#setup()', async () => { await setupCore(); expect(MockCoreApp.setup).toHaveBeenCalledTimes(1); @@ -310,6 +318,15 @@ describe('#start()', () => { expect(MockIntegrationsService.start).toHaveBeenCalledTimes(1); }); + it('calls workspaces#start()', async () => { + await startCore(); + expect(MockWorkspacesService.start).toHaveBeenCalledTimes(1); + expect(MockWorkspacesService.start).toHaveBeenCalledWith({ + application: expect.any(Object), + http: expect.any(Object), + }); + }); + it('calls coreApp#start()', async () => { await startCore(); expect(MockCoreApp.start).toHaveBeenCalledTimes(1); @@ -364,6 +381,14 @@ describe('#stop()', () => { expect(MockIntegrationsService.stop).toHaveBeenCalled(); }); + it('calls workspaces.stop()', () => { + const coreSystem = createCoreSystem(); + + expect(MockWorkspacesService.stop).not.toHaveBeenCalled(); + coreSystem.stop(); + expect(MockWorkspacesService.stop).toHaveBeenCalled(); + }); + it('calls coreApp.stop()', () => { const coreSystem = createCoreSystem(); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index d7de8b4595d5..0a64e0f4fa9a 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -311,6 +311,7 @@ export class CoreSystem { this.chrome.stop(); this.i18n.stop(); this.application.stop(); + this.workspaces.stop(); this.rootDomElement.textContent = ''; } } diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index 722070d5a9ea..3acc71424b91 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -87,7 +87,7 @@ function createCoreSetupMock({ getInjectedVar: injectedMetadataServiceMock.createSetupContract().getInjectedVar, getBranding: injectedMetadataServiceMock.createSetupContract().getBranding, }, - workspaces: workspacesServiceMock.createSetupContractMock(), + workspaces: workspacesServiceMock.createSetupContract(), }; return mock; diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts index 7e8c96c1f9d0..f26626ed1ca3 100644 --- a/src/core/public/plugins/plugins_service.test.ts +++ b/src/core/public/plugins/plugins_service.test.ts @@ -109,7 +109,7 @@ describe('PluginsService', () => { injectedMetadata: injectedMetadataServiceMock.createStartContract(), notifications: notificationServiceMock.createSetupContract(), uiSettings: uiSettingsServiceMock.createSetupContract(), - workspaces: workspacesServiceMock.createSetupContractMock(), + workspaces: workspacesServiceMock.createSetupContract(), }; mockSetupContext = { ...mockSetupDeps, diff --git a/src/core/public/workspace/workspaces_service.mock.ts b/src/core/public/workspace/workspaces_service.mock.ts index 3c35315aa850..3b1408b03045 100644 --- a/src/core/public/workspace/workspaces_service.mock.ts +++ b/src/core/public/workspace/workspaces_service.mock.ts @@ -4,6 +4,9 @@ */ import { BehaviorSubject } from 'rxjs'; +import type { PublicMethodsOf } from '@osd/utility-types'; + +import { WorkspacesService } from './workspaces_service'; import { WorkspaceAttribute } from '..'; const currentWorkspaceId$ = new BehaviorSubject(''); @@ -30,7 +33,15 @@ const createWorkspacesStartContractMock = () => ({ renderWorkspaceMenu: jest.fn(), }); +export type WorkspacesServiceContract = PublicMethodsOf; +const createMock = (): jest.Mocked => ({ + setup: jest.fn().mockReturnValue(createWorkspacesSetupContractMock()), + start: jest.fn().mockReturnValue(createWorkspacesStartContractMock()), + stop: jest.fn(), +}); + export const workspacesServiceMock = { - createSetupContractMock: createWorkspacesSetupContractMock, + create: createMock, + createSetupContract: createWorkspacesSetupContractMock, createStartContract: createWorkspacesStartContractMock, }; diff --git a/src/core/public/workspace/workspaces_service.test.ts b/src/core/public/workspace/workspaces_service.test.ts new file mode 100644 index 000000000000..b2d84152da83 --- /dev/null +++ b/src/core/public/workspace/workspaces_service.test.ts @@ -0,0 +1,94 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { httpServiceMock } from '../http/http_service.mock'; +import { applicationServiceMock } from '../application/application_service.mock'; +import { WorkspacesService, WorkspacesSetup, WorkspacesStart } from './workspaces_service'; + +describe('WorkspacesService', () => { + let workspaces: WorkspacesService; + let workspacesSetup: WorkspacesSetup; + let workspacesStart: WorkspacesStart; + + beforeEach(() => { + workspaces = new WorkspacesService(); + workspacesSetup = workspaces.setup(); + workspacesStart = workspaces.start({ + http: httpServiceMock.createStartContract(), + application: applicationServiceMock.createInternalStartContract(), + }); + }); + + afterEach(() => { + workspaces.stop(); + }); + + it('workspace initialized$ state is false by default', () => { + expect(workspacesStart.initialized$.value).toBe(false); + }); + + it('workspace is not enabled by default', () => { + expect(workspacesStart.workspaceEnabled$.value).toBe(false); + }); + + it('currentWorkspace is not set by default', () => { + expect(workspacesStart.currentWorkspace$.value).toBe(null); + expect(workspacesStart.currentWorkspaceId$.value).toBe(''); + }); + + it('workspaceList$ is empty by default', () => { + expect(workspacesStart.workspaceList$.value.length).toBe(0); + }); + + it('should call menu render function', () => { + const renderFn = jest.fn(); + workspacesSetup.registerWorkspaceMenuRender(renderFn); + workspacesStart.renderWorkspaceMenu(); + expect(renderFn).toHaveBeenCalled(); + }); + + it('should return null if NO menu render function was registered', () => { + expect(workspacesStart.renderWorkspaceMenu()).toBe(null); + }); + + it('the current workspace should also updated after changing current workspace id', () => { + expect(workspacesStart.currentWorkspace$.value).toBe(null); + + workspacesStart.initialized$.next(true); + workspacesStart.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1' }, + { id: 'workspace-2', name: 'workspace 2' }, + ]); + workspacesStart.currentWorkspaceId$.next('workspace-1'); + + expect(workspacesStart.currentWorkspace$.value).toEqual({ + id: 'workspace-1', + name: 'workspace 1', + }); + + workspacesStart.currentWorkspaceId$.next(''); + expect(workspacesStart.currentWorkspace$.value).toEqual(null); + }); + + it('should return error if the specified workspace id cannot be found', () => { + expect(workspacesStart.currentWorkspace$.hasError).toBe(false); + workspacesStart.initialized$.next(true); + workspacesStart.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1' }, + { id: 'workspace-2', name: 'workspace 2' }, + ]); + workspacesStart.currentWorkspaceId$.next('workspace-3'); + expect(workspacesStart.currentWorkspace$.hasError).toBe(true); + }); + + it('should stop all observables when workspace service stopped', () => { + workspaces.stop(); + expect(workspacesStart.currentWorkspaceId$.isStopped).toBe(true); + expect(workspacesStart.currentWorkspace$.isStopped).toBe(true); + expect(workspacesStart.workspaceList$.isStopped).toBe(true); + expect(workspacesStart.workspaceEnabled$.isStopped).toBe(true); + expect(workspacesStart.initialized$.isStopped).toBe(true); + }); +}); diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts index 39519ccdddbe..b5c8ff4cdba1 100644 --- a/src/core/public/workspace/workspaces_service.ts +++ b/src/core/public/workspace/workspaces_service.ts @@ -8,7 +8,7 @@ import { isEqual } from 'lodash'; import { CoreService, WorkspaceAttribute } from '../../types'; import { InternalApplicationStart } from '../application'; -import { HttpSetup } from '../http'; +import { HttpStart } from '../http'; type WorkspaceMenuRenderFn = ({ basePath, @@ -16,7 +16,7 @@ type WorkspaceMenuRenderFn = ({ observables, }: { getUrlForApp: InternalApplicationStart['getUrlForApp']; - basePath: HttpSetup['basePath']; + basePath: HttpStart['basePath']; observables: WorkspaceObservables; }) => JSX.Element | null; @@ -99,7 +99,7 @@ export class WorkspacesService implements CoreService Date: Fri, 22 Sep 2023 17:39:08 +0800 Subject: [PATCH 07/21] fix backport workflow Signed-off-by: Yulong Ruan --- .github/workflows/backport.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index c8dfef417daa..5b4772a59978 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -31,7 +31,7 @@ jobs: app_id: ${{ secrets.APP_ID }} private_key: ${{ secrets.APP_PRIVATE_KEY }} # opensearch-trigger-bot installation ID - installation_id: 22958780 + installation_id: 41494816 - name: Backport uses: VachaShah/backport@v2.2.0 From 5bc2ddf6b8fee6e092b4f6264b8862085471deb0 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Wed, 27 Sep 2023 08:23:39 +0800 Subject: [PATCH 08/21] remove unnecessary workspace menu register (#204) Signed-off-by: Yulong Ruan --- src/core/public/core_system.test.ts | 4 -- src/core/public/core_system.ts | 2 +- .../workspace/workspaces_service.mock.ts | 2 - .../workspace/workspaces_service.test.ts | 22 +------- .../public/workspace/workspaces_service.ts | 51 ++----------------- .../dashboard_listing.test.tsx.snap | 5 -- .../dashboard_top_nav.test.tsx.snap | 6 --- 7 files changed, 7 insertions(+), 85 deletions(-) diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index 81dfa97b9afa..24de9ea6b9ea 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -321,10 +321,6 @@ describe('#start()', () => { it('calls workspaces#start()', async () => { await startCore(); expect(MockWorkspacesService.start).toHaveBeenCalledTimes(1); - expect(MockWorkspacesService.start).toHaveBeenCalledWith({ - application: expect.any(Object), - http: expect.any(Object), - }); }); it('calls coreApp#start()', async () => { diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 0a64e0f4fa9a..a9dba002e44c 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -225,7 +225,7 @@ export class CoreSystem { targetDomElement: notificationsTargetDomElement, }); const application = await this.application.start({ http, overlays }); - const workspaces = this.workspaces.start({ application, http }); + const workspaces = this.workspaces.start(); const chrome = await this.chrome.start({ application, docLinks, diff --git a/src/core/public/workspace/workspaces_service.mock.ts b/src/core/public/workspace/workspaces_service.mock.ts index 3b1408b03045..a8d2a91bd3d1 100644 --- a/src/core/public/workspace/workspaces_service.mock.ts +++ b/src/core/public/workspace/workspaces_service.mock.ts @@ -21,7 +21,6 @@ const createWorkspacesSetupContractMock = () => ({ currentWorkspace$, initialized$, workspaceEnabled$, - registerWorkspaceMenuRender: jest.fn(), }); const createWorkspacesStartContractMock = () => ({ @@ -30,7 +29,6 @@ const createWorkspacesStartContractMock = () => ({ currentWorkspace$, initialized$, workspaceEnabled$, - renderWorkspaceMenu: jest.fn(), }); export type WorkspacesServiceContract = PublicMethodsOf; diff --git a/src/core/public/workspace/workspaces_service.test.ts b/src/core/public/workspace/workspaces_service.test.ts index b2d84152da83..b8f9b17ae0c8 100644 --- a/src/core/public/workspace/workspaces_service.test.ts +++ b/src/core/public/workspace/workspaces_service.test.ts @@ -3,22 +3,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { httpServiceMock } from '../http/http_service.mock'; -import { applicationServiceMock } from '../application/application_service.mock'; -import { WorkspacesService, WorkspacesSetup, WorkspacesStart } from './workspaces_service'; +import { WorkspacesService, WorkspacesStart } from './workspaces_service'; describe('WorkspacesService', () => { let workspaces: WorkspacesService; - let workspacesSetup: WorkspacesSetup; let workspacesStart: WorkspacesStart; beforeEach(() => { workspaces = new WorkspacesService(); - workspacesSetup = workspaces.setup(); - workspacesStart = workspaces.start({ - http: httpServiceMock.createStartContract(), - application: applicationServiceMock.createInternalStartContract(), - }); + workspacesStart = workspaces.start(); }); afterEach(() => { @@ -42,17 +35,6 @@ describe('WorkspacesService', () => { expect(workspacesStart.workspaceList$.value.length).toBe(0); }); - it('should call menu render function', () => { - const renderFn = jest.fn(); - workspacesSetup.registerWorkspaceMenuRender(renderFn); - workspacesStart.renderWorkspaceMenu(); - expect(renderFn).toHaveBeenCalled(); - }); - - it('should return null if NO menu render function was registered', () => { - expect(workspacesStart.renderWorkspaceMenu()).toBe(null); - }); - it('the current workspace should also updated after changing current workspace id', () => { expect(workspacesStart.currentWorkspace$.value).toBe(null); diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts index b5c8ff4cdba1..f3d1400ce709 100644 --- a/src/core/public/workspace/workspaces_service.ts +++ b/src/core/public/workspace/workspaces_service.ts @@ -7,18 +7,6 @@ import { BehaviorSubject, combineLatest } from 'rxjs'; import { isEqual } from 'lodash'; import { CoreService, WorkspaceAttribute } from '../../types'; -import { InternalApplicationStart } from '../application'; -import { HttpStart } from '../http'; - -type WorkspaceMenuRenderFn = ({ - basePath, - getUrlForApp, - observables, -}: { - getUrlForApp: InternalApplicationStart['getUrlForApp']; - basePath: HttpStart['basePath']; - observables: WorkspaceObservables; -}) => JSX.Element | null; type WorkspaceObject = WorkspaceAttribute & { readonly?: boolean }; @@ -34,16 +22,8 @@ enum WORKSPACE_ERROR { WORKSPACE_STALED = 'WORKSPACE_STALED', } -/** - * @public - */ -export interface WorkspacesSetup extends WorkspaceObservables { - registerWorkspaceMenuRender: (render: WorkspaceMenuRenderFn) => void; -} - -export interface WorkspacesStart extends WorkspaceObservables { - renderWorkspaceMenu: () => JSX.Element | null; -} +export type WorkspacesSetup = WorkspaceObservables; +export type WorkspacesStart = WorkspaceObservables; export class WorkspacesService implements CoreService { private currentWorkspaceId$ = new BehaviorSubject(''); @@ -51,7 +31,6 @@ export class WorkspacesService implements CoreService(null); private initialized$ = new BehaviorSubject(false); private workspaceEnabled$ = new BehaviorSubject(false); - private _renderWorkspaceMenu: WorkspaceMenuRenderFn | null = null; constructor() { combineLatest([this.initialized$, this.workspaceList$, this.currentWorkspaceId$]).subscribe( @@ -89,38 +68,17 @@ export class WorkspacesService implements CoreService - (this._renderWorkspaceMenu = render), }; } - public start({ - http, - application, - }: { - application: InternalApplicationStart; - http: HttpStart; - }): WorkspacesStart { - const observables = { + public start(): WorkspacesStart { + return { currentWorkspaceId$: this.currentWorkspaceId$, currentWorkspace$: this.currentWorkspace$, workspaceList$: this.workspaceList$, initialized$: this.initialized$, workspaceEnabled$: this.workspaceEnabled$, }; - return { - ...observables, - renderWorkspaceMenu: () => { - if (this._renderWorkspaceMenu) { - return this._renderWorkspaceMenu({ - basePath: http.basePath, - getUrlForApp: application.getUrlForApp, - observables, - }); - } - return null; - }, - }; } public async stop() { @@ -129,6 +87,5 @@ export class WorkspacesService implements CoreService Date: Fri, 29 Sep 2023 07:38:42 +0800 Subject: [PATCH 09/21] [Workspace] Add optional workspaces parameter to all saved objects API (#185) * [Workspace] Add workspaces parameters to all saved objects API Signed-off-by: gaobinlong * feat: update snapshot Signed-off-by: SuZhou-Joe * feat: optimize logic when checkConflict and bulkCreate (#189) * feat: optimize logic when checkConflict and bulkCreate Signed-off-by: SuZhou-Joe * feat: add options.workspace check Signed-off-by: SuZhou-Joe * feat: throw error when workspace check error in repository create Signed-off-by: SuZhou-Joe * feat: modify judgement Signed-off-by: SuZhou-Joe * feat: always get objects from DB when create-with-override Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe * feat: call get when create with override Signed-off-by: SuZhou-Joe * feat: update test according to count Signed-off-by: SuZhou-Joe * feat: add integration test Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe * feat: regenerate ids when import Signed-off-by: SuZhou-Joe * feat: add more unit test Signed-off-by: SuZhou-Joe * feat: minor changes logic on repository Signed-off-by: SuZhou-Joe * feat: update unit test Signed-off-by: SuZhou-Joe * feat: update test Signed-off-by: SuZhou-Joe * feat: optimization according to comments Signed-off-by: SuZhou-Joe * feat: update test Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe --------- Signed-off-by: gaobinlong Signed-off-by: SuZhou-Joe Co-authored-by: SuZhou-Joe --- .../saved_objects/saved_objects_client.ts | 1 + .../export/get_sorted_objects_for_export.ts | 11 +- .../saved_objects/import/check_conflicts.ts | 3 + .../import/create_saved_objects.ts | 3 + .../import/import_saved_objects.test.ts | 12 +- .../import/import_saved_objects.ts | 14 +- .../import/regenerate_ids.test.ts | 71 +++- .../saved_objects/import/regenerate_ids.ts | 35 +- .../import/resolve_import_errors.ts | 2 + src/core/server/saved_objects/import/types.ts | 4 + .../build_active_mappings.test.ts.snap | 8 + .../migrations/core/build_active_mappings.ts | 3 + .../migrations/core/index_migrator.test.ts | 12 + ...pensearch_dashboards_migrator.test.ts.snap | 4 + .../saved_objects/routes/bulk_create.ts | 12 +- .../server/saved_objects/routes/create.ts | 12 +- .../server/saved_objects/routes/export.ts | 11 +- src/core/server/saved_objects/routes/find.ts | 8 + .../server/saved_objects/routes/import.ts | 9 + .../routes/integration_tests/find.test.ts | 34 ++ .../routes/resolve_import_errors.ts | 9 + .../saved_objects/serialization/serializer.ts | 5 +- .../saved_objects/serialization/types.ts | 2 + .../lib/integration_tests/repository.test.ts | 310 ++++++++++++++++++ .../service/lib/repository.test.js | 183 ++++++++++- .../saved_objects/service/lib/repository.ts | 122 ++++++- .../lib/search_dsl/query_params.test.ts | 21 ++ .../service/lib/search_dsl/query_params.ts | 29 ++ .../service/lib/search_dsl/search_dsl.ts | 3 + .../server/saved_objects/service/lib/utils.ts | 7 + .../service/saved_objects_client.ts | 2 +- src/core/server/saved_objects/types.ts | 4 + src/core/types/saved_objects.ts | 2 + 33 files changed, 939 insertions(+), 29 deletions(-) create mode 100644 src/core/server/saved_objects/service/lib/integration_tests/repository.test.ts diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 6e5482614e40..d6b6b6b6d89c 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -345,6 +345,7 @@ export class SavedObjectsClient { filter: 'filter', namespaces: 'namespaces', preference: 'preference', + workspaces: 'workspaces', }; const renamedQuery = renameKeys(renameMap, options); diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts index 7bf6e9f6ccdc..660f86846137 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts @@ -60,6 +60,8 @@ export interface SavedObjectsExportOptions { excludeExportDetails?: boolean; /** optional namespace to override the namespace used by the savedObjectsClient. */ namespace?: string; + /** optional workspaces to override the workspaces used by the savedObjectsClient. */ + workspaces?: string[]; } /** @@ -87,6 +89,7 @@ async function fetchObjectsToExport({ exportSizeLimit, savedObjectsClient, namespace, + workspaces, }: { objects?: SavedObjectsExportOptions['objects']; types?: string[]; @@ -94,6 +97,7 @@ async function fetchObjectsToExport({ exportSizeLimit: number; savedObjectsClient: SavedObjectsClientContract; namespace?: string; + workspaces?: string[]; }) { if ((types?.length ?? 0) > 0 && (objects?.length ?? 0) > 0) { throw Boom.badRequest(`Can't specify both "types" and "objects" properties when exporting`); @@ -105,7 +109,9 @@ async function fetchObjectsToExport({ if (typeof search === 'string') { throw Boom.badRequest(`Can't specify both "search" and "objects" properties when exporting`); } - const bulkGetResult = await savedObjectsClient.bulkGet(objects, { namespace }); + const bulkGetResult = await savedObjectsClient.bulkGet(objects, { + namespace, + }); const erroredObjects = bulkGetResult.saved_objects.filter((obj) => !!obj.error); if (erroredObjects.length) { const err = Boom.badRequest(); @@ -121,6 +127,7 @@ async function fetchObjectsToExport({ search, perPage: exportSizeLimit, namespaces: namespace ? [namespace] : undefined, + ...(workspaces ? { workspaces } : {}), }); if (findResponse.total > exportSizeLimit) { throw Boom.badRequest(`Can't export more than ${exportSizeLimit} objects`); @@ -153,6 +160,7 @@ export async function exportSavedObjectsToStream({ includeReferencesDeep = false, excludeExportDetails = false, namespace, + workspaces, }: SavedObjectsExportOptions) { const rootObjects = await fetchObjectsToExport({ types, @@ -161,6 +169,7 @@ export async function exportSavedObjectsToStream({ savedObjectsClient, exportSizeLimit, namespace, + workspaces, }); let exportedObjects: Array> = []; let missingReferences: SavedObjectsExportResultDetails['missingReferences'] = []; diff --git a/src/core/server/saved_objects/import/check_conflicts.ts b/src/core/server/saved_objects/import/check_conflicts.ts index 830f7f55d7c5..f36bcf3a8a92 100644 --- a/src/core/server/saved_objects/import/check_conflicts.ts +++ b/src/core/server/saved_objects/import/check_conflicts.ts @@ -44,6 +44,7 @@ interface CheckConflictsParams { ignoreRegularConflicts?: boolean; retries?: SavedObjectsImportRetry[]; createNewCopies?: boolean; + workspaces?: string[]; } const isUnresolvableConflict = (error: SavedObjectError) => @@ -56,6 +57,7 @@ export async function checkConflicts({ ignoreRegularConflicts, retries = [], createNewCopies, + workspaces, }: CheckConflictsParams) { const filteredObjects: Array> = []; const errors: SavedObjectsImportError[] = []; @@ -77,6 +79,7 @@ export async function checkConflicts({ }); const checkConflictsResult = await savedObjectsClient.checkConflicts(objectsToCheck, { namespace, + workspaces, }); const errorMap = checkConflictsResult.errors.reduce( (acc, { type, id, error }) => acc.set(`${type}:${id}`, error), diff --git a/src/core/server/saved_objects/import/create_saved_objects.ts b/src/core/server/saved_objects/import/create_saved_objects.ts index a3a1eebbd2ab..6982f21bbf37 100644 --- a/src/core/server/saved_objects/import/create_saved_objects.ts +++ b/src/core/server/saved_objects/import/create_saved_objects.ts @@ -39,6 +39,7 @@ interface CreateSavedObjectsParams { importIdMap: Map; namespace?: string; overwrite?: boolean; + workspaces?: string[]; } interface CreateSavedObjectsResult { createdObjects: Array>; @@ -56,6 +57,7 @@ export const createSavedObjects = async ({ importIdMap, namespace, overwrite, + workspaces, }: CreateSavedObjectsParams): Promise> => { // filter out any objects that resulted in errors const errorSet = accumulatedErrors.reduce( @@ -103,6 +105,7 @@ export const createSavedObjects = async ({ const bulkCreateResponse = await savedObjectsClient.bulkCreate(objectsToCreate, { namespace, overwrite, + workspaces, }); expectedResults = bulkCreateResponse.saved_objects; } diff --git a/src/core/server/saved_objects/import/import_saved_objects.test.ts b/src/core/server/saved_objects/import/import_saved_objects.test.ts index de8fb34dfbed..4bf1717e895c 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.test.ts @@ -42,7 +42,7 @@ import { typeRegistryMock } from '../saved_objects_type_registry.mock'; import { importSavedObjectsFromStream } from './import_saved_objects'; import { collectSavedObjects } from './collect_saved_objects'; -import { regenerateIds } from './regenerate_ids'; +import { regenerateIds, regenerateIdsWithReference } from './regenerate_ids'; import { validateReferences } from './validate_references'; import { checkConflicts } from './check_conflicts'; import { checkOriginConflicts } from './check_origin_conflicts'; @@ -68,6 +68,7 @@ describe('#importSavedObjectsFromStream', () => { importIdMap: new Map(), }); getMockFn(regenerateIds).mockReturnValue(new Map()); + getMockFn(regenerateIdsWithReference).mockReturnValue(Promise.resolve(new Map())); getMockFn(validateReferences).mockResolvedValue([]); getMockFn(checkConflicts).mockResolvedValue({ errors: [], @@ -240,6 +241,15 @@ describe('#importSavedObjectsFromStream', () => { ]), }); getMockFn(validateReferences).mockResolvedValue([errors[1]]); + getMockFn(regenerateIdsWithReference).mockResolvedValue( + Promise.resolve( + new Map([ + ['foo', {}], + ['bar', {}], + ['baz', {}], + ]) + ) + ); getMockFn(checkConflicts).mockResolvedValue({ errors: [errors[2]], filteredObjects, diff --git a/src/core/server/saved_objects/import/import_saved_objects.ts b/src/core/server/saved_objects/import/import_saved_objects.ts index cd250fc5f65f..2d87f94b97e3 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.ts @@ -38,7 +38,7 @@ import { validateReferences } from './validate_references'; import { checkOriginConflicts } from './check_origin_conflicts'; import { createSavedObjects } from './create_saved_objects'; import { checkConflicts } from './check_conflicts'; -import { regenerateIds } from './regenerate_ids'; +import { regenerateIds, regenerateIdsWithReference } from './regenerate_ids'; /** * Import saved objects from given stream. See the {@link SavedObjectsImportOptions | options} for more @@ -54,6 +54,7 @@ export async function importSavedObjectsFromStream({ savedObjectsClient, typeRegistry, namespace, + workspaces, }: SavedObjectsImportOptions): Promise { let errorAccumulator: SavedObjectsImportError[] = []; const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((type) => type.name); @@ -80,12 +81,22 @@ export async function importSavedObjectsFromStream({ if (createNewCopies) { importIdMap = regenerateIds(collectSavedObjectsResult.collectedObjects); } else { + if (workspaces) { + importIdMap = await regenerateIdsWithReference({ + savedObjects: collectSavedObjectsResult.collectedObjects, + savedObjectsClient, + workspaces, + objectLimit, + importIdMap, + }); + } // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces const checkConflictsParams = { objects: collectSavedObjectsResult.collectedObjects, savedObjectsClient, namespace, ignoreRegularConflicts: overwrite, + workspaces, }; const checkConflictsResult = await checkConflicts(checkConflictsParams); errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors]; @@ -118,6 +129,7 @@ export async function importSavedObjectsFromStream({ importIdMap, overwrite, namespace, + ...(workspaces ? { workspaces } : {}), }; const createSavedObjectsResult = await createSavedObjects(createSavedObjectsParams); errorAccumulator = [...errorAccumulator, ...createSavedObjectsResult.errors]; diff --git a/src/core/server/saved_objects/import/regenerate_ids.test.ts b/src/core/server/saved_objects/import/regenerate_ids.test.ts index 11556c8a21c1..605a61774534 100644 --- a/src/core/server/saved_objects/import/regenerate_ids.test.ts +++ b/src/core/server/saved_objects/import/regenerate_ids.test.ts @@ -29,8 +29,10 @@ */ import { mockUuidv4 } from './__mocks__'; -import { regenerateIds } from './regenerate_ids'; +import { regenerateIds, regenerateIdsWithReference } from './regenerate_ids'; import { SavedObject } from '../types'; +import { savedObjectsClientMock } from '../service/saved_objects_client.mock'; +import { SavedObjectsBulkResponse } from '../service'; describe('#regenerateIds', () => { const objects = ([ @@ -62,3 +64,70 @@ describe('#regenerateIds', () => { `); }); }); + +describe('#regenerateIdsWithReference', () => { + const objects = ([ + { type: 'foo', id: '1' }, + { type: 'bar', id: '2' }, + { type: 'baz', id: '3' }, + ] as any) as SavedObject[]; + + test('returns expected values', async () => { + const mockedSavedObjectsClient = savedObjectsClientMock.create(); + mockUuidv4.mockReturnValueOnce('uuidv4 #1'); + const result: SavedObjectsBulkResponse = { + saved_objects: [ + { + error: { + statusCode: 404, + error: '', + message: '', + }, + id: '1', + type: 'foo', + attributes: {}, + references: [], + }, + { + id: '2', + type: 'bar', + attributes: {}, + references: [], + workspaces: ['bar'], + }, + { + id: '3', + type: 'baz', + attributes: {}, + references: [], + workspaces: ['foo'], + }, + ], + }; + mockedSavedObjectsClient.bulkGet.mockResolvedValue(result); + expect( + await regenerateIdsWithReference({ + savedObjects: objects, + savedObjectsClient: mockedSavedObjectsClient, + workspaces: ['bar'], + objectLimit: 1000, + importIdMap: new Map(), + }) + ).toMatchInlineSnapshot(` + Map { + "foo:1" => Object { + "id": "1", + "omitOriginId": true, + }, + "bar:2" => Object { + "id": "2", + "omitOriginId": false, + }, + "baz:3" => Object { + "id": "uuidv4 #1", + "omitOriginId": true, + }, + } + `); + }); +}); diff --git a/src/core/server/saved_objects/import/regenerate_ids.ts b/src/core/server/saved_objects/import/regenerate_ids.ts index 672a8f030620..b309753566e3 100644 --- a/src/core/server/saved_objects/import/regenerate_ids.ts +++ b/src/core/server/saved_objects/import/regenerate_ids.ts @@ -29,7 +29,8 @@ */ import { v4 as uuidv4 } from 'uuid'; -import { SavedObject } from '../types'; +import { SavedObject, SavedObjectsClientContract } from '../types'; +import { SavedObjectsUtils } from '../service'; /** * Takes an array of saved objects and returns an importIdMap of randomly-generated new IDs. @@ -42,3 +43,35 @@ export const regenerateIds = (objects: SavedObject[]) => { }, new Map()); return importIdMap; }; + +export const regenerateIdsWithReference = async (props: { + savedObjects: SavedObject[]; + savedObjectsClient: SavedObjectsClientContract; + workspaces: string[]; + objectLimit: number; + importIdMap: Map; +}): Promise> => { + const { savedObjects, savedObjectsClient, workspaces, importIdMap } = props; + + const bulkGetResult = await savedObjectsClient.bulkGet( + savedObjects.map((item) => ({ type: item.type, id: item.id })) + ); + + return bulkGetResult.saved_objects.reduce((acc, object) => { + if (object.error?.statusCode === 404) { + acc.set(`${object.type}:${object.id}`, { id: object.id, omitOriginId: true }); + return acc; + } + + const filteredWorkspaces = SavedObjectsUtils.filterWorkspacesAccordingToBaseWorkspaces( + workspaces, + object.workspaces + ); + if (filteredWorkspaces.length) { + acc.set(`${object.type}:${object.id}`, { id: uuidv4(), omitOriginId: true }); + } else { + acc.set(`${object.type}:${object.id}`, { id: object.id, omitOriginId: false }); + } + return acc; + }, importIdMap); +}; diff --git a/src/core/server/saved_objects/import/resolve_import_errors.ts b/src/core/server/saved_objects/import/resolve_import_errors.ts index 162410c4ce9b..207410136645 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.ts @@ -59,6 +59,7 @@ export async function resolveSavedObjectsImportErrors({ typeRegistry, namespace, createNewCopies, + workspaces, }: SavedObjectsResolveImportErrorsOptions): Promise { // throw a BadRequest error if we see invalid retries validateRetries(retries); @@ -157,6 +158,7 @@ export async function resolveSavedObjectsImportErrors({ importIdMap, namespace, overwrite, + workspaces, }; const { createdObjects, errors: bulkCreateErrors } = await createSavedObjects( createSavedObjectsParams diff --git a/src/core/server/saved_objects/import/types.ts b/src/core/server/saved_objects/import/types.ts index 88beacb9d2fd..924d1b18895a 100644 --- a/src/core/server/saved_objects/import/types.ts +++ b/src/core/server/saved_objects/import/types.ts @@ -187,6 +187,8 @@ export interface SavedObjectsImportOptions { namespace?: string; /** If true, will create new copies of import objects, each with a random `id` and undefined `originId`. */ createNewCopies: boolean; + /** if specified, will import in given workspaces, else will import as global object */ + workspaces?: string[]; } /** @@ -208,6 +210,8 @@ export interface SavedObjectsResolveImportErrorsOptions { namespace?: string; /** If true, will create new copies of import objects, each with a random `id` and undefined `originId`. */ createNewCopies: boolean; + /** if specified, will import in given workspaces, else will import as global object */ + workspaces?: string[]; } export type CreatedObject = SavedObject & { destinationId?: string }; diff --git a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap index 6f67893104e7..09e8ad8b5407 100644 --- a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap +++ b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap @@ -14,6 +14,7 @@ Object { "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "workspaces": "2f4316de49999235636386fe51dc06c1", }, }, "dynamic": "strict", @@ -111,6 +112,9 @@ Object { "updated_at": Object { "type": "date", }, + "workspaces": Object { + "type": "keyword", + }, }, } `; @@ -130,6 +134,7 @@ Object { "thirdType": "510f1f0adb69830cf8a1c5ce2923ed82", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "workspaces": "2f4316de49999235636386fe51dc06c1", }, }, "dynamic": "strict", @@ -244,6 +249,9 @@ Object { "updated_at": Object { "type": "date", }, + "workspaces": Object { + "type": "keyword", + }, }, } `; diff --git a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts index 02dc13b2cd3f..05fb534f7a11 100644 --- a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts +++ b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts @@ -186,6 +186,9 @@ function defaultMapping(): IndexMapping { }, }, }, + workspaces: { + type: 'keyword', + }, permissions: { properties: { read: principals, diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index 70f96c2e4daf..dde16977f015 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -83,6 +83,7 @@ describe('IndexMigrator', () => { references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', + workspaces: '2f4316de49999235636386fe51dc06c1', }, }, properties: { @@ -93,6 +94,9 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + workspaces: { + type: 'keyword', + }, permissions: { properties: { library_read: { @@ -235,6 +239,7 @@ describe('IndexMigrator', () => { references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', + workspaces: '2f4316de49999235636386fe51dc06c1', }, }, properties: { @@ -246,6 +251,9 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + workspaces: { + type: 'keyword', + }, permissions: { properties: { library_read: { @@ -331,6 +339,7 @@ describe('IndexMigrator', () => { references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', + workspaces: '2f4316de49999235636386fe51dc06c1', }, }, properties: { @@ -342,6 +351,9 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + workspaces: { + type: 'keyword', + }, permissions: { properties: { library_read: { diff --git a/src/core/server/saved_objects/migrations/opensearch_dashboards/__snapshots__/opensearch_dashboards_migrator.test.ts.snap b/src/core/server/saved_objects/migrations/opensearch_dashboards/__snapshots__/opensearch_dashboards_migrator.test.ts.snap index 5e39af788d79..2748ad2eaf6a 100644 --- a/src/core/server/saved_objects/migrations/opensearch_dashboards/__snapshots__/opensearch_dashboards_migrator.test.ts.snap +++ b/src/core/server/saved_objects/migrations/opensearch_dashboards/__snapshots__/opensearch_dashboards_migrator.test.ts.snap @@ -14,6 +14,7 @@ Object { "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "workspaces": "2f4316de49999235636386fe51dc06c1", }, }, "dynamic": "strict", @@ -119,6 +120,9 @@ Object { "updated_at": Object { "type": "date", }, + "workspaces": Object { + "type": "keyword", + }, }, } `; diff --git a/src/core/server/saved_objects/routes/bulk_create.ts b/src/core/server/saved_objects/routes/bulk_create.ts index 5c2844d64813..36fd4bda5bff 100644 --- a/src/core/server/saved_objects/routes/bulk_create.ts +++ b/src/core/server/saved_objects/routes/bulk_create.ts @@ -38,6 +38,9 @@ export const registerBulkCreateRoute = (router: IRouter) => { validate: { query: schema.object({ overwrite: schema.boolean({ defaultValue: false }), + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }), body: schema.arrayOf( schema.object({ @@ -62,7 +65,14 @@ export const registerBulkCreateRoute = (router: IRouter) => { }, router.handleLegacyErrors(async (context, req, res) => { const { overwrite } = req.query; - const result = await context.core.savedObjects.client.bulkCreate(req.body, { overwrite }); + let workspaces = req.query.workspaces; + if (typeof workspaces === 'string') { + workspaces = [workspaces]; + } + const result = await context.core.savedObjects.client.bulkCreate(req.body, { + overwrite, + workspaces, + }); return res.ok({ body: result }); }) ); diff --git a/src/core/server/saved_objects/routes/create.ts b/src/core/server/saved_objects/routes/create.ts index c8c330ba7774..4d22bd244a03 100644 --- a/src/core/server/saved_objects/routes/create.ts +++ b/src/core/server/saved_objects/routes/create.ts @@ -56,15 +56,23 @@ export const registerCreateRoute = (router: IRouter) => { ) ), initialNamespaces: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), + workspaces: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), }), }, }, router.handleLegacyErrors(async (context, req, res) => { const { type, id } = req.params; const { overwrite } = req.query; - const { attributes, migrationVersion, references, initialNamespaces } = req.body; + const { attributes, migrationVersion, references, initialNamespaces, workspaces } = req.body; - const options = { id, overwrite, migrationVersion, references, initialNamespaces }; + const options = { + id, + overwrite, + migrationVersion, + references, + initialNamespaces, + workspaces, + }; const result = await context.core.savedObjects.client.create(type, attributes, options); return res.ok({ body: result }); }) diff --git a/src/core/server/saved_objects/routes/export.ts b/src/core/server/saved_objects/routes/export.ts index 2c808b731b4e..9325b632e40f 100644 --- a/src/core/server/saved_objects/routes/export.ts +++ b/src/core/server/saved_objects/routes/export.ts @@ -57,12 +57,20 @@ export const registerExportRoute = (router: IRouter, config: SavedObjectConfig) search: schema.maybe(schema.string()), includeReferencesDeep: schema.boolean({ defaultValue: false }), excludeExportDetails: schema.boolean({ defaultValue: false }), + workspaces: schema.maybe(schema.arrayOf(schema.string())), }), }, }, router.handleLegacyErrors(async (context, req, res) => { const savedObjectsClient = context.core.savedObjects.client; - const { type, objects, search, excludeExportDetails, includeReferencesDeep } = req.body; + const { + type, + objects, + search, + excludeExportDetails, + includeReferencesDeep, + workspaces, + } = req.body; const types = typeof type === 'string' ? [type] : type; // need to access the registry for type validation, can't use the schema for this @@ -98,6 +106,7 @@ export const registerExportRoute = (router: IRouter, config: SavedObjectConfig) exportSizeLimit: maxImportExportSize, includeReferencesDeep, excludeExportDetails, + workspaces, }); const docsToExport: string[] = await createPromiseFromStreams([ diff --git a/src/core/server/saved_objects/routes/find.ts b/src/core/server/saved_objects/routes/find.ts index dbc9bf9e3a0d..6cfb2d780036 100644 --- a/src/core/server/saved_objects/routes/find.ts +++ b/src/core/server/saved_objects/routes/find.ts @@ -59,6 +59,9 @@ export const registerFindRoute = (router: IRouter) => { namespaces: schema.maybe( schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) ), + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }), }, }, @@ -67,6 +70,10 @@ export const registerFindRoute = (router: IRouter) => { const namespaces = typeof req.query.namespaces === 'string' ? [req.query.namespaces] : req.query.namespaces; + let workspaces = req.query.workspaces; + if (typeof workspaces === 'string') { + workspaces = [workspaces]; + } const result = await context.core.savedObjects.client.find({ perPage: query.per_page, @@ -81,6 +88,7 @@ export const registerFindRoute = (router: IRouter) => { fields: typeof query.fields === 'string' ? [query.fields] : query.fields, filter: query.filter, namespaces, + workspaces, }); return res.ok({ body: result }); diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts index b157feb0860e..2d221d2d47bc 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -60,6 +60,9 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) { overwrite: schema.boolean({ defaultValue: false }), createNewCopies: schema.boolean({ defaultValue: false }), + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }, { validate: (object) => { @@ -91,6 +94,11 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) }); } + let workspaces = req.query.workspaces; + if (typeof workspaces === 'string') { + workspaces = [workspaces]; + } + const result = await importSavedObjectsFromStream({ savedObjectsClient: context.core.savedObjects.client, typeRegistry: context.core.savedObjects.typeRegistry, @@ -98,6 +106,7 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) objectLimit: maxImportExportSize, overwrite, createNewCopies, + workspaces, }); return res.ok({ body: result }); diff --git a/src/core/server/saved_objects/routes/integration_tests/find.test.ts b/src/core/server/saved_objects/routes/integration_tests/find.test.ts index fc21eefed434..b21425386400 100644 --- a/src/core/server/saved_objects/routes/integration_tests/find.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/find.test.ts @@ -288,4 +288,38 @@ describe('GET /api/saved_objects/_find', () => { defaultSearchOperator: 'OR', }); }); + + it('accepts the query parameter workspaces as a string', async () => { + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=index-pattern&workspaces=foo') + .expect(200); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + + const options = savedObjectsClient.find.mock.calls[0][0]; + expect(options).toEqual({ + defaultSearchOperator: 'OR', + perPage: 20, + page: 1, + type: ['index-pattern'], + workspaces: ['foo'], + }); + }); + + it('accepts the query parameter workspaces as an array', async () => { + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=index-pattern&workspaces=default&workspaces=foo') + .expect(200); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + + const options = savedObjectsClient.find.mock.calls[0][0]; + expect(options).toEqual({ + perPage: 20, + page: 1, + type: ['index-pattern'], + workspaces: ['default', 'foo'], + defaultSearchOperator: 'OR', + }); + }); }); diff --git a/src/core/server/saved_objects/routes/resolve_import_errors.ts b/src/core/server/saved_objects/routes/resolve_import_errors.ts index 5e07125671f1..32d67b5ae8ab 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -58,6 +58,9 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO validate: { query: schema.object({ createNewCopies: schema.boolean({ defaultValue: false }), + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }), body: schema.object({ file: schema.stream(), @@ -98,6 +101,11 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO }); } + let workspaces = req.query.workspaces; + if (typeof workspaces === 'string') { + workspaces = [workspaces]; + } + const result = await resolveSavedObjectsImportErrors({ typeRegistry: context.core.savedObjects.typeRegistry, savedObjectsClient: context.core.savedObjects.client, @@ -105,6 +113,7 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO retries: req.body.retries, objectLimit: maxImportExportSize, createNewCopies: req.query.createNewCopies, + workspaces, }); return res.ok({ body: result }); diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index ff840a1fac60..5c3e22ac646a 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -73,7 +73,7 @@ export class SavedObjectsSerializer { */ public rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc { const { _id, _source, _seq_no, _primary_term } = doc; - const { type, namespace, namespaces, originId } = _source; + const { type, namespace, namespaces, originId, workspaces } = _source; const version = _seq_no != null || _primary_term != null @@ -91,6 +91,7 @@ export class SavedObjectsSerializer { ...(_source.migrationVersion && { migrationVersion: _source.migrationVersion }), ...(_source.updated_at && { updated_at: _source.updated_at }), ...(version && { version }), + ...(workspaces && { workspaces }), }; } @@ -112,6 +113,7 @@ export class SavedObjectsSerializer { updated_at, version, references, + workspaces, } = savedObj; const source = { [type]: attributes, @@ -122,6 +124,7 @@ export class SavedObjectsSerializer { ...(originId && { originId }), ...(migrationVersion && { migrationVersion }), ...(updated_at && { updated_at }), + ...(workspaces && { workspaces }), }; return { diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index d10ec75cdf41..473a63cf65f4 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -52,6 +52,7 @@ export interface SavedObjectsRawDocSource { updated_at?: string; references?: SavedObjectReference[]; originId?: string; + workspaces?: string[]; [typeMapping: string]: any; } @@ -69,6 +70,7 @@ interface SavedObjectDoc { version?: string; updated_at?: string; originId?: string; + workspaces?: string[]; } interface Referencable { diff --git a/src/core/server/saved_objects/service/lib/integration_tests/repository.test.ts b/src/core/server/saved_objects/service/lib/integration_tests/repository.test.ts new file mode 100644 index 000000000000..b601de985dc0 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/integration_tests/repository.test.ts @@ -0,0 +1,310 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { SavedObject } from 'src/core/types'; +import { isEqual } from 'lodash'; +import * as osdTestServer from '../../../../../test_helpers/osd_server'; +import { Readable } from 'stream'; + +const dashboard: Omit = { + type: 'dashboard', + attributes: {}, + references: [], +}; + +describe('repository integration test', () => { + let root: ReturnType; + let opensearchServer: osdTestServer.TestOpenSearchUtils; + beforeAll(async () => { + const { startOpenSearch, startOpenSearchDashboards } = osdTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + }); + opensearchServer = await startOpenSearch(); + const startOSDResp = await startOpenSearchDashboards(); + root = startOSDResp.root; + }, 30000); + afterAll(async () => { + await root.shutdown(); + await opensearchServer.stop(); + }); + + const deleteItem = async (object: Pick) => { + expect( + [200, 404].includes( + (await osdTestServer.request.delete(root, `/api/saved_objects/${object.type}/${object.id}`)) + .statusCode + ) + ); + }; + + const getItem = async (object: Pick) => { + return await osdTestServer.request + .get(root, `/api/saved_objects/${object.type}/${object.id}`) + .expect(200); + }; + + const clearFooAndBar = async () => { + await deleteItem({ + type: dashboard.type, + id: 'foo', + }); + await deleteItem({ + type: dashboard.type, + id: 'bar', + }); + }; + + describe('workspace related CRUD', () => { + it('create', async () => { + const createResult = await osdTestServer.request + .post(root, `/api/saved_objects/${dashboard.type}`) + .send({ + attributes: dashboard.attributes, + workspaces: ['foo'], + }) + .expect(200); + + expect(createResult.body.workspaces).toEqual(['foo']); + await deleteItem({ + type: dashboard.type, + id: createResult.body.id, + }); + }); + + it('create-with-override', async () => { + const createResult = await osdTestServer.request + .post(root, `/api/saved_objects/${dashboard.type}`) + .send({ + attributes: dashboard.attributes, + workspaces: ['foo'], + }) + .expect(200); + + await osdTestServer.request + .post(root, `/api/saved_objects/${dashboard.type}/${createResult.body.id}?overwrite=true`) + .send({ + attributes: dashboard.attributes, + workspaces: ['bar'], + }) + .expect(409); + + await deleteItem({ + type: dashboard.type, + id: createResult.body.id, + }); + }); + + it('bulk create', async () => { + await clearFooAndBar(); + const createResultFoo = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=foo`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=bar`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + expect((createResultFoo.body.saved_objects as any[]).some((item) => item.error)).toEqual( + false + ); + expect( + (createResultFoo.body.saved_objects as any[]).every((item) => + isEqual(item.workspaces, ['foo']) + ) + ).toEqual(true); + expect((createResultBar.body.saved_objects as any[]).some((item) => item.error)).toEqual( + false + ); + expect( + (createResultBar.body.saved_objects as any[]).every((item) => + isEqual(item.workspaces, ['bar']) + ) + ).toEqual(true); + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); + + it('bulk create with conflict', async () => { + await clearFooAndBar(); + const createResultFoo = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=foo`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=bar`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + /** + * overwrite with workspaces + */ + const overwriteWithWorkspacesResult = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?overwrite=true&workspaces=foo`) + .send([ + { + ...dashboard, + id: 'bar', + }, + { + ...dashboard, + id: 'foo', + attributes: { + title: 'foo', + }, + }, + ]) + .expect(200); + + expect(overwriteWithWorkspacesResult.body.saved_objects[0].error.statusCode).toEqual(409); + expect(overwriteWithWorkspacesResult.body.saved_objects[1].attributes.title).toEqual('foo'); + expect(overwriteWithWorkspacesResult.body.saved_objects[1].workspaces).toEqual(['foo']); + + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); + + it('checkConflicts when importing ndjson', async () => { + await clearFooAndBar(); + const createResultFoo = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=foo`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=bar`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + const getResultFoo = await getItem({ + type: dashboard.type, + id: 'foo', + }); + const getResultBar = await getItem({ + type: dashboard.type, + id: 'bar', + }); + + const readableStream = new Readable(); + readableStream.push( + `Content-Disposition: form-data; name="file"; filename="tmp.ndjson"\r\n\r\n` + ); + readableStream.push( + [JSON.stringify(getResultFoo.body), JSON.stringify(getResultBar.body)].join('\n') + ); + readableStream.push(null); + + /** + * import with workspaces when conflicts + */ + const importWithWorkspacesResult = await osdTestServer.request + .post(root, `/api/saved_objects/_import?workspaces=foo&overwrite=false`) + .attach( + 'file', + Buffer.from( + [JSON.stringify(getResultFoo.body), JSON.stringify(getResultBar.body)].join('\n'), + 'utf-8' + ), + 'tmp.ndjson' + ) + .expect(200); + + expect(importWithWorkspacesResult.body.success).toEqual(false); + expect(importWithWorkspacesResult.body.errors.length).toEqual(1); + expect(importWithWorkspacesResult.body.errors[0].id).toEqual('foo'); + expect(importWithWorkspacesResult.body.errors[0].error.type).toEqual('conflict'); + + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); + + it('find by workspaces', async () => { + const createResultFoo = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=foo`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=bar`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + const findResult = await osdTestServer.request + .get(root, `/api/saved_objects/_find?workspaces=bar&type=${dashboard.type}`) + .expect(200); + + expect(findResult.body.total).toEqual(1); + expect(findResult.body.saved_objects[0].workspaces).toEqual(['bar']); + + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); + }); +}); diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index fb5d366dd454..7bb22474ee76 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -27,7 +27,6 @@ * specific language governing permissions and limitations * under the License. */ - import { SavedObjectsRepository } from './repository'; import * as getSearchDslNS from './search_dsl/search_dsl'; import { SavedObjectsErrorHelpers } from './errors'; @@ -54,6 +53,12 @@ const createGenericNotFoundError = (...args) => const createUnsupportedTypeError = (...args) => SavedObjectsErrorHelpers.createUnsupportedTypeError(...args).output.payload; +const omitWorkspace = (object) => { + const newObject = JSON.parse(JSON.stringify(object)); + delete newObject.workspaces; + return newObject; +}; + describe('SavedObjectsRepository', () => { let client; let savedObjectsRepository; @@ -168,7 +173,7 @@ describe('SavedObjectsRepository', () => { }); const getMockGetResponse = ( - { type, id, references, namespace: objectNamespace, originId }, + { type, id, references, namespace: objectNamespace, originId, workspaces }, namespace ) => { const namespaceId = objectNamespace === 'default' ? undefined : objectNamespace ?? namespace; @@ -182,6 +187,7 @@ describe('SavedObjectsRepository', () => { _source: { ...(registry.isSingleNamespace(type) && { namespace: namespaceId }), ...(registry.isMultiNamespace(type) && { namespaces: [namespaceId ?? 'default'] }), + workspaces, ...(originId && { originId }), type, [type]: { title: 'Testing' }, @@ -444,6 +450,7 @@ describe('SavedObjectsRepository', () => { references: [{ name: 'ref_0', type: 'test', id: '2' }], }; const namespace = 'foo-namespace'; + const workspace = 'foo-workspace'; const getMockBulkCreateResponse = (objects, namespace) => { return { @@ -480,7 +487,9 @@ describe('SavedObjectsRepository', () => { opensearchClientMock.createSuccessTransportRequestPromise(response) ); const result = await savedObjectsRepository.bulkCreate(objects, options); - expect(client.mget).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 1 : 0); + expect(client.mget).toHaveBeenCalledTimes( + multiNamespaceObjects?.length || options?.workspaces ? 1 : 0 + ); return result; }; @@ -683,6 +692,7 @@ describe('SavedObjectsRepository', () => { expect.anything() ); client.bulk.mockClear(); + client.mget.mockClear(); }; await test(undefined); await test(namespace); @@ -730,6 +740,16 @@ describe('SavedObjectsRepository', () => { await bulkCreateSuccess(objects, { namespace }); expectClientCallArgsAction(objects, { method: 'create', getId }); }); + + it(`adds workspaces to request body for any types`, async () => { + await bulkCreateSuccess([obj1, obj2], { workspaces: [workspace] }); + const expected = expect.objectContaining({ workspaces: [workspace] }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); }); describe('errors', () => { @@ -877,6 +897,74 @@ describe('SavedObjectsRepository', () => { const expectedError = expectErrorResult(obj3, { message: JSON.stringify(opensearchError) }); await bulkCreateError(obj3, opensearchError, expectedError); }); + + it(`returns error when there is a conflict with an existing saved object according to workspaces`, async () => { + const obj = { ...obj3, workspaces: ['foo'] }; + const response1 = { + status: 200, + docs: [ + { + found: true, + _id: `${obj1.type}:${obj1.id}`, + _source: { + type: obj1.type, + workspaces: ['bar'], + }, + }, + { + found: true, + _id: `${obj.type}:${obj.id}`, + _source: { + type: obj.type, + workspaces: obj.workspaces, + }, + }, + { + found: true, + _id: `${obj2.type}:${obj2.id}`, + _source: { + type: obj2.type, + }, + }, + ], + }; + client.mget.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise(response1) + ); + const response2 = getMockBulkCreateResponse([obj1, obj, obj2]); + client.bulk.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise(response2) + ); + + const options = { overwrite: true, workspaces: ['bar'] }; + const result = await savedObjectsRepository.bulkCreate([obj1, obj, obj2], options); + expect(client.bulk).toHaveBeenCalled(); + expect(client.mget).toHaveBeenCalled(); + + const body1 = { + docs: [ + expect.objectContaining({ _id: `${obj1.type}:${obj1.id}` }), + expect.objectContaining({ _id: `${obj.type}:${obj.id}` }), + expect.objectContaining({ _id: `${obj2.type}:${obj2.id}` }), + ], + }; + expect(client.mget).toHaveBeenCalledWith( + expect.objectContaining({ body: body1 }), + expect.anything() + ); + const body2 = [...expectObjArgs(obj1)]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body: body2 }), + expect.anything() + ); + expect(result).toEqual({ + saved_objects: [ + expectSuccess(obj1), + expectErrorConflict(obj, { metadata: { isNotOverwritable: true } }), + expectErrorConflict(obj2, { metadata: { isNotOverwritable: true } }), + ], + }); + }); }); describe('migration', () => { @@ -1699,6 +1787,8 @@ describe('SavedObjectsRepository', () => { const obj5 = { type: MULTI_NAMESPACE_TYPE, id: 'five' }; const obj6 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'six' }; const obj7 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'seven' }; + const obj8 = { type: 'dashboard', id: 'eight', workspaces: ['foo'] }; + const obj9 = { type: 'dashboard', id: 'nine', workspaces: ['bar'] }; const namespace = 'foo-namespace'; const checkConflicts = async (objects, options) => @@ -1790,6 +1880,8 @@ describe('SavedObjectsRepository', () => { { found: false }, getMockGetResponse(obj6), { found: false }, + getMockGetResponse(obj7), + getMockGetResponse(obj8), ], }; client.mget.mockResolvedValue( @@ -1818,6 +1910,36 @@ describe('SavedObjectsRepository', () => { ], }); }); + + it(`expected results with workspaces`, async () => { + const objects = [obj8, obj9]; + const response = { + status: 200, + docs: [getMockGetResponse(obj8), getMockGetResponse(obj9)], + }; + client.mget.mockResolvedValue( + opensearchClientMock.createSuccessTransportRequestPromise(response) + ); + + const result = await checkConflicts(objects, { + workspaces: ['foo'], + }); + expect(client.mget).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + errors: [ + { ...omitWorkspace(obj8), error: createConflictError(obj8.type, obj8.id) }, + { + ...omitWorkspace(obj9), + error: { + ...createConflictError(obj9.type, obj9.id), + metadata: { + isNotOverwritable: true, + }, + }, + }, + ], + }); + }); }); }); @@ -1846,9 +1968,17 @@ describe('SavedObjectsRepository', () => { const createSuccess = async (type, attributes, options) => { const result = await savedObjectsRepository.create(type, attributes, options); - expect(client.get).toHaveBeenCalledTimes( - registry.isMultiNamespace(type) && options.overwrite ? 1 : 0 - ); + let count = 0; + if (options?.overwrite && options.id && options.workspaces) { + /** + * workspace will call extra one to get latest status of current object + */ + count++; + } + if (registry.isMultiNamespace(type) && options.overwrite) { + count++; + } + expect(client.get).toHaveBeenCalledTimes(count); return result; }; @@ -2040,6 +2170,29 @@ describe('SavedObjectsRepository', () => { expect.anything() ); }); + + it(`doesn't modify workspaces when overwrite without target workspaces`, async () => { + const response = getMockGetResponse({ workspaces: ['foo'], id }); + client.get.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise(response) + ); + + await savedObjectsRepository.create('dashboard', attributes, { + id, + overwrite: true, + workspaces: [], + }); + + expect(client.index).toHaveBeenCalledWith( + expect.objectContaining({ + id: `dashboard:${id}`, + body: expect.objectContaining({ + workspaces: ['foo'], + }), + }), + expect.anything() + ); + }); }); describe('errors', () => { @@ -2100,6 +2253,21 @@ describe('SavedObjectsRepository', () => { expect(client.get).toHaveBeenCalled(); }); + it(`throws error when there is a conflict with an existing workspaces saved object`, async () => { + const response = getMockGetResponse({ workspaces: ['foo'], id }); + client.get.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise(response) + ); + await expect( + savedObjectsRepository.create('dashboard', attributes, { + id, + overwrite: true, + workspaces: ['bar'], + }) + ).rejects.toThrowError(createConflictError('dashboard', id)); + expect(client.get).toHaveBeenCalled(); + }); + it.todo(`throws when automatic index creation fails`); it.todo(`throws when an unexpected failure occurs`); @@ -2186,10 +2354,11 @@ describe('SavedObjectsRepository', () => { const type = 'index-pattern'; const id = 'logstash-*'; const namespace = 'foo-namespace'; + const workspaces = ['bar-workspace']; const deleteSuccess = async (type, id, options) => { if (registry.isMultiNamespace(type)) { - const mockGetResponse = getMockGetResponse({ type, id }, options?.namespace); + const mockGetResponse = getMockGetResponse({ type, id }, options?.namespace, workspaces); client.get.mockResolvedValueOnce( opensearchClientMock.createSuccessTransportRequestPromise(mockGetResponse) ); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index bccfd8ff2265..daa40075caf9 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -243,6 +243,7 @@ export class SavedObjectsRepository { originId, initialNamespaces, version, + workspaces, } = options; const namespace = normalizeNamespace(options.namespace); @@ -279,6 +280,29 @@ export class SavedObjectsRepository { } } + let savedObjectWorkspaces = workspaces; + + if (id && overwrite && workspaces) { + let currentItem; + try { + currentItem = await this.get(type, id); + } catch (e) { + // this.get will throw an error when no items can be found + } + if (currentItem) { + if ( + SavedObjectsUtils.filterWorkspacesAccordingToBaseWorkspaces( + workspaces, + currentItem.workspaces + ).length + ) { + throw SavedObjectsErrorHelpers.createConflictError(type, id); + } else { + savedObjectWorkspaces = currentItem.workspaces; + } + } + } + const migrated = this._migrator.migrateDocument({ id, type, @@ -289,6 +313,7 @@ export class SavedObjectsRepository { migrationVersion, updated_at: time, ...(Array.isArray(references) && { references }), + ...(Array.isArray(savedObjectWorkspaces) && { workspaces: savedObjectWorkspaces }), }); const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); @@ -355,15 +380,28 @@ export class SavedObjectsRepository { const method = object.id && overwrite ? 'index' : 'create'; const requiresNamespacesCheck = object.id && this._registry.isMultiNamespace(object.type); + /** + * It requires a check when overwriting objects to target workspaces + */ + const requiresWorkspaceCheck = !!(object.id && options.workspaces); if (object.id == null) object.id = uuid.v1(); + let opensearchRequestIndexPayload = {}; + + if (requiresNamespacesCheck || requiresWorkspaceCheck) { + opensearchRequestIndexPayload = { + opensearchRequestIndex: bulkGetRequestIndexCounter, + }; + bulkGetRequestIndexCounter++; + } + return { tag: 'Right' as 'Right', value: { method, object, - ...(requiresNamespacesCheck && { opensearchRequestIndex: bulkGetRequestIndexCounter++ }), + ...opensearchRequestIndexPayload, }, }; }); @@ -374,7 +412,7 @@ export class SavedObjectsRepository { .map(({ value: { object: { type, id } } }) => ({ _id: this._serializer.generateRawId(namespace, type, id), _index: this.getIndexForType(type), - _source: ['type', 'namespaces'], + _source: ['type', 'namespaces', 'workspaces'], })); const bulkGetResponse = bulkGetDocs.length ? await this.client.mget( @@ -405,7 +443,7 @@ export class SavedObjectsRepository { if (opensearchRequestIndex !== undefined) { const indexFound = bulkGetResponse?.statusCode !== 404; const actualResult = indexFound - ? bulkGetResponse?.body.docs[opensearchRequestIndex] + ? bulkGetResponse?.body.docs?.[opensearchRequestIndex] : undefined; const docFound = indexFound && actualResult?.found === true; // @ts-expect-error MultiGetHit._source is optional @@ -438,6 +476,50 @@ export class SavedObjectsRepository { versionProperties = getExpectedVersionProperties(version); } + let savedObjectWorkspaces: string[] | undefined = options.workspaces; + + if (expectedBulkGetResult.value.method !== 'create') { + const rawId = this._serializer.generateRawId(namespace, object.type, object.id); + const findObject = + bulkGetResponse?.statusCode !== 404 + ? bulkGetResponse?.body.docs?.find((item) => item._id === rawId) + : null; + /** + * When it is about to overwrite a object into options.workspace. + * We need to check if the options.workspaces is the subset of object.workspaces, + * Or it will be treated as a conflict + */ + if (findObject && findObject.found) { + const transformedObject = this._serializer.rawToSavedObject( + findObject as SavedObjectsRawDoc + ) as SavedObject; + const filteredWorkspaces = SavedObjectsUtils.filterWorkspacesAccordingToBaseWorkspaces( + options.workspaces, + transformedObject.workspaces + ); + if (filteredWorkspaces.length) { + /** + * options.workspaces is not a subset of object.workspaces, + * return a conflict error. + */ + const { id, type } = object; + return { + tag: 'Left' as 'Left', + error: { + id, + type, + error: { + ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), + metadata: { isNotOverwritable: true }, + }, + }, + }; + } else { + savedObjectWorkspaces = transformedObject.workspaces; + } + } + } + const expectedResult = { opensearchRequestIndex: bulkRequestIndexCounter++, requestedId: object.id, @@ -452,6 +534,7 @@ export class SavedObjectsRepository { updated_at: time, references: object.references || [], originId: object.originId, + workspaces: savedObjectWorkspaces, }) as SavedObjectSanitizedDoc ), }; @@ -549,7 +632,7 @@ export class SavedObjectsRepository { const bulkGetDocs = expectedBulkGetResults.filter(isRight).map(({ value: { type, id } }) => ({ _id: this._serializer.generateRawId(namespace, type, id), _index: this.getIndexForType(type), - _source: ['type', 'namespaces'], + _source: ['type', 'namespaces', 'workspaces'], })); const bulkGetResponse = bulkGetDocs.length ? await this.client.mget( @@ -572,13 +655,24 @@ export class SavedObjectsRepository { const { type, id, opensearchRequestIndex } = expectedResult.value; const doc = bulkGetResponse?.body.docs[opensearchRequestIndex]; if (doc?.found) { + let workspaceConflict = false; + if (options.workspaces) { + const transformedObject = this._serializer.rawToSavedObject(doc as SavedObjectsRawDoc); + const filteredWorkspaces = SavedObjectsUtils.filterWorkspacesAccordingToBaseWorkspaces( + options.workspaces, + transformedObject.workspaces + ); + if (filteredWorkspaces.length) { + workspaceConflict = true; + } + } errors.push({ id, type, error: { ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), // @ts-expect-error MultiGetHit._source is optional - ...(!this.rawDocExistsInNamespace(doc!, namespace) && { + ...((!this.rawDocExistsInNamespace(doc!, namespace) || workspaceConflict) && { metadata: { isNotOverwritable: true }, }), }, @@ -736,6 +830,7 @@ export class SavedObjectsRepository { typeToNamespacesMap, filter, preference, + workspaces, } = options; if (!type && !typeToNamespacesMap) { @@ -809,6 +904,7 @@ export class SavedObjectsRepository { typeToNamespacesMap, hasReference, kueryNode, + workspaces, }), }, }; @@ -862,7 +958,7 @@ export class SavedObjectsRepository { */ async bulkGet( objects: SavedObjectsBulkGetObject[] = [], - options: SavedObjectsBaseOptions = {} + options: Omit = {} ): Promise> { const namespace = normalizeNamespace(options.namespace); @@ -950,7 +1046,7 @@ export class SavedObjectsRepository { async get( type: string, id: string, - options: SavedObjectsBaseOptions = {} + options: Omit = {} ): Promise> { if (!this._allowedTypes.includes(type)) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); @@ -976,7 +1072,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { originId, updated_at: updatedAt } = body._source; + const { originId, updated_at: updatedAt, workspaces } = body._source; let namespaces: string[] = []; if (!this._registry.isNamespaceAgnostic(type)) { @@ -991,6 +1087,7 @@ export class SavedObjectsRepository { namespaces, ...(originId && { originId }), ...(updatedAt && { updated_at: updatedAt }), + ...(workspaces && { workspaces }), version: encodeHitVersion(body), attributes: body._source[type], references: body._source.references || [], @@ -1055,7 +1152,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { originId } = body.get?._source ?? {}; + const { originId, workspaces } = body.get?._source ?? {}; let namespaces: string[] = []; if (!this._registry.isNamespaceAgnostic(type)) { namespaces = body.get?._source.namespaces ?? [ @@ -1070,6 +1167,7 @@ export class SavedObjectsRepository { version: encodeHitVersion(body), namespaces, ...(originId && { originId }), + ...(workspaces && { workspaces }), references, attributes, }; @@ -1452,12 +1550,13 @@ export class SavedObjectsRepository { }; } - const { originId } = get._source; + const { originId, workspaces } = get._source; return { id, type, ...(namespaces && { namespaces }), ...(originId && { originId }), + ...(workspaces && { workspaces }), updated_at, version: encodeVersion(seqNo, primaryTerm), attributes, @@ -1754,7 +1853,7 @@ function getSavedObjectFromSource( id: string, doc: { _seq_no?: number; _primary_term?: number; _source: SavedObjectsRawDocSource } ): SavedObject { - const { originId, updated_at: updatedAt } = doc._source; + const { originId, updated_at: updatedAt, workspaces } = doc._source; let namespaces: string[] = []; if (!registry.isNamespaceAgnostic(type)) { @@ -1769,6 +1868,7 @@ function getSavedObjectFromSource( namespaces, ...(originId && { originId }), ...(updatedAt && { updated_at: updatedAt }), + ...(workspaces && { workspaces }), version: encodeHitVersion(doc), attributes: doc._source[type], references: doc._source.references || [], diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts index 518e2ff56d0e..a47bc27fcd92 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts @@ -625,6 +625,27 @@ describe('#getQueryParams', () => { ]); }); }); + + describe('when using workspace search', () => { + it('using normal workspaces', () => { + const result: Result = getQueryParams({ + registry, + workspaces: ['foo'], + }); + expect(result.query.bool.filter[1]).toEqual({ + bool: { + should: [ + { + bool: { + must: [{ term: { workspaces: 'foo' } }], + }, + }, + ], + minimum_should_match: 1, + }, + }); + }); + }); }); describe('namespaces property', () => { diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index 5bbb0a1fe24f..4236eaf7fc74 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -127,6 +127,20 @@ function getClauseForType( }, }; } +/** + * Gets the clause that will filter for the workspace. + */ +function getClauseForWorkspace(workspace: string) { + if (!workspace) { + return {}; + } + + return { + bool: { + must: [{ term: { workspaces: workspace } }], + }, + }; +} interface HasReferenceQueryParams { type: string; @@ -144,6 +158,7 @@ interface QueryParams { defaultSearchOperator?: string; hasReference?: HasReferenceQueryParams; kueryNode?: KueryNode; + workspaces?: string[]; } export function getClauseForReference(reference: HasReferenceQueryParams) { @@ -200,6 +215,7 @@ export function getQueryParams({ defaultSearchOperator, hasReference, kueryNode, + workspaces, }: QueryParams) { const types = getTypes( registry, @@ -224,6 +240,19 @@ export function getQueryParams({ ], }; + if (workspaces?.filter((workspace) => workspace).length) { + bool.filter.push({ + bool: { + should: workspaces + .filter((workspace) => workspace) + .map((workspace) => { + return getClauseForWorkspace(workspace); + }), + minimum_should_match: 1, + }, + }); + } + if (search) { const useMatchPhrasePrefix = shouldUseMatchPhrasePrefix(search); const simpleQueryStringClause = getSimpleQueryStringClause({ diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index 8b54141a4c3c..df6109eb9d0a 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -52,6 +52,7 @@ interface GetSearchDslOptions { id: string; }; kueryNode?: KueryNode; + workspaces?: string[]; } export function getSearchDsl( @@ -71,6 +72,7 @@ export function getSearchDsl( typeToNamespacesMap, hasReference, kueryNode, + workspaces, } = options; if (!type) { @@ -93,6 +95,7 @@ export function getSearchDsl( defaultSearchOperator, hasReference, kueryNode, + workspaces, }), ...getSortingParams(mappings, type, sortField, sortOrder), }; diff --git a/src/core/server/saved_objects/service/lib/utils.ts b/src/core/server/saved_objects/service/lib/utils.ts index 4823e52d77c9..9fc4a6280b63 100644 --- a/src/core/server/saved_objects/service/lib/utils.ts +++ b/src/core/server/saved_objects/service/lib/utils.ts @@ -80,4 +80,11 @@ export class SavedObjectsUtils { total: 0, saved_objects: [], }); + + public static filterWorkspacesAccordingToBaseWorkspaces( + targetWorkspaces?: string[], + sourceWorkspaces?: string[] + ): string[] { + return targetWorkspaces?.filter((item) => !sourceWorkspaces?.includes(item)) || []; + } } diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index 5f92dacacf36..a087dc6c388a 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -363,7 +363,7 @@ export class SavedObjectsClient { */ async bulkGet( objects: SavedObjectsBulkGetObject[] = [], - options: SavedObjectsBaseOptions = {} + options: Omit = {} ): Promise> { return await this._repository.bulkGet(objects, options); } diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 3e2553b8ce51..1b1409570fea 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -110,6 +110,8 @@ export interface SavedObjectsFindOptions { typeToNamespacesMap?: Map; /** An optional OpenSearch preference value to be used for the query **/ preference?: string; + /** If specified, will find all objects belong to specified workspaces **/ + workspaces?: string[]; } /** @@ -119,6 +121,8 @@ export interface SavedObjectsFindOptions { export interface SavedObjectsBaseOptions { /** Specify the namespace for this operation */ namespace?: string; + /** Specify the workspaces for this operation */ + workspaces?: string[]; } /** diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index 81e1ed029ddc..fa4f5ab97fdf 100644 --- a/src/core/types/saved_objects.ts +++ b/src/core/types/saved_objects.ts @@ -113,6 +113,8 @@ export interface SavedObject { * space. */ originId?: string; + /** Workspaces that this saved object exists in. */ + workspaces?: string[]; } export interface SavedObjectError { From 87c0c8ac6afc787bb000ca0dc28d656f57b9c98d Mon Sep 17 00:00:00 2001 From: tygao Date: Tue, 10 Oct 2023 15:13:58 +0800 Subject: [PATCH 10/21] Register Advance Settings, Data Source management,Index Pattern management and SavedObject management as standalone app, retire dashboard management (#208) * feat: init retire dashboard management Signed-off-by: tygao * move index pattern to Library (#91) * move index pattern to libaray Signed-off-by: Hailong Cui * Remove it from Dashboards management when workspace is on Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui index pattern always show under library Signed-off-by: Hailong Cui * functional test Signed-off-by: Hailong Cui * feat: move data source / advanced settings / saved objects management out of Dashboard management Signed-off-by: SuZhou-Joe * feat: update test Signed-off-by: SuZhou-Joe * feat: update snapshot Signed-off-by: SuZhou-Joe * feat: update snapshot Signed-off-by: SuZhou-Joe * fix: fix failed overview header ut Signed-off-by: tygao * fix: deeplink inside saved objects management page Signed-off-by: SuZhou-Joe * fix: unit test fail Signed-off-by: SuZhou-Joe * feat: add unit test for each page wrapper Signed-off-by: SuZhou-Joe * feat: some optimization Signed-off-by: SuZhou-Joe * remove management dependency Signed-off-by: Hailong Cui * test: update cypress config to use workspace branch Signed-off-by: tygao * Replace ManagementAppMountParams with AppMountParameters Signed-off-by: Hailong Cui --------- Signed-off-by: tygao Signed-off-by: Hailong Cui Signed-off-by: SuZhou-Joe Co-authored-by: Hailong Cui Co-authored-by: SuZhou-Joe --- .github/workflows/cypress_workflow.yml | 6 +- .../with-security/check_advanced_settings.js | 2 +- .../with-security/helpers/generate_data.js | 4 +- .../check_advanced_settings.js | 2 +- .../without-security/helpers/generate_data.js | 2 +- .../core_app/errors/url_overflow.test.ts | 2 +- .../public/core_app/errors/url_overflow.tsx | 2 +- .../core_app/errors/url_overflow_ui.tsx | 2 +- .../ui_settings/saved_objects/ui_settings.ts | 2 +- .../__snapshots__/page_wrapper.test.tsx.snap | 13 +++ .../components/page_wrapper/index.ts | 6 ++ .../page_wrapper/page_wrapper.test.tsx | 16 ++++ .../components/page_wrapper/page_wrapper.tsx | 21 +++++ .../mount_management_section.tsx | 55 +++++++----- .../advanced_settings/public/plugin.ts | 24 ++--- .../server/saved_objects/dashboard.ts | 4 +- .../index_patterns/index_patterns.ts | 6 +- .../redirect_no_index_pattern.tsx | 8 +- .../public/search/errors/painless_error.tsx | 4 +- .../server/saved_objects/index_patterns.ts | 8 +- .../server/saved_objects/data_source.ts | 4 +- .../opensearch_dashboards.json | 2 +- .../data_source_column/data_source_column.tsx | 6 +- .../__snapshots__/page_wrapper.test.tsx.snap | 13 +++ .../public/components/page_wrapper/index.ts | 6 ++ .../page_wrapper/page_wrapper.test.tsx | 16 ++++ .../components/page_wrapper/page_wrapper.tsx | 21 +++++ .../public/management_app/index.ts | 2 +- .../mount_management_section.tsx | 69 +++++++++------ .../data_source_management/public/plugin.ts | 35 ++++---- .../data_source_management/public/types.ts | 5 ++ .../open_search_panel.test.tsx.snap | 2 +- .../components/top_nav/open_search_panel.tsx | 2 +- .../discover/server/saved_objects/search.ts | 4 +- .../open_search_panel.test.js.snap | 2 +- .../components/top_nav/open_search_panel.js | 2 +- .../components/new_theme_modal.tsx | 4 +- .../opensearch_dashboards.json | 2 +- .../mount_management_section.tsx | 73 +++++++++------ .../index_pattern_management/public/plugin.ts | 33 ++++--- .../getting_started.test.tsx.snap | 4 +- .../getting_started/getting_started.tsx | 6 +- .../overview_page_footer.tsx | 2 +- .../overview_page_header.test.tsx | 2 +- .../overview_page_header.tsx | 2 +- .../table_list_view/table_list_view.tsx | 2 +- .../saved_objects_management/README.md | 8 +- .../public/constants.ts | 41 +++++++++ .../management_section/mount_section.tsx | 64 ++++++++------ .../saved_objects_table.test.tsx.snap | 10 +-- .../__snapshots__/header.test.tsx.snap | 8 +- .../__snapshots__/relationships.test.tsx.snap | 20 ++--- .../__snapshots__/table.test.tsx.snap | 8 +- .../objects_table/components/header.tsx | 9 +- .../components/relationships.test.tsx | 34 +++---- .../objects_table/components/table.test.tsx | 8 +- .../saved_objects_table.test.tsx | 14 +-- .../objects_table/saved_objects_table.tsx | 6 +- .../__snapshots__/page_wrapper.test.tsx.snap | 13 +++ .../management_section/page_wrapper/index.ts | 6 ++ .../page_wrapper/page_wrapper.test.tsx | 16 ++++ .../page_wrapper/page_wrapper.tsx | 21 +++++ .../saved_objects_table_page.tsx | 13 +-- .../saved_objects_management/public/plugin.ts | 88 ++++++++++++++----- .../server/saved_objects/augment_vis.ts | 4 +- .../server/saved_objects/vis_builder_app.ts | 3 +- .../server/saved_objects/visualization.ts | 4 +- .../apis/saved_objects_management/find.ts | 21 ++--- .../saved_objects_management/relationships.ts | 48 ++++------ .../dashboard/create_and_add_embeddables.js | 6 +- test/functional/apps/dashboard/time_zones.js | 2 - .../apps/management/_import_objects.js | 2 - .../_index_pattern_create_delete.js | 2 +- .../management/_mgmt_import_saved_objects.js | 1 - .../_opensearch_dashboards_settings.js | 6 +- .../apps/management/_scripted_fields.js | 7 -- .../management/_scripted_fields_filter.js | 1 - .../edit_saved_object.ts | 2 - .../apps/visualize/_custom_branding.ts | 10 +-- test/functional/apps/visualize/_lab_mode.js | 6 +- test/functional/apps/visualize/_tag_cloud.js | 2 - test/functional/config.js | 4 - test/functional/page_objects/settings_page.ts | 6 +- 83 files changed, 635 insertions(+), 399 deletions(-) create mode 100644 src/plugins/advanced_settings/public/management_app/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap create mode 100644 src/plugins/advanced_settings/public/management_app/components/page_wrapper/index.ts create mode 100644 src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.test.tsx create mode 100644 src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.tsx create mode 100644 src/plugins/data_source_management/public/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap create mode 100644 src/plugins/data_source_management/public/components/page_wrapper/index.ts create mode 100644 src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.test.tsx create mode 100644 src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.tsx create mode 100644 src/plugins/saved_objects_management/public/constants.ts create mode 100644 src/plugins/saved_objects_management/public/management_section/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap create mode 100644 src/plugins/saved_objects_management/public/management_section/page_wrapper/index.ts create mode 100644 src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.test.tsx create mode 100644 src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.tsx diff --git a/.github/workflows/cypress_workflow.yml b/.github/workflows/cypress_workflow.yml index cb5af78fcfc5..f96a13558787 100644 --- a/.github/workflows/cypress_workflow.yml +++ b/.github/workflows/cypress_workflow.yml @@ -3,7 +3,7 @@ name: Run cypress tests # trigger on every PR for all branches on: pull_request: - branches: [ '**' ] + branches: ['**'] paths-ignore: - '**/*.md' @@ -54,8 +54,8 @@ jobs: uses: actions/checkout@v2 with: path: ${{ env.FTR_PATH }} - repository: ruanyl/opensearch-dashboards-functional-test - ref: '${{ github.base_ref }}' + repository: opensearch-project/opensearch-dashboards-functional-test + ref: 'workspace' - name: Get Cypress version id: cypress_version diff --git a/cypress/integration/with-security/check_advanced_settings.js b/cypress/integration/with-security/check_advanced_settings.js index 9ca41207724e..379362063e92 100644 --- a/cypress/integration/with-security/check_advanced_settings.js +++ b/cypress/integration/with-security/check_advanced_settings.js @@ -13,7 +13,7 @@ const loginPage = new LoginPage(cy); describe('verify the advanced settings are saved', () => { beforeEach(() => { - miscUtils.visitPage('app/management/opensearch-dashboards/settings'); + miscUtils.visitPage('app/settings'); loginPage.enterUserName('admin'); loginPage.enterPassword('admin'); loginPage.submit(); diff --git a/cypress/integration/with-security/helpers/generate_data.js b/cypress/integration/with-security/helpers/generate_data.js index dcd711fc7c18..c2c4d2dbe57d 100755 --- a/cypress/integration/with-security/helpers/generate_data.js +++ b/cypress/integration/with-security/helpers/generate_data.js @@ -13,7 +13,7 @@ const loginPage = new LoginPage(cy); describe('Generating BWC test data with security', () => { beforeEach(() => { - miscUtils.visitPage('app/management/opensearch-dashboards/settings'); + miscUtils.visitPage('app/settings'); loginPage.enterUserName('admin'); loginPage.enterPassword('admin'); loginPage.submit(); @@ -29,7 +29,7 @@ describe('Generating BWC test data with security', () => { }); it('adds advanced settings', () => { - miscUtils.visitPage('app/management/opensearch-dashboards/settings'); + miscUtils.visitPage('app/settings'); cy.get('[data-test-subj="advancedSetting-editField-theme:darkMode"]').click(); cy.get('[data-test-subj="advancedSetting-editField-timeline:max_buckets"]').type( '{selectAll}4' diff --git a/cypress/integration/without-security/check_advanced_settings.js b/cypress/integration/without-security/check_advanced_settings.js index 9268d86a16e5..0094d53835b0 100644 --- a/cypress/integration/without-security/check_advanced_settings.js +++ b/cypress/integration/without-security/check_advanced_settings.js @@ -9,7 +9,7 @@ const miscUtils = new MiscUtils(cy); describe('verify the advanced settings are saved', () => { beforeEach(() => { - miscUtils.visitPage('app/management/opensearch-dashboards/settings'); + miscUtils.visitPage('app/settings'); }); it('the dark mode is on', () => { diff --git a/cypress/integration/without-security/helpers/generate_data.js b/cypress/integration/without-security/helpers/generate_data.js index 47e9c2f5f5ed..3aff136a70e0 100755 --- a/cypress/integration/without-security/helpers/generate_data.js +++ b/cypress/integration/without-security/helpers/generate_data.js @@ -12,7 +12,7 @@ describe('Generating BWC test data without security', () => { miscUtils.visitPage('app'); }); it('adds advanced settings', () => { - miscUtils.visitPage('app/management/opensearch-dashboards/settings'); + miscUtils.visitPage('app/settings'); cy.get('[data-test-subj="advancedSetting-editField-theme:darkMode"]').click(); cy.get('[data-test-subj="advancedSetting-editField-timeline:max_buckets"]').type( '{selectAll}4' diff --git a/src/core/public/core_app/errors/url_overflow.test.ts b/src/core/public/core_app/errors/url_overflow.test.ts index b2eee9c17d58..fe9cb8dca661 100644 --- a/src/core/public/core_app/errors/url_overflow.test.ts +++ b/src/core/public/core_app/errors/url_overflow.test.ts @@ -102,7 +102,7 @@ describe('url overflow detection', () => { option in advanced settings diff --git a/src/core/public/core_app/errors/url_overflow.tsx b/src/core/public/core_app/errors/url_overflow.tsx index 6dbfa96fff46..1de6fe785cf9 100644 --- a/src/core/public/core_app/errors/url_overflow.tsx +++ b/src/core/public/core_app/errors/url_overflow.tsx @@ -92,7 +92,7 @@ export const setupUrlOverflowDetection = ({ basePath, history, toasts, uiSetting values={{ storeInSessionStorageParam: state:storeInSessionStorage, advancedSettingsLink: ( - + = ({ basePath }) = values={{ storeInSessionStorageConfig: state:storeInSessionStorage, opensearchDashboardsSettingsLink: ( - + +
+ Foo +
+ +`; diff --git a/src/plugins/advanced_settings/public/management_app/components/page_wrapper/index.ts b/src/plugins/advanced_settings/public/management_app/components/page_wrapper/index.ts new file mode 100644 index 000000000000..3cf0cdd26c99 --- /dev/null +++ b/src/plugins/advanced_settings/public/management_app/components/page_wrapper/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { PageWrapper } from './page_wrapper'; diff --git a/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.test.tsx b/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.test.tsx new file mode 100644 index 000000000000..550eb3ee1cae --- /dev/null +++ b/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.test.tsx @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { PageWrapper } from './page_wrapper'; + +describe('PageWrapper', () => { + it('should render normally', async () => { + const { findByText, container } = render(Foo); + await findByText('Foo'); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.tsx b/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.tsx new file mode 100644 index 000000000000..1b1949c334e4 --- /dev/null +++ b/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.tsx @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiPageContent } from '@elastic/eui'; +import React from 'react'; + +export const PageWrapper = (props: { children?: React.ReactChild }) => { + return ( + + ); +}; 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 7fa0b9ddd2c0..648382771ba8 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,18 +34,24 @@ 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, + ChromeBreadcrumb, + ScopedHistory, + StartServicesAccessor, +} from 'src/core/public'; import { AdvancedSettings } from './advanced_settings'; -import { ManagementAppMountParams } from '../../../management/public'; import { ComponentRegistry } from '../types'; +import { reactRouterNavigate } from '../../../opensearch_dashboards_react/public'; +import { PageWrapper } from './components/page_wrapper'; import './index.scss'; const title = i18n.translate('advancedSettings.advancedSettingsLabel', { defaultMessage: 'Advanced settings', }); -const crumb = [{ text: title }]; +const crumb: ChromeBreadcrumb[] = [{ text: title }]; const readOnlyBadge = { text: i18n.translate('advancedSettings.badge.readOnly.text', { @@ -57,13 +63,18 @@ const readOnlyBadge = { iconType: 'glasses', }; -export async function mountManagementSection( +export async function mountAdvancedSettingsManagementSection( getStartServices: StartServicesAccessor, - params: ManagementAppMountParams, + params: AppMountParameters, componentRegistry: ComponentRegistry['start'] ) { - params.setBreadcrumbs(crumb); const [{ uiSettings, notifications, docLinks, application, chrome }] = await getStartServices(); + chrome.setBreadcrumbs([ + ...crumb.map((item) => ({ + ...item, + ...(item.href ? reactRouterNavigate(params.history, item.href) : {}), + })), + ]); const canSave = application.capabilities.advancedSettings.save as boolean; @@ -72,21 +83,23 @@ export async function mountManagementSection( } ReactDOM.render( - - - - - - - - - , + + + + + + + + + + + , params.element ); return () => { diff --git a/src/plugins/advanced_settings/public/plugin.ts b/src/plugins/advanced_settings/public/plugin.ts index 608bfc6a25e7..91fe18612749 100644 --- a/src/plugins/advanced_settings/public/plugin.ts +++ b/src/plugins/advanced_settings/public/plugin.ts @@ -29,10 +29,11 @@ */ import { i18n } from '@osd/i18n'; -import { CoreSetup, Plugin } from 'opensearch-dashboards/public'; +import { AppMountParameters, CoreSetup, Plugin } from 'opensearch-dashboards/public'; import { FeatureCatalogueCategory } from '../../home/public'; import { ComponentRegistry } from './component_registry'; import { AdvancedSettingsSetup, AdvancedSettingsStart, AdvancedSettingsPluginSetup } from './types'; +import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; const component = new ComponentRegistry(); @@ -42,18 +43,21 @@ const title = i18n.translate('advancedSettings.advancedSettingsLabel', { export class AdvancedSettingsPlugin implements Plugin { - public setup(core: CoreSetup, { management, home }: AdvancedSettingsPluginSetup) { - const opensearchDashboardsSection = management.sections.section.opensearchDashboards; - - opensearchDashboardsSection.registerApp({ + public setup(core: CoreSetup, { home }: AdvancedSettingsPluginSetup) { + core.application.register({ id: 'settings', title, - order: 3, - async mount(params) { - const { mountManagementSection } = await import( + order: 99, + category: DEFAULT_APP_CATEGORIES.management, + async mount(params: AppMountParameters) { + const { mountAdvancedSettingsManagementSection } = await import( './management_app/mount_management_section' ); - return mountManagementSection(core.getStartServices, params, component.start); + return mountAdvancedSettingsManagementSection( + core.getStartServices, + params, + component.start + ); }, }); @@ -66,7 +70,7 @@ export class AdvancedSettingsPlugin 'Customize your OpenSearch Dashboards experience — change the date format, turn on dark mode, and more.', }), icon: 'gear', - path: '/app/management/opensearch-dashboards/settings', + path: '/app/settings', showOnHomePage: false, category: FeatureCatalogueCategory.ADMIN, }); diff --git a/src/plugins/dashboard/server/saved_objects/dashboard.ts b/src/plugins/dashboard/server/saved_objects/dashboard.ts index ee2c162733bc..6d6a08954fbe 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard.ts @@ -43,9 +43,7 @@ export const dashboardSavedObjectType: SavedObjectsType = { return obj.attributes.title; }, getEditUrl(obj) { - return `/management/opensearch-dashboards/objects/savedDashboards/${encodeURIComponent( - obj.id - )}`; + return `/objects/savedDashboards/${encodeURIComponent(obj.id)}`; }, getInAppUrl(obj) { return { diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts index 688605821097..489ad154afa0 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts @@ -418,11 +418,7 @@ export class IndexPatternsService { ); if (!savedObject.version) { - throw new SavedObjectNotFound( - savedObjectType, - id, - 'management/opensearch-dashboards/indexPatterns' - ); + throw new SavedObjectNotFound(savedObjectType, id, 'indexPatterns'); } const spec = this.savedObjectToSpec(savedObject); diff --git a/src/plugins/data/public/index_patterns/index_patterns/redirect_no_index_pattern.tsx b/src/plugins/data/public/index_patterns/index_patterns/redirect_no_index_pattern.tsx index b09bc8adde6f..1a43ab22aaae 100644 --- a/src/plugins/data/public/index_patterns/index_patterns/redirect_no_index_pattern.tsx +++ b/src/plugins/data/public/index_patterns/index_patterns/redirect_no_index_pattern.tsx @@ -42,9 +42,7 @@ export const onRedirectNoIndexPattern = ( overlays: CoreStart['overlays'] ) => () => { const canManageIndexPatterns = capabilities.management.opensearchDashboards.indexPatterns; - const redirectTarget = canManageIndexPatterns - ? '/management/opensearch-dashboards/indexPatterns' - : '/home'; + const redirectTarget = canManageIndexPatterns ? '/indexPatterns' : '/home'; let timeoutId: NodeJS.Timeout | undefined; if (timeoutId) { @@ -72,8 +70,8 @@ export const onRedirectNoIndexPattern = ( if (redirectTarget === '/home') { navigateToApp('home'); } else { - navigateToApp('management', { - path: `/opensearch-dashboards/indexPatterns?bannerMessage=${bannerMessage}`, + navigateToApp('indexPatterns', { + path: `?bannerMessage=${bannerMessage}`, }); } diff --git a/src/plugins/data/public/search/errors/painless_error.tsx b/src/plugins/data/public/search/errors/painless_error.tsx index 1522dcf97cb0..ee11d77b98f5 100644 --- a/src/plugins/data/public/search/errors/painless_error.tsx +++ b/src/plugins/data/public/search/errors/painless_error.tsx @@ -53,9 +53,7 @@ export class PainlessError extends OsdError { public getErrorMessage(application: ApplicationStart) { function onClick() { - application.navigateToApp('management', { - path: `/opensearch-dashboards/indexPatterns`, - }); + application.navigateToApp('indexPatterns'); } return ( diff --git a/src/plugins/data/server/saved_objects/index_patterns.ts b/src/plugins/data/server/saved_objects/index_patterns.ts index 5f0864bac926..391adf6a973f 100644 --- a/src/plugins/data/server/saved_objects/index_patterns.ts +++ b/src/plugins/data/server/saved_objects/index_patterns.ts @@ -43,15 +43,11 @@ export const indexPatternSavedObjectType: SavedObjectsType = { return obj.attributes.title; }, getEditUrl(obj) { - return `/management/opensearch-dashboards/indexPatterns/patterns/${encodeURIComponent( - obj.id - )}`; + return `/indexPatterns/patterns/${encodeURIComponent(obj.id)}`; }, getInAppUrl(obj) { return { - path: `/app/management/opensearch-dashboards/indexPatterns/patterns/${encodeURIComponent( - obj.id - )}`, + path: `/app/indexPatterns/patterns/${encodeURIComponent(obj.id)}`, uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }; }, diff --git a/src/plugins/data_source/server/saved_objects/data_source.ts b/src/plugins/data_source/server/saved_objects/data_source.ts index 9404a4bcf371..58cace8ada2d 100644 --- a/src/plugins/data_source/server/saved_objects/data_source.ts +++ b/src/plugins/data_source/server/saved_objects/data_source.ts @@ -17,11 +17,11 @@ export const dataSource: SavedObjectsType = { return obj.attributes.title; }, getEditUrl(obj) { - return `/management/opensearch-dashboards/dataSources/${encodeURIComponent(obj.id)}`; + return `/dataSources/${encodeURIComponent(obj.id)}`; }, getInAppUrl(obj) { return { - path: `/app/management/opensearch-dashboards/dataSources/${encodeURIComponent(obj.id)}`, + path: `/app/dataSources/${encodeURIComponent(obj.id)}`, uiCapabilitiesPath: 'management.opensearchDashboards.dataSources', }; }, diff --git a/src/plugins/data_source_management/opensearch_dashboards.json b/src/plugins/data_source_management/opensearch_dashboards.json index 58e81a337e7d..6b58c63bb5a5 100644 --- a/src/plugins/data_source_management/opensearch_dashboards.json +++ b/src/plugins/data_source_management/opensearch_dashboards.json @@ -3,7 +3,7 @@ "version": "opensearchDashboards", "server": false, "ui": true, - "requiredPlugins": ["management", "dataSource", "indexPatternManagement"], + "requiredPlugins": ["dataSource", "indexPatternManagement"], "optionalPlugins": [], "requiredBundles": ["opensearchDashboardsReact"], "extraPublicDirs": ["public/components/utils"] diff --git a/src/plugins/data_source_management/public/components/data_source_column/data_source_column.tsx b/src/plugins/data_source_management/public/components/data_source_column/data_source_column.tsx index 640eb1b369fd..cd6fc7c17ae2 100644 --- a/src/plugins/data_source_management/public/components/data_source_column/data_source_column.tsx +++ b/src/plugins/data_source_management/public/components/data_source_column/data_source_column.tsx @@ -56,11 +56,7 @@ export class DataSourceColumn implements IndexPatternTableColumn ?.map((dataSource) => { return { ...dataSource, - relativeUrl: basePath.prepend( - `/app/management/opensearch-dashboards/dataSources/${encodeURIComponent( - dataSource.id - )}` - ), + relativeUrl: basePath.prepend(`/app/dataSources/${encodeURIComponent(dataSource.id)}`), }; }) ?.reduce( diff --git a/src/plugins/data_source_management/public/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap b/src/plugins/data_source_management/public/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap new file mode 100644 index 000000000000..3c5257e2e8d1 --- /dev/null +++ b/src/plugins/data_source_management/public/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PageWrapper should render normally 1`] = ` +
+
+ Foo +
+
+`; diff --git a/src/plugins/data_source_management/public/components/page_wrapper/index.ts b/src/plugins/data_source_management/public/components/page_wrapper/index.ts new file mode 100644 index 000000000000..3cf0cdd26c99 --- /dev/null +++ b/src/plugins/data_source_management/public/components/page_wrapper/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { PageWrapper } from './page_wrapper'; diff --git a/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.test.tsx b/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.test.tsx new file mode 100644 index 000000000000..550eb3ee1cae --- /dev/null +++ b/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.test.tsx @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { PageWrapper } from './page_wrapper'; + +describe('PageWrapper', () => { + it('should render normally', async () => { + const { findByText, container } = render(Foo); + await findByText('Foo'); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.tsx b/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.tsx new file mode 100644 index 000000000000..1b1949c334e4 --- /dev/null +++ b/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.tsx @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiPageContent } from '@elastic/eui'; +import React from 'react'; + +export const PageWrapper = (props: { children?: React.ReactChild }) => { + return ( + + ); +}; diff --git a/src/plugins/data_source_management/public/management_app/index.ts b/src/plugins/data_source_management/public/management_app/index.ts index 5ccbfb947646..960adc7ba5a6 100644 --- a/src/plugins/data_source_management/public/management_app/index.ts +++ b/src/plugins/data_source_management/public/management_app/index.ts @@ -3,4 +3,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { mountManagementSection } from './mount_management_section'; +export { mountDataSourcesManagementSection } from './mount_management_section'; diff --git a/src/plugins/data_source_management/public/management_app/mount_management_section.tsx b/src/plugins/data_source_management/public/management_app/mount_management_section.tsx index 9fe1f2406382..f61113042458 100644 --- a/src/plugins/data_source_management/public/management_app/mount_management_section.tsx +++ b/src/plugins/data_source_management/public/management_app/mount_management_section.tsx @@ -3,33 +3,42 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { StartServicesAccessor } from 'src/core/public'; +import { + AppMountParameters, + ChromeBreadcrumb, + ScopedHistory, + StartServicesAccessor, +} from 'src/core/public'; import { I18nProvider } from '@osd/i18n/react'; import React from 'react'; import ReactDOM from 'react-dom'; import { Route, Router, Switch } from 'react-router-dom'; -import { DataPublicPluginStart } from 'src/plugins/data/public'; -import { ManagementAppMountParams } from '../../../management/public'; - import { OpenSearchDashboardsContextProvider } from '../../../opensearch_dashboards_react/public'; import { CreateDataSourceWizardWithRouter } from '../components/create_data_source_wizard'; import { DataSourceTableWithRouter } from '../components/data_source_table'; -import { DataSourceManagementContext } from '../types'; +import { DataSourceManagementContext, DataSourceManagementStartDependencies } from '../types'; import { EditDataSourceWithRouter } from '../components/edit_data_source'; +import { PageWrapper } from '../components/page_wrapper'; +import { reactRouterNavigate } from '../../../opensearch_dashboards_react/public'; -export interface DataSourceManagementStartDependencies { - data: DataPublicPluginStart; -} - -export async function mountManagementSection( +export async function mountDataSourcesManagementSection( getStartServices: StartServicesAccessor, - params: ManagementAppMountParams + params: AppMountParameters ) { const [ { chrome, application, savedObjects, uiSettings, notifications, overlays, http, docLinks }, ] = await getStartServices(); + const setBreadcrumbsScoped = (crumbs: ChromeBreadcrumb[] = []) => { + const wrapBreadcrumb = (item: ChromeBreadcrumb, scopedHistory: ScopedHistory) => ({ + ...item, + ...(item.href ? reactRouterNavigate(scopedHistory, item.href) : {}), + }); + + chrome.setBreadcrumbs([...crumbs.map((item) => wrapBreadcrumb(item, params.history))]); + }; + const deps: DataSourceManagementContext = { chrome, application, @@ -39,27 +48,29 @@ export async function mountManagementSection( overlays, http, docLinks, - setBreadcrumbs: params.setBreadcrumbs, + setBreadcrumbs: setBreadcrumbsScoped, }; ReactDOM.render( - - - - - - - - - - - - - - - - - , + + + + + + + + + + + + + + + + + + + , params.element ); diff --git a/src/plugins/data_source_management/public/plugin.ts b/src/plugins/data_source_management/public/plugin.ts index 941107d74638..d0c900effce2 100644 --- a/src/plugins/data_source_management/public/plugin.ts +++ b/src/plugins/data_source_management/public/plugin.ts @@ -3,13 +3,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; +import { + AppMountParameters, + CoreSetup, + CoreStart, + DEFAULT_APP_CATEGORIES, + Plugin, + StartServicesAccessor, +} from '../../../core/public'; import { PLUGIN_NAME } from '../common'; import { ManagementSetup } from '../../management/public'; import { IndexPatternManagementSetup } from '../../index_pattern_management/public'; import { DataSourceColumn } from './components/data_source_column/data_source_column'; +import { DataSourceManagementStartDependencies } from './types'; export interface DataSourceManagementSetupDependencies { management: ManagementSetup; @@ -20,16 +28,7 @@ const DSM_APP_ID = 'dataSources'; export class DataSourceManagementPlugin implements Plugin { - public setup( - core: CoreSetup, - { management, indexPatternManagement }: DataSourceManagementSetupDependencies - ) { - const opensearchDashboardsSection = management.sections.section.opensearchDashboards; - - if (!opensearchDashboardsSection) { - throw new Error('`opensearchDashboards` management section not found.'); - } - + public setup(core: CoreSetup, { indexPatternManagement }: DataSourceManagementSetupDependencies) { const savedObjectPromise = core .getStartServices() .then(([coreStart]) => coreStart.savedObjects); @@ -37,14 +36,18 @@ export class DataSourceManagementPlugin const column = new DataSourceColumn(savedObjectPromise, httpPromise); indexPatternManagement.columns.register(column); - opensearchDashboardsSection.registerApp({ + core.application.register({ id: DSM_APP_ID, title: PLUGIN_NAME, order: 1, - mount: async (params) => { - const { mountManagementSection } = await import('./management_app'); - - return mountManagementSection(core.getStartServices, params); + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + mount: async (params: AppMountParameters) => { + const { mountDataSourcesManagementSection } = await import('./management_app'); + + return mountDataSourcesManagementSection( + core.getStartServices as StartServicesAccessor, + params + ); }, }); } diff --git a/src/plugins/data_source_management/public/types.ts b/src/plugins/data_source_management/public/types.ts index 1bede8fbfca9..be2b7725d7ed 100644 --- a/src/plugins/data_source_management/public/types.ts +++ b/src/plugins/data_source_management/public/types.ts @@ -14,6 +14,7 @@ import { HttpSetup, } from 'src/core/public'; import { ManagementAppMountParams } from 'src/plugins/management/public'; +import { DataPublicPluginStart } from 'src/plugins/data/public'; import { SavedObjectAttributes } from 'src/core/types'; import { i18n } from '@osd/i18n'; import { SigV4ServiceName } from '../../data_source/common/data_sources'; @@ -115,3 +116,7 @@ export interface SigV4Content extends SavedObjectAttributes { region: string; service?: SigV4ServiceName; } + +export interface DataSourceManagementStartDependencies { + data: DataPublicPluginStart; +} diff --git a/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap b/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap index 342dea206c30..54c90bd3ce92 100644 --- a/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap +++ b/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap @@ -53,7 +53,7 @@ exports[`render 1`] = ` > = ({ addBasePath, onClose }) => { modes. You or your administrator can change to the previous theme by visiting {advancedSettingsLink}." values={{ advancedSettingsLink: ( - + , - params: ManagementAppMountParams, + params: AppMountParameters, getMlCardState: () => MlCardState ) { const [ @@ -74,6 +82,17 @@ export async function mountManagementSection( chrome.setBadge(readOnlyBadge); } + const setBreadcrumbsScope = (crumbs: ChromeBreadcrumb[] = [], appHistory?: ScopedHistory) => { + const wrapBreadcrumb = (item: ChromeBreadcrumb, scopedHistory: ScopedHistory) => ({ + ...item, + ...(item.href ? reactRouterNavigate(scopedHistory, item.href) : {}), + }); + + chrome.setBreadcrumbs([ + ...crumbs.map((item) => wrapBreadcrumb(item, appHistory || params.history)), + ]); + }; + const deps: IndexPatternManagmentContext = { chrome, application, @@ -85,32 +104,36 @@ export async function mountManagementSection( docLinks, data, indexPatternManagementStart: indexPatternManagementStart as IndexPatternManagementStart, - setBreadcrumbs: params.setBreadcrumbs, + setBreadcrumbs: setBreadcrumbsScope, getMlCardState, dataSourceEnabled, }; ReactDOM.render( - - - - - - - - - - - - - - - - - - - - , + + + + + + + + + + + + + + + + + + + + + + + + , params.element ); diff --git a/src/plugins/index_pattern_management/public/plugin.ts b/src/plugins/index_pattern_management/public/plugin.ts index cf68e043b76c..862c9e3b5c06 100644 --- a/src/plugins/index_pattern_management/public/plugin.ts +++ b/src/plugins/index_pattern_management/public/plugin.ts @@ -29,7 +29,15 @@ */ import { i18n } from '@osd/i18n'; -import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + AppMountParameters, + ChromeBreadcrumb, + ScopedHistory, +} from 'src/core/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; import { DataSourcePluginStart } from 'src/plugins/data_source/public'; import { UrlForwardingSetup } from '../../url_forwarding/public'; @@ -39,10 +47,11 @@ import { IndexPatternManagementServiceStart, } from './service'; -import { ManagementSetup } from '../../management/public'; +import { ManagementAppMountParams } from '../../management/public'; +import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; +import { reactRouterNavigate } from '../../opensearch_dashboards_react/public'; export interface IndexPatternManagementSetupDependencies { - management: ManagementSetup; urlForwarding: UrlForwardingSetup; } @@ -75,15 +84,9 @@ export class IndexPatternManagementPlugin public setup( core: CoreSetup, - { management, urlForwarding }: IndexPatternManagementSetupDependencies + { urlForwarding }: IndexPatternManagementSetupDependencies ) { - const opensearchDashboardsSection = management.sections.section.opensearchDashboards; - - if (!opensearchDashboardsSection) { - throw new Error('`opensearchDashboards` management section not found.'); - } - - const newAppPath = `management/opensearch-dashboards/${IPM_APP_ID}`; + const newAppPath = IPM_APP_ID; const legacyPatternsPath = 'management/opensearch-dashboards/index_patterns'; urlForwarding.forwardApp( @@ -96,11 +99,13 @@ export class IndexPatternManagementPlugin return pathInApp && `/patterns${pathInApp}`; }); - opensearchDashboardsSection.registerApp({ + // register it under Library + core.application.register({ id: IPM_APP_ID, title: sectionsHeader, - order: 0, - mount: async (params) => { + order: 8100, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + mount: async (params: AppMountParameters) => { const { mountManagementSection } = await import('./management_app'); return mountManagementSection(core.getStartServices, params, () => diff --git a/src/plugins/opensearch_dashboards_overview/public/components/getting_started/__snapshots__/getting_started.test.tsx.snap b/src/plugins/opensearch_dashboards_overview/public/components/getting_started/__snapshots__/getting_started.test.tsx.snap index 9df3bb12caec..db7484e21379 100644 --- a/src/plugins/opensearch_dashboards_overview/public/components/getting_started/__snapshots__/getting_started.test.tsx.snap +++ b/src/plugins/opensearch_dashboards_overview/public/components/getting_started/__snapshots__/getting_started.test.tsx.snap @@ -171,7 +171,7 @@ exports[`GettingStarted dark mode on 1`] = ` = ({ addBasePath, isDarkTheme, apps }) => - + = ({ addBasePath, path }) => { diff --git a/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.test.tsx b/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.test.tsx index 2e27ebd0cb6b..fcd417a42826 100644 --- a/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.test.tsx +++ b/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.test.tsx @@ -200,7 +200,7 @@ describe('OverviewPageHeader toolbar items - Management', () => { return component.find({ className: 'osdOverviewPageHeader__actionButton', - href: '/app/management', + href: '/app/settings', }); }; diff --git a/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.tsx b/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.tsx index a636f7ecdb7d..e27a99fc4d44 100644 --- a/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.tsx +++ b/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.tsx @@ -136,7 +136,7 @@ export const OverviewPageHeader: FC = ({ className="osdOverviewPageHeader__actionButton" flush="both" iconType="gear" - href={addBasePath('/app/management')} + href={addBasePath('/app/settings')} > {i18n.translate( 'opensearch-dashboards-react.osdOverviewPageHeader.stackManagementButtonLabel', diff --git a/src/plugins/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx b/src/plugins/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx index 438971862c79..0df7289caf75 100644 --- a/src/plugins/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx +++ b/src/plugins/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx @@ -315,7 +315,7 @@ class TableListView extends React.ComponentlistingLimit, advancedSettingsLink: ( - + ; serviceRegistry: ISavedObjectsManagementServiceRegistry; - mountParams: ManagementAppMountParams; + appMountParams?: AppMountParameters; + title: string; + allowedObjectTypes?: string[]; } -let allowedObjectTypes: string[] | undefined; - -const title = i18n.translate('savedObjectsManagement.objects.savedObjectsTitle', { - defaultMessage: 'Saved Objects', -}); - const SavedObjectsEditionPage = lazy(() => import('./saved_objects_edition_page')); const SavedObjectsTablePage = lazy(() => import('./saved_objects_table_page')); export const mountManagementSection = async ({ core, - mountParams, + appMountParams, serviceRegistry, + title, + allowedObjectTypes, }: MountParams) => { const [coreStart, { data, uiActions }, pluginStart] = await core.getStartServices(); - const { element, history, setBreadcrumbs } = mountParams; + const usedMountParams = appMountParams || ({} as ManagementAppMountParams); + const { element, history } = usedMountParams; + const { chrome } = coreStart; + const setBreadcrumbs = chrome.setBreadcrumbs; if (allowedObjectTypes === undefined) { allowedObjectTypes = await getAllowedTypes(coreStart.http); } @@ -86,29 +87,34 @@ export const mountManagementSection = async ({ }> - + + + }> - + + + diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap index d18762f4912f..a232c07a3a86 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap @@ -314,10 +314,10 @@ exports[`SavedObjectsTable should render normally 1`] = ` Object { "id": "1", "meta": Object { - "editUrl": "#/management/opensearch-dashboards/indexPatterns/patterns/1", + "editUrl": "#/indexPatterns/patterns/1", "icon": "indexPatternApp", "inAppUrl": Object { - "path": "/management/opensearch-dashboards/indexPatterns/patterns/1", + "path": "/indexPatterns/patterns/1", "uiCapabilitiesPath": "management.opensearchDashboards.indexPatterns", }, "title": "MyIndexPattern*", @@ -327,7 +327,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` Object { "id": "2", "meta": Object { - "editUrl": "/management/opensearch-dashboards/objects/savedSearches/2", + "editUrl": "/objects/savedSearches/2", "icon": "search", "inAppUrl": Object { "path": "/discover/2", @@ -340,7 +340,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` Object { "id": "3", "meta": Object { - "editUrl": "/management/opensearch-dashboards/objects/savedDashboards/3", + "editUrl": "/objects/savedDashboards/3", "icon": "dashboardApp", "inAppUrl": Object { "path": "/dashboard/3", @@ -353,7 +353,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` Object { "id": "4", "meta": Object { - "editUrl": "/management/opensearch-dashboards/objects/savedVisualizations/4", + "editUrl": "/objects/savedVisualizations/4", "icon": "visualizeApp", "inAppUrl": Object { "path": "/edit/4", diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap index 038e1aaf2d8f..dace178024f2 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap @@ -10,13 +10,7 @@ exports[`Header should render normally 1`] = ` grow={false} > -

- -

+

void; onImport: () => void; onRefresh: () => void; filteredCount: number; + title: string; }) => ( -

- -

+

{title}

diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx index 1f21e5990c74..5afdbacf6dff 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx @@ -53,7 +53,7 @@ describe('Relationships', () => { id: '1', relationship: 'parent', meta: { - editUrl: '/management/opensearch-dashboards/objects/savedSearches/1', + editUrl: '/objects/savedSearches/1', icon: 'search', inAppUrl: { path: '/app/discover#//1', @@ -67,7 +67,7 @@ describe('Relationships', () => { id: '2', relationship: 'parent', meta: { - editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/2', + editUrl: '/objects/savedVisualizations/2', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/2', @@ -85,9 +85,9 @@ describe('Relationships', () => { meta: { title: 'MyIndexPattern*', icon: 'indexPatternApp', - editUrl: '#/management/opensearch-dashboards/indexPatterns/patterns/1', + editUrl: '#/indexPatterns/patterns/1', inAppUrl: { - path: '/management/opensearch-dashboards/indexPatterns/patterns/1', + path: '/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, }, @@ -120,10 +120,10 @@ describe('Relationships', () => { id: '1', relationship: 'child', meta: { - editUrl: '/management/opensearch-dashboards/indexPatterns/patterns/1', + editUrl: '/indexPatterns/patterns/1', icon: 'indexPatternApp', inAppUrl: { - path: '/app/management/opensearch-dashboards/indexPatterns/patterns/1', + path: '/app/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, title: 'My Index Pattern', @@ -134,7 +134,7 @@ describe('Relationships', () => { id: '2', relationship: 'parent', meta: { - editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/2', + editUrl: '/objects/savedVisualizations/2', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/2', @@ -152,7 +152,7 @@ describe('Relationships', () => { meta: { title: 'MySearch', icon: 'search', - editUrl: '/management/opensearch-dashboards/objects/savedSearches/1', + editUrl: '/objects/savedSearches/1', inAppUrl: { path: '/discover/1', uiCapabilitiesPath: 'discover.show', @@ -187,7 +187,7 @@ describe('Relationships', () => { id: '1', relationship: 'parent', meta: { - editUrl: '/management/opensearch-dashboards/objects/savedDashboards/1', + editUrl: '/objects/savedDashboards/1', icon: 'dashboardApp', inAppUrl: { path: '/app/opensearch-dashboards#/dashboard/1', @@ -201,7 +201,7 @@ describe('Relationships', () => { id: '2', relationship: 'parent', meta: { - editUrl: '/management/opensearch-dashboards/objects/savedDashboards/2', + editUrl: '/objects/savedDashboards/2', icon: 'dashboardApp', inAppUrl: { path: '/app/opensearch-dashboards#/dashboard/2', @@ -219,7 +219,7 @@ describe('Relationships', () => { meta: { title: 'MyViz', icon: 'visualizeApp', - editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/1', + editUrl: '/objects/savedVisualizations/1', inAppUrl: { path: '/edit/1', uiCapabilitiesPath: 'visualize.show', @@ -256,7 +256,7 @@ describe('Relationships', () => { meta: { title: 'MyViz', icon: 'visualizeApp', - editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/1', + editUrl: '/objects/savedVisualizations/1', inAppUrl: { path: '/edit/1', uiCapabilitiesPath: 'visualize.show', @@ -272,7 +272,7 @@ describe('Relationships', () => { meta: { title: 'MyAugmentVisObject', icon: 'savedObject', - editUrl: '/management/opensearch-dashboards/objects/savedAugmentVis/1', + editUrl: '/objects/savedAugmentVis/1', }, }, close: jest.fn(), @@ -303,7 +303,7 @@ describe('Relationships', () => { id: '1', relationship: 'child', meta: { - editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/1', + editUrl: '/objects/savedVisualizations/1', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/1', @@ -317,7 +317,7 @@ describe('Relationships', () => { id: '2', relationship: 'child', meta: { - editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/2', + editUrl: '/objects/savedVisualizations/2', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/2', @@ -335,7 +335,7 @@ describe('Relationships', () => { meta: { title: 'MyDashboard', icon: 'dashboardApp', - editUrl: '/management/opensearch-dashboards/objects/savedDashboards/1', + editUrl: '/objects/savedDashboards/1', inAppUrl: { path: '/dashboard/1', uiCapabilitiesPath: 'dashboard.show', @@ -375,7 +375,7 @@ describe('Relationships', () => { meta: { title: 'MyDashboard', icon: 'dashboardApp', - editUrl: '/management/opensearch-dashboards/objects/savedDashboards/1', + editUrl: '/objects/savedDashboards/1', inAppUrl: { path: '/dashboard/1', uiCapabilitiesPath: 'dashboard.show', diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx index 7e5bb318f4d0..c8e378b93b92 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx @@ -51,9 +51,9 @@ const defaultProps: TableProps = { meta: { title: `MyIndexPattern*`, icon: 'indexPatternApp', - editUrl: '#/management/opensearch-dashboards/indexPatterns/patterns/1', + editUrl: '#/indexPatterns/patterns/1', inAppUrl: { - path: '/management/opensearch-dashboards/indexPatterns/patterns/1', + path: '/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, }, @@ -91,9 +91,9 @@ const defaultProps: TableProps = { meta: { title: `MyIndexPattern*`, icon: 'indexPatternApp', - editUrl: '#/management/opensearch-dashboards/indexPatterns/patterns/1', + editUrl: '#/indexPatterns/patterns/1', inAppUrl: { - path: '/management/opensearch-dashboards/indexPatterns/patterns/1', + path: '/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, }, diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx index 5a6bf0713d95..443026e92964 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx @@ -172,9 +172,9 @@ describe('SavedObjectsTable', () => { meta: { title: `MyIndexPattern*`, icon: 'indexPatternApp', - editUrl: '#/management/opensearch-dashboards/indexPatterns/patterns/1', + editUrl: '#/indexPatterns/patterns/1', inAppUrl: { - path: '/management/opensearch-dashboards/indexPatterns/patterns/1', + path: '/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, }, @@ -185,7 +185,7 @@ describe('SavedObjectsTable', () => { meta: { title: `MySearch`, icon: 'search', - editUrl: '/management/opensearch-dashboards/objects/savedSearches/2', + editUrl: '/objects/savedSearches/2', inAppUrl: { path: '/discover/2', uiCapabilitiesPath: 'discover.show', @@ -198,7 +198,7 @@ describe('SavedObjectsTable', () => { meta: { title: `MyDashboard`, icon: 'dashboardApp', - editUrl: '/management/opensearch-dashboards/objects/savedDashboards/3', + editUrl: '/objects/savedDashboards/3', inAppUrl: { path: '/dashboard/3', uiCapabilitiesPath: 'dashboard.show', @@ -211,7 +211,7 @@ describe('SavedObjectsTable', () => { meta: { title: `MyViz`, icon: 'visualizeApp', - editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/4', + editUrl: '/objects/savedVisualizations/4', inAppUrl: { path: '/edit/4', uiCapabilitiesPath: 'visualize.show', @@ -460,7 +460,7 @@ describe('SavedObjectsTable', () => { meta: { title: `MySearch`, icon: 'search', - editUrl: '/management/opensearch-dashboards/objects/savedSearches/2', + editUrl: '/objects/savedSearches/2', inAppUrl: { path: '/discover/2', uiCapabilitiesPath: 'discover.show', @@ -475,7 +475,7 @@ describe('SavedObjectsTable', () => { type: 'search', meta: { title: 'MySearch', - editUrl: '/management/opensearch-dashboards/objects/savedSearches/2', + editUrl: '/objects/savedSearches/2', icon: 'search', inAppUrl: { path: '/discover/2', diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx index 2f78f307d165..ac5d0854cacc 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx @@ -114,6 +114,7 @@ export interface SavedObjectsTableProps { goInspectObject: (obj: SavedObjectWithMetadata) => void; canGoInApp: (obj: SavedObjectWithMetadata) => boolean; dateFormat: string; + title: string; } export interface SavedObjectsTableState { @@ -543,9 +544,7 @@ export class SavedObjectsTable extends Component diff --git a/src/plugins/saved_objects_management/public/management_section/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap new file mode 100644 index 000000000000..3c5257e2e8d1 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PageWrapper should render normally 1`] = ` +
+
+ Foo +
+
+`; diff --git a/src/plugins/saved_objects_management/public/management_section/page_wrapper/index.ts b/src/plugins/saved_objects_management/public/management_section/page_wrapper/index.ts new file mode 100644 index 000000000000..3cf0cdd26c99 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/page_wrapper/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { PageWrapper } from './page_wrapper'; diff --git a/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.test.tsx b/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.test.tsx new file mode 100644 index 000000000000..550eb3ee1cae --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.test.tsx @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { PageWrapper } from './page_wrapper'; + +describe('PageWrapper', () => { + it('should render normally', async () => { + const { findByText, container } = render(Foo); + await findByText('Foo'); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.tsx b/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.tsx new file mode 100644 index 000000000000..1b1949c334e4 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.tsx @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiPageContent } from '@elastic/eui'; +import React from 'react'; + +export const PageWrapper = (props: { children?: React.ReactChild }) => { + return ( + + ); +}; diff --git a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx index 09937388ba57..4ba9575f9f62 100644 --- a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx +++ b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx @@ -30,13 +30,13 @@ import React, { useEffect } from 'react'; import { get } from 'lodash'; -import { i18n } from '@osd/i18n'; import { CoreStart, ChromeBreadcrumb } from 'src/core/public'; import { DataPublicPluginStart } from '../../../data/public'; import { ISavedObjectsManagementServiceRegistry, SavedObjectsManagementActionServiceStart, SavedObjectsManagementColumnServiceStart, + SavedObjectsManagementNamespaceServiceStart, } from '../services'; import { SavedObjectsTable } from './objects_table'; @@ -49,6 +49,7 @@ const SavedObjectsTablePage = ({ columnRegistry, namespaceRegistry, setBreadcrumbs, + title, }: { coreStart: CoreStart; dataStart: DataPublicPluginStart; @@ -58,6 +59,7 @@ const SavedObjectsTablePage = ({ columnRegistry: SavedObjectsManagementColumnServiceStart; namespaceRegistry: SavedObjectsManagementNamespaceServiceStart; setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; + title: string; }) => { const capabilities = coreStart.application.capabilities; const itemsPerPage = coreStart.uiSettings.get('savedObjects:perPage', 50); @@ -66,13 +68,11 @@ const SavedObjectsTablePage = ({ useEffect(() => { setBreadcrumbs([ { - text: i18n.translate('savedObjectsManagement.breadcrumb.index', { - defaultMessage: 'Saved objects', - }), - href: '/', + text: title, + href: undefined, }, ]); - }, [setBreadcrumbs]); + }, [setBreadcrumbs, title]); return ( ); }; diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index 14beb73386a8..dba28e05e98e 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -29,7 +29,7 @@ */ import { i18n } from '@osd/i18n'; -import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { AppMountParameters, CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { VisBuilderStart } from '../../vis_builder/public'; import { ManagementSetup } from '../../management/public'; @@ -55,6 +55,12 @@ import { } from './services'; import { registerServices } from './register_services'; import { bootstrap } from './ui_actions_bootstrap'; +import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; +import { + MANAGE_LIBRARY_TITLE_WORDINGS, + SAVED_QUERIES_WORDINGS, + SAVED_SEARCHES_WORDINGS, +} from './constants'; export interface SavedObjectsManagementPluginSetup { actions: SavedObjectsManagementActionServiceSetup; @@ -98,9 +104,66 @@ export class SavedObjectsManagementPlugin private namespaceService = new SavedObjectsManagementNamespaceService(); private serviceRegistry = new SavedObjectsManagementServiceRegistry(); + private registerLibrarySubApp( + coreSetup: CoreSetup + ) { + const core = coreSetup; + const mountWrapper = ({ + title, + allowedObjectTypes, + }: { + title: string; + allowedObjectTypes?: string[]; + }) => async (appMountParams: AppMountParameters) => { + const { mountManagementSection } = await import('./management_section'); + return mountManagementSection({ + core, + serviceRegistry: this.serviceRegistry, + appMountParams, + title, + allowedObjectTypes, + }); + }; + + /** + * Register saved objects overview & saved search & saved query here + */ + core.application.register({ + id: 'objects', + title: MANAGE_LIBRARY_TITLE_WORDINGS, + order: 10000, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + mount: mountWrapper({ + title: MANAGE_LIBRARY_TITLE_WORDINGS, + }), + }); + + core.application.register({ + id: 'objects_searches', + title: SAVED_SEARCHES_WORDINGS, + order: 8000, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + mount: mountWrapper({ + title: SAVED_SEARCHES_WORDINGS, + allowedObjectTypes: ['search'], + }), + }); + + core.application.register({ + id: 'objects_query', + title: SAVED_QUERIES_WORDINGS, + order: 8001, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + mount: mountWrapper({ + title: SAVED_QUERIES_WORDINGS, + allowedObjectTypes: ['query'], + }), + }); + } + public setup( core: CoreSetup, - { home, management, uiActions }: SetupDependencies + { home, uiActions }: SetupDependencies ): SavedObjectsManagementPluginSetup { const actionSetup = this.actionService.setup(); const columnSetup = this.columnService.setup(); @@ -117,35 +180,20 @@ export class SavedObjectsManagementPlugin 'Import, export, and manage your saved searches, visualizations, and dashboards.', }), icon: 'savedObjectsApp', - path: '/app/management/opensearch-dashboards/objects', + path: '/app/objects', showOnHomePage: false, category: FeatureCatalogueCategory.ADMIN, }); } - const opensearchDashboardsSection = management.sections.section.opensearchDashboards; - opensearchDashboardsSection.registerApp({ - id: 'objects', - title: i18n.translate('savedObjectsManagement.managementSectionLabel', { - defaultMessage: 'Saved objects', - }), - order: 1, - mount: async (mountParams) => { - const { mountManagementSection } = await import('./management_section'); - return mountManagementSection({ - core, - serviceRegistry: this.serviceRegistry, - mountParams, - }); - }, - }); - // sets up the context mappings and registers any triggers/actions for the plugin bootstrap(uiActions); // depends on `getStartServices`, should not be awaited registerServices(this.serviceRegistry, core.getStartServices); + this.registerLibrarySubApp(core); + return { actions: actionSetup, columns: columnSetup, diff --git a/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts b/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts index 52188d52998a..558649f900bd 100644 --- a/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts +++ b/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts @@ -15,9 +15,7 @@ export const augmentVisSavedObjectType: SavedObjectsType = { return `augment-vis-${obj?.attributes?.originPlugin}`; }, getEditUrl(obj) { - return `/management/opensearch-dashboards/objects/savedAugmentVis/${encodeURIComponent( - obj.id - )}`; + return `/objects/savedAugmentVis/${encodeURIComponent(obj.id)}`; }, }, mappings: { diff --git a/src/plugins/vis_builder/server/saved_objects/vis_builder_app.ts b/src/plugins/vis_builder/server/saved_objects/vis_builder_app.ts index 029557010bee..2d329227491c 100644 --- a/src/plugins/vis_builder/server/saved_objects/vis_builder_app.ts +++ b/src/plugins/vis_builder/server/saved_objects/vis_builder_app.ts @@ -20,8 +20,7 @@ export const visBuilderSavedObjectType: SavedObjectsType = { defaultSearchField: 'title', importableAndExportable: true, getTitle: ({ attributes: { title } }: SavedObject) => title, - getEditUrl: ({ id }: SavedObject) => - `/management/opensearch-dashboards/objects/savedVisBuilder/${encodeURIComponent(id)}`, + getEditUrl: ({ id }: SavedObject) => `/objects/savedVisBuilder/${encodeURIComponent(id)}`, getInAppUrl({ id }: SavedObject) { return { path: `/app/${PLUGIN_ID}${EDIT_PATH}/${encodeURIComponent(id)}`, diff --git a/src/plugins/visualizations/server/saved_objects/visualization.ts b/src/plugins/visualizations/server/saved_objects/visualization.ts index 15a926b3f81d..4e46c83db157 100644 --- a/src/plugins/visualizations/server/saved_objects/visualization.ts +++ b/src/plugins/visualizations/server/saved_objects/visualization.ts @@ -43,9 +43,7 @@ export const visualizationSavedObjectType: SavedObjectsType = { return obj.attributes.title; }, getEditUrl(obj) { - return `/management/opensearch-dashboards/objects/savedVisualizations/${encodeURIComponent( - obj.id - )}`; + return `/objects/savedVisualizations/${encodeURIComponent(obj.id)}`; }, getInAppUrl(obj) { return { diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index a82d4e792cdc..065541a36d77 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -73,8 +73,7 @@ export default function ({ getService }: FtrProviderContext) { score: 0, updated_at: '2017-09-21T18:51:23.794Z', meta: { - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/dd7caf20-9efd-11e7-acb3-3dab96693fab', + editUrl: '/objects/savedVisualizations/dd7caf20-9efd-11e7-acb3-3dab96693fab', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/dd7caf20-9efd-11e7-acb3-3dab96693fab', @@ -237,8 +236,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'discoverApp', title: 'OneRecord', - editUrl: - '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -256,8 +254,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'dashboardApp', title: 'Dashboard', - editUrl: - '/management/opensearch-dashboards/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'dashboard.show', @@ -275,8 +272,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -286,8 +282,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[1].meta).to.eql({ icon: 'visualizeApp', title: 'Visualization', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -305,11 +300,9 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'indexPatternApp', title: 'saved_objects*', - editUrl: - '/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + editUrl: '/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: - '/app/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + path: '/app/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, namespaceType: 'single', diff --git a/test/api_integration/apis/saved_objects_management/relationships.ts b/test/api_integration/apis/saved_objects_management/relationships.ts index f0af2d8d9e79..77e838cfed42 100644 --- a/test/api_integration/apis/saved_objects_management/relationships.ts +++ b/test/api_integration/apis/saved_objects_management/relationships.ts @@ -94,11 +94,9 @@ export default function ({ getService }: FtrProviderContext) { meta: { title: 'saved_objects*', icon: 'indexPatternApp', - editUrl: - '/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + editUrl: '/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: - '/app/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + path: '/app/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, namespaceType: 'single', @@ -111,8 +109,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { title: 'VisualizationFromSavedSearch', icon: 'visualizeApp', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -137,11 +134,9 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'indexPatternApp', title: 'saved_objects*', - editUrl: - '/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + editUrl: '/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: - '/app/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + path: '/app/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, namespaceType: 'single', @@ -154,8 +149,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -199,8 +193,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'Visualization', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -215,8 +208,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -239,8 +231,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'Visualization', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -255,8 +246,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -300,8 +290,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: - '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -316,8 +305,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'dashboardApp', title: 'Dashboard', - editUrl: - '/management/opensearch-dashboards/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'dashboard.show', @@ -342,8 +330,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: - '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -386,8 +373,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: - '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -402,8 +388,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'Visualization', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -428,8 +413,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: - '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', diff --git a/test/functional/apps/dashboard/create_and_add_embeddables.js b/test/functional/apps/dashboard/create_and_add_embeddables.js index 3b6e8a243556..6701ae0fc94c 100644 --- a/test/functional/apps/dashboard/create_and_add_embeddables.js +++ b/test/functional/apps/dashboard/create_and_add_embeddables.js @@ -112,8 +112,7 @@ export default function ({ getService, getPageObjects }) { describe('is false', () => { before(async () => { - await PageObjects.header.clickStackManagement(); - await PageObjects.settings.clickOpenSearchDashboardsSettings(); + await PageObjects.common.navigateToApp('settings'); await PageObjects.settings.toggleAdvancedSettingCheckbox(VISUALIZE_ENABLE_LABS_SETTING); }); @@ -127,8 +126,7 @@ export default function ({ getService, getPageObjects }) { }); after(async () => { - await PageObjects.header.clickStackManagement(); - await PageObjects.settings.clickOpenSearchDashboardsSettings(); + await PageObjects.settings.navigateTo(); await PageObjects.settings.clearAdvancedSettings(VISUALIZE_ENABLE_LABS_SETTING); await PageObjects.header.clickDashboard(); }); diff --git a/test/functional/apps/dashboard/time_zones.js b/test/functional/apps/dashboard/time_zones.js index 13a424bd7ea6..7c3e2f162779 100644 --- a/test/functional/apps/dashboard/time_zones.js +++ b/test/functional/apps/dashboard/time_zones.js @@ -51,7 +51,6 @@ export default function ({ getService, getPageObjects }) { await opensearchDashboardsServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); await PageObjects.savedObjects.importFile( path.join(__dirname, 'exports', 'timezonetest_6_2_4.json') @@ -75,7 +74,6 @@ export default function ({ getService, getPageObjects }) { it('Changing timezone changes dashboard timestamp and shows the same data', async () => { await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickOpenSearchDashboardsSettings(); await PageObjects.settings.setAdvancedSettingsSelect('dateFormat:tz', 'Etc/GMT+5'); await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.loadSavedDashboard('time zone test'); diff --git a/test/functional/apps/management/_import_objects.js b/test/functional/apps/management/_import_objects.js index 2c432964f309..50700a12b718 100644 --- a/test/functional/apps/management/_import_objects.js +++ b/test/functional/apps/management/_import_objects.js @@ -46,7 +46,6 @@ export default function ({ getService, getPageObjects }) { beforeEach(async function () { // delete .kibana index and then wait for OpenSearch Dashboards to re-create it await opensearchDashboardsServer.uiSettings.replace({}); - await PageObjects.settings.navigateTo(); await opensearchArchiver.load('management'); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); }); @@ -215,7 +214,6 @@ export default function ({ getService, getPageObjects }) { beforeEach(async function () { // delete .kibana index and then wait for OpenSearch Dashboards to re-create it await opensearchDashboardsServer.uiSettings.replace({}); - await PageObjects.settings.navigateTo(); await opensearchArchiver.load('saved_objects_imports'); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); }); diff --git a/test/functional/apps/management/_index_pattern_create_delete.js b/test/functional/apps/management/_index_pattern_create_delete.js index b7214590ebd4..1d154718c26d 100644 --- a/test/functional/apps/management/_index_pattern_create_delete.js +++ b/test/functional/apps/management/_index_pattern_create_delete.js @@ -129,7 +129,7 @@ export default function ({ getService, getPageObjects }) { return retry.try(function tryingForTime() { return browser.getCurrentUrl().then(function (currentUrl) { log.debug('currentUrl = ' + currentUrl); - expect(currentUrl).to.contain('management/opensearch-dashboards/indexPatterns'); + expect(currentUrl).to.contain('indexPatterns'); }); }); }); diff --git a/test/functional/apps/management/_mgmt_import_saved_objects.js b/test/functional/apps/management/_mgmt_import_saved_objects.js index 631b4e85cb8b..fe19eb141b9c 100644 --- a/test/functional/apps/management/_mgmt_import_saved_objects.js +++ b/test/functional/apps/management/_mgmt_import_saved_objects.js @@ -42,7 +42,6 @@ export default function ({ getService, getPageObjects }) { beforeEach(async function () { await opensearchArchiver.load('empty_opensearch_dashboards'); await opensearchArchiver.load('discover'); - await PageObjects.settings.navigateTo(); }); afterEach(async function () { diff --git a/test/functional/apps/management/_opensearch_dashboards_settings.js b/test/functional/apps/management/_opensearch_dashboards_settings.js index 98cda687e23b..637f7073d517 100644 --- a/test/functional/apps/management/_opensearch_dashboards_settings.js +++ b/test/functional/apps/management/_opensearch_dashboards_settings.js @@ -40,11 +40,9 @@ export default function ({ getService, getPageObjects }) { // delete .kibana index and then wait for OpenSearch Dashboards to re-create it await opensearchDashboardsServer.uiSettings.replace({}); await PageObjects.settings.createIndexPattern('logstash-*'); - await PageObjects.settings.navigateTo(); }); after(async function afterAll() { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.removeLogstashIndexPatternIfExist(); }); @@ -90,7 +88,6 @@ export default function ({ getService, getPageObjects }) { }); it('setting to true change is preserved', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsSettings(); await PageObjects.settings.toggleAdvancedSettingCheckbox('state:storeInSessionStorage'); const storeInSessionStorage = await PageObjects.settings.getAdvancedSettingCheckbox( @@ -113,8 +110,7 @@ export default function ({ getService, getPageObjects }) { it("changing 'state:storeInSessionStorage' also takes effect without full page reload", async () => { await PageObjects.dashboard.preserveCrossAppState(); - await PageObjects.header.clickStackManagement(); - await PageObjects.settings.clickOpenSearchDashboardsSettings(); + await PageObjects.settings.navigateTo(); await PageObjects.settings.toggleAdvancedSettingCheckbox('state:storeInSessionStorage'); await PageObjects.header.clickDashboard(); const [globalState, appState] = await getStateFromUrl(); diff --git a/test/functional/apps/management/_scripted_fields.js b/test/functional/apps/management/_scripted_fields.js index fd290ce76b8a..f0b69344c472 100644 --- a/test/functional/apps/management/_scripted_fields.js +++ b/test/functional/apps/management/_scripted_fields.js @@ -78,13 +78,11 @@ export default function ({ getService, getPageObjects }) { }); after(async function afterAll() { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.removeLogstashIndexPatternIfExist(); }); it('should not allow saving of invalid scripts', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.clickScriptedFieldsTab(); @@ -102,7 +100,6 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName = 'ram_Pain_reg'; it('should create and edit scripted field', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); @@ -136,7 +133,6 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName = 'ram_Pain1'; it('should create scripted field', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); @@ -256,7 +252,6 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName2 = 'painString'; it('should create scripted field', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); @@ -351,7 +346,6 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName2 = 'painBool'; it('should create scripted field', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); @@ -447,7 +441,6 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName2 = 'painDate'; it('should create scripted field', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); diff --git a/test/functional/apps/management/_scripted_fields_filter.js b/test/functional/apps/management/_scripted_fields_filter.js index b1714c425aac..55ec8895608c 100644 --- a/test/functional/apps/management/_scripted_fields_filter.js +++ b/test/functional/apps/management/_scripted_fields_filter.js @@ -58,7 +58,6 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName = 'ram_pain1'; it('should filter scripted fields', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.clickScriptedFieldsTab(); diff --git a/test/functional/apps/saved_objects_management/edit_saved_object.ts b/test/functional/apps/saved_objects_management/edit_saved_object.ts index 1534c710179b..64fe2bf199b0 100644 --- a/test/functional/apps/saved_objects_management/edit_saved_object.ts +++ b/test/functional/apps/saved_objects_management/edit_saved_object.ts @@ -88,7 +88,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); it('allows to update the saved object when submitting', async () => { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); let objects = await PageObjects.savedObjects.getRowTitles(); @@ -154,7 +153,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }, ]; - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); const objects = await PageObjects.savedObjects.getRowTitles(); diff --git a/test/functional/apps/visualize/_custom_branding.ts b/test/functional/apps/visualize/_custom_branding.ts index 37f07e932ee5..52cbc8e5fec9 100644 --- a/test/functional/apps/visualize/_custom_branding.ts +++ b/test/functional/apps/visualize/_custom_branding.ts @@ -46,7 +46,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('with customized logo for opensearch overview header in dark mode', async () => { - await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); + await PageObjects.settings.navigateTo(); await PageObjects.settings.toggleAdvancedSettingCheckbox('theme:darkMode'); await PageObjects.common.navigateToApp('opensearch_dashboards_overview'); await testSubjects.existOrFail('osdOverviewPageHeaderLogo'); @@ -100,7 +100,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('with customized logo in dark mode', async () => { - await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); + await PageObjects.settings.navigateTo(); await PageObjects.settings.toggleAdvancedSettingCheckbox('theme:darkMode'); await PageObjects.common.navigateToApp('home'); await testSubjects.existOrFail('welcomeCustomLogo'); @@ -179,13 +179,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('in dark mode', async () => { before(async function () { - await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); + await PageObjects.settings.navigateTo(); await PageObjects.settings.toggleAdvancedSettingCheckbox('theme:darkMode'); await PageObjects.common.navigateToApp('home'); }); after(async function () { - await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); + await PageObjects.settings.navigateTo(); await PageObjects.settings.clearAdvancedSettings('theme:darkMode'); }); @@ -206,7 +206,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('with customized mark logo button that navigates to home page', async () => { - await PageObjects.common.navigateToApp('settings'); + await PageObjects.settings.navigateTo(); await globalNav.clickHomeButton(); await PageObjects.header.waitUntilLoadingHasFinished(); const url = await browser.getCurrentUrl(); diff --git a/test/functional/apps/visualize/_lab_mode.js b/test/functional/apps/visualize/_lab_mode.js index d852ac484eaa..faca1ff394b1 100644 --- a/test/functional/apps/visualize/_lab_mode.js +++ b/test/functional/apps/visualize/_lab_mode.js @@ -53,8 +53,7 @@ export default function ({ getService, getPageObjects }) { log.info('found saved search before toggling enableLabs mode'); // Navigate to advanced setting and disable lab mode - await PageObjects.header.clickStackManagement(); - await PageObjects.settings.clickOpenSearchDashboardsSettings(); + await PageObjects.settings.navigateTo(); await PageObjects.settings.toggleAdvancedSettingCheckbox(VISUALIZE_ENABLE_LABS_SETTING); // Expect the discover still to list that saved visualization in the open list @@ -67,8 +66,7 @@ export default function ({ getService, getPageObjects }) { after(async () => { await PageObjects.discover.closeLoadSaveSearchPanel(); - await PageObjects.header.clickStackManagement(); - await PageObjects.settings.clickOpenSearchDashboardsSettings(); + await PageObjects.settings.navigateTo(); await PageObjects.settings.clearAdvancedSettings(VISUALIZE_ENABLE_LABS_SETTING); }); }); diff --git a/test/functional/apps/visualize/_tag_cloud.js b/test/functional/apps/visualize/_tag_cloud.js index a5123434115d..075e7fa22907 100644 --- a/test/functional/apps/visualize/_tag_cloud.js +++ b/test/functional/apps/visualize/_tag_cloud.js @@ -160,7 +160,6 @@ export default function ({ getService, getPageObjects }) { describe('formatted field', function () { before(async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.filterField(termsField); @@ -178,7 +177,6 @@ export default function ({ getService, getPageObjects }) { after(async function () { await filterBar.removeFilter(termsField); - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.filterField(termsField); diff --git a/test/functional/config.js b/test/functional/config.js index b862208276bf..75c9c3f9b6fe 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -102,10 +102,6 @@ export default async function ({ readConfigFile }) { management: { pathname: '/app/management', }, - /** @obsolete "management" should be instead of "settings" **/ - settings: { - pathname: '/app/management', - }, console: { pathname: '/app/dev_tools', hash: '/console', diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index af2bf046e3a9..1e0106229d3d 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -51,19 +51,19 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider await find.clickByDisplayedLinkText(text); } async clickOpenSearchDashboardsSettings() { - await testSubjects.click('settings'); + await PageObjects.common.navigateToApp('settings'); await PageObjects.header.waitUntilLoadingHasFinished(); await testSubjects.existOrFail('managementSettingsTitle'); } async clickOpenSearchDashboardsSavedObjects() { - await testSubjects.click('objects'); + await PageObjects.common.navigateToApp('objects'); await PageObjects.savedObjects.waitTableIsLoaded(); } async clickOpenSearchDashboardsIndexPatterns() { log.debug('clickOpenSearchDashboardsIndexPatterns link'); - await testSubjects.click('indexPatterns'); + await PageObjects.common.navigateToApp('indexPatterns'); await PageObjects.header.waitUntilLoadingHasFinished(); } From 903e594e1152662a6854c779f77a4fd251f4857d Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Tue, 10 Oct 2023 18:07:27 +0800 Subject: [PATCH 11/21] remove unnecessary workspace enabled flag from core workspace module (#215) Update test description per comment Signed-off-by: Yulong Ruan Co-authored-by: Miki --- .../capabilities/capabilities_service.mock.ts | 1 + .../header/__snapshots__/header.test.tsx.snap | 2 + src/core/public/index.ts | 7 +-- src/core/public/workspace/index.ts | 8 +-- .../workspace/workspaces_service.mock.ts | 3 - .../workspace/workspaces_service.test.ts | 7 +-- .../public/workspace/workspaces_service.ts | 28 +++++++-- .../capabilities/capabilities_service.mock.ts | 1 + .../capabilities/capabilities_service.test.ts | 4 ++ .../capabilities/capabilities_service.ts | 1 + .../capabilities_service.test.ts | 2 + .../capabilities/resolve_capabilities.test.ts | 2 + src/core/types/capabilities.ts | 3 + .../dashboard_listing.test.tsx.snap | 50 ++-------------- .../dashboard_top_nav.test.tsx.snap | 60 ++----------------- 15 files changed, 53 insertions(+), 126 deletions(-) diff --git a/src/core/public/application/capabilities/capabilities_service.mock.ts b/src/core/public/application/capabilities/capabilities_service.mock.ts index 971a43d06d05..d4490b60901b 100644 --- a/src/core/public/application/capabilities/capabilities_service.mock.ts +++ b/src/core/public/application/capabilities/capabilities_service.mock.ts @@ -37,6 +37,7 @@ const createStartContractMock = (): jest.Mocked => ({ catalogue: {}, management: {}, navLinks: {}, + workspaces: {}, }), }); diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index b9da5ac37dbe..d5903379b2eb 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -66,6 +66,7 @@ exports[`Header handles visibility and lock changes 1`] = ` "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentActionMenu$": BehaviorSubject { "_isScalar": false, @@ -6756,6 +6757,7 @@ exports[`Header renders condensed header 1`] = ` "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentActionMenu$": BehaviorSubject { "_isScalar": false, diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 14ab91e1cb13..2967b45f1d75 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -348,9 +348,4 @@ export { export { __osdBootstrap__ } from './osd_bootstrap'; -export { - WorkspacesStart, - WorkspacesSetup, - WorkspacesService, - WorkspaceObservables, -} from './workspace'; +export { WorkspacesStart, WorkspacesSetup, WorkspacesService } from './workspace'; diff --git a/src/core/public/workspace/index.ts b/src/core/public/workspace/index.ts index 4ef6aaae7fd4..4b9b2c86f649 100644 --- a/src/core/public/workspace/index.ts +++ b/src/core/public/workspace/index.ts @@ -2,9 +2,5 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -export { - WorkspacesStart, - WorkspacesService, - WorkspacesSetup, - WorkspaceObservables, -} from './workspaces_service'; + +export { WorkspacesStart, WorkspacesService, WorkspacesSetup } from './workspaces_service'; diff --git a/src/core/public/workspace/workspaces_service.mock.ts b/src/core/public/workspace/workspaces_service.mock.ts index a8d2a91bd3d1..ae56c035eb3a 100644 --- a/src/core/public/workspace/workspaces_service.mock.ts +++ b/src/core/public/workspace/workspaces_service.mock.ts @@ -13,14 +13,12 @@ const currentWorkspaceId$ = new BehaviorSubject(''); const workspaceList$ = new BehaviorSubject([]); const currentWorkspace$ = new BehaviorSubject(null); const initialized$ = new BehaviorSubject(false); -const workspaceEnabled$ = new BehaviorSubject(false); const createWorkspacesSetupContractMock = () => ({ currentWorkspaceId$, workspaceList$, currentWorkspace$, initialized$, - workspaceEnabled$, }); const createWorkspacesStartContractMock = () => ({ @@ -28,7 +26,6 @@ const createWorkspacesStartContractMock = () => ({ workspaceList$, currentWorkspace$, initialized$, - workspaceEnabled$, }); export type WorkspacesServiceContract = PublicMethodsOf; diff --git a/src/core/public/workspace/workspaces_service.test.ts b/src/core/public/workspace/workspaces_service.test.ts index b8f9b17ae0c8..4eca97ef2ed2 100644 --- a/src/core/public/workspace/workspaces_service.test.ts +++ b/src/core/public/workspace/workspaces_service.test.ts @@ -22,10 +22,6 @@ describe('WorkspacesService', () => { expect(workspacesStart.initialized$.value).toBe(false); }); - it('workspace is not enabled by default', () => { - expect(workspacesStart.workspaceEnabled$.value).toBe(false); - }); - it('currentWorkspace is not set by default', () => { expect(workspacesStart.currentWorkspace$.value).toBe(null); expect(workspacesStart.currentWorkspaceId$.value).toBe(''); @@ -35,7 +31,7 @@ describe('WorkspacesService', () => { expect(workspacesStart.workspaceList$.value.length).toBe(0); }); - it('the current workspace should also updated after changing current workspace id', () => { + it('currentWorkspace is updated when currentWorkspaceId changes', () => { expect(workspacesStart.currentWorkspace$.value).toBe(null); workspacesStart.initialized$.next(true); @@ -70,7 +66,6 @@ describe('WorkspacesService', () => { expect(workspacesStart.currentWorkspaceId$.isStopped).toBe(true); expect(workspacesStart.currentWorkspace$.isStopped).toBe(true); expect(workspacesStart.workspaceList$.isStopped).toBe(true); - expect(workspacesStart.workspaceEnabled$.isStopped).toBe(true); expect(workspacesStart.initialized$.isStopped).toBe(true); }); }); diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts index f3d1400ce709..a7c62a76bec2 100644 --- a/src/core/public/workspace/workspaces_service.ts +++ b/src/core/public/workspace/workspaces_service.ts @@ -10,11 +10,31 @@ import { CoreService, WorkspaceAttribute } from '../../types'; type WorkspaceObject = WorkspaceAttribute & { readonly?: boolean }; -export interface WorkspaceObservables { +interface WorkspaceObservables { + /** + * Indicates the current activated workspace id, the value should be changed every time + * when switching to a different workspace + */ currentWorkspaceId$: BehaviorSubject; + + /** + * The workspace that is derived from `currentWorkspaceId` and `workspaceList`, if + * `currentWorkspaceId` cannot be found from `workspaceList`, it will return an error + * + * This value MUST NOT set manually from outside of WorkspacesService + */ currentWorkspace$: BehaviorSubject; + + /** + * The list of available workspaces. This workspace list should be set by whoever + * the workspace functionalities + */ workspaceList$: BehaviorSubject; - workspaceEnabled$: BehaviorSubject; + + /** + * This is a flag which indicates the WorkspacesService module is initialized and ready + * for consuming by others. For example, the `workspaceList` has been set, etc + */ initialized$: BehaviorSubject; } @@ -30,7 +50,6 @@ export class WorkspacesService implements CoreService([]); private currentWorkspace$ = new BehaviorSubject(null); private initialized$ = new BehaviorSubject(false); - private workspaceEnabled$ = new BehaviorSubject(false); constructor() { combineLatest([this.initialized$, this.workspaceList$, this.currentWorkspaceId$]).subscribe( @@ -67,7 +86,6 @@ export class WorkspacesService implements CoreService { navLinks: {}, management: {}, catalogue: {}, + workspaces: {}, }; }; diff --git a/src/core/server/capabilities/capabilities_service.test.ts b/src/core/server/capabilities/capabilities_service.test.ts index 80e22fdb6721..be28130afd4e 100644 --- a/src/core/server/capabilities/capabilities_service.test.ts +++ b/src/core/server/capabilities/capabilities_service.test.ts @@ -72,6 +72,7 @@ describe('CapabilitiesService', () => { "navLinks": Object { "myLink": true, }, + "workspaces": Object {}, } `); }); @@ -107,6 +108,7 @@ describe('CapabilitiesService', () => { "B": true, "C": true, }, + "workspaces": Object {}, } `); }); @@ -134,6 +136,7 @@ describe('CapabilitiesService', () => { }, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, } `); }); @@ -192,6 +195,7 @@ describe('CapabilitiesService', () => { "b": true, "c": false, }, + "workspaces": Object {}, } `); }); diff --git a/src/core/server/capabilities/capabilities_service.ts b/src/core/server/capabilities/capabilities_service.ts index b92166427271..5153cd5ded1d 100644 --- a/src/core/server/capabilities/capabilities_service.ts +++ b/src/core/server/capabilities/capabilities_service.ts @@ -123,6 +123,7 @@ const defaultCapabilities: Capabilities = { navLinks: {}, management: {}, catalogue: {}, + workspaces: {}, }; /** @internal */ diff --git a/src/core/server/capabilities/integration_tests/capabilities_service.test.ts b/src/core/server/capabilities/integration_tests/capabilities_service.test.ts index b60a067982e0..3cab62478cbc 100644 --- a/src/core/server/capabilities/integration_tests/capabilities_service.test.ts +++ b/src/core/server/capabilities/integration_tests/capabilities_service.test.ts @@ -79,6 +79,7 @@ describe('CapabilitiesService', () => { "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, } `); }); @@ -101,6 +102,7 @@ describe('CapabilitiesService', () => { }, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, } `); }); diff --git a/src/core/server/capabilities/resolve_capabilities.test.ts b/src/core/server/capabilities/resolve_capabilities.test.ts index 25968d858e7a..88e2aa7a4f7c 100644 --- a/src/core/server/capabilities/resolve_capabilities.test.ts +++ b/src/core/server/capabilities/resolve_capabilities.test.ts @@ -42,6 +42,7 @@ describe('resolveCapabilities', () => { navLinks: {}, catalogue: {}, management: {}, + workspaces: {}, }; request = httpServerMock.createOpenSearchDashboardsRequest(); }); @@ -75,6 +76,7 @@ describe('resolveCapabilities', () => { }, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, } `); }); diff --git a/src/core/types/capabilities.ts b/src/core/types/capabilities.ts index d964cd75b997..454f997de8f1 100644 --- a/src/core/types/capabilities.ts +++ b/src/core/types/capabilities.ts @@ -47,6 +47,9 @@ export interface Capabilities { /** Catalogue capabilities. Catalogue entries drive the visibility of the OpenSearch Dashboards homepage options. */ catalogue: Record; + /** Workspaces capabilities. */ + workspaces: Record; + /** Custom capabilities, registered by plugins. undefined if the key does not exist */ [key: string]: Record> | undefined; } diff --git a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap index 5d32f6a74c78..28265faad578 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -104,6 +104,7 @@ exports[`dashboard listing hideWriteControls 1`] = ` "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -1021,15 +1022,6 @@ exports[`dashboard listing hideWriteControls 1`] = ` "observers": Array [], "thrownError": null, }, - "workspaceEnabled$": BehaviorSubject { - "_isScalar": false, - "_value": false, - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [], - "thrownError": null, - }, "workspaceList$": BehaviorSubject { "_isScalar": false, "_value": Array [], @@ -1243,6 +1235,7 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -2160,15 +2153,6 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "observers": Array [], "thrownError": null, }, - "workspaceEnabled$": BehaviorSubject { - "_isScalar": false, - "_value": false, - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [], - "thrownError": null, - }, "workspaceList$": BehaviorSubject { "_isScalar": false, "_value": Array [], @@ -2443,6 +2427,7 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -3360,15 +3345,6 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "observers": Array [], "thrownError": null, }, - "workspaceEnabled$": BehaviorSubject { - "_isScalar": false, - "_value": false, - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [], - "thrownError": null, - }, "workspaceList$": BehaviorSubject { "_isScalar": false, "_value": Array [], @@ -3643,6 +3619,7 @@ exports[`dashboard listing renders table rows 1`] = ` "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -4560,15 +4537,6 @@ exports[`dashboard listing renders table rows 1`] = ` "observers": Array [], "thrownError": null, }, - "workspaceEnabled$": BehaviorSubject { - "_isScalar": false, - "_value": false, - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [], - "thrownError": null, - }, "workspaceList$": BehaviorSubject { "_isScalar": false, "_value": Array [], @@ -4843,6 +4811,7 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -5760,15 +5729,6 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "observers": Array [], "thrownError": null, }, - "workspaceEnabled$": BehaviorSubject { - "_isScalar": false, - "_value": false, - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [], - "thrownError": null, - }, "workspaceList$": BehaviorSubject { "_isScalar": false, "_value": Array [], diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap index 0b877eb199c6..7cb635c2f2c0 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap @@ -104,6 +104,7 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -887,15 +888,6 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "observers": Array [], "thrownError": null, }, - "workspaceEnabled$": BehaviorSubject { - "_isScalar": false, - "_value": false, - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [], - "thrownError": null, - }, "workspaceList$": BehaviorSubject { "_isScalar": false, "_value": Array [], @@ -1068,6 +1060,7 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -1851,15 +1844,6 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "observers": Array [], "thrownError": null, }, - "workspaceEnabled$": BehaviorSubject { - "_isScalar": false, - "_value": false, - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [], - "thrownError": null, - }, "workspaceList$": BehaviorSubject { "_isScalar": false, "_value": Array [], @@ -2032,6 +2016,7 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -2815,15 +2800,6 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "observers": Array [], "thrownError": null, }, - "workspaceEnabled$": BehaviorSubject { - "_isScalar": false, - "_value": false, - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [], - "thrownError": null, - }, "workspaceList$": BehaviorSubject { "_isScalar": false, "_value": Array [], @@ -2996,6 +2972,7 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -3779,15 +3756,6 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "observers": Array [], "thrownError": null, }, - "workspaceEnabled$": BehaviorSubject { - "_isScalar": false, - "_value": false, - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [], - "thrownError": null, - }, "workspaceList$": BehaviorSubject { "_isScalar": false, "_value": Array [], @@ -3960,6 +3928,7 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -4743,15 +4712,6 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "observers": Array [], "thrownError": null, }, - "workspaceEnabled$": BehaviorSubject { - "_isScalar": false, - "_value": false, - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [], - "thrownError": null, - }, "workspaceList$": BehaviorSubject { "_isScalar": false, "_value": Array [], @@ -4924,6 +4884,7 @@ exports[`Dashboard top nav render with all components 1`] = ` "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -5707,15 +5668,6 @@ exports[`Dashboard top nav render with all components 1`] = ` "observers": Array [], "thrownError": null, }, - "workspaceEnabled$": BehaviorSubject { - "_isScalar": false, - "_value": false, - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [], - "thrownError": null, - }, "workspaceList$": BehaviorSubject { "_isScalar": false, "_value": Array [], From 0906bf473762c19809e065779b1ef474a0ebe8c8 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Thu, 12 Oct 2023 10:35:08 +0800 Subject: [PATCH 12/21] feat: add unit test for mountWrapper (#223) Signed-off-by: SuZhou-Joe --- .../public/plugin.test.ts | 63 +++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/src/plugins/saved_objects_management/public/plugin.test.ts b/src/plugins/saved_objects_management/public/plugin.test.ts index c8e762f73dcc..149cee7c5c86 100644 --- a/src/plugins/saved_objects_management/public/plugin.test.ts +++ b/src/plugins/saved_objects_management/public/plugin.test.ts @@ -28,12 +28,23 @@ * under the License. */ +const mountManagementSectionMock = jest.fn(); +jest.doMock('./management_section', () => ({ + mountManagementSection: mountManagementSectionMock, +})); +import { waitFor } from '@testing-library/dom'; import { coreMock } from '../../../core/public/mocks'; import { homePluginMock } from '../../home/public/mocks'; import { managementPluginMock } from '../../management/public/mocks'; import { dataPluginMock } from '../../data/public/mocks'; import { uiActionsPluginMock } from '../../ui_actions/public/mocks'; import { SavedObjectsManagementPlugin } from './plugin'; +import { + MANAGE_LIBRARY_TITLE_WORDINGS, + SAVED_QUERIES_WORDINGS, + SAVED_SEARCHES_WORDINGS, +} from './constants'; +import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; describe('SavedObjectsManagementPlugin', () => { let plugin: SavedObjectsManagementPlugin; @@ -50,12 +61,22 @@ describe('SavedObjectsManagementPlugin', () => { const homeSetup = homePluginMock.createSetupContract(); const managementSetup = managementPluginMock.createSetupContract(); const uiActionsSetup = uiActionsPluginMock.createSetupContract(); + const registerMock = jest.fn((params) => params.mount({} as any, {} as any)); - await plugin.setup(coreSetup, { - home: homeSetup, - management: managementSetup, - uiActions: uiActionsSetup, - }); + await plugin.setup( + { + ...coreSetup, + application: { + ...coreSetup.application, + register: registerMock, + }, + }, + { + home: homeSetup, + management: managementSetup, + uiActions: uiActionsSetup, + } + ); expect(homeSetup.featureCatalogue.register).toHaveBeenCalledTimes(1); expect(homeSetup.featureCatalogue.register).toHaveBeenCalledWith( @@ -63,6 +84,38 @@ describe('SavedObjectsManagementPlugin', () => { id: 'saved_objects', }) ); + expect(registerMock).toBeCalledWith( + expect.objectContaining({ + id: 'objects', + title: MANAGE_LIBRARY_TITLE_WORDINGS, + order: 10000, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + }) + ); + expect(registerMock).toBeCalledWith( + expect.objectContaining({ + id: 'objects_searches', + title: SAVED_SEARCHES_WORDINGS, + order: 8000, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + }) + ); + expect(registerMock).toBeCalledWith( + expect.objectContaining({ + id: 'objects_query', + title: SAVED_QUERIES_WORDINGS, + order: 8001, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + }) + ); + waitFor( + () => { + expect(mountManagementSectionMock).toBeCalledTimes(3); + }, + { + container: document.body, + } + ); }); }); }); From 43e91faec7ab11131207f598668b073f727ef073 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Thu, 12 Oct 2023 17:45:28 +0800 Subject: [PATCH 13/21] [Workspace]Add workspace id in basePath (#212) * feat: enable workspace id in basePath Signed-off-by: SuZhou-Joe * feat: add unit test Signed-off-by: SuZhou-Joe * feat: remove useless test object id Signed-off-by: SuZhou-Joe * feat: add unit test Signed-off-by: SuZhou-Joe * feat: add unit test Signed-off-by: SuZhou-Joe * feat: update snapshot Signed-off-by: SuZhou-Joe * feat: move formatUrlWithWorkspaceId to core/public/utils Signed-off-by: SuZhou-Joe * feat: remove useless variable Signed-off-by: SuZhou-Joe * feat: remove useless variable Signed-off-by: SuZhou-Joe * feat: optimization Signed-off-by: SuZhou-Joe * feat: optimization Signed-off-by: SuZhou-Joe * feat: optimization Signed-off-by: SuZhou-Joe * feat: move workspace/utils to core Signed-off-by: SuZhou-Joe * feat: move workspace/utils to core Signed-off-by: SuZhou-Joe * feat: update comment Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: update unit test Signed-off-by: SuZhou-Joe * feat: optimization Signed-off-by: SuZhou-Joe * feat: add space under license Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe --- .../collapsible_nav.test.tsx.snap | 16 ++ .../header/__snapshots__/header.test.tsx.snap | 8 + src/core/public/http/base_path.test.ts | 32 +++ src/core/public/http/base_path.ts | 28 +- src/core/public/http/http_service.mock.ts | 10 +- src/core/public/http/http_service.test.ts | 26 ++ src/core/public/http/http_service.ts | 10 +- src/core/public/http/types.ts | 25 +- src/core/public/index.ts | 2 + src/core/public/utils/index.ts | 6 + src/core/server/utils/index.ts | 1 + src/core/utils/constants.ts | 2 + src/core/utils/index.ts | 3 +- src/core/utils/workspace.test.ts | 32 +++ src/core/utils/workspace.ts | 42 +++ .../dashboard_listing.test.tsx.snap | 10 + .../dashboard_top_nav.test.tsx.snap | 12 + .../dashboard_empty_screen.test.tsx.snap | 6 + .../saved_objects_table.test.tsx.snap | 2 + .../__snapshots__/flyout.test.tsx.snap | 2 + ...telemetry_management_section.test.tsx.snap | 2 + src/plugins/workspace/common/constants.ts | 2 + .../workspace/opensearch_dashboards.json | 2 +- src/plugins/workspace/public/application.tsx | 26 ++ .../workspace_fatal_error.test.tsx.snap | 180 ++++++++++++ .../components/workspace_fatal_error/index.ts | 6 + .../workspace_fatal_error.test.tsx | 71 +++++ .../workspace_fatal_error.tsx | 68 +++++ src/plugins/workspace/public/plugin.test.ts | 115 ++++++++ src/plugins/workspace/public/plugin.ts | 79 +++++- src/plugins/workspace/public/types.ts | 9 + .../workspace/public/workspace_client.mock.ts | 25 ++ .../workspace/public/workspace_client.test.ts | 181 ++++++++++++ .../workspace/public/workspace_client.ts | 262 ++++++++++++++++++ src/plugins/workspace/server/index.ts | 1 + src/plugins/workspace/server/plugin.ts | 20 ++ src/plugins/workspace/server/routes/index.ts | 1 + src/plugins/workspace/server/types.ts | 1 + .../workspace/server/workspace_client.ts | 1 + 39 files changed, 1302 insertions(+), 25 deletions(-) create mode 100644 src/core/utils/workspace.test.ts create mode 100644 src/core/utils/workspace.ts create mode 100644 src/plugins/workspace/public/application.tsx create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/index.ts create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx create mode 100644 src/plugins/workspace/public/plugin.test.ts create mode 100644 src/plugins/workspace/public/types.ts create mode 100644 src/plugins/workspace/public/workspace_client.mock.ts create mode 100644 src/plugins/workspace/public/workspace_client.test.ts create mode 100644 src/plugins/workspace/public/workspace_client.ts diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 7b4e3ba472dc..f0cd8afddfa3 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -55,9 +55,11 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} @@ -2006,9 +2008,11 @@ exports[`CollapsibleNav renders the default nav 1`] = ` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} @@ -2308,9 +2312,11 @@ exports[`CollapsibleNav renders the default nav 2`] = ` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} @@ -2611,9 +2617,11 @@ exports[`CollapsibleNav renders the default nav 3`] = ` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} @@ -3205,9 +3213,11 @@ exports[`CollapsibleNav with custom branding renders the nav bar in dark mode 1` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={ @@ -4320,9 +4330,11 @@ exports[`CollapsibleNav with custom branding renders the nav bar in default mode BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={ @@ -5434,9 +5446,11 @@ exports[`CollapsibleNav without custom branding renders the nav bar in dark mode BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={ @@ -6541,9 +6555,11 @@ exports[`CollapsibleNav without custom branding renders the nav bar in default m BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index d5903379b2eb..3633fdb23894 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -243,9 +243,11 @@ exports[`Header handles visibility and lock changes 1`] = ` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} @@ -5873,9 +5875,11 @@ exports[`Header handles visibility and lock changes 1`] = ` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } closeNav={[Function]} @@ -6934,9 +6938,11 @@ exports[`Header renders condensed header 1`] = ` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={ @@ -11345,9 +11351,11 @@ exports[`Header renders condensed header 1`] = ` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } closeNav={[Function]} diff --git a/src/core/public/http/base_path.test.ts b/src/core/public/http/base_path.test.ts index 27cfa9bf0581..f80d41631b9b 100644 --- a/src/core/public/http/base_path.test.ts +++ b/src/core/public/http/base_path.test.ts @@ -110,4 +110,36 @@ describe('BasePath', () => { expect(new BasePath('/foo/bar', '/foo').serverBasePath).toEqual('/foo'); }); }); + + describe('workspaceBasePath', () => { + it('get path with workspace', () => { + expect(new BasePath('/foo/bar', '/foo/bar', '/workspace').get()).toEqual( + '/foo/bar/workspace' + ); + }); + + it('getBasePath with workspace provided', () => { + expect(new BasePath('/foo/bar', '/foo/bar', '/workspace').getBasePath()).toEqual('/foo/bar'); + }); + + it('prepend with workspace provided', () => { + expect(new BasePath('/foo/bar', '/foo/bar', '/workspace').prepend('/prepend')).toEqual( + '/foo/bar/workspace/prepend' + ); + }); + + it('prepend with workspace provided but calls without workspace', () => { + expect( + new BasePath('/foo/bar', '/foo/bar', '/workspace').prepend('/prepend', { + withoutWorkspace: true, + }) + ).toEqual('/foo/bar/prepend'); + }); + + it('remove with workspace provided', () => { + expect( + new BasePath('/foo/bar', '/foo/bar', '/workspace').remove('/foo/bar/workspace/remove') + ).toEqual('/remove'); + }); + }); }); diff --git a/src/core/public/http/base_path.ts b/src/core/public/http/base_path.ts index b31504676dba..254e4e2e6ad8 100644 --- a/src/core/public/http/base_path.ts +++ b/src/core/public/http/base_path.ts @@ -29,37 +29,47 @@ */ import { modifyUrl } from '@osd/std'; +import type { PrependOptions } from './types'; export class BasePath { constructor( private readonly basePath: string = '', - public readonly serverBasePath: string = basePath + public readonly serverBasePath: string = basePath, + private readonly workspaceBasePath: string = '' ) {} public get = () => { + return `${this.basePath}${this.workspaceBasePath}`; + }; + + public getBasePath = () => { return this.basePath; }; - public prepend = (path: string): string => { - if (!this.basePath) return path; + public prepend = (path: string, prependOptions?: PrependOptions): string => { + const { withoutWorkspace } = prependOptions || {}; + const basePath = withoutWorkspace ? this.basePath : this.get(); + if (!basePath) return path; return modifyUrl(path, (parts) => { if (!parts.hostname && parts.pathname && parts.pathname.startsWith('/')) { - parts.pathname = `${this.basePath}${parts.pathname}`; + parts.pathname = `${basePath}${parts.pathname}`; } }); }; - public remove = (path: string): string => { - if (!this.basePath) { + public remove = (path: string, prependOptions?: PrependOptions): string => { + const { withoutWorkspace } = prependOptions || {}; + const basePath = withoutWorkspace ? this.basePath : this.get(); + if (!basePath) { return path; } - if (path === this.basePath) { + if (path === basePath) { return '/'; } - if (path.startsWith(`${this.basePath}/`)) { - return path.slice(this.basePath.length); + if (path.startsWith(`${basePath}/`)) { + return path.slice(basePath.length); } return path; diff --git a/src/core/public/http/http_service.mock.ts b/src/core/public/http/http_service.mock.ts index 8c10d10017e5..934e4cbc9394 100644 --- a/src/core/public/http/http_service.mock.ts +++ b/src/core/public/http/http_service.mock.ts @@ -39,7 +39,7 @@ export type HttpSetupMock = jest.Mocked & { anonymousPaths: jest.Mocked; }; -const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({ +const createServiceMock = ({ basePath = '', workspaceBasePath = '' } = {}): HttpSetupMock => ({ fetch: jest.fn(), get: jest.fn(), head: jest.fn(), @@ -48,7 +48,7 @@ const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({ patch: jest.fn(), delete: jest.fn(), options: jest.fn(), - basePath: new BasePath(basePath), + basePath: new BasePath(basePath, undefined, workspaceBasePath), anonymousPaths: { register: jest.fn(), isAnonymous: jest.fn(), @@ -58,14 +58,14 @@ const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({ intercept: jest.fn(), }); -const createMock = ({ basePath = '' } = {}) => { +const createMock = ({ basePath = '', workspaceBasePath = '' } = {}) => { const mocked: jest.Mocked> = { setup: jest.fn(), start: jest.fn(), stop: jest.fn(), }; - mocked.setup.mockReturnValue(createServiceMock({ basePath })); - mocked.start.mockReturnValue(createServiceMock({ basePath })); + mocked.setup.mockReturnValue(createServiceMock({ basePath, workspaceBasePath })); + mocked.start.mockReturnValue(createServiceMock({ basePath, workspaceBasePath })); return mocked; }; diff --git a/src/core/public/http/http_service.test.ts b/src/core/public/http/http_service.test.ts index e60e506dfc0a..5671064e4c52 100644 --- a/src/core/public/http/http_service.test.ts +++ b/src/core/public/http/http_service.test.ts @@ -74,6 +74,32 @@ describe('#setup()', () => { // We don't verify that this Observable comes from Fetch#getLoadingCount$() to avoid complex mocking expect(loadingServiceSetup.addLoadingCountSource).toHaveBeenCalledWith(expect.any(Observable)); }); + + it('setup basePath without workspaceId provided in window.location.href', () => { + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + const fatalErrors = fatalErrorsServiceMock.createSetupContract(); + const httpService = new HttpService(); + const setupResult = httpService.setup({ fatalErrors, injectedMetadata }); + expect(setupResult.basePath.get()).toEqual(''); + }); + + it('setup basePath with workspaceId provided in window.location.href', () => { + const windowSpy = jest.spyOn(window, 'window', 'get'); + windowSpy.mockImplementation( + () => + ({ + location: { + href: 'http://localhost/w/workspaceId/app', + }, + } as any) + ); + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + const fatalErrors = fatalErrorsServiceMock.createSetupContract(); + const httpService = new HttpService(); + const setupResult = httpService.setup({ fatalErrors, injectedMetadata }); + expect(setupResult.basePath.get()).toEqual('/w/workspaceId'); + windowSpy.mockRestore(); + }); }); describe('#stop()', () => { diff --git a/src/core/public/http/http_service.ts b/src/core/public/http/http_service.ts index f26323f261aa..c2caf18be880 100644 --- a/src/core/public/http/http_service.ts +++ b/src/core/public/http/http_service.ts @@ -36,6 +36,8 @@ import { AnonymousPathsService } from './anonymous_paths_service'; import { LoadingCountService } from './loading_count_service'; import { Fetch } from './fetch'; import { CoreService } from '../../types'; +import { getWorkspaceIdFromUrl } from '../utils'; +import { WORKSPACE_PATH_PREFIX } from '../../utils/constants'; interface HttpDeps { injectedMetadata: InjectedMetadataSetup; @@ -50,9 +52,15 @@ export class HttpService implements CoreService { public setup({ injectedMetadata, fatalErrors }: HttpDeps): HttpSetup { const opensearchDashboardsVersion = injectedMetadata.getOpenSearchDashboardsVersion(); + let workspaceBasePath = ''; + const workspaceId = getWorkspaceIdFromUrl(window.location.href); + if (workspaceId) { + workspaceBasePath = `${WORKSPACE_PATH_PREFIX}/${workspaceId}`; + } const basePath = new BasePath( injectedMetadata.getBasePath(), - injectedMetadata.getServerBasePath() + injectedMetadata.getServerBasePath(), + workspaceBasePath ); const fetchService = new Fetch({ basePath, opensearchDashboardsVersion }); const loadingCount = this.loadingCount.setup({ fatalErrors }); diff --git a/src/core/public/http/types.ts b/src/core/public/http/types.ts index 3b7dff71c811..98809d4c885b 100644 --- a/src/core/public/http/types.ts +++ b/src/core/public/http/types.ts @@ -87,25 +87,40 @@ export interface HttpSetup { */ export type HttpStart = HttpSetup; +/** + * prepend options + * + * withoutWorkspace option will prepend a relative url with only basePath + * workspaceId will rewrite the /w/{workspaceId} part, if workspace id is an empty string, prepend will remove the workspaceId part + */ +export interface PrependOptions { + withoutWorkspace?: boolean; +} + /** * APIs for manipulating the basePath on URL segments. * @public */ export interface IBasePath { /** - * Gets the `basePath` string. + * Gets the `basePath + workspace` string. */ get: () => string; /** - * Prepends `path` with the basePath. + * Gets the `basePath + */ + getBasePath: () => string; + + /** + * Prepends `path` with the basePath + workspace. */ - prepend: (url: string) => string; + prepend: (url: string, prependOptions?: PrependOptions) => string; /** - * Removes the prepended basePath from the `path`. + * Removes the prepended basePath + workspace from the `path`. */ - remove: (url: string) => string; + remove: (url: string, prependOptions?: PrependOptions) => string; /** * Returns the server's root basePath as configured, without any namespace prefix. diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 2967b45f1d75..ecdb578890dd 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -349,3 +349,5 @@ export { export { __osdBootstrap__ } from './osd_bootstrap'; export { WorkspacesStart, WorkspacesSetup, WorkspacesService } from './workspace'; + +export { WORKSPACE_TYPE } from '../utils'; diff --git a/src/core/public/utils/index.ts b/src/core/public/utils/index.ts index 7676b9482aac..c0c6f2582e9c 100644 --- a/src/core/public/utils/index.ts +++ b/src/core/public/utils/index.ts @@ -31,3 +31,9 @@ export { shareWeakReplay } from './share_weak_replay'; export { Sha256 } from './crypto'; export { MountWrapper, mountReactNode } from './mount'; +export { + WORKSPACE_PATH_PREFIX, + WORKSPACE_TYPE, + formatUrlWithWorkspaceId, + getWorkspaceIdFromUrl, +} from '../../utils'; diff --git a/src/core/server/utils/index.ts b/src/core/server/utils/index.ts index d2c9e0086ad7..42b01e72b0d1 100644 --- a/src/core/server/utils/index.ts +++ b/src/core/server/utils/index.ts @@ -32,3 +32,4 @@ export * from './crypto'; export * from './from_root'; export * from './package_json'; export * from './streams'; +export { getWorkspaceIdFromUrl, cleanWorkspaceId } from '../../utils'; diff --git a/src/core/utils/constants.ts b/src/core/utils/constants.ts index 73c2d6010846..ecc1b7e863c4 100644 --- a/src/core/utils/constants.ts +++ b/src/core/utils/constants.ts @@ -4,3 +4,5 @@ */ export const WORKSPACE_TYPE = 'workspace'; + +export const WORKSPACE_PATH_PREFIX = '/w'; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index af4f9a17ae58..a83f85a8fce0 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -37,4 +37,5 @@ export { IContextProvider, } from './context'; export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; -export { WORKSPACE_TYPE } from './constants'; +export { WORKSPACE_PATH_PREFIX, WORKSPACE_TYPE } from './constants'; +export { getWorkspaceIdFromUrl, formatUrlWithWorkspaceId, cleanWorkspaceId } from './workspace'; diff --git a/src/core/utils/workspace.test.ts b/src/core/utils/workspace.test.ts new file mode 100644 index 000000000000..7d2a1f700c5f --- /dev/null +++ b/src/core/utils/workspace.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getWorkspaceIdFromUrl, formatUrlWithWorkspaceId } from './workspace'; +import { httpServiceMock } from '../public/mocks'; + +describe('#getWorkspaceIdFromUrl', () => { + it('return workspace when there is a match', () => { + expect(getWorkspaceIdFromUrl('http://localhost/w/foo')).toEqual('foo'); + }); + + it('return empty when there is not a match', () => { + expect(getWorkspaceIdFromUrl('http://localhost/w2/foo')).toEqual(''); + }); +}); + +describe('#formatUrlWithWorkspaceId', () => { + const basePathWithoutWorkspaceBasePath = httpServiceMock.createSetupContract().basePath; + it('return url with workspace prefix when format with a id provided', () => { + expect( + formatUrlWithWorkspaceId('/app/dashboard', 'foo', basePathWithoutWorkspaceBasePath) + ).toEqual('http://localhost/w/foo/app/dashboard'); + }); + + it('return url without workspace prefix when format without a id', () => { + expect( + formatUrlWithWorkspaceId('/w/foo/app/dashboard', '', basePathWithoutWorkspaceBasePath) + ).toEqual('http://localhost/app/dashboard'); + }); +}); diff --git a/src/core/utils/workspace.ts b/src/core/utils/workspace.ts new file mode 100644 index 000000000000..c369f95d5817 --- /dev/null +++ b/src/core/utils/workspace.ts @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WORKSPACE_PATH_PREFIX } from './constants'; +import { IBasePath } from '../public'; + +export const getWorkspaceIdFromUrl = (url: string): string => { + const regexp = /\/w\/([^\/]*)/; + const urlObject = new URL(url); + const matchedResult = urlObject.pathname.match(regexp); + if (matchedResult) { + return matchedResult[1]; + } + + return ''; +}; + +export const cleanWorkspaceId = (path: string) => { + return path.replace(/^\/w\/([^\/]*)/, ''); +}; + +export const formatUrlWithWorkspaceId = (url: string, workspaceId: string, basePath: IBasePath) => { + const newUrl = new URL(url, window.location.href); + /** + * Patch workspace id into path + */ + newUrl.pathname = basePath.remove(newUrl.pathname); + + if (workspaceId) { + newUrl.pathname = `${WORKSPACE_PATH_PREFIX}/${workspaceId}${newUrl.pathname}`; + } else { + newUrl.pathname = cleanWorkspaceId(newUrl.pathname); + } + + newUrl.pathname = basePath.prepend(newUrl.pathname, { + withoutWorkspace: true, + }); + + return newUrl.toString(); +}; diff --git a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap index 28265faad578..71e393cf25cc 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -856,9 +856,11 @@ exports[`dashboard listing hideWriteControls 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -1987,9 +1989,11 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -3179,9 +3183,11 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -4371,9 +4377,11 @@ exports[`dashboard listing renders table rows 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -5563,9 +5571,11 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap index 7cb635c2f2c0..8720101629cb 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap @@ -748,9 +748,11 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -1704,9 +1706,11 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -2660,9 +2664,11 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -3616,9 +3622,11 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -4572,9 +4580,11 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -5528,9 +5538,11 @@ exports[`Dashboard top nav render with all components 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], diff --git a/src/plugins/dashboard/public/application/embeddable/empty/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty/__snapshots__/dashboard_empty_screen.test.tsx.snap index 04120e429393..187c24ba1528 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -12,9 +12,11 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -379,9 +381,11 @@ exports[`DashboardEmptyScreen renders correctly with visualize paragraph 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -756,9 +760,11 @@ exports[`DashboardEmptyScreen renders correctly without visualize paragraph 1`] "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap index a232c07a3a86..b6e7a4379c58 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap @@ -261,9 +261,11 @@ exports[`SavedObjectsTable should render normally 1`] = ` BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", } } canDelete={false} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap index c91fcbd7769e..4e390c9a2834 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap @@ -169,9 +169,11 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], diff --git a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap index 5d71bc774cff..1576310d60e9 100644 --- a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap +++ b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap @@ -314,9 +314,11 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index b6bd7b00f676..39a0769b4852 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -3,4 +3,6 @@ * SPDX-License-Identifier: Apache-2.0 */ +export const WORKSPACE_OVERVIEW_APP_ID = 'workspace_overview'; +export const WORKSPACE_FATAL_ERROR_APP_ID = 'workspace_fatal_error'; export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json index ea2fe1cbed49..c864f76e73f4 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -5,5 +5,5 @@ "ui": true, "requiredPlugins": [], "optionalPlugins": [], - "requiredBundles": [] + "requiredBundles": ["opensearchDashboardsReact"] } diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx new file mode 100644 index 000000000000..a6f496304889 --- /dev/null +++ b/src/plugins/workspace/public/application.tsx @@ -0,0 +1,26 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { AppMountParameters, ScopedHistory } from '../../../core/public'; +import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public'; +import { WorkspaceFatalError } from './components/workspace_fatal_error'; +import { Services } from './types'; + +export const renderFatalErrorApp = (params: AppMountParameters, services: Services) => { + const { element } = params; + const history = params.history as ScopedHistory<{ error?: string }>; + ReactDOM.render( + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; diff --git a/src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap b/src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap new file mode 100644 index 000000000000..594066e959f7 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap @@ -0,0 +1,180 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` render error with callout 1`] = ` +
+
+
+
+
+ +
+

+ + Something went wrong + +

+ +
+
+

+ + The workspace you want to go can not be found, try go back to home. + +

+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+`; + +exports[` render normally 1`] = ` +
+
+
+
+
+ +
+

+ + Something went wrong + +

+ +
+
+

+ + The workspace you want to go can not be found, try go back to home. + +

+
+ +
+
+
+ +
+
+
+
+
+
+
+`; diff --git a/src/plugins/workspace/public/components/workspace_fatal_error/index.ts b/src/plugins/workspace/public/components/workspace_fatal_error/index.ts new file mode 100644 index 000000000000..afb34b10d913 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_fatal_error/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspaceFatalError } from './workspace_fatal_error'; diff --git a/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.test.tsx b/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.test.tsx new file mode 100644 index 000000000000..d98e0063dcfa --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { IntlProvider } from 'react-intl'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { WorkspaceFatalError } from './workspace_fatal_error'; +import { context } from '../../../../opensearch_dashboards_react/public'; +import { coreMock } from '../../../../../core/public/mocks'; + +describe('', () => { + it('render normally', async () => { + const { findByText, container } = render( + + + + ); + await findByText('Something went wrong'); + expect(container).toMatchSnapshot(); + }); + + it('render error with callout', async () => { + const { findByText, container } = render( + + + + ); + await findByText('errorInCallout'); + expect(container).toMatchSnapshot(); + }); + + it('click go back to home', async () => { + const { location } = window; + const setHrefSpy = jest.fn((href) => href); + if (window.location) { + // @ts-ignore + delete window.location; + } + window.location = {} as Location; + Object.defineProperty(window.location, 'href', { + get: () => 'http://localhost/', + set: setHrefSpy, + }); + const coreStartMock = coreMock.createStart(); + const { getByText } = render( + + + + + + ); + fireEvent.click(getByText('Go back to home')); + await waitFor( + () => { + expect(setHrefSpy).toBeCalledTimes(1); + }, + { + container: document.body, + } + ); + window.location = location; + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx b/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx new file mode 100644 index 000000000000..b1081e92237f --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx @@ -0,0 +1,68 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiEmptyPrompt, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiCallOut, +} from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@osd/i18n/react'; +import { IBasePath } from 'opensearch-dashboards/public'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; + +export function WorkspaceFatalError(props: { error?: string }) { + const { + services: { application, http }, + } = useOpenSearchDashboards(); + const goBackToHome = () => { + window.location.href = formatUrlWithWorkspaceId( + application?.getUrlForApp('home') || '', + '', + http?.basePath as IBasePath + ); + }; + return ( + + + + + +

+ } + body={ +

+ +

+ } + actions={[ + + + , + ]} + /> + {props.error ? : null} +
+ + + ); +} diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts new file mode 100644 index 000000000000..370f60caab52 --- /dev/null +++ b/src/plugins/workspace/public/plugin.test.ts @@ -0,0 +1,115 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { waitFor } from '@testing-library/dom'; +import { workspaceClientMock, WorkspaceClientMock } from './workspace_client.mock'; +import { applicationServiceMock, chromeServiceMock, coreMock } from '../../../core/public/mocks'; +import { WorkspacePlugin } from './plugin'; +import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; +import { Observable, Subscriber } from 'rxjs'; + +describe('Workspace plugin', () => { + beforeEach(() => { + WorkspaceClientMock.mockClear(); + Object.values(workspaceClientMock).forEach((item) => item.mockClear()); + }); + it('#setup', async () => { + const setupMock = coreMock.createSetup(); + const workspacePlugin = new WorkspacePlugin(); + await workspacePlugin.setup(setupMock); + expect(setupMock.application.register).toBeCalledTimes(1); + expect(WorkspaceClientMock).toBeCalledTimes(1); + expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0); + }); + + it('#setup when workspace id is in url and enterWorkspace return error', async () => { + const windowSpy = jest.spyOn(window, 'window', 'get'); + windowSpy.mockImplementation( + () => + ({ + location: { + href: 'http://localhost/w/workspaceId/app', + }, + } as any) + ); + workspaceClientMock.enterWorkspace.mockResolvedValue({ + success: false, + error: 'error', + }); + const setupMock = coreMock.createSetup(); + const applicationStartMock = applicationServiceMock.createStartContract(); + const chromeStartMock = chromeServiceMock.createStartContract(); + setupMock.getStartServices.mockImplementation(() => { + return Promise.resolve([ + { + application: applicationStartMock, + chrome: chromeStartMock, + }, + {}, + {}, + ]) as any; + }); + + const workspacePlugin = new WorkspacePlugin(); + await workspacePlugin.setup(setupMock); + expect(setupMock.application.register).toBeCalledTimes(1); + expect(WorkspaceClientMock).toBeCalledTimes(1); + expect(workspaceClientMock.enterWorkspace).toBeCalledWith('workspaceId'); + expect(setupMock.getStartServices).toBeCalledTimes(1); + await waitFor( + () => { + expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_FATAL_ERROR_APP_ID, { + replace: true, + state: { + error: 'error', + }, + }); + }, + { + container: document.body, + } + ); + windowSpy.mockRestore(); + }); + + it('#setup when workspace id is in url and enterWorkspace return success', async () => { + const windowSpy = jest.spyOn(window, 'window', 'get'); + windowSpy.mockImplementation( + () => + ({ + location: { + href: 'http://localhost/w/workspaceId/app', + }, + } as any) + ); + workspaceClientMock.enterWorkspace.mockResolvedValue({ + success: true, + error: 'error', + }); + const setupMock = coreMock.createSetup(); + const applicationStartMock = applicationServiceMock.createStartContract(); + let currentAppIdSubscriber: Subscriber | undefined; + setupMock.getStartServices.mockImplementation(() => { + return Promise.resolve([ + { + application: { + ...applicationStartMock, + currentAppId$: new Observable((subscriber) => { + currentAppIdSubscriber = subscriber; + }), + }, + }, + {}, + {}, + ]) as any; + }); + + const workspacePlugin = new WorkspacePlugin(); + await workspacePlugin.setup(setupMock); + currentAppIdSubscriber?.next(WORKSPACE_FATAL_ERROR_APP_ID); + expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_OVERVIEW_APP_ID); + windowSpy.mockRestore(); + }); +}); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 933afa5858be..5589299903d5 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -2,10 +2,83 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -import { Plugin } from '../../../core/public'; -export class WorkspacePlugin implements Plugin<{}, {}, {}> { - public async setup() { +import { AppMountParameters, AppNavLinkStatus, CoreSetup, Plugin } from '../../../core/public'; +import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; +import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; +import { Services } from './types'; +import { WorkspaceClient } from './workspace_client'; + +type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; + +export class WorkspacePlugin implements Plugin<{}, {}> { + private getWorkspaceIdFromURL(): string | null { + return getWorkspaceIdFromUrl(window.location.href); + } + public async setup(core: CoreSetup) { + const workspaceClient = new WorkspaceClient(core.http, core.workspaces); + await workspaceClient.init(); + + /** + * Retrieve workspace id from url + */ + const workspaceId = this.getWorkspaceIdFromURL(); + + if (workspaceId) { + const result = await workspaceClient.enterWorkspace(workspaceId); + if (!result.success) { + /** + * Fatal error service does not support customized actions + * So we have to use a self-hosted page to show the errors and redirect. + */ + (async () => { + const [{ application, chrome }] = await core.getStartServices(); + chrome.setIsVisible(false); + application.navigateToApp(WORKSPACE_FATAL_ERROR_APP_ID, { + replace: true, + state: { + error: result.error, + }, + }); + })(); + } else { + /** + * If the workspace id is valid and user is currently on workspace_fatal_error page, + * we should redirect user to overview page of workspace. + */ + (async () => { + const [{ application }] = await core.getStartServices(); + const currentAppIdSubscription = application.currentAppId$.subscribe((currentAppId) => { + if (currentAppId === WORKSPACE_FATAL_ERROR_APP_ID) { + application.navigateToApp(WORKSPACE_OVERVIEW_APP_ID); + } + currentAppIdSubscription.unsubscribe(); + }); + })(); + } + } + + const mountWorkspaceApp = async (params: AppMountParameters, renderApp: WorkspaceAppType) => { + const [coreStart] = await core.getStartServices(); + const services = { + ...coreStart, + workspaceClient, + }; + + return renderApp(params, services); + }; + + // workspace fatal error + core.application.register({ + id: WORKSPACE_FATAL_ERROR_APP_ID, + title: '', + navLinkStatus: AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + const { renderFatalErrorApp } = await import('./application'); + return mountWorkspaceApp(params, renderFatalErrorApp); + }, + }); + return {}; } diff --git a/src/plugins/workspace/public/types.ts b/src/plugins/workspace/public/types.ts new file mode 100644 index 000000000000..1b3f38e50857 --- /dev/null +++ b/src/plugins/workspace/public/types.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CoreStart } from '../../../core/public'; +import { WorkspaceClient } from './workspace_client'; + +export type Services = CoreStart & { workspaceClient: WorkspaceClient }; diff --git a/src/plugins/workspace/public/workspace_client.mock.ts b/src/plugins/workspace/public/workspace_client.mock.ts new file mode 100644 index 000000000000..2ceeae5627d1 --- /dev/null +++ b/src/plugins/workspace/public/workspace_client.mock.ts @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const workspaceClientMock = { + init: jest.fn(), + enterWorkspace: jest.fn(), + getCurrentWorkspaceId: jest.fn(), + getCurrentWorkspace: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + list: jest.fn(), + get: jest.fn(), + update: jest.fn(), + stop: jest.fn(), +}; + +export const WorkspaceClientMock = jest.fn(function () { + return workspaceClientMock; +}); + +jest.doMock('./workspace_client', () => ({ + WorkspaceClient: WorkspaceClientMock, +})); diff --git a/src/plugins/workspace/public/workspace_client.test.ts b/src/plugins/workspace/public/workspace_client.test.ts new file mode 100644 index 000000000000..7d05c3f22458 --- /dev/null +++ b/src/plugins/workspace/public/workspace_client.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { httpServiceMock, workspacesServiceMock } from '../../../core/public/mocks'; +import { WorkspaceClient } from './workspace_client'; + +const getWorkspaceClient = () => { + const httpSetupMock = httpServiceMock.createSetupContract(); + const workspaceMock = workspacesServiceMock.createSetupContract(); + return { + httpSetupMock, + workspaceMock, + workspaceClient: new WorkspaceClient(httpSetupMock, workspaceMock), + }; +}; + +describe('#WorkspaceClient', () => { + it('#init', async () => { + const { workspaceClient, httpSetupMock, workspaceMock } = getWorkspaceClient(); + await workspaceClient.init(); + expect(workspaceMock.initialized$.getValue()).toEqual(true); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); + + it('#enterWorkspace', async () => { + const { workspaceClient, httpSetupMock, workspaceMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: false, + }); + const result = await workspaceClient.enterWorkspace('foo'); + expect(result.success).toEqual(false); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + }); + const successResult = await workspaceClient.enterWorkspace('foo'); + expect(workspaceMock.currentWorkspaceId$.getValue()).toEqual('foo'); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { + method: 'GET', + }); + expect(successResult.success).toEqual(true); + }); + + it('#getCurrentWorkspaceId', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + }); + await workspaceClient.enterWorkspace('foo'); + expect(await workspaceClient.getCurrentWorkspaceId()).toEqual({ + success: true, + result: 'foo', + }); + }); + + it('#getCurrentWorkspace', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + name: 'foo', + }, + }); + await workspaceClient.enterWorkspace('foo'); + expect(await workspaceClient.getCurrentWorkspace()).toEqual({ + success: true, + result: { + name: 'foo', + }, + }); + }); + + it('#create', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + name: 'foo', + workspaces: [], + }, + }); + await workspaceClient.create({ + name: 'foo', + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces', { + method: 'POST', + body: JSON.stringify({ + attributes: { + name: 'foo', + }, + }), + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); + + it('#delete', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + name: 'foo', + workspaces: [], + }, + }); + await workspaceClient.delete('foo'); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { + method: 'DELETE', + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); + + it('#list', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + workspaces: [], + }, + }); + await workspaceClient.list({ + perPage: 999, + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); + + it('#get', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + await workspaceClient.get('foo'); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { + method: 'GET', + }); + }); + + it('#update', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + workspaces: [], + }, + }); + await workspaceClient.update('foo', { + name: 'foo', + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { + method: 'PUT', + body: JSON.stringify({ + attributes: { + name: 'foo', + }, + }), + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); +}); diff --git a/src/plugins/workspace/public/workspace_client.ts b/src/plugins/workspace/public/workspace_client.ts new file mode 100644 index 000000000000..f9c219645d14 --- /dev/null +++ b/src/plugins/workspace/public/workspace_client.ts @@ -0,0 +1,262 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + HttpFetchError, + HttpFetchOptions, + HttpSetup, + WorkspaceAttribute, + WorkspacesSetup, +} from '../../../core/public'; + +const WORKSPACES_API_BASE_URL = '/api/workspaces'; + +const join = (...uriComponents: Array) => + uriComponents + .filter((comp): comp is string => Boolean(comp)) + .map(encodeURIComponent) + .join('/'); + +type IResponse = + | { + result: T; + success: true; + } + | { + success: false; + error?: string; + }; + +interface WorkspaceFindOptions { + page?: number; + perPage?: number; + search?: string; + searchFields?: string[]; + sortField?: string; + sortOrder?: string; +} + +/** + * Workspaces is OpenSearchDashboards's visualize mechanism allowing admins to + * organize related features + * + * @public + */ +export class WorkspaceClient { + private http: HttpSetup; + private workspaces: WorkspacesSetup; + + constructor(http: HttpSetup, workspaces: WorkspacesSetup) { + this.http = http; + this.workspaces = workspaces; + } + + /** + * Initialize workspace list + */ + public async init() { + await this.updateWorkspaceList(); + this.workspaces.initialized$.next(true); + } + + /** + * Add a non-throw-error fetch method for internal use. + */ + private safeFetch = async ( + path: string, + options: HttpFetchOptions + ): Promise> => { + try { + return await this.http.fetch>(path, options); + } catch (error: unknown) { + if (error instanceof HttpFetchError) { + return { + success: false, + error: error.body?.message || error.body?.error || error.message, + }; + } + + if (error instanceof Error) { + return { + success: false, + error: error.message, + }; + } + + return { + success: false, + error: 'Unknown error', + }; + } + }; + + private getPath(...path: Array): string { + return [WORKSPACES_API_BASE_URL, join(...path)].filter((item) => item).join('/'); + } + + private async updateWorkspaceList(): Promise { + const result = await this.list({ + perPage: 999, + }); + + if (result?.success) { + this.workspaces.workspaceList$.next(result.result.workspaces); + } + } + + public async enterWorkspace(id: string): Promise> { + const workspaceResp = await this.get(id); + if (workspaceResp.success) { + this.workspaces.currentWorkspaceId$.next(id); + return { + success: true, + result: null, + }; + } else { + return workspaceResp; + } + } + + public async getCurrentWorkspaceId(): Promise> { + const currentWorkspaceId = this.workspaces.currentWorkspaceId$.getValue(); + if (!currentWorkspaceId) { + return { + success: false, + error: 'You are not in any workspace yet.', + }; + } + + return { + success: true, + result: currentWorkspaceId, + }; + } + + public async getCurrentWorkspace(): Promise> { + const currentWorkspaceIdResp = await this.getCurrentWorkspaceId(); + if (currentWorkspaceIdResp.success) { + const currentWorkspaceResp = await this.get(currentWorkspaceIdResp.result); + return currentWorkspaceResp; + } else { + return currentWorkspaceIdResp; + } + } + + /** + * Persists an workspace + * + * @param attributes + * @returns + */ + public async create( + attributes: Omit + ): Promise> { + const path = this.getPath(); + + const result = await this.safeFetch(path, { + method: 'POST', + body: JSON.stringify({ + attributes, + }), + }); + + if (result.success) { + await this.updateWorkspaceList(); + } + + return result; + } + + /** + * Deletes a workspace + * + * @param id + * @returns + */ + public async delete(id: string): Promise> { + const result = await this.safeFetch(this.getPath(id), { method: 'DELETE' }); + + if (result.success) { + await this.updateWorkspaceList(); + } + + return result; + } + + /** + * Search for workspaces + * + * @param {object} [options={}] + * @property {string} options.search + * @property {string} options.searchFields - see OpenSearch Simple Query String + * Query field argument for more information + * @property {integer} [options.page=1] + * @property {integer} [options.perPage=20] + * @property {array} options.fields + * @returns A find result with workspaces matching the specified search. + */ + public list( + options?: WorkspaceFindOptions + ): Promise< + IResponse<{ + workspaces: WorkspaceAttribute[]; + total: number; + per_page: number; + page: number; + }> + > { + const path = this.getPath('_list'); + return this.safeFetch(path, { + method: 'POST', + body: JSON.stringify(options || {}), + }); + } + + /** + * Fetches a single workspace + * + * @param {string} id + * @returns The workspace for the given id. + */ + public get(id: string): Promise> { + const path = this.getPath(id); + return this.safeFetch(path, { + method: 'GET', + }); + } + + /** + * Updates a workspace + * + * @param {string} id + * @param {object} attributes + * @returns + */ + public async update( + id: string, + attributes: Partial + ): Promise> { + const path = this.getPath(id); + const body = { + attributes, + }; + + const result = await this.safeFetch(path, { + method: 'PUT', + body: JSON.stringify(body), + }); + + if (result.success) { + await this.updateWorkspaceList(); + } + + return result; + } + + public stop() { + this.workspaces.workspaceList$.unsubscribe(); + this.workspaces.currentWorkspaceId$.unsubscribe(); + } +} diff --git a/src/plugins/workspace/server/index.ts b/src/plugins/workspace/server/index.ts index 9447b7c6dc8c..fe44b4d71757 100644 --- a/src/plugins/workspace/server/index.ts +++ b/src/plugins/workspace/server/index.ts @@ -2,6 +2,7 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ + import { PluginConfigDescriptor, PluginInitializerContext } from '../../../core/server'; import { WorkspacePlugin } from './plugin'; import { configSchema } from '../config'; diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index 568f536d65e8..d6d4b4381203 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -2,6 +2,7 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ + import { PluginInitializerContext, CoreSetup, @@ -12,11 +13,28 @@ import { import { IWorkspaceDBImpl } from './types'; import { WorkspaceClientWithSavedObject } from './workspace_client'; import { registerRoutes } from './routes'; +import { cleanWorkspaceId, getWorkspaceIdFromUrl } from '../../../core/server/utils'; export class WorkspacePlugin implements Plugin<{}, {}> { private readonly logger: Logger; private client?: IWorkspaceDBImpl; + private proxyWorkspaceTrafficToRealHandler(setupDeps: CoreSetup) { + /** + * Proxy all {basePath}/w/{workspaceId}{osdPath*} paths to {basePath}{osdPath*} + */ + setupDeps.http.registerOnPreRouting(async (request, response, toolkit) => { + const workspaceId = getWorkspaceIdFromUrl(request.url.toString()); + + if (workspaceId) { + const requestUrl = new URL(request.url.toString()); + requestUrl.pathname = cleanWorkspaceId(requestUrl.pathname); + return toolkit.rewriteUrl(requestUrl.toString()); + } + return toolkit.next(); + }); + } + constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get('plugins', 'workspace'); } @@ -28,6 +46,8 @@ export class WorkspacePlugin implements Plugin<{}, {}> { await this.client.setup(core); + this.proxyWorkspaceTrafficToRealHandler(core); + registerRoutes({ http: core.http, logger: this.logger, diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts index f968c853fc90..2567a058ccab 100644 --- a/src/plugins/workspace/server/routes/index.ts +++ b/src/plugins/workspace/server/routes/index.ts @@ -2,6 +2,7 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ + import { schema } from '@osd/config-schema'; import { CoreSetup, Logger } from '../../../../core/server'; diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts index 28d8c25fd3b0..bbbd59ec7d20 100644 --- a/src/plugins/workspace/server/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -2,6 +2,7 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ + import { Logger, OpenSearchDashboardsRequest, diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index 9dcbc2906d43..1273fa4a72f7 100644 --- a/src/plugins/workspace/server/workspace_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -2,6 +2,7 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ + import { i18n } from '@osd/i18n'; import type { SavedObject, From 77738118758626f30aa8ae5517e25e739a884cbd Mon Sep 17 00:00:00 2001 From: Hailong Cui Date: Fri, 13 Oct 2023 09:55:19 +0800 Subject: [PATCH 14/21] [API] Delete saved objects by workspace (#216) * Delete saved objects by workspace Signed-off-by: Hailong Cui fix osd boostrap Signed-off-by: Hailong Cui * add unit test Signed-off-by: Hailong Cui * fix can't delete workspace due to invalid permission Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui --- src/core/server/index.ts | 1 + .../service/lib/repository.mock.ts | 1 + .../service/lib/repository.test.js | 79 +++++++++++++++++++ .../saved_objects/service/lib/repository.ts | 50 ++++++++++++ .../service/saved_objects_client.test.js | 15 ++++ .../service/saved_objects_client.ts | 21 +++++ .../server/integration_tests/routes.test.ts | 40 +++++++++- src/plugins/workspace/server/routes/index.ts | 1 + .../workspace/server/workspace_client.ts | 18 ++++- 9 files changed, 224 insertions(+), 2 deletions(-) diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 379411398fca..3c61fcd81664 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -319,6 +319,7 @@ export { exportSavedObjectsToStream, importSavedObjectsFromStream, resolveSavedObjectsImportErrors, + SavedObjectsDeleteByWorkspaceOptions, } from './saved_objects'; export { diff --git a/src/core/server/saved_objects/service/lib/repository.mock.ts b/src/core/server/saved_objects/service/lib/repository.mock.ts index b9436b364f05..1271bca35129 100644 --- a/src/core/server/saved_objects/service/lib/repository.mock.ts +++ b/src/core/server/saved_objects/service/lib/repository.mock.ts @@ -44,6 +44,7 @@ const create = (): jest.Mocked => ({ deleteFromNamespaces: jest.fn(), deleteByNamespace: jest.fn(), incrementCounter: jest.fn(), + deleteByWorkspace: jest.fn(), }); export const savedObjectsRepositoryMock = { create }; diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 7bb22474ee76..9950d0d253bc 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -2637,6 +2637,85 @@ describe('SavedObjectsRepository', () => { }); }); + describe('#deleteByWorkspace', () => { + const workspace = 'bar-workspace'; + const mockUpdateResults = { + took: 15, + timed_out: false, + total: 3, + updated: 2, + deleted: 1, + batches: 1, + version_conflicts: 0, + noops: 0, + retries: { bulk: 0, search: 0 }, + throttled_millis: 0, + requests_per_second: -1.0, + throttled_until_millis: 0, + failures: [], + }; + + const deleteByWorkspaceSuccess = async (workspace, options) => { + client.updateByQuery.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise(mockUpdateResults) + ); + const result = await savedObjectsRepository.deleteByWorkspace(workspace, options); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledTimes(1); + expect(client.updateByQuery).toHaveBeenCalledTimes(1); + return result; + }; + + describe('client calls', () => { + it(`should use the OpenSearch updateByQuery action`, async () => { + await deleteByWorkspaceSuccess(workspace); + expect(client.updateByQuery).toHaveBeenCalledTimes(1); + }); + + it(`should use all indices for all types`, async () => { + await deleteByWorkspaceSuccess(workspace); + expect(client.updateByQuery).toHaveBeenCalledWith( + expect.objectContaining({ index: ['.opensearch_dashboards_test', 'custom'] }), + expect.anything() + ); + }); + }); + + describe('errors', () => { + it(`throws when workspace is not a string or is '*'`, async () => { + const test = async (workspace) => { + await expect(savedObjectsRepository.deleteByWorkspace(workspace)).rejects.toThrowError( + `workspace is required, and must be a string that is not equal to '*'` + ); + expect(client.updateByQuery).not.toHaveBeenCalled(); + }; + await test(undefined); + await test(null); + await test(['foo-workspace']); + await test(123); + await test(true); + await test(ALL_NAMESPACES_STRING); + }); + }); + + describe('returns', () => { + it(`returns the query results on success`, async () => { + const result = await deleteByWorkspaceSuccess(workspace); + expect(result).toEqual(mockUpdateResults); + }); + }); + + describe('search dsl', () => { + it(`constructs a query that have workspace as search critieria`, async () => { + await deleteByWorkspaceSuccess(workspace); + const allTypes = registry.getAllTypes().map((type) => type.name); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, { + workspaces: [workspace], + type: allTypes, + }); + }); + }); + }); + describe('#find', () => { const generateSearchResults = (namespace) => { return { diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index daa40075caf9..d45bac5cf836 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -68,6 +68,7 @@ import { SavedObjectsAddToNamespacesResponse, SavedObjectsDeleteFromNamespacesOptions, SavedObjectsDeleteFromNamespacesResponse, + SavedObjectsDeleteByWorkspaceOptions, } from '../saved_objects_client'; import { SavedObject, @@ -796,6 +797,55 @@ export class SavedObjectsRepository { return body; } + /** + * Deletes all objects from the provided workspace. It used when deleting a workspace. + * + * @param {string} workspace + * @param options SavedObjectsDeleteByWorkspaceOptions + * @returns {promise} - { took, timed_out, total, deleted, batches, version_conflicts, noops, retries, failures } + */ + async deleteByWorkspace( + workspace: string, + options: SavedObjectsDeleteByWorkspaceOptions = {} + ): Promise { + if (!workspace || typeof workspace !== 'string' || workspace === '*') { + throw new TypeError(`workspace is required, and must be a string that is not equal to '*'`); + } + + const allTypes = Object.keys(getRootPropertiesObjects(this._mappings)); + + const { body } = await this.client.updateByQuery( + { + index: this.getIndicesForTypes(allTypes), + refresh: options.refresh, + body: { + script: { + source: ` + if (!ctx._source.containsKey('workspaces')) { + ctx.op = "delete"; + } else { + ctx._source['workspaces'].removeAll(Collections.singleton(params['workspace'])); + if (ctx._source['workspaces'].empty) { + ctx.op = "delete"; + } + } + `, + lang: 'painless', + params: { workspace }, + }, + conflicts: 'proceed', + ...getSearchDsl(this._mappings, this._registry, { + workspaces: [workspace], + type: allTypes, + }), + }, + }, + { ignore: [404] } + ); + + return body; + } + /** * @param {object} [options={}] * @property {(string|Array)} [options.type] diff --git a/src/core/server/saved_objects/service/saved_objects_client.test.js b/src/core/server/saved_objects/service/saved_objects_client.test.js index d22ffa502f79..676b1a37e051 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.test.js +++ b/src/core/server/saved_objects/service/saved_objects_client.test.js @@ -207,3 +207,18 @@ test(`#deleteFromNamespaces`, async () => { expect(mockRepository.deleteFromNamespaces).toHaveBeenCalledWith(type, id, namespaces, options); expect(result).toBe(returnValue); }); + +test(`#deleteByWorkspace`, async () => { + const returnValue = Symbol(); + const mockRepository = { + deleteByWorkspace: jest.fn().mockResolvedValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const workspace = Symbol(); + const options = Symbol(); + const result = await client.deleteByWorkspace(workspace, options); + + expect(mockRepository.deleteByWorkspace).toHaveBeenCalledWith(workspace, options); + expect(result).toBe(returnValue); +}); diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index a087dc6c388a..732b566d22db 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -277,6 +277,15 @@ export interface SavedObjectsUpdateResponse references: SavedObjectReference[] | undefined; } +/** + * + * @public + */ +export interface SavedObjectsDeleteByWorkspaceOptions extends SavedObjectsBaseOptions { + /** The OpenSearch supports only boolean flag for this operation */ + refresh?: boolean; +} + /** * * @public @@ -433,6 +442,18 @@ export class SavedObjectsClient { return await this._repository.deleteFromNamespaces(type, id, namespaces, options); } + /** + * delete saved objects by workspace id + * @param workspace + * @param options + */ + deleteByWorkspace = async ( + workspace: string, + options: SavedObjectsDeleteByWorkspaceOptions = {} + ): Promise => { + return await this._repository.deleteByWorkspace(workspace, options); + }; + /** * Bulk Updates multiple SavedObject at once * diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts index e4d29b86ac55..7d36eb7ce336 100644 --- a/src/plugins/workspace/server/integration_tests/routes.test.ts +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -6,6 +6,7 @@ import { WorkspaceAttribute } from 'src/core/types'; import { omit } from 'lodash'; import * as osdTestServer from '../../../../core/test_helpers/osd_server'; +import { WORKSPACE_TYPE } from '../../../../core/server'; const testWorkspace: WorkspaceAttribute = { id: 'fake_id', @@ -24,6 +25,7 @@ describe('workspace service', () => { workspace: { enabled: true, }, + migrations: { skip: false }, }, }, }); @@ -45,7 +47,10 @@ describe('workspace service', () => { .expect(200); await Promise.all( listResult.body.result.workspaces.map((item: WorkspaceAttribute) => - osdTestServer.request.delete(root, `/api/workspaces/${item.id}`).expect(200) + // this will delete reserved workspace + osdTestServer.request + .delete(root, `/api/saved_objects/${WORKSPACE_TYPE}/${item.id}`) + .expect(200) ) ); }); @@ -115,6 +120,16 @@ describe('workspace service', () => { }) .expect(200); + await osdTestServer.request + .post(root, `/api/saved_objects/index-pattern/logstash-*`) + .send({ + attributes: { + title: 'logstash-*', + }, + workspaces: [result.body.result.id], + }) + .expect(200); + await osdTestServer.request .delete(root, `/api/workspaces/${result.body.result.id}`) .expect(200); @@ -125,6 +140,29 @@ describe('workspace service', () => { ); expect(getResult.body.success).toEqual(false); + + // saved objects been deleted + await osdTestServer.request + .get(root, `/api/saved_objects/index-pattern/logstash-*`) + .expect(404); + }); + it('delete reserved workspace', async () => { + const reservedWorkspace: WorkspaceAttribute = { ...testWorkspace, reserved: true }; + const result: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omit(reservedWorkspace, 'id'), + }) + .expect(200); + + const deleteResult = await osdTestServer.request + .delete(root, `/api/workspaces/${result.body.result.id}`) + .expect(200); + + expect(deleteResult.body.success).toEqual(false); + expect(deleteResult.body.error).toEqual( + `Reserved workspace ${result.body.result.id} is not allowed to delete.` + ); }); it('list', async () => { await osdTestServer.request diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts index 2567a058ccab..518673d9867b 100644 --- a/src/plugins/workspace/server/routes/index.ts +++ b/src/plugins/workspace/server/routes/index.ts @@ -17,6 +17,7 @@ const workspaceAttributesSchema = schema.object({ color: schema.maybe(schema.string()), icon: schema.maybe(schema.string()), defaultVISTheme: schema.maybe(schema.string()), + reserved: schema.maybe(schema.boolean()), }); export function registerRoutes({ diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index 1273fa4a72f7..3a214bd7f240 100644 --- a/src/plugins/workspace/server/workspace_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -183,7 +183,23 @@ export class WorkspaceClientWithSavedObject implements IWorkspaceDBImpl { } public async delete(requestDetail: IRequestDetail, id: string): Promise> { try { - await this.getSavedObjectClientsFromRequestDetail(requestDetail).delete(WORKSPACE_TYPE, id); + const savedObjectClient = this.getSavedObjectClientsFromRequestDetail(requestDetail); + const workspaceInDB: SavedObject = await savedObjectClient.get( + WORKSPACE_TYPE, + id + ); + if (workspaceInDB.attributes.reserved) { + return { + success: false, + error: i18n.translate('workspace.deleteReservedWorkspace.errorMessage', { + defaultMessage: 'Reserved workspace {id} is not allowed to delete.', + values: { id: workspaceInDB.id }, + }), + }; + } + await savedObjectClient.deleteByWorkspace(id); + // delete workspace itself at last, deleteByWorkspace depends on the workspace to do permission check + await savedObjectClient.delete(WORKSPACE_TYPE, id); return { success: true, result: true, From 2c7aadfa31038ae9fcea528f608e98fd0ba404a6 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Fri, 13 Oct 2023 11:07:57 +0800 Subject: [PATCH 15/21] [Workspace][Feature] Import sample data to workspace (#210) * feat: import sample data saved objects to workspace Signed-off-by: Lin Wang * refactor: simplify sample data saved object id prefix logic (#1) * refactor: simplify sample data saved object id prefix logic Signed-off-by: Yulong Ruan * fix: align the prefix order of sample data install and uninstall rename appendPrefix to addPrefix Signed-off-by: Yulong Ruan --------- Signed-off-by: Yulong Ruan * refactor: assigned copied saved objects to new variables Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang Signed-off-by: Yulong Ruan Co-authored-by: Yulong Ruan --- .../components/sample_data_set_cards.js | 19 ++- .../opensearch_dashboards_services.ts | 2 + .../public/application/sample_data_client.js | 23 +++- src/plugins/home/public/plugin.ts | 1 + .../sample_data/data_sets/ecommerce/index.ts | 8 +- .../sample_data/data_sets/flights/index.ts | 8 +- .../sample_data/data_sets/logs/index.ts | 8 +- .../services/sample_data/data_sets/util.ts | 124 +++++++++++------- .../lib/sample_dataset_registry_types.ts | 6 +- .../sample_data/routes/install.test.ts | 63 +++++++++ .../services/sample_data/routes/install.ts | 22 +++- .../services/sample_data/routes/list.test.ts | 105 +++++++++++++++ .../services/sample_data/routes/list.ts | 8 +- .../sample_data/routes/uninstall.test.ts | 31 +++++ .../services/sample_data/routes/uninstall.ts | 16 ++- 15 files changed, 356 insertions(+), 88 deletions(-) diff --git a/src/plugins/home/public/application/components/sample_data_set_cards.js b/src/plugins/home/public/application/components/sample_data_set_cards.js index ee128e11ec75..2eed83727263 100644 --- a/src/plugins/home/public/application/components/sample_data_set_cards.js +++ b/src/plugins/home/public/application/components/sample_data_set_cards.js @@ -81,7 +81,10 @@ export class SampleDataSetCards extends React.Component { loadSampleDataSets = async (dataSourceId) => { let sampleDataSets; try { - sampleDataSets = await listSampleDataSets(dataSourceId); + sampleDataSets = await listSampleDataSets( + dataSourceId, + getServices().workspaces.currentWorkspaceId$.getValue() + ); } catch (fetchError) { this.toastNotifications.addDanger({ title: i18n.translate('home.sampleDataSet.unableToLoadListErrorMessage', { @@ -114,7 +117,12 @@ export class SampleDataSetCards extends React.Component { })); try { - await installSampleDataSet(id, targetSampleDataSet.defaultIndex, dataSourceId); + await installSampleDataSet( + id, + targetSampleDataSet.defaultIndex, + dataSourceId, + getServices().workspaces.currentWorkspaceId$.getValue() + ); } catch (fetchError) { if (this._isMounted) { this.setState((prevState) => ({ @@ -162,7 +170,12 @@ export class SampleDataSetCards extends React.Component { })); try { - await uninstallSampleDataSet(id, targetSampleDataSet.defaultIndex, dataSourceId); + await uninstallSampleDataSet( + id, + targetSampleDataSet.defaultIndex, + dataSourceId, + getServices().workspaces.currentWorkspaceId$.getValue() + ); } catch (fetchError) { if (this._isMounted) { this.setState((prevState) => ({ diff --git a/src/plugins/home/public/application/opensearch_dashboards_services.ts b/src/plugins/home/public/application/opensearch_dashboards_services.ts index 60f9e70621ff..ac918c005db2 100644 --- a/src/plugins/home/public/application/opensearch_dashboards_services.ts +++ b/src/plugins/home/public/application/opensearch_dashboards_services.ts @@ -37,6 +37,7 @@ import { SavedObjectsClientContract, IUiSettingsClient, ApplicationStart, + WorkspaceStart, } from 'opensearch-dashboards/public'; import { UiStatsMetricType } from '@osd/analytics'; import { TelemetryPluginStart } from '../../../telemetry/public'; @@ -73,6 +74,7 @@ export interface HomeOpenSearchDashboardsServices { getBranding: () => HomePluginBranding; }; dataSource?: DataSourcePluginStart; + workspaces: WorkspaceStart; } let services: HomeOpenSearchDashboardsServices | null = null; diff --git a/src/plugins/home/public/application/sample_data_client.js b/src/plugins/home/public/application/sample_data_client.js index 045736c428f6..7334c14a7033 100644 --- a/src/plugins/home/public/application/sample_data_client.js +++ b/src/plugins/home/public/application/sample_data_client.js @@ -36,13 +36,13 @@ function clearIndexPatternsCache() { getServices().indexPatternService.clearCache(); } -export async function listSampleDataSets(dataSourceId) { - const query = buildQuery(dataSourceId); +export async function listSampleDataSets(dataSourceId, workspaceId) { + const query = buildQuery(dataSourceId, workspaceId); return await getServices().http.get(sampleDataUrl, { query }); } -export async function installSampleDataSet(id, sampleDataDefaultIndex, dataSourceId) { - const query = buildQuery(dataSourceId); +export async function installSampleDataSet(id, sampleDataDefaultIndex, dataSourceId, workspaceId) { + const query = buildQuery(dataSourceId, workspaceId); await getServices().http.post(`${sampleDataUrl}/${id}`, { query }); if (getServices().uiSettings.isDefault('defaultIndex')) { @@ -52,8 +52,13 @@ export async function installSampleDataSet(id, sampleDataDefaultIndex, dataSourc clearIndexPatternsCache(); } -export async function uninstallSampleDataSet(id, sampleDataDefaultIndex, dataSourceId) { - const query = buildQuery(dataSourceId); +export async function uninstallSampleDataSet( + id, + sampleDataDefaultIndex, + dataSourceId, + workspaceId +) { + const query = buildQuery(dataSourceId, workspaceId); await getServices().http.delete(`${sampleDataUrl}/${id}`, { query }); const uiSettings = getServices().uiSettings; @@ -68,12 +73,16 @@ export async function uninstallSampleDataSet(id, sampleDataDefaultIndex, dataSou clearIndexPatternsCache(); } -function buildQuery(dataSourceId) { +function buildQuery(dataSourceId, workspaceId) { const query = {}; if (dataSourceId) { query.data_source_id = dataSourceId; } + if (workspaceId) { + query.workspace_id = workspaceId; + } + return query; } diff --git a/src/plugins/home/public/plugin.ts b/src/plugins/home/public/plugin.ts index 1538156a801e..bf815a30c74d 100644 --- a/src/plugins/home/public/plugin.ts +++ b/src/plugins/home/public/plugin.ts @@ -122,6 +122,7 @@ export class HomePublicPlugin featureCatalogue: this.featuresCatalogueRegistry, injectedMetadata: coreStart.injectedMetadata, dataSource, + workspaces: coreStart.workspaces, }); coreStart.chrome.docTitle.change( i18n.translate('home.pageTitle', { defaultMessage: 'Home' }) diff --git a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts index 75e9ea50ff87..1a4ebd2a5e72 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts @@ -33,7 +33,7 @@ import { i18n } from '@osd/i18n'; import { getSavedObjects } from './saved_objects'; import { fieldMappings } from './field_mappings'; import { SampleDatasetSchema, AppLinkSchema } from '../../lib/sample_dataset_registry_types'; -import { getSavedObjectsWithDataSource, appendDataSourceId } from '../util'; +import { addPrefixTo } from '../util'; const ecommerceName = i18n.translate('home.sampleData.ecommerceSpecTitle', { defaultMessage: 'Sample eCommerce orders', @@ -55,13 +55,11 @@ export const ecommerceSpecProvider = function (): SampleDatasetSchema { darkPreviewImagePath: '/plugins/home/assets/sample_data_resources/ecommerce/dashboard_dark.png', hasNewThemeImages: true, overviewDashboard: DASHBOARD_ID, - getDataSourceIntegratedDashboard: appendDataSourceId(DASHBOARD_ID), + getDashboardWithPrefix: addPrefixTo(DASHBOARD_ID), appLinks: initialAppLinks, defaultIndex: DEFAULT_INDEX, - getDataSourceIntegratedDefaultIndex: appendDataSourceId(DEFAULT_INDEX), + getDataSourceIntegratedDefaultIndex: addPrefixTo(DEFAULT_INDEX), savedObjects: getSavedObjects(), - getDataSourceIntegratedSavedObjects: (dataSourceId?: string, dataSourceTitle?: string) => - getSavedObjectsWithDataSource(getSavedObjects(), dataSourceId, dataSourceTitle), dataIndices: [ { id: 'ecommerce', diff --git a/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts b/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts index 415d98027c4f..2e42b78e5305 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts @@ -33,7 +33,7 @@ import { i18n } from '@osd/i18n'; import { getSavedObjects } from './saved_objects'; import { fieldMappings } from './field_mappings'; import { SampleDatasetSchema, AppLinkSchema } from '../../lib/sample_dataset_registry_types'; -import { getSavedObjectsWithDataSource, appendDataSourceId } from '../util'; +import { addPrefixTo } from '../util'; const flightsName = i18n.translate('home.sampleData.flightsSpecTitle', { defaultMessage: 'Sample flight data', @@ -55,13 +55,11 @@ export const flightsSpecProvider = function (): SampleDatasetSchema { darkPreviewImagePath: '/plugins/home/assets/sample_data_resources/flights/dashboard_dark.png', hasNewThemeImages: true, overviewDashboard: DASHBOARD_ID, - getDataSourceIntegratedDashboard: appendDataSourceId(DASHBOARD_ID), + getDashboardWithPrefix: addPrefixTo(DASHBOARD_ID), appLinks: initialAppLinks, defaultIndex: DEFAULT_INDEX, - getDataSourceIntegratedDefaultIndex: appendDataSourceId(DEFAULT_INDEX), + getDataSourceIntegratedDefaultIndex: addPrefixTo(DEFAULT_INDEX), savedObjects: getSavedObjects(), - getDataSourceIntegratedSavedObjects: (dataSourceId?: string, dataSourceTitle?: string) => - getSavedObjectsWithDataSource(getSavedObjects(), dataSourceId, dataSourceTitle), dataIndices: [ { id: 'flights', diff --git a/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts b/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts index 0e8eaf99d411..5c3cc9bf6861 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts @@ -33,7 +33,7 @@ import { i18n } from '@osd/i18n'; import { getSavedObjects } from './saved_objects'; import { fieldMappings } from './field_mappings'; import { SampleDatasetSchema, AppLinkSchema } from '../../lib/sample_dataset_registry_types'; -import { appendDataSourceId, getSavedObjectsWithDataSource } from '../util'; +import { addPrefixTo } from '../util'; const logsName = i18n.translate('home.sampleData.logsSpecTitle', { defaultMessage: 'Sample web logs', @@ -55,13 +55,11 @@ export const logsSpecProvider = function (): SampleDatasetSchema { darkPreviewImagePath: '/plugins/home/assets/sample_data_resources/logs/dashboard_dark.png', hasNewThemeImages: true, overviewDashboard: DASHBOARD_ID, - getDataSourceIntegratedDashboard: appendDataSourceId(DASHBOARD_ID), + getDashboardWithPrefix: addPrefixTo(DASHBOARD_ID), appLinks: initialAppLinks, defaultIndex: DEFAULT_INDEX, - getDataSourceIntegratedDefaultIndex: appendDataSourceId(DEFAULT_INDEX), + getDataSourceIntegratedDefaultIndex: addPrefixTo(DEFAULT_INDEX), savedObjects: getSavedObjects(), - getDataSourceIntegratedSavedObjects: (dataSourceId?: string, dataSourceTitle?: string) => - getSavedObjectsWithDataSource(getSavedObjects(), dataSourceId, dataSourceTitle), dataIndices: [ { id: 'logs', diff --git a/src/plugins/home/server/services/sample_data/data_sets/util.ts b/src/plugins/home/server/services/sample_data/data_sets/util.ts index 46022f1c22d3..26736d503ce6 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/util.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/util.ts @@ -4,60 +4,74 @@ */ import { SavedObject } from 'opensearch-dashboards/server'; +import { cloneDeep } from 'lodash'; -export const appendDataSourceId = (id: string) => { - return (dataSourceId?: string) => (dataSourceId ? `${dataSourceId}_` + id : id); +const withPrefix = (...args: Array) => (id: string) => { + const prefix = args.filter(Boolean).join('_'); + if (prefix) { + return `${prefix}_${id}`; + } + return id; }; -export const getSavedObjectsWithDataSource = ( - saveObjectList: SavedObject[], - dataSourceId?: string, - dataSourceTitle?: string -): SavedObject[] => { - if (dataSourceId) { - return saveObjectList.map((saveObject) => { - saveObject.id = `${dataSourceId}_` + saveObject.id; - // update reference - if (saveObject.type === 'dashboard') { - saveObject.references.map((reference) => { - if (reference.id) { - reference.id = `${dataSourceId}_` + reference.id; - } - }); +export const addPrefixTo = (id: string) => (...args: Array) => { + return withPrefix(...args)(id); +}; + +const overrideSavedObjectId = (savedObject: SavedObject, idGenerator: (id: string) => string) => { + savedObject.id = idGenerator(savedObject.id); + // update reference + if (savedObject.type === 'dashboard') { + savedObject.references.map((reference) => { + if (reference.id) { + reference.id = idGenerator(reference.id); } + }); + } - // update reference - if (saveObject.type === 'visualization' || saveObject.type === 'search') { - const searchSourceString = saveObject.attributes?.kibanaSavedObjectMeta?.searchSourceJSON; - const visStateString = saveObject.attributes?.visState; + // update reference + if (savedObject.type === 'visualization' || savedObject.type === 'search') { + const searchSourceString = savedObject.attributes?.kibanaSavedObjectMeta?.searchSourceJSON; + const visStateString = savedObject.attributes?.visState; - if (searchSourceString) { - const searchSource = JSON.parse(searchSourceString); - if (searchSource.index) { - searchSource.index = `${dataSourceId}_` + searchSource.index; - saveObject.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify( - searchSource - ); - } - } + if (searchSourceString) { + const searchSource = JSON.parse(searchSourceString); + if (searchSource.index) { + searchSource.index = idGenerator(searchSource.index); + savedObject.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify( + searchSource + ); + } + } - if (visStateString) { - const visState = JSON.parse(visStateString); - const controlList = visState.params?.controls; - if (controlList) { - controlList.map((control) => { - if (control.indexPattern) { - control.indexPattern = `${dataSourceId}_` + control.indexPattern; - } - }); + if (visStateString) { + const visState = JSON.parse(visStateString); + const controlList = visState.params?.controls; + if (controlList) { + controlList.map((control) => { + if (control.indexPattern) { + control.indexPattern = idGenerator(control.indexPattern); } - saveObject.attributes.visState = JSON.stringify(visState); - } + }); } + savedObject.attributes.visState = JSON.stringify(visState); + } + } +}; + +export const getDataSourceIntegratedSavedObjects = ( + savedObjectList: SavedObject[], + dataSourceId?: string, + dataSourceTitle?: string +): SavedObject[] => { + savedObjectList = cloneDeep(savedObjectList); + if (dataSourceId) { + return savedObjectList.map((savedObject) => { + overrideSavedObjectId(savedObject, withPrefix(dataSourceId)); // update reference - if (saveObject.type === 'index-pattern') { - saveObject.references = [ + if (savedObject.type === 'index-pattern') { + savedObject.references = [ { id: `${dataSourceId}`, type: 'data-source', @@ -68,17 +82,29 @@ export const getSavedObjectsWithDataSource = ( if (dataSourceTitle) { if ( - saveObject.type === 'dashboard' || - saveObject.type === 'visualization' || - saveObject.type === 'search' + savedObject.type === 'dashboard' || + savedObject.type === 'visualization' || + savedObject.type === 'search' ) { - saveObject.attributes.title = saveObject.attributes.title + `_${dataSourceTitle}`; + savedObject.attributes.title = savedObject.attributes.title + `_${dataSourceTitle}`; } } - return saveObject; + return savedObject; }); } - return saveObjectList; + return savedObjectList; +}; + +export const getWorkspaceIntegratedSavedObjects = ( + savedObjectList: SavedObject[], + workspaceId?: string +) => { + const savedObjectListCopy = cloneDeep(savedObjectList); + + savedObjectListCopy.forEach((savedObject) => { + overrideSavedObjectId(savedObject, withPrefix(workspaceId)); + }); + return savedObjectListCopy; }; diff --git a/src/plugins/home/server/services/sample_data/lib/sample_dataset_registry_types.ts b/src/plugins/home/server/services/sample_data/lib/sample_dataset_registry_types.ts index 5f6d036d6b39..33b997c4303a 100644 --- a/src/plugins/home/server/services/sample_data/lib/sample_dataset_registry_types.ts +++ b/src/plugins/home/server/services/sample_data/lib/sample_dataset_registry_types.ts @@ -89,7 +89,7 @@ export interface SampleDatasetSchema { // saved object id of main dashboard for sample data set overviewDashboard: string; - getDataSourceIntegratedDashboard: (dataSourceId?: string) => string; + getDashboardWithPrefix: (...args: Array) => string; appLinks: AppLinkSchema[]; // saved object id of default index-pattern for sample data set @@ -99,10 +99,6 @@ export interface SampleDatasetSchema { // OpenSearch Dashboards saved objects (index patter, visualizations, dashboard, ...) // Should provide a nice demo of OpenSearch Dashboards's functionality with the sample data set savedObjects: Array>; - getDataSourceIntegratedSavedObjects: ( - dataSourceId?: string, - dataSourceTitle?: string - ) => Array>; dataIndices: DataIndexSchema[]; status?: string | undefined; statusMsg?: unknown; diff --git a/src/plugins/home/server/services/sample_data/routes/install.test.ts b/src/plugins/home/server/services/sample_data/routes/install.test.ts index ad7b421c23d5..590edb5980ff 100644 --- a/src/plugins/home/server/services/sample_data/routes/install.test.ts +++ b/src/plugins/home/server/services/sample_data/routes/install.test.ts @@ -157,4 +157,67 @@ describe('sample data install route', () => { }, }); }); + + it('handler calls expected api with the given request with workspace', async () => { + const mockWorkspaceId = 'workspace'; + + const mockClient = jest.fn().mockResolvedValue(true); + + const mockSOClientGetResponse = { + saved_objects: [ + { + type: 'dashboard', + id: '12345', + namespaces: ['default'], + attributes: { title: 'dashboard' }, + }, + ], + }; + const mockSOClient = { + bulkCreate: jest.fn().mockResolvedValue(mockSOClientGetResponse), + get: jest.fn().mockResolvedValue(mockSOClientGetResponse), + }; + + const mockContext = { + core: { + opensearch: { + legacy: { + client: { callAsCurrentUser: mockClient }, + }, + }, + savedObjects: { client: mockSOClient }, + }, + }; + const mockBody = { id: 'flights' }; + const mockQuery = { workspace: mockWorkspaceId }; + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest({ + params: mockBody, + query: mockQuery, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + createInstallRoute( + mockCoreSetup.http.createRouter(), + sampleDatasets, + mockLogger, + mockUsageTracker + ); + + const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; + const handler = mockRouter.post.mock.calls[0][1]; + + await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + + expect(mockClient.mock.calls[1][1].body.settings).toMatchObject({ + index: { number_of_shards: 1 }, + }); + + expect(mockResponse.ok).toBeCalled(); + expect(mockResponse.ok.mock.calls[0][0]).toMatchObject({ + body: { + opensearchIndicesCreated: { opensearch_dashboards_sample_data_flights: 13059 }, + opensearchDashboardsSavedObjectsLoaded: 20, + }, + }); + }); }); diff --git a/src/plugins/home/server/services/sample_data/routes/install.ts b/src/plugins/home/server/services/sample_data/routes/install.ts index 279357fc1977..38fb7f3fbe21 100644 --- a/src/plugins/home/server/services/sample_data/routes/install.ts +++ b/src/plugins/home/server/services/sample_data/routes/install.ts @@ -39,6 +39,10 @@ import { } from '../lib/translate_timestamp'; import { loadData } from '../lib/load_data'; import { SampleDataUsageTracker } from '../usage/usage'; +import { + getDataSourceIntegratedSavedObjects, + getWorkspaceIntegratedSavedObjects, +} from '../data_sets/util'; const insertDataIntoIndex = ( dataIndexConfig: any, @@ -113,12 +117,14 @@ export function createInstallRoute( query: schema.object({ now: schema.maybe(schema.string()), data_source_id: schema.maybe(schema.string()), + workspace_id: schema.maybe(schema.string()), }), }, }, async (context, req, res) => { const { params, query } = req; const dataSourceId = query.data_source_id; + const workspaceId = query.workspace_id; const sampleDataset = sampleDatasets.find(({ id }) => id === params.id); if (!sampleDataset) { @@ -198,14 +204,22 @@ export function createInstallRoute( } let createResults; - const savedObjectsList = dataSourceId - ? sampleDataset.getDataSourceIntegratedSavedObjects(dataSourceId, dataSourceTitle) - : sampleDataset.savedObjects; + let savedObjectsList = sampleDataset.savedObjects; + if (workspaceId) { + savedObjectsList = getWorkspaceIntegratedSavedObjects(savedObjectsList, workspaceId); + } + if (dataSourceId) { + savedObjectsList = getDataSourceIntegratedSavedObjects( + savedObjectsList, + dataSourceId, + dataSourceTitle + ); + } try { createResults = await context.core.savedObjects.client.bulkCreate( savedObjectsList.map(({ version, ...savedObject }) => savedObject), - { overwrite: true } + { overwrite: true, workspaces: workspaceId ? [workspaceId] : undefined } ); } catch (err) { const errMsg = `bulkCreate failed, error: ${err.message}`; diff --git a/src/plugins/home/server/services/sample_data/routes/list.test.ts b/src/plugins/home/server/services/sample_data/routes/list.test.ts index 70201fafd06b..d8fb572da128 100644 --- a/src/plugins/home/server/services/sample_data/routes/list.test.ts +++ b/src/plugins/home/server/services/sample_data/routes/list.test.ts @@ -119,4 +119,109 @@ describe('sample data list route', () => { `${mockDataSourceId}_7adfa750-4c81-11e8-b3d7-01146121b73d` ); }); + + it('handler calls expected api with the given request with workspace', async () => { + const mockWorkspaceId = 'workspace'; + const mockClient = jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce({ count: 1 }); + + const mockSOClientGetResponse = { + saved_objects: [ + { + type: 'dashboard', + id: `${mockWorkspaceId}_7adfa750-4c81-11e8-b3d7-01146121b73d`, + namespaces: ['default'], + attributes: { title: 'dashboard' }, + }, + ], + }; + const mockSOClient = { get: jest.fn().mockResolvedValue(mockSOClientGetResponse) }; + + const mockContext = { + core: { + opensearch: { + legacy: { + client: { callAsCurrentUser: mockClient }, + }, + }, + savedObjects: { client: mockSOClient }, + }, + }; + + const mockBody = {}; + const mockQuery = { workspace_id: mockWorkspaceId }; + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest({ + body: mockBody, + query: mockQuery, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + createListRoute(mockCoreSetup.http.createRouter(), sampleDatasets); + + const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; + const handler = mockRouter.get.mock.calls[0][1]; + + await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + + expect(mockClient).toBeCalledTimes(2); + expect(mockResponse.ok).toBeCalled(); + expect(mockSOClient.get.mock.calls[0][1]).toMatch( + `${mockWorkspaceId}_7adfa750-4c81-11e8-b3d7-01146121b73d` + ); + }); + + it('handler calls expected api with the given request with workspace and data source', async () => { + const mockWorkspaceId = 'workspace'; + const mockDataSourceId = 'dataSource'; + const mockClient = jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce({ count: 1 }); + + const mockSOClientGetResponse = { + saved_objects: [ + { + type: 'dashboard', + id: `${mockDataSourceId}_${mockWorkspaceId}_7adfa750-4c81-11e8-b3d7-01146121b73d`, + namespaces: ['default'], + attributes: { title: 'dashboard' }, + }, + ], + }; + const mockSOClient = { get: jest.fn().mockResolvedValue(mockSOClientGetResponse) }; + + const mockContext = { + dataSource: { + opensearch: { + legacy: { + getClient: (id) => { + return { + callAPI: mockClient, + }; + }, + }, + }, + }, + core: { + savedObjects: { client: mockSOClient }, + }, + }; + + const mockBody = {}; + const mockQuery = { workspace_id: mockWorkspaceId, data_source_id: mockDataSourceId }; + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest({ + body: mockBody, + query: mockQuery, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + createListRoute(mockCoreSetup.http.createRouter(), sampleDatasets); + + const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; + const handler = mockRouter.get.mock.calls[0][1]; + + await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + + expect(mockClient).toBeCalledTimes(2); + expect(mockResponse.ok).toBeCalled(); + expect(mockSOClient.get.mock.calls[0][1]).toMatch( + `${mockDataSourceId}_${mockWorkspaceId}_7adfa750-4c81-11e8-b3d7-01146121b73d` + ); + }); }); diff --git a/src/plugins/home/server/services/sample_data/routes/list.ts b/src/plugins/home/server/services/sample_data/routes/list.ts index 5d4b036a9ead..431ab9437d55 100644 --- a/src/plugins/home/server/services/sample_data/routes/list.ts +++ b/src/plugins/home/server/services/sample_data/routes/list.ts @@ -42,11 +42,15 @@ export const createListRoute = (router: IRouter, sampleDatasets: SampleDatasetSc { path: '/api/sample_data', validate: { - query: schema.object({ data_source_id: schema.maybe(schema.string()) }), + query: schema.object({ + data_source_id: schema.maybe(schema.string()), + workspace_id: schema.maybe(schema.string()), + }), }, }, async (context, req, res) => { const dataSourceId = req.query.data_source_id; + const workspaceId = req.query.workspace_id; const registeredSampleDatasets = sampleDatasets.map((sampleDataset) => { return { @@ -56,7 +60,7 @@ export const createListRoute = (router: IRouter, sampleDatasets: SampleDatasetSc previewImagePath: sampleDataset.previewImagePath, darkPreviewImagePath: sampleDataset.darkPreviewImagePath, hasNewThemeImages: sampleDataset.hasNewThemeImages, - overviewDashboard: sampleDataset.getDataSourceIntegratedDashboard(dataSourceId), + overviewDashboard: sampleDataset.getDashboardWithPrefix(dataSourceId, workspaceId), appLinks: sampleDataset.appLinks, defaultIndex: sampleDataset.getDataSourceIntegratedDefaultIndex(dataSourceId), dataIndices: sampleDataset.dataIndices.map(({ id }) => ({ id })), diff --git a/src/plugins/home/server/services/sample_data/routes/uninstall.test.ts b/src/plugins/home/server/services/sample_data/routes/uninstall.test.ts index 7d9797d752cb..c12e39ba1634 100644 --- a/src/plugins/home/server/services/sample_data/routes/uninstall.test.ts +++ b/src/plugins/home/server/services/sample_data/routes/uninstall.test.ts @@ -98,4 +98,35 @@ describe('sample data uninstall route', () => { expect(mockClient).toBeCalled(); expect(mockSOClient.delete).toBeCalled(); }); + + it('handler calls expected api with the given request with workspace', async () => { + const mockWorkspaceId = 'workspace'; + const mockContext = { + core: { + opensearch: { + legacy: { + client: { callAsCurrentUser: mockClient }, + }, + }, + savedObjects: { client: mockSOClient }, + }, + }; + const mockBody = { id: 'flights' }; + const mockQuery = { workspace_id: mockWorkspaceId }; + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest({ + params: mockBody, + query: mockQuery, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + createUninstallRoute(mockCoreSetup.http.createRouter(), sampleDatasets, mockUsageTracker); + + const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; + const handler = mockRouter.delete.mock.calls[0][1]; + + await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + + expect(mockClient).toBeCalled(); + expect(mockSOClient.delete).toBeCalled(); + }); }); diff --git a/src/plugins/home/server/services/sample_data/routes/uninstall.ts b/src/plugins/home/server/services/sample_data/routes/uninstall.ts index d5a09ce56070..95398e63683c 100644 --- a/src/plugins/home/server/services/sample_data/routes/uninstall.ts +++ b/src/plugins/home/server/services/sample_data/routes/uninstall.ts @@ -34,6 +34,10 @@ import { IRouter } from 'src/core/server'; import { SampleDatasetSchema } from '../lib/sample_dataset_registry_types'; import { createIndexName } from '../lib/create_index_name'; import { SampleDataUsageTracker } from '../usage/usage'; +import { + getDataSourceIntegratedSavedObjects, + getWorkspaceIntegratedSavedObjects, +} from '../data_sets/util'; export function createUninstallRoute( router: IRouter, @@ -47,12 +51,14 @@ export function createUninstallRoute( params: schema.object({ id: schema.string() }), query: schema.object({ data_source_id: schema.maybe(schema.string()), + workspace_id: schema.maybe(schema.string()), }), }, }, async (context, request, response) => { const sampleDataset = sampleDatasets.find(({ id }) => id === request.params.id); const dataSourceId = request.query.data_source_id; + const workspaceId = request.query.workspace_id; if (!sampleDataset) { return response.notFound(); @@ -78,9 +84,13 @@ export function createUninstallRoute( } } - const savedObjectsList = dataSourceId - ? sampleDataset.getDataSourceIntegratedSavedObjects(dataSourceId) - : sampleDataset.savedObjects; + let savedObjectsList = sampleDataset.savedObjects; + if (workspaceId) { + savedObjectsList = getWorkspaceIntegratedSavedObjects(savedObjectsList, workspaceId); + } + if (dataSourceId) { + savedObjectsList = getDataSourceIntegratedSavedObjects(savedObjectsList, dataSourceId); + } const deletePromises = savedObjectsList.map(({ type, id }) => context.core.savedObjects.client.delete(type, id) From 120d3b7cede38d4578b4ef259b031ddc4fcfc5e8 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Tue, 17 Oct 2023 00:04:34 +0800 Subject: [PATCH 16/21] Patch/acl (#231) * consume permissions in repository Signed-off-by: SuZhou-Joe * feat: consume permissions in serializer Signed-off-by: SuZhou-Joe * Add unit tests for consuming permissions in repository Signed-off-by: gaobinlong * feat: update Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe Signed-off-by: gaobinlong Co-authored-by: gaobinlong --- .../saved_objects/serialization/serializer.ts | 5 +- .../saved_objects/serialization/types.ts | 2 + .../service/lib/repository.test.js | 179 ++++++++++++++++-- .../saved_objects/service/lib/repository.ts | 19 +- .../service/saved_objects_client.ts | 7 +- src/core/types/saved_objects.ts | 3 + 6 files changed, 192 insertions(+), 23 deletions(-) diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index 5c3e22ac646a..9aa6aca713f0 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -73,7 +73,7 @@ export class SavedObjectsSerializer { */ public rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc { const { _id, _source, _seq_no, _primary_term } = doc; - const { type, namespace, namespaces, originId, workspaces } = _source; + const { type, namespace, namespaces, originId, workspaces, permissions } = _source; const version = _seq_no != null || _primary_term != null @@ -86,6 +86,7 @@ export class SavedObjectsSerializer { ...(namespace && this.registry.isSingleNamespace(type) && { namespace }), ...(namespaces && this.registry.isMultiNamespace(type) && { namespaces }), ...(originId && { originId }), + ...(permissions && { permissions }), attributes: _source[type], references: _source.references || [], ...(_source.migrationVersion && { migrationVersion: _source.migrationVersion }), @@ -114,6 +115,7 @@ export class SavedObjectsSerializer { version, references, workspaces, + permissions, } = savedObj; const source = { [type]: attributes, @@ -125,6 +127,7 @@ export class SavedObjectsSerializer { ...(migrationVersion && { migrationVersion }), ...(updated_at && { updated_at }), ...(workspaces && { workspaces }), + ...(permissions && { permissions }), }; return { diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index 473a63cf65f4..f4c6569de4fa 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -28,6 +28,7 @@ * under the License. */ +import { Permissions } from '../permission_control/acl'; import { SavedObjectsMigrationVersion, SavedObjectReference } from '../types'; /** @@ -71,6 +72,7 @@ interface SavedObjectDoc { updated_at?: string; originId?: string; workspaces?: string[]; + permissions?: Permissions; } interface Referencable { diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 9950d0d253bc..fc3b7bfba572 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -173,7 +173,7 @@ describe('SavedObjectsRepository', () => { }); const getMockGetResponse = ( - { type, id, references, namespace: objectNamespace, originId, workspaces }, + { type, id, references, namespace: objectNamespace, originId, workspaces, permissions }, namespace ) => { const namespaceId = objectNamespace === 'default' ? undefined : objectNamespace ?? namespace; @@ -189,6 +189,7 @@ describe('SavedObjectsRepository', () => { ...(registry.isMultiNamespace(type) && { namespaces: [namespaceId ?? 'default'] }), workspaces, ...(originId && { originId }), + ...(permissions && { permissions }), type, [type]: { title: 'Testing' }, references, @@ -451,24 +452,35 @@ describe('SavedObjectsRepository', () => { }; const namespace = 'foo-namespace'; const workspace = 'foo-workspace'; + const permissions = { + read: { + users: ['user1'], + }, + write: { + groups: ['groups1'], + }, + }; const getMockBulkCreateResponse = (objects, namespace) => { return { - items: objects.map(({ type, id, originId, attributes, references, migrationVersion }) => ({ - create: { - _id: `${namespace ? `${namespace}:` : ''}${type}:${id}`, - _source: { - [type]: attributes, - type, - namespace, - ...(originId && { originId }), - references, - ...mockTimestampFields, - migrationVersion: migrationVersion || { [type]: '1.1.1' }, + items: objects.map( + ({ type, id, originId, attributes, references, migrationVersion, permissions }) => ({ + create: { + _id: `${namespace ? `${namespace}:` : ''}${type}:${id}`, + _source: { + [type]: attributes, + type, + namespace, + ...(originId && { originId }), + ...(permissions && { permissions }), + references, + ...mockTimestampFields, + migrationVersion: migrationVersion || { [type]: '1.1.1' }, + }, + ...mockVersionProps, }, - ...mockVersionProps, - }, - })), + }) + ), }; }; @@ -750,6 +762,18 @@ describe('SavedObjectsRepository', () => { expect.anything() ); }); + + it(`accepts permissions property when providing permissions info`, async () => { + const objects = [obj1, obj2].map((obj) => ({ ...obj, permissions: permissions })); + await bulkCreateSuccess(objects); + const expected = expect.objectContaining({ permissions }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + }); }); describe('errors', () => { @@ -1088,6 +1112,17 @@ describe('SavedObjectsRepository', () => { ); expect(result.saved_objects[1].id).toEqual(obj2.id); }); + + it(`includes permissions property if present`, async () => { + const objects = [obj1, obj2].map((obj) => ({ ...obj, permissions: permissions })); + const result = await bulkCreateSuccess(objects); + expect(result).toEqual({ + saved_objects: [ + expect.objectContaining({ permissions }), + expect.objectContaining({ permissions }), + ], + }); + }); }); }); @@ -1307,6 +1342,22 @@ describe('SavedObjectsRepository', () => { ], }); }); + + it(`includes permissions property if present`, async () => { + const permissions = { + read: { + users: ['user1'], + }, + write: { + groups: ['groups1'], + }, + }; + const obj = { id: 'three', type: MULTI_NAMESPACE_TYPE, permissions: permissions }; + const result = await bulkGetSuccess([obj]); + expect(result).toEqual({ + saved_objects: [expect.objectContaining({ permissions: permissions })], + }); + }); }); }); @@ -1324,6 +1375,14 @@ describe('SavedObjectsRepository', () => { const references = [{ name: 'ref_0', type: 'test', id: '1' }]; const originId = 'some-origin-id'; const namespace = 'foo-namespace'; + const permissions = { + read: { + users: ['user1'], + }, + write: { + groups: ['groups1'], + }, + }; const getMockBulkUpdateResponse = (objects, options, includeOriginId) => ({ items: objects.map(({ type, id }) => ({ @@ -1584,6 +1643,20 @@ describe('SavedObjectsRepository', () => { await bulkUpdateSuccess([{ ..._obj2, namespace }]); expectClientCallArgsAction([_obj2], { method: 'update', getId, overrides }, 2); }); + + it(`accepts permissions property when providing permissions info`, async () => { + const objects = [obj1, obj2].map((obj) => ({ ...obj, permissions: permissions })); + await bulkUpdateSuccess(objects); + const doc = { + doc: expect.objectContaining({ permissions }), + }; + const body = [expect.any(Object), doc, expect.any(Object), doc]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + }); }); describe('errors', () => { @@ -1776,6 +1849,14 @@ describe('SavedObjectsRepository', () => { ], }); }); + + it(`includes permissions property if present`, async () => { + const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three', permissions: permissions }; + const result = await bulkUpdateSuccess([obj1, obj], {}, true); + expect(result).toEqual({ + saved_objects: [expect.anything(), expect.objectContaining({ permissions })], + }); + }); }); }); @@ -1965,6 +2046,14 @@ describe('SavedObjectsRepository', () => { id: '123', }, ]; + const permissions = { + read: { + users: ['user1'], + }, + write: { + groups: ['groups1'], + }, + }; const createSuccess = async (type, attributes, options) => { const result = await savedObjectsRepository.create(type, attributes, options); @@ -2193,6 +2282,16 @@ describe('SavedObjectsRepository', () => { expect.anything() ); }); + + it(`accepts permissions property`, async () => { + await createSuccess(type, attributes, { id, permissions }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ permissions }), + }), + expect.anything() + ); + }); }); describe('errors', () => { @@ -2288,6 +2387,11 @@ describe('SavedObjectsRepository', () => { expect(serializer.savedObjectToRaw).toHaveBeenLastCalledWith(migratedDoc); }); + it(`adds permissions to body when providing permissions info`, async () => { + await createSuccess(type, attributes, { id, permissions }); + expectMigrationArgs({ permissions }); + }); + it(`adds namespace to body when providing namespace for single-namespace type`, async () => { await createSuccess(type, attributes, { id, namespace }); expectMigrationArgs({ namespace }); @@ -2334,11 +2438,13 @@ describe('SavedObjectsRepository', () => { namespace, references, originId, + permissions, }); expect(result).toEqual({ type, id, originId, + permissions, ...mockTimestampFields, version: mockVersion, attributes, @@ -3208,7 +3314,7 @@ describe('SavedObjectsRepository', () => { const namespace = 'foo-namespace'; const originId = 'some-origin-id'; - const getSuccess = async (type, id, options, includeOriginId) => { + const getSuccess = async (type, id, options, includeOriginId, permissions) => { const response = getMockGetResponse( { type, @@ -3216,6 +3322,7 @@ describe('SavedObjectsRepository', () => { // "includeOriginId" is not an option for the operation; however, if the existing saved object contains an originId attribute, the // operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response. ...(includeOriginId && { originId }), + ...(permissions && { permissions }), }, options?.namespace ); @@ -3366,6 +3473,21 @@ describe('SavedObjectsRepository', () => { const result = await getSuccess(type, id, {}, true); expect(result).toMatchObject({ originId }); }); + + it(`includes permissions property if present`, async () => { + const permissions = { + read: { + users: ['user1'], + }, + write: { + groups: ['groups1'], + }, + }; + const result = await getSuccess(type, id, { namespace }, undefined, permissions); + expect(result).toMatchObject({ + permissions: permissions, + }); + }); }); }); @@ -3967,6 +4089,14 @@ describe('SavedObjectsRepository', () => { }, ]; const originId = 'some-origin-id'; + const permissions = { + read: { + users: ['user1'], + }, + write: { + groups: ['groups1'], + }, + }; const updateSuccess = async (type, id, attributes, options, includeOriginId) => { if (registry.isMultiNamespace(type)) { @@ -4143,6 +4273,18 @@ describe('SavedObjectsRepository', () => { expect.anything() ); }); + + it(`accepts permissions when providing permissions info`, async () => { + await updateSuccess(type, id, attributes, { permissions }); + const expected = expect.objectContaining({ permissions }); + const body = { + doc: expected, + }; + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); }); describe('errors', () => { @@ -4237,6 +4379,11 @@ describe('SavedObjectsRepository', () => { const result = await updateSuccess(type, id, attributes, {}, true); expect(result).toMatchObject({ originId }); }); + + it(`includes permissions property if present`, async () => { + const result = await updateSuccess(type, id, attributes, { permissions }); + expect(result).toMatchObject({ permissions }); + }); }); }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index d45bac5cf836..d38b1e31811a 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -245,6 +245,7 @@ export class SavedObjectsRepository { initialNamespaces, version, workspaces, + permissions, } = options; const namespace = normalizeNamespace(options.namespace); @@ -315,6 +316,7 @@ export class SavedObjectsRepository { updated_at: time, ...(Array.isArray(references) && { references }), ...(Array.isArray(savedObjectWorkspaces) && { workspaces: savedObjectWorkspaces }), + ...(permissions && { permissions }), }); const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); @@ -536,6 +538,7 @@ export class SavedObjectsRepository { references: object.references || [], originId: object.originId, workspaces: savedObjectWorkspaces, + ...(object.permissions && { permissions: object.permissions }), }) as SavedObjectSanitizedDoc ), }; @@ -1122,7 +1125,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { originId, updated_at: updatedAt, workspaces } = body._source; + const { originId, updated_at: updatedAt, workspaces, permissions } = body._source; let namespaces: string[] = []; if (!this._registry.isNamespaceAgnostic(type)) { @@ -1138,6 +1141,7 @@ export class SavedObjectsRepository { ...(originId && { originId }), ...(updatedAt && { updated_at: updatedAt }), ...(workspaces && { workspaces }), + ...(permissions && { permissions }), version: encodeHitVersion(body), attributes: body._source[type], references: body._source.references || [], @@ -1166,7 +1170,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { version, references, refresh = DEFAULT_REFRESH_SETTING } = options; + const { version, references, refresh = DEFAULT_REFRESH_SETTING, permissions } = options; const namespace = normalizeNamespace(options.namespace); let preflightResult: SavedObjectsRawDoc | undefined; @@ -1180,6 +1184,7 @@ export class SavedObjectsRepository { [type]: attributes, updated_at: time, ...(Array.isArray(references) && { references }), + ...(permissions && { permissions }), }; const { body, statusCode } = await this.client.update( @@ -1218,6 +1223,7 @@ export class SavedObjectsRepository { namespaces, ...(originId && { originId }), ...(workspaces && { workspaces }), + ...(permissions && { permissions }), references, attributes, }; @@ -1418,7 +1424,7 @@ export class SavedObjectsRepository { }; } - const { attributes, references, version, namespace: objectNamespace } = object; + const { attributes, references, version, namespace: objectNamespace, permissions } = object; if (objectNamespace === ALL_NAMESPACES_STRING) { return { @@ -1439,6 +1445,7 @@ export class SavedObjectsRepository { [type]: attributes, updated_at: time, ...(Array.isArray(references) && { references }), + ...(permissions && { permissions }), }; const requiresNamespacesCheck = this._registry.isMultiNamespace(object.type); @@ -1591,7 +1598,7 @@ export class SavedObjectsRepository { )[0] as any; // eslint-disable-next-line @typescript-eslint/naming-convention - const { [type]: attributes, references, updated_at } = documentToSave; + const { [type]: attributes, references, updated_at, permissions } = documentToSave; if (error) { return { id, @@ -1611,6 +1618,7 @@ export class SavedObjectsRepository { version: encodeVersion(seqNo, primaryTerm), attributes, references, + ...(permissions && { permissions }), }; }), }; @@ -1903,7 +1911,7 @@ function getSavedObjectFromSource( id: string, doc: { _seq_no?: number; _primary_term?: number; _source: SavedObjectsRawDocSource } ): SavedObject { - const { originId, updated_at: updatedAt, workspaces } = doc._source; + const { originId, updated_at: updatedAt, workspaces, permissions } = doc._source; let namespaces: string[] = []; if (!registry.isNamespaceAgnostic(type)) { @@ -1923,6 +1931,7 @@ function getSavedObjectFromSource( attributes: doc._source[type], references: doc._source.references || [], migrationVersion: doc._source.migrationVersion, + ...(permissions && { permissions }), }; } diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index 732b566d22db..0f2aaac4db1e 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -39,6 +39,7 @@ import { SavedObjectsFindOptions, } from '../types'; import { SavedObjectsErrorHelpers } from './lib/errors'; +import { Permissions } from '../permission_control/acl'; /** * @@ -68,6 +69,8 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { * Note: this can only be used for multi-namespace object types. */ initialNamespaces?: string[]; + /** permission control describe by ACL object */ + permissions?: Permissions; } /** @@ -98,7 +101,7 @@ export interface SavedObjectsBulkCreateObject { * @public */ export interface SavedObjectsBulkUpdateObject - extends Pick { + extends Pick { /** The ID of this Saved Object, guaranteed to be unique for all objects of the same `type` */ id: string; /** The type of this Saved Object. Each plugin can define it's own custom Saved Object types. */ @@ -180,6 +183,8 @@ export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions { references?: SavedObjectReference[]; /** The OpenSearch Refresh setting for this operation */ refresh?: MutatingOperationRefreshSetting; + /** permission control describe by ACL object */ + permissions?: Permissions; } /** diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index fa4f5ab97fdf..9c14aeca98bb 100644 --- a/src/core/types/saved_objects.ts +++ b/src/core/types/saved_objects.ts @@ -27,6 +27,7 @@ * specific language governing permissions and limitations * under the License. */ +import { Permissions } from '../server/saved_objects/permission_control/acl'; /** * Don't use this type, it's simply a helper type for {@link SavedObjectAttribute} @@ -115,6 +116,8 @@ export interface SavedObject { originId?: string; /** Workspaces that this saved object exists in. */ workspaces?: string[]; + /** ACL description of this saved object */ + permissions?: Permissions; } export interface SavedObjectError { From c271e741f8bb2aee06aeff1976249cb37813f3cd Mon Sep 17 00:00:00 2001 From: Yuye Zhu Date: Wed, 18 Oct 2023 09:50:18 +0800 Subject: [PATCH 17/21] [Workspace][Feature] Left navigation menu adjustment (#192) * add util function to filter workspace feature by wildcard Signed-off-by: Yulong Ruan * resolve conflict Signed-off-by: yuye-aws * update tests and snapshots Signed-off-by: yuye-aws * small adjustment to left menu Signed-off-by: yuye-aws * resolve git conflict Signed-off-by: yuye-aws * rename nav link service function Signed-off-by: yuye-aws * unit test for workspace plugin.ts Signed-off-by: yuye-aws * update snapshots Signed-off-by: yuye-aws * optimize code Signed-off-by: yuye-aws * optimize code Signed-off-by: yuye-aws * optimize code Signed-off-by: yuye-aws * optimize code Signed-off-by: yuye-aws * optimize code Signed-off-by: yuye-aws * optimize code Signed-off-by: yuye-aws --------- Signed-off-by: Yulong Ruan Signed-off-by: yuye-aws Co-authored-by: Yulong Ruan --- src/core/public/chrome/chrome_service.mock.ts | 2 + src/core/public/chrome/nav_links/nav_link.ts | 9 +- .../nav_links/nav_links_service.test.ts | 143 +- .../chrome/nav_links/nav_links_service.ts | 44 +- .../collapsible_nav.test.tsx.snap | 2354 +++++++++-------- .../header/__snapshots__/header.test.tsx.snap | 377 +-- .../chrome/ui/header/collapsible_nav.test.tsx | 6 +- .../chrome/ui/header/collapsible_nav.tsx | 203 +- src/core/public/chrome/ui/header/nav_link.tsx | 56 +- src/core/public/index.ts | 2 +- .../workspace/workspaces_service.mock.ts | 7 +- .../public/workspace/workspaces_service.ts | 5 +- src/core/types/workspace.ts | 4 + src/core/utils/default_app_categories.ts | 10 +- .../dashboard_listing.test.tsx.snap | 10 + .../dashboard_top_nav.test.tsx.snap | 12 + src/plugins/dev_tools/public/plugin.ts | 2 +- .../objects_table/saved_objects_table.tsx | 2 +- src/plugins/workspace/public/plugin.test.ts | 28 + src/plugins/workspace/public/plugin.ts | 59 +- src/plugins/workspace/public/utils.test.ts | 93 + src/plugins/workspace/public/utils.ts | 56 + 22 files changed, 2012 insertions(+), 1472 deletions(-) create mode 100644 src/plugins/workspace/public/utils.test.ts create mode 100644 src/plugins/workspace/public/utils.ts diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index 14b516ff95bc..f5b9d31bbb38 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -37,7 +37,9 @@ const createStartContractMock = () => { const startContract: DeeplyMockedKeys = { getHeaderComponent: jest.fn(), navLinks: { + setNavLinks: jest.fn(), getNavLinks$: jest.fn(), + getAllNavLinks$: jest.fn(), has: jest.fn(), get: jest.fn(), getAll: jest.fn(), diff --git a/src/core/public/chrome/nav_links/nav_link.ts b/src/core/public/chrome/nav_links/nav_link.ts index cddd45234514..19e2fd2eddab 100644 --- a/src/core/public/chrome/nav_links/nav_link.ts +++ b/src/core/public/chrome/nav_links/nav_link.ts @@ -93,8 +93,10 @@ export interface ChromeNavLink { * Disables a link from being clickable. * * @internalRemarks - * This is only used by the ML and Graph plugins currently. They use this field + * This is used by the ML and Graph plugins. They use this field * to disable the nav link when the license is expired. + * This is also used by recently visited category in left menu + * to disable "No recently visited items". */ readonly disabled?: boolean; @@ -102,6 +104,11 @@ export interface ChromeNavLink { * Hides a link from the navigation. */ readonly hidden?: boolean; + + /** + * Links can be navigated through url. + */ + readonly externalLink?: boolean; } /** @public */ diff --git a/src/core/public/chrome/nav_links/nav_links_service.test.ts b/src/core/public/chrome/nav_links/nav_links_service.test.ts index 3fe2b57676e0..d4cfb2630496 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.test.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.test.ts @@ -32,18 +32,12 @@ import { NavLinksService } from './nav_links_service'; import { take, map, takeLast } from 'rxjs/operators'; import { App } from '../../application'; import { BehaviorSubject } from 'rxjs'; +import { ChromeNavLink } from 'opensearch-dashboards/public'; const availableApps = new Map([ ['app1', { id: 'app1', order: 0, title: 'App 1', icon: 'app1' }], - [ - 'app2', - { - id: 'app2', - order: -10, - title: 'App 2', - euiIconType: 'canvasApp', - }, - ], + ['app2', { id: 'app2', order: -10, title: 'App 2', euiIconType: 'canvasApp' }], + ['app3', { id: 'app3', order: 10, title: 'App 3', icon: 'app3' }], ['chromelessApp', { id: 'chromelessApp', order: 20, title: 'Chromless App', chromeless: true }], ]); @@ -66,7 +60,110 @@ describe('NavLinksService', () => { start = service.start({ application: mockAppService, http: mockHttp }); }); - describe('#getNavLinks$()', () => { + describe('#getAllNavLinks$()', () => { + it('does not include `chromeless` applications', async () => { + expect( + await start + .getAllNavLinks$() + .pipe( + take(1), + map((links) => links.map((l) => l.id)) + ) + .toPromise() + ).not.toContain('chromelessApp'); + }); + + it('sorts navLinks by `order` property', async () => { + expect( + await start + .getAllNavLinks$() + .pipe( + take(1), + map((links) => links.map((l) => l.id)) + ) + .toPromise() + ).toEqual(['app2', 'app1', 'app3']); + }); + + it('emits multiple values', async () => { + const navLinkIds$ = start.getAllNavLinks$().pipe(map((links) => links.map((l) => l.id))); + const emittedLinks: string[][] = []; + navLinkIds$.subscribe((r) => emittedLinks.push(r)); + start.update('app1', { href: '/foo' }); + + service.stop(); + expect(emittedLinks).toEqual([ + ['app2', 'app1', 'app3'], + ['app2', 'app1', 'app3'], + ]); + }); + + it('completes when service is stopped', async () => { + const last$ = start.getAllNavLinks$().pipe(takeLast(1)).toPromise(); + service.stop(); + await expect(last$).resolves.toBeInstanceOf(Array); + }); + }); + + describe('#getNavLinks$() when non null', () => { + // set filtered nav links, nav link with order smaller than 0 will be filtered + beforeEach(() => { + const filteredNavLinks = new Map(); + start.getAllNavLinks$().subscribe((links) => + links.forEach((link) => { + if (link.order !== undefined && link.order >= 0) { + filteredNavLinks.set(link.id, link); + } + }) + ); + start.setNavLinks(filteredNavLinks); + }); + + it('does not include `app2` applications', async () => { + expect( + await start + .getNavLinks$() + .pipe( + take(1), + map((links) => links.map((l) => l.id)) + ) + .toPromise() + ).not.toContain('app2'); + }); + + it('sorts navLinks by `order` property', async () => { + expect( + await start + .getNavLinks$() + .pipe( + take(1), + map((links) => links.map((l) => l.id)) + ) + .toPromise() + ).toEqual(['app1', 'app3']); + }); + + it('emits multiple values', async () => { + const navLinkIds$ = start.getNavLinks$().pipe(map((links) => links.map((l) => l.id))); + const emittedLinks: string[][] = []; + navLinkIds$.subscribe((r) => emittedLinks.push(r)); + start.update('app1', { href: '/foo' }); + + service.stop(); + expect(emittedLinks).toEqual([ + ['app1', 'app3'], + ['app1', 'app3'], + ]); + }); + + it('completes when service is stopped', async () => { + const last$ = start.getNavLinks$().pipe(takeLast(1)).toPromise(); + service.stop(); + await expect(last$).resolves.toBeInstanceOf(Array); + }); + }); + + describe('#getNavLinks$() when null', () => { it('does not include `chromeless` applications', async () => { expect( await start @@ -79,7 +176,19 @@ describe('NavLinksService', () => { ).not.toContain('chromelessApp'); }); - it('sorts navlinks by `order` property', async () => { + it('include `app2` applications', async () => { + expect( + await start + .getNavLinks$() + .pipe( + take(1), + map((links) => links.map((l) => l.id)) + ) + .toPromise() + ).toContain('app2'); + }); + + it('sorts navLinks by `order` property', async () => { expect( await start .getNavLinks$() @@ -88,7 +197,7 @@ describe('NavLinksService', () => { map((links) => links.map((l) => l.id)) ) .toPromise() - ).toEqual(['app2', 'app1']); + ).toEqual(['app2', 'app1', 'app3']); }); it('emits multiple values', async () => { @@ -99,8 +208,8 @@ describe('NavLinksService', () => { service.stop(); expect(emittedLinks).toEqual([ - ['app2', 'app1'], - ['app2', 'app1'], + ['app2', 'app1', 'app3'], + ['app2', 'app1', 'app3'], ]); }); @@ -123,7 +232,7 @@ describe('NavLinksService', () => { describe('#getAll()', () => { it('returns a sorted array of navlinks', () => { - expect(start.getAll().map((l) => l.id)).toEqual(['app2', 'app1']); + expect(start.getAll().map((l) => l.id)).toEqual(['app2', 'app1', 'app3']); }); }); @@ -148,7 +257,7 @@ describe('NavLinksService', () => { map((links) => links.map((l) => l.id)) ) .toPromise() - ).toEqual(['app2', 'app1']); + ).toEqual(['app2', 'app1', 'app3']); }); it('does nothing on chromeless applications', async () => { @@ -161,7 +270,7 @@ describe('NavLinksService', () => { map((links) => links.map((l) => l.id)) ) .toPromise() - ).toEqual(['app2', 'app1']); + ).toEqual(['app2', 'app1', 'app3']); }); it('removes all other links', async () => { diff --git a/src/core/public/chrome/nav_links/nav_links_service.ts b/src/core/public/chrome/nav_links/nav_links_service.ts index 93c138eac62c..d4c899a57be8 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.ts @@ -53,6 +53,16 @@ export interface ChromeNavLinks { */ getNavLinks$(): Observable>>; + /** + * Get an observable for a sorted list of all navlinks. + */ + getAllNavLinks$(): Observable>>; + + /** + * Set navlinks. + */ + setNavLinks(navLinks: ReadonlyMap): void; + /** * Get the state of a navlink at this point in time. * @param id @@ -132,7 +142,10 @@ export class NavLinksService { // manual link modifications to be able to re-apply then after every // availableApps$ changes. const linkUpdaters$ = new BehaviorSubject([]); - const navLinks$ = new BehaviorSubject>(new Map()); + const displayedNavLinks$ = new BehaviorSubject | undefined>( + undefined + ); + const allNavLinks$ = new BehaviorSubject>(new Map()); combineLatest([appLinks$, linkUpdaters$]) .pipe( @@ -140,28 +153,41 @@ export class NavLinksService { return linkUpdaters.reduce((links, updater) => updater(links), appLinks); }) ) - .subscribe((navlinks) => { - navLinks$.next(navlinks); + .subscribe((navLinks) => { + allNavLinks$.next(navLinks); }); const forceAppSwitcherNavigation$ = new BehaviorSubject(false); return { getNavLinks$: () => { - return navLinks$.pipe(map(sortNavLinks), takeUntil(this.stop$)); + return combineLatest([allNavLinks$, displayedNavLinks$]).pipe( + map(([allNavLinks, displayedNavLinks]) => + displayedNavLinks === undefined ? sortLinks(allNavLinks) : sortLinks(displayedNavLinks) + ), + takeUntil(this.stop$) + ); + }, + + setNavLinks: (navLinks: ReadonlyMap) => { + displayedNavLinks$.next(navLinks); + }, + + getAllNavLinks$: () => { + return allNavLinks$.pipe(map(sortLinks), takeUntil(this.stop$)); }, get(id: string) { - const link = navLinks$.value.get(id); + const link = allNavLinks$.value.get(id); return link && link.properties; }, getAll() { - return sortNavLinks(navLinks$.value); + return sortLinks(allNavLinks$.value); }, has(id: string) { - return navLinks$.value.has(id); + return allNavLinks$.value.has(id); }, showOnly(id: string) { @@ -209,9 +235,9 @@ export class NavLinksService { } } -function sortNavLinks(navLinks: ReadonlyMap) { +function sortLinks(links: ReadonlyMap) { return sortBy( - [...navLinks.values()].map((link) => link.properties), + [...links.values()].map((link) => ('properties' in link ? link.properties : link)), 'order' ); } diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index f0cd8afddfa3..f3fa72c923ff 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -122,7 +122,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` } } homeHref="/" - id="collapsibe-nav" + id="collapsible-nav" isLocked={false} isNavOpen={true} logos={ @@ -242,7 +242,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "euiIconType": "managementApp", "id": "management", "label": "Management", - "order": 5000, + "order": 6000, }, "data-test-subj": "monitoring", "href": "monitoring", @@ -422,7 +422,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` - - - - -

- Recently viewed -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-recentlyViewed" - id="mockId" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
- - - -
-
- -
+
+ +
- - - - -

- Recently viewed -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-recentlyViewed" - id="mockId" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
-
-
- -
-
- -
-
+ + +
+
+ -
- +
- -
-

- No recently viewed items -

-
-
+ +
  • + +
  • +
    + +
    - +
    -
    +
    -
    -
    -
    - - - -
    -
    - -
    +
    + + - - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-recentlyViewed" - id="mockId" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
    -
    -
    - -
    -
    - -
    -
    + + +
    +
    + -
    - +
    - - + + + recent + + + + + + +
    +
    -
    +
    - -
    -
    -
    -
    - -
    -
    - -
    +
    + + - - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-recentlyViewed" - id="mockId" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
    -
    -
    + + + + + +

    + Recently Visited +

    +
    +
    + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + data-test-opensearch-logo="clock" + data-test-subj="collapsibleNavGroup-recentlyVisited" + id="mockId" + initialIsOpen={true} + isLoading={false} + isLoadingMessage={false} + onToggle={[Function]} + paddingSize="none" > - +
    +
    - -
    - -
    - -

    - Recently viewed -

    -
    -
    -
    -
    -
    - - -
    -
    - -
    -
    -
    - +
    - - + + + recent + + + + + + +
    +
    -
    +
    - -
    -
    -
    -
    - -
    -
    - -
    +
    + +
    - - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-recentlyViewed" - id="mockId" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
    -
    -
    - -
    -
    - -
    -
    + + +
    +
    + -
    - +
    - - + + + recent + + + + + + +
    +
    -
    +
    - -
    -
    -
    -
    - -
    -
    - -
    +
    + +
    - - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-recentlyViewed" - id="mockId" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
    -
    -
    - -
    -
    - -
    -
    + + +
    +
    + -
    - +
    - - + + + recent + + + + + + +
    +
    -
    +
    - -
    -
    -
    -
    - -
    -
    - -
    +
    + +
    - - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-recentlyViewed" - id="mockId" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
    -
    -
    - -
    -
    - -
    -
    + + +
    + -
    +
    - -
    -
    -
    -
    - -
    -
    - -
    +
    + +
    { ); expectShownNavLinksCount(component, 3); clickGroup(component, 'opensearchDashboards'); - clickGroup(component, 'recentlyViewed'); + clickGroup(component, 'recentlyVisited'); expectShownNavLinksCount(component, 1); component.setProps({ isNavOpen: false }); expectNavIsClosed(component); @@ -205,7 +205,7 @@ describe('CollapsibleNav', () => { }, }); - component.find('[data-test-subj="collapsibleNavGroup-recentlyViewed"] a').simulate('click'); + component.find('[data-test-subj="collapsibleNavGroup-recentlyVisited"] a').simulate('click'); expect(onClose.callCount).toEqual(1); expectNavIsClosed(component); component.setProps({ isNavOpen: true }); diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index 8b178200114a..26c1ae985a90 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -37,22 +37,27 @@ import { EuiListGroup, EuiListGroupItem, EuiShowFor, - EuiText, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { groupBy, sortBy } from 'lodash'; import React, { Fragment, useRef } from 'react'; import useObservable from 'react-use/lib/useObservable'; import * as Rx from 'rxjs'; +import { DEFAULT_APP_CATEGORIES } from '../../../../utils'; import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem } from '../..'; import { AppCategory } from '../../../../types'; -import { InternalApplicationStart } from '../../../application/types'; +import { InternalApplicationStart } from '../../../application'; import { HttpStart } from '../../../http'; import { OnIsLockedUpdate } from './'; -import { createEuiListItem, createRecentNavLink, isModifiedOrPrevented } from './nav_link'; -import type { Logos } from '../../../../common/types'; +import type { Logos } from '../../../../common'; +import { + createEuiListItem, + createRecentChromeNavLink, + emptyRecentlyVisited, + CollapsibleNavLink, +} from './nav_link'; -function getAllCategories(allCategorizedLinks: Record) { +function getAllCategories(allCategorizedLinks: Record) { const allCategories = {} as Record; for (const [key, value] of Object.entries(allCategorizedLinks)) { @@ -62,14 +67,28 @@ function getAllCategories(allCategorizedLinks: Record) return allCategories; } -function getOrderedCategories( - mainCategories: Record, +function getSortedLinksAndCategories( + uncategorizedLinks: CollapsibleNavLink[], categoryDictionary: ReturnType -) { - return sortBy( - Object.keys(mainCategories), - (categoryName) => categoryDictionary[categoryName]?.order +): Array { + // uncategorized links and categories are ranked according the order + // if order is not defined, categories will be placed above uncategorized links + const categories = Object.values(categoryDictionary).filter( + (category) => category !== undefined + ) as AppCategory[]; + const uncategorizedLinksWithOrder = uncategorizedLinks.filter((link) => link.order !== null); + const uncategorizedLinksWithoutOrder = uncategorizedLinks.filter((link) => link.order === null); + const categoriesWithOrder = categories.filter((category) => category.order !== null); + const categoriesWithoutOrder = categories.filter((category) => category.order === null); + const sortedLinksAndCategories = sortBy( + [...uncategorizedLinksWithOrder, ...categoriesWithOrder], + 'order' ); + return [ + ...sortedLinksAndCategories, + ...categoriesWithoutOrder, + ...uncategorizedLinksWithoutOrder, + ]; } function getCategoryLocalStorageKey(id: string) { @@ -119,15 +138,30 @@ export function CollapsibleNav({ ...observables }: Props) { const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); + let customNavLink = useObservable(observables.customNavLink$, undefined); + if (customNavLink) { + customNavLink = { ...customNavLink, externalLink: true }; + } const recentlyAccessed = useObservable(observables.recentlyAccessed$, []); - const customNavLink = useObservable(observables.customNavLink$, undefined); + const allNavLinks: CollapsibleNavLink[] = [...navLinks]; + if (recentlyAccessed.length) { + allNavLinks.push( + ...recentlyAccessed.map((link) => createRecentChromeNavLink(link, navLinks, basePath)) + ); + } else { + allNavLinks.push(emptyRecentlyVisited); + } const appId = useObservable(observables.appId$, ''); const lockRef = useRef(null); - const groupedNavLinks = groupBy(navLinks, (link) => link?.category?.id); - const { undefined: unknowns = [], ...allCategorizedLinks } = groupedNavLinks; + const groupedNavLinks = groupBy(allNavLinks, (link) => link?.category?.id); + const { undefined: uncategorizedLinks = [], ...allCategorizedLinks } = groupedNavLinks; const categoryDictionary = getAllCategories(allCategorizedLinks); - const orderedCategories = getOrderedCategories(allCategorizedLinks, categoryDictionary); - const readyForEUI = (link: ChromeNavLink, needsIcon: boolean = false) => { + const sortedLinksAndCategories = getSortedLinksAndCategories( + uncategorizedLinks, + categoryDictionary + ); + + const readyForEUI = (link: CollapsibleNavLink, needsIcon: boolean = false) => { return createEuiListItem({ link, appId, @@ -166,7 +200,6 @@ export function CollapsibleNav({ navigateToApp, dataTestSubj: 'collapsibleNavCustomNavLink', onClick: closeNav, - externalLink: true, }), ]} maxWidth="none" @@ -181,103 +214,53 @@ export function CollapsibleNav({ )} - {/* Recently viewed */} - setIsCategoryOpen('recentlyViewed', isCategoryOpen, storage)} - data-test-subj="collapsibleNavGroup-recentlyViewed" - > - {recentlyAccessed.length > 0 ? ( - { - // TODO #64541 - // Can remove icon from recent links completely - const { iconType, onClick, ...hydratedLink } = createRecentNavLink( - link, - navLinks, - basePath, - navigateToUrl - ); - - return { - ...hydratedLink, - 'data-test-subj': 'collapsibleNavAppLink--recent', - onClick: (event) => { - if (!isModifiedOrPrevented(event)) { - closeNav(); - onClick(event); - } - }, - }; - })} - maxWidth="none" - color="subdued" - gutterSize="none" - size="s" - className="osdCollapsibleNav__recentsListGroup" - /> - ) : ( - -

    - {i18n.translate('core.ui.EmptyRecentlyViewed', { - defaultMessage: 'No recently viewed items', - })} -

    -
    - )} -
    - - - - {/* OpenSearchDashboards, Observability, Security, and Management sections */} - {orderedCategories.map((categoryName) => { - const category = categoryDictionary[categoryName]!; - const opensearchLinkLogo = - category.id === 'opensearchDashboards' ? logos.Mark.url : category.euiIconType; + {sortedLinksAndCategories.map((item, i) => { + if (!('href' in item)) { + // CollapsibleNavLink has href property, while AppCategory does not have + const category = item; + const opensearchLinkLogo = + category.id === DEFAULT_APP_CATEGORIES.opensearchDashboards.id + ? logos.Mark.url + : category.euiIconType; - return ( - setIsCategoryOpen(category.id, isCategoryOpen, storage)} - data-test-subj={`collapsibleNavGroup-${category.id}`} - data-test-opensearch-logo={opensearchLinkLogo} - > - readyForEUI(link))} - maxWidth="none" - color="subdued" - gutterSize="none" - size="s" - /> - - ); + return ( + + setIsCategoryOpen(category.id, isCategoryOpen, storage) + } + data-test-subj={`collapsibleNavGroup-${category.id}`} + data-test-opensearch-logo={opensearchLinkLogo} + > + readyForEUI(link))} + maxWidth="none" + color="subdued" + gutterSize="none" + size="s" + /> + + ); + } else { + return ( + + + + + + ); + } })} - {/* Things with no category (largely for custom plugins) */} - {unknowns.map((link, i) => ( - - - - - - ))} - {/* Docking button only for larger screens that can support it*/} diff --git a/src/core/public/chrome/ui/header/nav_link.tsx b/src/core/public/chrome/ui/header/nav_link.tsx index 11ff0b472bd0..72dfff09f9f3 100644 --- a/src/core/public/chrome/ui/header/nav_link.tsx +++ b/src/core/public/chrome/ui/header/nav_link.tsx @@ -31,16 +31,16 @@ import { EuiIcon } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import React from 'react'; -import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem, CoreStart } from '../../..'; +import { AppCategory, ChromeNavLink, ChromeRecentlyAccessedHistoryItem, CoreStart } from '../../..'; import { HttpStart } from '../../../http'; -import { InternalApplicationStart } from '../../../application/types'; import { relativeToAbsolute } from '../../nav_links/to_nav_link'; export const isModifiedOrPrevented = (event: React.MouseEvent) => event.metaKey || event.altKey || event.ctrlKey || event.shiftKey || event.defaultPrevented; +export type CollapsibleNavLink = ChromeNavLink | RecentNavLink; interface Props { - link: ChromeNavLink; + link: CollapsibleNavLink; appId?: string; basePath?: HttpStart['basePath']; dataTestSubj: string; @@ -60,9 +60,8 @@ export function createEuiListItem({ onClick = () => {}, navigateToApp, dataTestSubj, - externalLink = false, }: Props) { - const { href, id, title, disabled, euiIconType, icon, tooltip } = link; + const { href, id, title, disabled, euiIconType, icon, tooltip, externalLink } = link; return { label: tooltip ?? title, @@ -93,14 +92,16 @@ export function createEuiListItem({ }; } -export interface RecentNavLink { - href: string; - label: string; - title: string; - 'aria-label': string; - iconType?: string; - onClick: React.MouseEventHandler; -} +export type RecentNavLink = Omit; + +const recentlyVisitedCategory: AppCategory = { + id: 'recentlyVisited', + label: i18n.translate('core.ui.recentlyVisited.label', { + defaultMessage: 'Recently Visited', + }), + order: 0, + euiIconType: 'clock', +}; /** * Add saved object type info to recently links @@ -112,11 +113,10 @@ export interface RecentNavLink { * @param navLinks * @param basePath */ -export function createRecentNavLink( +export function createRecentChromeNavLink( recentLink: ChromeRecentlyAccessedHistoryItem, navLinks: ChromeNavLink[], - basePath: HttpStart['basePath'], - navigateToUrl: InternalApplicationStart['navigateToUrl'] + basePath: HttpStart['basePath'] ): RecentNavLink { const { link, label } = recentLink; const href = relativeToAbsolute(basePath.prepend(link)); @@ -135,16 +135,20 @@ export function createRecentNavLink( return { href, - label, + id: recentLink.id, + externalLink: true, + category: recentlyVisitedCategory, title: titleAndAriaLabel, - 'aria-label': titleAndAriaLabel, - iconType: navLink?.euiIconType, - /* Use href and onClick to support "open in new tab" and SPA navigation in the same link */ - onClick(event: React.MouseEvent) { - if (event.button === 0 && !isModifiedOrPrevented(event)) { - event.preventDefault(); - navigateToUrl(href); - } - }, }; } + +// As emptyRecentlyVisited is disabled, values for id, href and baseUrl does not affect +export const emptyRecentlyVisited: RecentNavLink = { + id: '', + href: '', + disabled: true, + category: recentlyVisitedCategory, + title: i18n.translate('core.ui.EmptyRecentlyVisited', { + defaultMessage: 'No recently visited items', + }), +}; diff --git a/src/core/public/index.ts b/src/core/public/index.ts index ecdb578890dd..c4d41281bcbd 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -103,7 +103,7 @@ export { StringValidation, StringValidationRegex, StringValidationRegexString, - WorkspaceAttribute, + WorkspaceObject, } from '../types'; export { diff --git a/src/core/public/workspace/workspaces_service.mock.ts b/src/core/public/workspace/workspaces_service.mock.ts index ae56c035eb3a..ab8bda09730a 100644 --- a/src/core/public/workspace/workspaces_service.mock.ts +++ b/src/core/public/workspace/workspaces_service.mock.ts @@ -5,13 +5,12 @@ import { BehaviorSubject } from 'rxjs'; import type { PublicMethodsOf } from '@osd/utility-types'; - import { WorkspacesService } from './workspaces_service'; -import { WorkspaceAttribute } from '..'; +import { WorkspaceObject } from '..'; const currentWorkspaceId$ = new BehaviorSubject(''); -const workspaceList$ = new BehaviorSubject([]); -const currentWorkspace$ = new BehaviorSubject(null); +const workspaceList$ = new BehaviorSubject([]); +const currentWorkspace$ = new BehaviorSubject(null); const initialized$ = new BehaviorSubject(false); const createWorkspacesSetupContractMock = () => ({ diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts index a7c62a76bec2..9f5c97340f24 100644 --- a/src/core/public/workspace/workspaces_service.ts +++ b/src/core/public/workspace/workspaces_service.ts @@ -5,10 +5,7 @@ import { BehaviorSubject, combineLatest } from 'rxjs'; import { isEqual } from 'lodash'; - -import { CoreService, WorkspaceAttribute } from '../../types'; - -type WorkspaceObject = WorkspaceAttribute & { readonly?: boolean }; +import { CoreService, WorkspaceObject } from '../../types'; interface WorkspaceObservables { /** diff --git a/src/core/types/workspace.ts b/src/core/types/workspace.ts index e99744183cac..d66a93fcc61d 100644 --- a/src/core/types/workspace.ts +++ b/src/core/types/workspace.ts @@ -13,3 +13,7 @@ export interface WorkspaceAttribute { defaultVISTheme?: string; reserved?: boolean; } + +export interface WorkspaceObject extends WorkspaceAttribute { + readonly?: boolean; +} diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts index 3c0920624e1b..e6e53f9101ed 100644 --- a/src/core/utils/default_app_categories.ts +++ b/src/core/utils/default_app_categories.ts @@ -65,12 +65,20 @@ export const DEFAULT_APP_CATEGORIES: Record = Object.freeze order: 4000, euiIconType: 'logoSecurity', }, + openSearchFeatures: { + id: 'openSearchFeatures', + label: i18n.translate('core.ui.openSearchFeaturesNavList.label', { + defaultMessage: 'OpenSearch Features', + }), + order: 5000, + euiIconType: 'folderClosed', + }, management: { id: 'management', label: i18n.translate('core.ui.managementNavList.label', { defaultMessage: 'Management', }), - order: 5000, + order: 6000, euiIconType: 'managementApp', }, }); diff --git a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap index 71e393cf25cc..4cefbb32793b 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -223,9 +223,11 @@ exports[`dashboard listing hideWriteControls 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -1356,9 +1358,11 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -2550,9 +2554,11 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -3744,9 +3750,11 @@ exports[`dashboard listing renders table rows 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -4938,9 +4946,11 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap index 8720101629cb..e382867ae35f 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap @@ -211,9 +211,11 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -1169,9 +1171,11 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -2127,9 +2131,11 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -3085,9 +3091,11 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -4043,9 +4051,11 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -5001,9 +5011,11 @@ exports[`Dashboard top nav render with all components 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, diff --git a/src/plugins/dev_tools/public/plugin.ts b/src/plugins/dev_tools/public/plugin.ts index bb0b6ee1d981..e22f12b9234a 100644 --- a/src/plugins/dev_tools/public/plugin.ts +++ b/src/plugins/dev_tools/public/plugin.ts @@ -85,7 +85,7 @@ export class DevToolsPlugin implements Plugin { icon: '/ui/logos/opensearch_mark.svg', /* the order of dev tools, it shows as last item of management section */ order: 9070, - category: DEFAULT_APP_CATEGORIES.management, + category: DEFAULT_APP_CATEGORIES.openSearchFeatures, mount: async (params: AppMountParameters) => { const { element, history } = params; element.classList.add('devAppWrapper'); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx index ac5d0854cacc..faf6c133ddc0 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx @@ -871,7 +871,7 @@ export class SavedObjectsTable extends Component { expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_OVERVIEW_APP_ID); windowSpy.mockRestore(); }); + + it('#start filter nav links according to workspace feature', () => { + const workspacePlugin = new WorkspacePlugin(); + const coreStart = coreMock.createStart(); + const navLinksService = coreStart.chrome.navLinks; + const devToolsNavLink = { + id: 'dev_tools', + category: { id: 'management', label: 'Management' }, + }; + const discoverNavLink = { + id: 'discover', + category: { id: 'opensearchDashboards', label: 'Library' }, + }; + const workspace = { + id: 'test', + name: 'test', + features: ['dev_tools'], + }; + const allNavLinks = of([devToolsNavLink, discoverNavLink] as ChromeNavLink[]); + const filteredNavLinksMap = new Map(); + filteredNavLinksMap.set(devToolsNavLink.id, devToolsNavLink as ChromeNavLink); + navLinksService.getAllNavLinks$.mockReturnValue(allNavLinks); + coreStart.workspaces.currentWorkspace$.next(workspace); + workspacePlugin.start(coreStart); + expect(navLinksService.setNavLinks).toHaveBeenCalledWith(filteredNavLinksMap); + }); }); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 5589299903d5..8035f73b6d28 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -2,8 +2,11 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ - -import { AppMountParameters, AppNavLinkStatus, CoreSetup, Plugin } from '../../../core/public'; +import { combineLatest } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { i18n } from '@osd/i18n'; +import { featureMatchesConfig } from './utils'; +import { AppMountParameters, AppNavLinkStatus, ChromeNavLink, CoreSetup, CoreStart, Plugin, WorkspaceObject, DEFAULT_APP_CATEGORIES } from '../../../core/public'; import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { Services } from './types'; @@ -15,10 +18,55 @@ export class WorkspacePlugin implements Plugin<{}, {}> { private getWorkspaceIdFromURL(): string | null { return getWorkspaceIdFromUrl(window.location.href); } + + private filterByWorkspace(workspace: WorkspaceObject | null, allNavLinks: ChromeNavLink[]) { + if (!workspace) return allNavLinks; + const features = workspace.features ?? ['*']; + return allNavLinks.filter(featureMatchesConfig(features)); + } + + private filterNavLinks(core: CoreStart) { + const navLinksService = core.chrome.navLinks; + const allNavLinks$ = navLinksService.getAllNavLinks$(); + const currentWorkspace$ = core.workspaces.currentWorkspace$; + combineLatest([ + allNavLinks$.pipe(map(this.changeCategoryNameByWorkspaceFeatureFlag)), + currentWorkspace$, + ]).subscribe(([allNavLinks, currentWorkspace]) => { + const filteredNavLinks = this.filterByWorkspace(currentWorkspace, allNavLinks); + const navLinks = new Map(); + filteredNavLinks.forEach((chromeNavLink) => { + navLinks.set(chromeNavLink.id, chromeNavLink); + }); + navLinksService.setNavLinks(navLinks); + }); + } + + /** + * The category "Opensearch Dashboards" needs to be renamed as "Library" + * when workspace feature flag is on, we need to do it here and generate + * a new item without polluting the original ChromeNavLink. + */ + private changeCategoryNameByWorkspaceFeatureFlag(chromeLinks: ChromeNavLink[]): ChromeNavLink[] { + return chromeLinks.map((item) => { + if (item.category?.id === DEFAULT_APP_CATEGORIES.opensearchDashboards.id) { + return { + ...item, + category: { + ...item.category, + label: i18n.translate('core.ui.libraryNavList.label', { + defaultMessage: 'Library', + }), + }, + }; + } + return item; + }); + } + public async setup(core: CoreSetup) { const workspaceClient = new WorkspaceClient(core.http, core.workspaces); await workspaceClient.init(); - /** * Retrieve workspace id from url */ @@ -82,7 +130,10 @@ export class WorkspacePlugin implements Plugin<{}, {}> { return {}; } - public start() { + public start(core: CoreStart) { + if (core) { + this.filterNavLinks(core); + } return {}; } diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts new file mode 100644 index 000000000000..510a775cd745 --- /dev/null +++ b/src/plugins/workspace/public/utils.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { featureMatchesConfig } from './utils'; + +describe('workspace utils: featureMatchesConfig', () => { + it('feature configured with `*` should match any features', () => { + const match = featureMatchesConfig(['*']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + expect( + match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } }) + ).toBe(true); + }); + + it('should NOT match the config if feature id not matches', () => { + const match = featureMatchesConfig(['discover', 'dashboards', 'visualize']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + }); + + it('should match the config if feature id matches', () => { + const match = featureMatchesConfig(['discover', 'dashboards', 'visualize']); + expect( + match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } }) + ).toBe(true); + }); + + it('should match the config if feature category matches', () => { + const match = featureMatchesConfig(['discover', 'dashboards', '@management', 'visualize']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + }); + + it('should match any features but not the excluded feature id', () => { + const match = featureMatchesConfig(['*', '!discover']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + expect( + match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } }) + ).toBe(false); + }); + + it('should match any features but not the excluded feature category', () => { + const match = featureMatchesConfig(['*', '!@management']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + expect( + match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } }) + ).toBe(true); + }); + + it('should NOT match the excluded feature category', () => { + const match = featureMatchesConfig(['!@management']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + }); + + it('should match features of a category but NOT the excluded feature', () => { + const match = featureMatchesConfig(['@management', '!dev_tools']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + }); + + it('a config presents later in the config array should override the previous config', () => { + // though `dev_tools` is excluded, but this config will override by '@management' as dev_tools has category 'management' + const match = featureMatchesConfig(['!dev_tools', '@management']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + }); +}); diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts new file mode 100644 index 000000000000..f7c59dbfc53c --- /dev/null +++ b/src/plugins/workspace/public/utils.ts @@ -0,0 +1,56 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AppCategory } from '../../../core/public'; + +/** + * Given a list of feature config, check if a feature matches config + * Rules: + * 1. `*` matches any feature + * 2. config starts with `@` matches category, for example, @management matches any feature of `management` category + * 3. to match a specific feature, just use the feature id, such as `discover` + * 4. to exclude feature or category, use `!@management` or `!discover` + * 5. the order of featureConfig array matters, from left to right, the later config override the previous config, + * for example, ['!@management', '*'] matches any feature because '*' overrides the previous setting: '!@management' + */ +export const featureMatchesConfig = (featureConfigs: string[]) => ({ + id, + category, +}: { + id: string; + category?: AppCategory; +}) => { + let matched = false; + + for (const featureConfig of featureConfigs) { + // '*' matches any feature + if (featureConfig === '*') { + matched = true; + } + + // The config starts with `@` matches a category + if (category && featureConfig === `@${category.id}`) { + matched = true; + } + + // The config matches a feature id + if (featureConfig === id) { + matched = true; + } + + // If a config starts with `!`, such feature or category will be excluded + if (featureConfig.startsWith('!')) { + if (category && featureConfig === `!@${category.id}`) { + matched = false; + } + + if (featureConfig === `!${id}`) { + matched = false; + } + } + } + + return matched; +}; From 4217efa183717643ed7f37db331e0186ccf69acf Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Thu, 19 Oct 2023 10:20:11 +0800 Subject: [PATCH 18/21] Add copy saved objects API (#217) * Add copy saved objects API Signed-off-by: gaobinlong * Modify file header Signed-off-by: gaobinlong --------- Signed-off-by: gaobinlong --- src/core/server/saved_objects/routes/copy.ts | 72 +++++ src/core/server/saved_objects/routes/index.ts | 2 + .../routes/integration_tests/copy.test.ts | 264 ++++++++++++++++++ 3 files changed, 338 insertions(+) create mode 100644 src/core/server/saved_objects/routes/copy.ts create mode 100644 src/core/server/saved_objects/routes/integration_tests/copy.test.ts diff --git a/src/core/server/saved_objects/routes/copy.ts b/src/core/server/saved_objects/routes/copy.ts new file mode 100644 index 000000000000..95e79ffd40a1 --- /dev/null +++ b/src/core/server/saved_objects/routes/copy.ts @@ -0,0 +1,72 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; +import { IRouter } from '../../http'; +import { SavedObjectConfig } from '../saved_objects_config'; +import { exportSavedObjectsToStream } from '../export'; +import { importSavedObjectsFromStream } from '../import'; + +export const registerCopyRoute = (router: IRouter, config: SavedObjectConfig) => { + const { maxImportExportSize } = config; + + router.post( + { + path: '/_copy', + validate: { + body: schema.object({ + objects: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }) + ), + includeReferencesDeep: schema.boolean({ defaultValue: false }), + targetWorkspace: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const savedObjectsClient = context.core.savedObjects.client; + const { objects, includeReferencesDeep, targetWorkspace } = req.body; + + // need to access the registry for type validation, can't use the schema for this + const supportedTypes = context.core.savedObjects.typeRegistry + .getImportableAndExportableTypes() + .map((t) => t.name); + + const invalidObjects = objects.filter((obj) => !supportedTypes.includes(obj.type)); + if (invalidObjects.length) { + return res.badRequest({ + body: { + message: `Trying to copy object(s) with unsupported types: ${invalidObjects + .map((obj) => `${obj.type}:${obj.id}`) + .join(', ')}`, + }, + }); + } + + const objectsListStream = await exportSavedObjectsToStream({ + savedObjectsClient, + objects, + exportSizeLimit: maxImportExportSize, + includeReferencesDeep, + excludeExportDetails: true, + }); + + const result = await importSavedObjectsFromStream({ + savedObjectsClient: context.core.savedObjects.client, + typeRegistry: context.core.savedObjects.typeRegistry, + readStream: objectsListStream, + objectLimit: maxImportExportSize, + overwrite: false, + createNewCopies: true, + workspaces: [targetWorkspace], + }); + + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/core/server/saved_objects/routes/index.ts b/src/core/server/saved_objects/routes/index.ts index 7149474e446c..6c70276d7387 100644 --- a/src/core/server/saved_objects/routes/index.ts +++ b/src/core/server/saved_objects/routes/index.ts @@ -45,6 +45,7 @@ import { registerExportRoute } from './export'; import { registerImportRoute } from './import'; import { registerResolveImportErrorsRoute } from './resolve_import_errors'; import { registerMigrateRoute } from './migrate'; +import { registerCopyRoute } from './copy'; export function registerRoutes({ http, @@ -71,6 +72,7 @@ export function registerRoutes({ registerExportRoute(router, config); registerImportRoute(router, config); registerResolveImportErrorsRoute(router, config); + registerCopyRoute(router, config); const internalRouter = http.createRouter('/internal/saved_objects/'); diff --git a/src/core/server/saved_objects/routes/integration_tests/copy.test.ts b/src/core/server/saved_objects/routes/integration_tests/copy.test.ts new file mode 100644 index 000000000000..e8a9d83b30ea --- /dev/null +++ b/src/core/server/saved_objects/routes/integration_tests/copy.test.ts @@ -0,0 +1,264 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as exportMock from '../../export'; +import { createListStream } from '../../../utils/streams'; +import { mockUuidv4 } from '../../import/__mocks__'; +import supertest from 'supertest'; +import { UnwrapPromise } from '@osd/utility-types'; +import { registerCopyRoute } from '../copy'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { SavedObjectConfig } from '../../saved_objects_config'; +import { setupServer, createExportableType } from '../test_utils'; +import { SavedObjectsErrorHelpers } from '../..'; + +jest.mock('../../export', () => ({ + exportSavedObjectsToStream: jest.fn(), +})); + +type SetupServerReturn = UnwrapPromise>; + +const { v4: uuidv4 } = jest.requireActual('uuid'); +const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; +const config = { maxImportPayloadBytes: 26214400, maxImportExportSize: 10000 } as SavedObjectConfig; +const URL = '/internal/saved_objects/_copy'; +const exportSavedObjectsToStream = exportMock.exportSavedObjectsToStream as jest.Mock; + +describe(`POST ${URL}`, () => { + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + let handlerContext: SetupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; + + const emptyResponse = { saved_objects: [], total: 0, per_page: 0, page: 0 }; + const mockIndexPattern = { + type: 'index-pattern', + id: 'my-pattern', + attributes: { title: 'my-pattern-*' }, + references: [], + }; + const mockVisualization = { + type: 'visualization', + id: 'my-visualization', + attributes: { title: 'Test visualization' }, + references: [ + { + name: 'ref_0', + type: 'index-pattern', + id: 'my-pattern', + }, + ], + }; + const mockDashboard = { + type: 'dashboard', + id: 'my-dashboard', + attributes: { title: 'Look at my dashboard' }, + references: [], + }; + + beforeEach(async () => { + mockUuidv4.mockReset(); + mockUuidv4.mockImplementation(() => uuidv4()); + ({ server, httpSetup, handlerContext } = await setupServer()); + handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( + allowedTypes.map(createExportableType) + ); + handlerContext.savedObjects.typeRegistry.getType.mockImplementation( + (type: string) => + // other attributes aren't needed for the purposes of injecting metadata + ({ management: { icon: `${type}-icon` } } as any) + ); + + savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient.find.mockResolvedValue(emptyResponse); + savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] }); + + const router = httpSetup.createRouter('/internal/saved_objects/'); + registerCopyRoute(router, config); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('formats successful response', async () => { + exportSavedObjectsToStream.mockResolvedValueOnce(createListStream([])); + + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + }, + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + includeReferencesDeep: true, + targetWorkspace: 'test_workspace', + }) + .expect(200); + + expect(result.body).toEqual({ success: true, successCount: 0 }); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created + }); + + it('requires objects', async () => { + const result = await supertest(httpSetup.server.listener).post(URL).send({}).expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"[request body.objects]: expected value of type [array] but got [undefined]"` + ); + }); + + it('requires target workspace', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + }, + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + includeReferencesDeep: true, + }) + .expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"[request body.targetWorkspace]: expected value of type [string] but got [undefined]"` + ); + }); + + it('copy unsupported objects', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + objects: [ + { + type: 'unknown', + id: 'my-pattern', + }, + ], + includeReferencesDeep: true, + targetWorkspace: 'test_workspace', + }) + .expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"Trying to copy object(s) with unsupported types: unknown:my-pattern"` + ); + }); + + it('copy index pattern and dashboard into a workspace successfully', async () => { + const targetWorkspace = 'target_workspace_id'; + const savedObjects = [mockIndexPattern, mockDashboard]; + exportSavedObjectsToStream.mockResolvedValueOnce(createListStream(savedObjects)); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: savedObjects.map((obj) => ({ ...obj, workspaces: [targetWorkspace] })), + }); + + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + }, + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + includeReferencesDeep: true, + targetWorkspace, + }) + .expect(200); + expect(result.body).toEqual({ + success: true, + successCount: 2, + successResults: [ + { + type: mockIndexPattern.type, + id: mockIndexPattern.id, + meta: { title: mockIndexPattern.attributes.title, icon: 'index-pattern-icon' }, + }, + { + type: mockDashboard.type, + id: mockDashboard.id, + meta: { title: mockDashboard.attributes.title, icon: 'dashboard-icon' }, + }, + ], + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); + }); + + it('copy a visualization with missing references', async () => { + const targetWorkspace = 'target_workspace_id'; + const savedObjects = [mockVisualization]; + const exportDetail = { + exportedCount: 2, + missingRefCount: 1, + missingReferences: [{ type: 'index-pattern', id: 'my-pattern' }], + }; + exportSavedObjectsToStream.mockResolvedValueOnce( + createListStream(...savedObjects, exportDetail) + ); + + const error = SavedObjectsErrorHelpers.createGenericNotFoundError( + 'index-pattern', + 'my-pattern-*' + ).output.payload; + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [{ ...mockIndexPattern, error }], + }); + + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + objects: [ + { + type: 'visualization', + id: 'my-visualization', + }, + ], + includeReferencesDeep: true, + targetWorkspace, + }) + .expect(200); + expect(result.body).toEqual({ + success: false, + successCount: 0, + errors: [ + { + id: 'my-visualization', + type: 'visualization', + title: 'Test visualization', + meta: { title: 'Test visualization', icon: 'visualization-icon' }, + error: { + type: 'missing_references', + references: [{ type: 'index-pattern', id: 'my-pattern' }], + }, + }, + ], + }); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [{ fields: ['id'], id: 'my-pattern', type: 'index-pattern' }], + expect.any(Object) // options + ); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); + }); +}); From 8ff9e8839813b701057b4c76269f35943944fdab Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Mon, 23 Oct 2023 17:04:13 +0800 Subject: [PATCH 19/21] Feature: create management / public workspaces when calling list api (#236) * feat: create management / public workspaces when calling list api Signed-off-by: SuZhou-Joe * feat: fix bootstrap Signed-off-by: SuZhou-Joe * fix: integration test Signed-off-by: SuZhou-Joe * fix: flaky test Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe --- src/core/server/index.ts | 8 +- src/core/utils/constants.ts | 6 + src/core/utils/index.ts | 8 +- .../components/import_flyout.test.tsx | 9 +- src/plugins/workspace/common/constants.ts | 1 + .../server/integration_tests/routes.test.ts | 2 +- src/plugins/workspace/server/plugin.ts | 2 +- .../workspace/server/workspace_client.ts | 127 +++++++++++++++++- 8 files changed, 151 insertions(+), 12 deletions(-) diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 3c61fcd81664..fa6b2663ac48 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -347,7 +347,13 @@ export { } from './metrics'; export { AppCategory, WorkspaceAttribute } from '../types'; -export { DEFAULT_APP_CATEGORIES, WORKSPACE_TYPE } from '../utils'; +export { + DEFAULT_APP_CATEGORIES, + PUBLIC_WORKSPACE_ID, + MANAGEMENT_WORKSPACE_ID, + WORKSPACE_TYPE, + PERSONAL_WORKSPACE_ID_PREFIX, +} from '../utils'; export { SavedObject, diff --git a/src/core/utils/constants.ts b/src/core/utils/constants.ts index ecc1b7e863c4..0993f0587e28 100644 --- a/src/core/utils/constants.ts +++ b/src/core/utils/constants.ts @@ -6,3 +6,9 @@ export const WORKSPACE_TYPE = 'workspace'; export const WORKSPACE_PATH_PREFIX = '/w'; + +export const PUBLIC_WORKSPACE_ID = 'public'; + +export const MANAGEMENT_WORKSPACE_ID = 'management'; + +export const PERSONAL_WORKSPACE_ID_PREFIX = 'personal'; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index a83f85a8fce0..a4b6cd4a922b 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -37,5 +37,11 @@ export { IContextProvider, } from './context'; export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; -export { WORKSPACE_PATH_PREFIX, WORKSPACE_TYPE } from './constants'; export { getWorkspaceIdFromUrl, formatUrlWithWorkspaceId, cleanWorkspaceId } from './workspace'; +export { + WORKSPACE_PATH_PREFIX, + PUBLIC_WORKSPACE_ID, + MANAGEMENT_WORKSPACE_ID, + WORKSPACE_TYPE, + PERSONAL_WORKSPACE_ID_PREFIX, +} from './constants'; diff --git a/src/plugins/console/public/application/components/import_flyout.test.tsx b/src/plugins/console/public/application/components/import_flyout.test.tsx index 5e7093fe306c..f04678155405 100644 --- a/src/plugins/console/public/application/components/import_flyout.test.tsx +++ b/src/plugins/console/public/application/components/import_flyout.test.tsx @@ -10,6 +10,7 @@ import { ContextValue, ServicesContextProvider } from '../contexts'; import { serviceContextMock } from '../contexts/services_context.mock'; import { wrapWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { ReactWrapper, mount } from 'enzyme'; +import { waitFor } from '@testing-library/dom'; const mockFile = new File(['{"text":"Sample JSON data"}'], 'sample.json', { type: 'application/json', @@ -122,9 +123,11 @@ describe('ImportFlyout Component', () => { expect(component.find(overwriteOptionIdentifier).first().props().checked).toBe(false); // should update existing query - expect(mockUpdate).toBeCalledTimes(1); - expect(mockClose).toBeCalledTimes(1); - expect(mockRefresh).toBeCalledTimes(1); + await waitFor(() => { + expect(mockUpdate).toBeCalledTimes(1); + expect(mockClose).toBeCalledTimes(1); + expect(mockRefresh).toBeCalledTimes(1); + }); }); it('should handle errors during import', async () => { diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 39a0769b4852..a88f98c03a47 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +export const WORKSPACE_UPDATE_APP_ID = 'workspace_update'; export const WORKSPACE_OVERVIEW_APP_ID = 'workspace_overview'; export const WORKSPACE_FATAL_ERROR_APP_ID = 'workspace_fatal_error'; export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts index 7d36eb7ce336..5968d84a2ca9 100644 --- a/src/plugins/workspace/server/integration_tests/routes.test.ts +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -178,7 +178,7 @@ describe('workspace service', () => { page: 1, }) .expect(200); - expect(listResult.body.result.total).toEqual(1); + expect(listResult.body.result.total).toEqual(3); }); }); }); diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index d6d4b4381203..1f0c2830d581 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -42,7 +42,7 @@ export class WorkspacePlugin implements Plugin<{}, {}> { public async setup(core: CoreSetup) { this.logger.debug('Setting up Workspaces service'); - this.client = new WorkspaceClientWithSavedObject(core); + this.client = new WorkspaceClientWithSavedObject(core, this.logger); await this.client.setup(core); diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index 3a214bd7f240..fe1688c95084 100644 --- a/src/plugins/workspace/server/workspace_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -11,11 +11,21 @@ import type { WorkspaceAttribute, SavedObjectsServiceStart, } from '../../../core/server'; -import { WORKSPACE_TYPE } from '../../../core/server'; +import { + DEFAULT_APP_CATEGORIES, + MANAGEMENT_WORKSPACE_ID, + PUBLIC_WORKSPACE_ID, + WORKSPACE_TYPE, + Logger, +} from '../../../core/server'; import { IWorkspaceDBImpl, WorkspaceFindOptions, IResponse, IRequestDetail } from './types'; import { workspace } from './saved_objects'; import { generateRandomId } from './utils'; -import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../common/constants'; +import { + WORKSPACE_OVERVIEW_APP_ID, + WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, + WORKSPACE_UPDATE_APP_ID, +} from '../common/constants'; const WORKSPACE_ID_SIZE = 6; @@ -23,12 +33,19 @@ const DUPLICATE_WORKSPACE_NAME_ERROR = i18n.translate('workspace.duplicate.name. defaultMessage: 'workspace name has already been used, try with a different name', }); +const RESERVED_WORKSPACE_NAME_ERROR = i18n.translate('workspace.reserved.name.error', { + defaultMessage: 'reserved workspace name cannot be changed', +}); + export class WorkspaceClientWithSavedObject implements IWorkspaceDBImpl { private setupDep: CoreSetup; + private logger: Logger; + private savedObjects?: SavedObjectsServiceStart; - constructor(core: CoreSetup) { + constructor(core: CoreSetup, logger: Logger) { this.setupDep = core; + this.logger = logger; } private getScopedClientWithoutPermission( @@ -55,6 +72,55 @@ export class WorkspaceClientWithSavedObject implements IWorkspaceDBImpl { private formatError(error: Error | any): string { return error.message || error.error || 'Error'; } + private async checkAndCreateWorkspace( + savedObjectClient: SavedObjectsClientContract | undefined, + workspaceId: string, + workspaceAttribute: Omit + ) { + try { + await savedObjectClient?.get(WORKSPACE_TYPE, workspaceId); + } catch (error) { + this.logger.debug(error?.toString() || ''); + this.logger.info(`Workspace ${workspaceId} is not found, create it by using internal user`); + try { + const createResult = await savedObjectClient?.create(WORKSPACE_TYPE, workspaceAttribute, { + id: workspaceId, + }); + if (createResult?.id) { + this.logger.info(`Created workspace ${createResult.id}.`); + } + } catch (e) { + this.logger.error(`Create ${workspaceId} workspace error: ${e?.toString() || ''}`); + } + } + } + private async setupPublicWorkspace(savedObjectClient?: SavedObjectsClientContract) { + return this.checkAndCreateWorkspace(savedObjectClient, PUBLIC_WORKSPACE_ID, { + name: i18n.translate('workspaces.public.workspace.default.name', { + defaultMessage: 'Global workspace', + }), + features: ['*', `!@${DEFAULT_APP_CATEGORIES.management.id}`], + reserved: true, + }); + } + private async setupManagementWorkspace(savedObjectClient?: SavedObjectsClientContract) { + const DSM_APP_ID = 'dataSources'; + const DEV_TOOLS_APP_ID = 'dev_tools'; + + return this.checkAndCreateWorkspace(savedObjectClient, MANAGEMENT_WORKSPACE_ID, { + name: i18n.translate('workspaces.management.workspace.default.name', { + defaultMessage: 'Management', + }), + features: [ + `@${DEFAULT_APP_CATEGORIES.management.id}`, + WORKSPACE_OVERVIEW_APP_ID, + WORKSPACE_UPDATE_APP_ID, + DSM_APP_ID, + DEV_TOOLS_APP_ID, + ], + reserved: true, + }); + } public async setup(core: CoreSetup): Promise> { this.setupDep.savedObjects.registerType(workspace); return { @@ -105,7 +171,7 @@ export class WorkspaceClientWithSavedObject implements IWorkspaceDBImpl { options: WorkspaceFindOptions ): ReturnType { try { - const { + let { saved_objects: savedObjects, ...others } = await this.getSavedObjectClientsFromRequestDetail(requestDetail).find( @@ -114,6 +180,49 @@ export class WorkspaceClientWithSavedObject implements IWorkspaceDBImpl { type: WORKSPACE_TYPE, } ); + const scopedClientWithoutPermissionCheck = this.getScopedClientWithoutPermission( + requestDetail + ); + const tasks: Array> = []; + + /** + * Setup public workspace if public workspace can not be found + */ + const hasPublicWorkspace = savedObjects.some((item) => item.id === PUBLIC_WORKSPACE_ID); + + if (!hasPublicWorkspace) { + tasks.push(this.setupPublicWorkspace(scopedClientWithoutPermissionCheck)); + } + + /** + * Setup management workspace if management workspace can not be found + */ + const hasManagementWorkspace = savedObjects.some( + (item) => item.id === MANAGEMENT_WORKSPACE_ID + ); + if (!hasManagementWorkspace) { + tasks.push(this.setupManagementWorkspace(scopedClientWithoutPermissionCheck)); + } + + try { + await Promise.all(tasks); + if (tasks.length) { + const { + saved_objects: retryFindSavedObjects, + ...retryFindOthers + } = await this.getSavedObjectClientsFromRequestDetail(requestDetail).find< + WorkspaceAttribute + >({ + ...options, + type: WORKSPACE_TYPE, + }); + savedObjects = retryFindSavedObjects; + others = retryFindOthers; + } + } catch (e) { + this.logger.error(`Some error happened when initializing reserved workspace: ${e}`); + } + return { success: true, result: { @@ -157,6 +266,9 @@ export class WorkspaceClientWithSavedObject implements IWorkspaceDBImpl { const client = this.getSavedObjectClientsFromRequestDetail(requestDetail); const workspaceInDB: SavedObject = await client.get(WORKSPACE_TYPE, id); if (workspaceInDB.attributes.name !== attributes.name) { + if (workspaceInDB.attributes.reserved) { + throw new Error(RESERVED_WORKSPACE_NAME_ERROR); + } const existingWorkspaceRes = await this.getScopedClientWithoutPermission( requestDetail )?.find({ @@ -169,7 +281,12 @@ export class WorkspaceClientWithSavedObject implements IWorkspaceDBImpl { throw new Error(DUPLICATE_WORKSPACE_NAME_ERROR); } } - await client.update>(WORKSPACE_TYPE, id, attributes, {}); + + await client.create>(WORKSPACE_TYPE, attributes, { + id, + overwrite: true, + version: workspaceInDB.version, + }); return { success: true, result: true, From 508fc1b5b286adb67b44101497d1a3ff375f4332 Mon Sep 17 00:00:00 2001 From: tygao Date: Thu, 26 Oct 2023 13:14:34 +0800 Subject: [PATCH 20/21] Revert "feat: add unit test for mountWrapper (#223)" This reverts commit 0906bf473762c19809e065779b1ef474a0ebe8c8. --- .../public/plugin.test.ts | 63 ++----------------- 1 file changed, 5 insertions(+), 58 deletions(-) diff --git a/src/plugins/saved_objects_management/public/plugin.test.ts b/src/plugins/saved_objects_management/public/plugin.test.ts index 149cee7c5c86..c8e762f73dcc 100644 --- a/src/plugins/saved_objects_management/public/plugin.test.ts +++ b/src/plugins/saved_objects_management/public/plugin.test.ts @@ -28,23 +28,12 @@ * under the License. */ -const mountManagementSectionMock = jest.fn(); -jest.doMock('./management_section', () => ({ - mountManagementSection: mountManagementSectionMock, -})); -import { waitFor } from '@testing-library/dom'; import { coreMock } from '../../../core/public/mocks'; import { homePluginMock } from '../../home/public/mocks'; import { managementPluginMock } from '../../management/public/mocks'; import { dataPluginMock } from '../../data/public/mocks'; import { uiActionsPluginMock } from '../../ui_actions/public/mocks'; import { SavedObjectsManagementPlugin } from './plugin'; -import { - MANAGE_LIBRARY_TITLE_WORDINGS, - SAVED_QUERIES_WORDINGS, - SAVED_SEARCHES_WORDINGS, -} from './constants'; -import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; describe('SavedObjectsManagementPlugin', () => { let plugin: SavedObjectsManagementPlugin; @@ -61,22 +50,12 @@ describe('SavedObjectsManagementPlugin', () => { const homeSetup = homePluginMock.createSetupContract(); const managementSetup = managementPluginMock.createSetupContract(); const uiActionsSetup = uiActionsPluginMock.createSetupContract(); - const registerMock = jest.fn((params) => params.mount({} as any, {} as any)); - await plugin.setup( - { - ...coreSetup, - application: { - ...coreSetup.application, - register: registerMock, - }, - }, - { - home: homeSetup, - management: managementSetup, - uiActions: uiActionsSetup, - } - ); + await plugin.setup(coreSetup, { + home: homeSetup, + management: managementSetup, + uiActions: uiActionsSetup, + }); expect(homeSetup.featureCatalogue.register).toHaveBeenCalledTimes(1); expect(homeSetup.featureCatalogue.register).toHaveBeenCalledWith( @@ -84,38 +63,6 @@ describe('SavedObjectsManagementPlugin', () => { id: 'saved_objects', }) ); - expect(registerMock).toBeCalledWith( - expect.objectContaining({ - id: 'objects', - title: MANAGE_LIBRARY_TITLE_WORDINGS, - order: 10000, - category: DEFAULT_APP_CATEGORIES.opensearchDashboards, - }) - ); - expect(registerMock).toBeCalledWith( - expect.objectContaining({ - id: 'objects_searches', - title: SAVED_SEARCHES_WORDINGS, - order: 8000, - category: DEFAULT_APP_CATEGORIES.opensearchDashboards, - }) - ); - expect(registerMock).toBeCalledWith( - expect.objectContaining({ - id: 'objects_query', - title: SAVED_QUERIES_WORDINGS, - order: 8001, - category: DEFAULT_APP_CATEGORIES.opensearchDashboards, - }) - ); - waitFor( - () => { - expect(mountManagementSectionMock).toBeCalledTimes(3); - }, - { - container: document.body, - } - ); }); }); }); From 3f3e2b22f21737464ad85ac74c57608eb9e52bfd Mon Sep 17 00:00:00 2001 From: tygao Date: Thu, 26 Oct 2023 13:15:10 +0800 Subject: [PATCH 21/21] Revert "Register Advance Settings, Data Source management,Index Pattern management and SavedObject management as standalone app, retire dashboard management (#208)" This reverts commit 87c0c8ac6afc787bb000ca0dc28d656f57b9c98d. --- .github/workflows/cypress_workflow.yml | 6 +- .../with-security/check_advanced_settings.js | 2 +- .../with-security/helpers/generate_data.js | 4 +- .../check_advanced_settings.js | 2 +- .../without-security/helpers/generate_data.js | 2 +- .../core_app/errors/url_overflow.test.ts | 2 +- .../public/core_app/errors/url_overflow.tsx | 2 +- .../core_app/errors/url_overflow_ui.tsx | 2 +- .../ui_settings/saved_objects/ui_settings.ts | 2 +- .../__snapshots__/page_wrapper.test.tsx.snap | 13 --- .../components/page_wrapper/index.ts | 6 -- .../page_wrapper/page_wrapper.test.tsx | 16 ---- .../components/page_wrapper/page_wrapper.tsx | 21 ----- .../mount_management_section.tsx | 55 +++++------- .../advanced_settings/public/plugin.ts | 24 +++-- .../server/saved_objects/dashboard.ts | 4 +- .../index_patterns/index_patterns.ts | 6 +- .../redirect_no_index_pattern.tsx | 8 +- .../public/search/errors/painless_error.tsx | 4 +- .../server/saved_objects/index_patterns.ts | 8 +- .../server/saved_objects/data_source.ts | 4 +- .../opensearch_dashboards.json | 2 +- .../data_source_column/data_source_column.tsx | 6 +- .../__snapshots__/page_wrapper.test.tsx.snap | 13 --- .../public/components/page_wrapper/index.ts | 6 -- .../page_wrapper/page_wrapper.test.tsx | 16 ---- .../components/page_wrapper/page_wrapper.tsx | 21 ----- .../public/management_app/index.ts | 2 +- .../mount_management_section.tsx | 69 ++++++--------- .../data_source_management/public/plugin.ts | 35 ++++---- .../data_source_management/public/types.ts | 5 -- .../open_search_panel.test.tsx.snap | 2 +- .../components/top_nav/open_search_panel.tsx | 2 +- .../discover/server/saved_objects/search.ts | 4 +- .../open_search_panel.test.js.snap | 2 +- .../components/top_nav/open_search_panel.js | 2 +- .../components/new_theme_modal.tsx | 4 +- .../opensearch_dashboards.json | 2 +- .../mount_management_section.tsx | 73 ++++++--------- .../index_pattern_management/public/plugin.ts | 33 +++---- .../getting_started.test.tsx.snap | 4 +- .../getting_started/getting_started.tsx | 6 +- .../overview_page_footer.tsx | 2 +- .../overview_page_header.test.tsx | 2 +- .../overview_page_header.tsx | 2 +- .../table_list_view/table_list_view.tsx | 2 +- .../saved_objects_management/README.md | 8 +- .../public/constants.ts | 41 --------- .../management_section/mount_section.tsx | 64 ++++++-------- .../saved_objects_table.test.tsx.snap | 10 +-- .../__snapshots__/header.test.tsx.snap | 8 +- .../__snapshots__/relationships.test.tsx.snap | 20 ++--- .../__snapshots__/table.test.tsx.snap | 8 +- .../objects_table/components/header.tsx | 9 +- .../components/relationships.test.tsx | 34 +++---- .../objects_table/components/table.test.tsx | 8 +- .../saved_objects_table.test.tsx | 14 +-- .../objects_table/saved_objects_table.tsx | 6 +- .../__snapshots__/page_wrapper.test.tsx.snap | 13 --- .../management_section/page_wrapper/index.ts | 6 -- .../page_wrapper/page_wrapper.test.tsx | 16 ---- .../page_wrapper/page_wrapper.tsx | 21 ----- .../saved_objects_table_page.tsx | 13 ++- .../saved_objects_management/public/plugin.ts | 88 +++++-------------- .../server/saved_objects/augment_vis.ts | 4 +- .../server/saved_objects/vis_builder_app.ts | 3 +- .../server/saved_objects/visualization.ts | 4 +- .../apis/saved_objects_management/find.ts | 21 +++-- .../saved_objects_management/relationships.ts | 48 ++++++---- .../dashboard/create_and_add_embeddables.js | 6 +- test/functional/apps/dashboard/time_zones.js | 2 + .../apps/management/_import_objects.js | 2 + .../_index_pattern_create_delete.js | 2 +- .../management/_mgmt_import_saved_objects.js | 1 + .../_opensearch_dashboards_settings.js | 6 +- .../apps/management/_scripted_fields.js | 7 ++ .../management/_scripted_fields_filter.js | 1 + .../edit_saved_object.ts | 2 + .../apps/visualize/_custom_branding.ts | 10 +-- test/functional/apps/visualize/_lab_mode.js | 6 +- test/functional/apps/visualize/_tag_cloud.js | 2 + test/functional/config.js | 4 + test/functional/page_objects/settings_page.ts | 6 +- 83 files changed, 399 insertions(+), 635 deletions(-) delete mode 100644 src/plugins/advanced_settings/public/management_app/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap delete mode 100644 src/plugins/advanced_settings/public/management_app/components/page_wrapper/index.ts delete mode 100644 src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.test.tsx delete mode 100644 src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.tsx delete mode 100644 src/plugins/data_source_management/public/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap delete mode 100644 src/plugins/data_source_management/public/components/page_wrapper/index.ts delete mode 100644 src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.test.tsx delete mode 100644 src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.tsx delete mode 100644 src/plugins/saved_objects_management/public/constants.ts delete mode 100644 src/plugins/saved_objects_management/public/management_section/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap delete mode 100644 src/plugins/saved_objects_management/public/management_section/page_wrapper/index.ts delete mode 100644 src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.test.tsx delete mode 100644 src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.tsx diff --git a/.github/workflows/cypress_workflow.yml b/.github/workflows/cypress_workflow.yml index f96a13558787..cb5af78fcfc5 100644 --- a/.github/workflows/cypress_workflow.yml +++ b/.github/workflows/cypress_workflow.yml @@ -3,7 +3,7 @@ name: Run cypress tests # trigger on every PR for all branches on: pull_request: - branches: ['**'] + branches: [ '**' ] paths-ignore: - '**/*.md' @@ -54,8 +54,8 @@ jobs: uses: actions/checkout@v2 with: path: ${{ env.FTR_PATH }} - repository: opensearch-project/opensearch-dashboards-functional-test - ref: 'workspace' + repository: ruanyl/opensearch-dashboards-functional-test + ref: '${{ github.base_ref }}' - name: Get Cypress version id: cypress_version diff --git a/cypress/integration/with-security/check_advanced_settings.js b/cypress/integration/with-security/check_advanced_settings.js index 379362063e92..9ca41207724e 100644 --- a/cypress/integration/with-security/check_advanced_settings.js +++ b/cypress/integration/with-security/check_advanced_settings.js @@ -13,7 +13,7 @@ const loginPage = new LoginPage(cy); describe('verify the advanced settings are saved', () => { beforeEach(() => { - miscUtils.visitPage('app/settings'); + miscUtils.visitPage('app/management/opensearch-dashboards/settings'); loginPage.enterUserName('admin'); loginPage.enterPassword('admin'); loginPage.submit(); diff --git a/cypress/integration/with-security/helpers/generate_data.js b/cypress/integration/with-security/helpers/generate_data.js index c2c4d2dbe57d..dcd711fc7c18 100755 --- a/cypress/integration/with-security/helpers/generate_data.js +++ b/cypress/integration/with-security/helpers/generate_data.js @@ -13,7 +13,7 @@ const loginPage = new LoginPage(cy); describe('Generating BWC test data with security', () => { beforeEach(() => { - miscUtils.visitPage('app/settings'); + miscUtils.visitPage('app/management/opensearch-dashboards/settings'); loginPage.enterUserName('admin'); loginPage.enterPassword('admin'); loginPage.submit(); @@ -29,7 +29,7 @@ describe('Generating BWC test data with security', () => { }); it('adds advanced settings', () => { - miscUtils.visitPage('app/settings'); + miscUtils.visitPage('app/management/opensearch-dashboards/settings'); cy.get('[data-test-subj="advancedSetting-editField-theme:darkMode"]').click(); cy.get('[data-test-subj="advancedSetting-editField-timeline:max_buckets"]').type( '{selectAll}4' diff --git a/cypress/integration/without-security/check_advanced_settings.js b/cypress/integration/without-security/check_advanced_settings.js index 0094d53835b0..9268d86a16e5 100644 --- a/cypress/integration/without-security/check_advanced_settings.js +++ b/cypress/integration/without-security/check_advanced_settings.js @@ -9,7 +9,7 @@ const miscUtils = new MiscUtils(cy); describe('verify the advanced settings are saved', () => { beforeEach(() => { - miscUtils.visitPage('app/settings'); + miscUtils.visitPage('app/management/opensearch-dashboards/settings'); }); it('the dark mode is on', () => { diff --git a/cypress/integration/without-security/helpers/generate_data.js b/cypress/integration/without-security/helpers/generate_data.js index 3aff136a70e0..47e9c2f5f5ed 100755 --- a/cypress/integration/without-security/helpers/generate_data.js +++ b/cypress/integration/without-security/helpers/generate_data.js @@ -12,7 +12,7 @@ describe('Generating BWC test data without security', () => { miscUtils.visitPage('app'); }); it('adds advanced settings', () => { - miscUtils.visitPage('app/settings'); + miscUtils.visitPage('app/management/opensearch-dashboards/settings'); cy.get('[data-test-subj="advancedSetting-editField-theme:darkMode"]').click(); cy.get('[data-test-subj="advancedSetting-editField-timeline:max_buckets"]').type( '{selectAll}4' diff --git a/src/core/public/core_app/errors/url_overflow.test.ts b/src/core/public/core_app/errors/url_overflow.test.ts index fe9cb8dca661..b2eee9c17d58 100644 --- a/src/core/public/core_app/errors/url_overflow.test.ts +++ b/src/core/public/core_app/errors/url_overflow.test.ts @@ -102,7 +102,7 @@ describe('url overflow detection', () => { option in advanced settings diff --git a/src/core/public/core_app/errors/url_overflow.tsx b/src/core/public/core_app/errors/url_overflow.tsx index 1de6fe785cf9..6dbfa96fff46 100644 --- a/src/core/public/core_app/errors/url_overflow.tsx +++ b/src/core/public/core_app/errors/url_overflow.tsx @@ -92,7 +92,7 @@ export const setupUrlOverflowDetection = ({ basePath, history, toasts, uiSetting values={{ storeInSessionStorageParam: state:storeInSessionStorage, advancedSettingsLink: ( - + = ({ basePath }) = values={{ storeInSessionStorageConfig: state:storeInSessionStorage, opensearchDashboardsSettingsLink: ( - + -
    - Foo -
    -
    -`; diff --git a/src/plugins/advanced_settings/public/management_app/components/page_wrapper/index.ts b/src/plugins/advanced_settings/public/management_app/components/page_wrapper/index.ts deleted file mode 100644 index 3cf0cdd26c99..000000000000 --- a/src/plugins/advanced_settings/public/management_app/components/page_wrapper/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -export { PageWrapper } from './page_wrapper'; diff --git a/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.test.tsx b/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.test.tsx deleted file mode 100644 index 550eb3ee1cae..000000000000 --- a/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { render } from '@testing-library/react'; -import { PageWrapper } from './page_wrapper'; - -describe('PageWrapper', () => { - it('should render normally', async () => { - const { findByText, container } = render(Foo); - await findByText('Foo'); - expect(container).toMatchSnapshot(); - }); -}); diff --git a/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.tsx b/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.tsx deleted file mode 100644 index 1b1949c334e4..000000000000 --- a/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { EuiPageContent } from '@elastic/eui'; -import React from 'react'; - -export const PageWrapper = (props: { children?: React.ReactChild }) => { - return ( - - ); -}; 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 648382771ba8..7fa0b9ddd2c0 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,24 +34,18 @@ import { Router, Switch, Route } from 'react-router-dom'; import { i18n } from '@osd/i18n'; import { I18nProvider } from '@osd/i18n/react'; -import { - AppMountParameters, - ChromeBreadcrumb, - ScopedHistory, - StartServicesAccessor, -} from 'src/core/public'; +import { StartServicesAccessor } from 'src/core/public'; import { AdvancedSettings } from './advanced_settings'; +import { ManagementAppMountParams } from '../../../management/public'; import { ComponentRegistry } from '../types'; -import { reactRouterNavigate } from '../../../opensearch_dashboards_react/public'; -import { PageWrapper } from './components/page_wrapper'; import './index.scss'; const title = i18n.translate('advancedSettings.advancedSettingsLabel', { defaultMessage: 'Advanced settings', }); -const crumb: ChromeBreadcrumb[] = [{ text: title }]; +const crumb = [{ text: title }]; const readOnlyBadge = { text: i18n.translate('advancedSettings.badge.readOnly.text', { @@ -63,18 +57,13 @@ const readOnlyBadge = { iconType: 'glasses', }; -export async function mountAdvancedSettingsManagementSection( +export async function mountManagementSection( getStartServices: StartServicesAccessor, - params: AppMountParameters, + params: ManagementAppMountParams, componentRegistry: ComponentRegistry['start'] ) { + params.setBreadcrumbs(crumb); const [{ uiSettings, notifications, docLinks, application, chrome }] = await getStartServices(); - chrome.setBreadcrumbs([ - ...crumb.map((item) => ({ - ...item, - ...(item.href ? reactRouterNavigate(params.history, item.href) : {}), - })), - ]); const canSave = application.capabilities.advancedSettings.save as boolean; @@ -83,23 +72,21 @@ export async function mountAdvancedSettingsManagementSection( } ReactDOM.render( - - - - - - - - - - - , + + + + + + + + + , params.element ); return () => { diff --git a/src/plugins/advanced_settings/public/plugin.ts b/src/plugins/advanced_settings/public/plugin.ts index 91fe18612749..608bfc6a25e7 100644 --- a/src/plugins/advanced_settings/public/plugin.ts +++ b/src/plugins/advanced_settings/public/plugin.ts @@ -29,11 +29,10 @@ */ import { i18n } from '@osd/i18n'; -import { AppMountParameters, CoreSetup, Plugin } from 'opensearch-dashboards/public'; +import { CoreSetup, Plugin } from 'opensearch-dashboards/public'; import { FeatureCatalogueCategory } from '../../home/public'; import { ComponentRegistry } from './component_registry'; import { AdvancedSettingsSetup, AdvancedSettingsStart, AdvancedSettingsPluginSetup } from './types'; -import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; const component = new ComponentRegistry(); @@ -43,21 +42,18 @@ const title = i18n.translate('advancedSettings.advancedSettingsLabel', { export class AdvancedSettingsPlugin implements Plugin { - public setup(core: CoreSetup, { home }: AdvancedSettingsPluginSetup) { - core.application.register({ + public setup(core: CoreSetup, { management, home }: AdvancedSettingsPluginSetup) { + const opensearchDashboardsSection = management.sections.section.opensearchDashboards; + + opensearchDashboardsSection.registerApp({ id: 'settings', title, - order: 99, - category: DEFAULT_APP_CATEGORIES.management, - async mount(params: AppMountParameters) { - const { mountAdvancedSettingsManagementSection } = await import( + order: 3, + async mount(params) { + const { mountManagementSection } = await import( './management_app/mount_management_section' ); - return mountAdvancedSettingsManagementSection( - core.getStartServices, - params, - component.start - ); + return mountManagementSection(core.getStartServices, params, component.start); }, }); @@ -70,7 +66,7 @@ export class AdvancedSettingsPlugin 'Customize your OpenSearch Dashboards experience — change the date format, turn on dark mode, and more.', }), icon: 'gear', - path: '/app/settings', + path: '/app/management/opensearch-dashboards/settings', showOnHomePage: false, category: FeatureCatalogueCategory.ADMIN, }); diff --git a/src/plugins/dashboard/server/saved_objects/dashboard.ts b/src/plugins/dashboard/server/saved_objects/dashboard.ts index 6d6a08954fbe..ee2c162733bc 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard.ts @@ -43,7 +43,9 @@ export const dashboardSavedObjectType: SavedObjectsType = { return obj.attributes.title; }, getEditUrl(obj) { - return `/objects/savedDashboards/${encodeURIComponent(obj.id)}`; + return `/management/opensearch-dashboards/objects/savedDashboards/${encodeURIComponent( + obj.id + )}`; }, getInAppUrl(obj) { return { diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts index 489ad154afa0..688605821097 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts @@ -418,7 +418,11 @@ export class IndexPatternsService { ); if (!savedObject.version) { - throw new SavedObjectNotFound(savedObjectType, id, 'indexPatterns'); + throw new SavedObjectNotFound( + savedObjectType, + id, + 'management/opensearch-dashboards/indexPatterns' + ); } const spec = this.savedObjectToSpec(savedObject); diff --git a/src/plugins/data/public/index_patterns/index_patterns/redirect_no_index_pattern.tsx b/src/plugins/data/public/index_patterns/index_patterns/redirect_no_index_pattern.tsx index 1a43ab22aaae..b09bc8adde6f 100644 --- a/src/plugins/data/public/index_patterns/index_patterns/redirect_no_index_pattern.tsx +++ b/src/plugins/data/public/index_patterns/index_patterns/redirect_no_index_pattern.tsx @@ -42,7 +42,9 @@ export const onRedirectNoIndexPattern = ( overlays: CoreStart['overlays'] ) => () => { const canManageIndexPatterns = capabilities.management.opensearchDashboards.indexPatterns; - const redirectTarget = canManageIndexPatterns ? '/indexPatterns' : '/home'; + const redirectTarget = canManageIndexPatterns + ? '/management/opensearch-dashboards/indexPatterns' + : '/home'; let timeoutId: NodeJS.Timeout | undefined; if (timeoutId) { @@ -70,8 +72,8 @@ export const onRedirectNoIndexPattern = ( if (redirectTarget === '/home') { navigateToApp('home'); } else { - navigateToApp('indexPatterns', { - path: `?bannerMessage=${bannerMessage}`, + navigateToApp('management', { + path: `/opensearch-dashboards/indexPatterns?bannerMessage=${bannerMessage}`, }); } diff --git a/src/plugins/data/public/search/errors/painless_error.tsx b/src/plugins/data/public/search/errors/painless_error.tsx index ee11d77b98f5..1522dcf97cb0 100644 --- a/src/plugins/data/public/search/errors/painless_error.tsx +++ b/src/plugins/data/public/search/errors/painless_error.tsx @@ -53,7 +53,9 @@ export class PainlessError extends OsdError { public getErrorMessage(application: ApplicationStart) { function onClick() { - application.navigateToApp('indexPatterns'); + application.navigateToApp('management', { + path: `/opensearch-dashboards/indexPatterns`, + }); } return ( diff --git a/src/plugins/data/server/saved_objects/index_patterns.ts b/src/plugins/data/server/saved_objects/index_patterns.ts index 391adf6a973f..5f0864bac926 100644 --- a/src/plugins/data/server/saved_objects/index_patterns.ts +++ b/src/plugins/data/server/saved_objects/index_patterns.ts @@ -43,11 +43,15 @@ export const indexPatternSavedObjectType: SavedObjectsType = { return obj.attributes.title; }, getEditUrl(obj) { - return `/indexPatterns/patterns/${encodeURIComponent(obj.id)}`; + return `/management/opensearch-dashboards/indexPatterns/patterns/${encodeURIComponent( + obj.id + )}`; }, getInAppUrl(obj) { return { - path: `/app/indexPatterns/patterns/${encodeURIComponent(obj.id)}`, + path: `/app/management/opensearch-dashboards/indexPatterns/patterns/${encodeURIComponent( + obj.id + )}`, uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }; }, diff --git a/src/plugins/data_source/server/saved_objects/data_source.ts b/src/plugins/data_source/server/saved_objects/data_source.ts index 58cace8ada2d..9404a4bcf371 100644 --- a/src/plugins/data_source/server/saved_objects/data_source.ts +++ b/src/plugins/data_source/server/saved_objects/data_source.ts @@ -17,11 +17,11 @@ export const dataSource: SavedObjectsType = { return obj.attributes.title; }, getEditUrl(obj) { - return `/dataSources/${encodeURIComponent(obj.id)}`; + return `/management/opensearch-dashboards/dataSources/${encodeURIComponent(obj.id)}`; }, getInAppUrl(obj) { return { - path: `/app/dataSources/${encodeURIComponent(obj.id)}`, + path: `/app/management/opensearch-dashboards/dataSources/${encodeURIComponent(obj.id)}`, uiCapabilitiesPath: 'management.opensearchDashboards.dataSources', }; }, diff --git a/src/plugins/data_source_management/opensearch_dashboards.json b/src/plugins/data_source_management/opensearch_dashboards.json index 6b58c63bb5a5..58e81a337e7d 100644 --- a/src/plugins/data_source_management/opensearch_dashboards.json +++ b/src/plugins/data_source_management/opensearch_dashboards.json @@ -3,7 +3,7 @@ "version": "opensearchDashboards", "server": false, "ui": true, - "requiredPlugins": ["dataSource", "indexPatternManagement"], + "requiredPlugins": ["management", "dataSource", "indexPatternManagement"], "optionalPlugins": [], "requiredBundles": ["opensearchDashboardsReact"], "extraPublicDirs": ["public/components/utils"] diff --git a/src/plugins/data_source_management/public/components/data_source_column/data_source_column.tsx b/src/plugins/data_source_management/public/components/data_source_column/data_source_column.tsx index cd6fc7c17ae2..640eb1b369fd 100644 --- a/src/plugins/data_source_management/public/components/data_source_column/data_source_column.tsx +++ b/src/plugins/data_source_management/public/components/data_source_column/data_source_column.tsx @@ -56,7 +56,11 @@ export class DataSourceColumn implements IndexPatternTableColumn ?.map((dataSource) => { return { ...dataSource, - relativeUrl: basePath.prepend(`/app/dataSources/${encodeURIComponent(dataSource.id)}`), + relativeUrl: basePath.prepend( + `/app/management/opensearch-dashboards/dataSources/${encodeURIComponent( + dataSource.id + )}` + ), }; }) ?.reduce( diff --git a/src/plugins/data_source_management/public/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap b/src/plugins/data_source_management/public/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap deleted file mode 100644 index 3c5257e2e8d1..000000000000 --- a/src/plugins/data_source_management/public/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap +++ /dev/null @@ -1,13 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PageWrapper should render normally 1`] = ` -
    -
    - Foo -
    -
    -`; diff --git a/src/plugins/data_source_management/public/components/page_wrapper/index.ts b/src/plugins/data_source_management/public/components/page_wrapper/index.ts deleted file mode 100644 index 3cf0cdd26c99..000000000000 --- a/src/plugins/data_source_management/public/components/page_wrapper/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -export { PageWrapper } from './page_wrapper'; diff --git a/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.test.tsx b/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.test.tsx deleted file mode 100644 index 550eb3ee1cae..000000000000 --- a/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { render } from '@testing-library/react'; -import { PageWrapper } from './page_wrapper'; - -describe('PageWrapper', () => { - it('should render normally', async () => { - const { findByText, container } = render(Foo); - await findByText('Foo'); - expect(container).toMatchSnapshot(); - }); -}); diff --git a/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.tsx b/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.tsx deleted file mode 100644 index 1b1949c334e4..000000000000 --- a/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { EuiPageContent } from '@elastic/eui'; -import React from 'react'; - -export const PageWrapper = (props: { children?: React.ReactChild }) => { - return ( - - ); -}; diff --git a/src/plugins/data_source_management/public/management_app/index.ts b/src/plugins/data_source_management/public/management_app/index.ts index 960adc7ba5a6..5ccbfb947646 100644 --- a/src/plugins/data_source_management/public/management_app/index.ts +++ b/src/plugins/data_source_management/public/management_app/index.ts @@ -3,4 +3,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { mountDataSourcesManagementSection } from './mount_management_section'; +export { mountManagementSection } from './mount_management_section'; diff --git a/src/plugins/data_source_management/public/management_app/mount_management_section.tsx b/src/plugins/data_source_management/public/management_app/mount_management_section.tsx index f61113042458..9fe1f2406382 100644 --- a/src/plugins/data_source_management/public/management_app/mount_management_section.tsx +++ b/src/plugins/data_source_management/public/management_app/mount_management_section.tsx @@ -3,42 +3,33 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - AppMountParameters, - ChromeBreadcrumb, - ScopedHistory, - StartServicesAccessor, -} from 'src/core/public'; +import { StartServicesAccessor } from 'src/core/public'; import { I18nProvider } from '@osd/i18n/react'; import React from 'react'; import ReactDOM from 'react-dom'; import { Route, Router, Switch } from 'react-router-dom'; +import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { ManagementAppMountParams } from '../../../management/public'; + import { OpenSearchDashboardsContextProvider } from '../../../opensearch_dashboards_react/public'; import { CreateDataSourceWizardWithRouter } from '../components/create_data_source_wizard'; import { DataSourceTableWithRouter } from '../components/data_source_table'; -import { DataSourceManagementContext, DataSourceManagementStartDependencies } from '../types'; +import { DataSourceManagementContext } from '../types'; import { EditDataSourceWithRouter } from '../components/edit_data_source'; -import { PageWrapper } from '../components/page_wrapper'; -import { reactRouterNavigate } from '../../../opensearch_dashboards_react/public'; -export async function mountDataSourcesManagementSection( +export interface DataSourceManagementStartDependencies { + data: DataPublicPluginStart; +} + +export async function mountManagementSection( getStartServices: StartServicesAccessor, - params: AppMountParameters + params: ManagementAppMountParams ) { const [ { chrome, application, savedObjects, uiSettings, notifications, overlays, http, docLinks }, ] = await getStartServices(); - const setBreadcrumbsScoped = (crumbs: ChromeBreadcrumb[] = []) => { - const wrapBreadcrumb = (item: ChromeBreadcrumb, scopedHistory: ScopedHistory) => ({ - ...item, - ...(item.href ? reactRouterNavigate(scopedHistory, item.href) : {}), - }); - - chrome.setBreadcrumbs([...crumbs.map((item) => wrapBreadcrumb(item, params.history))]); - }; - const deps: DataSourceManagementContext = { chrome, application, @@ -48,29 +39,27 @@ export async function mountDataSourcesManagementSection( overlays, http, docLinks, - setBreadcrumbs: setBreadcrumbsScoped, + setBreadcrumbs: params.setBreadcrumbs, }; ReactDOM.render( - - - - - - - - - - - - - - - - - - - , + + + + + + + + + + + + + + + + + , params.element ); diff --git a/src/plugins/data_source_management/public/plugin.ts b/src/plugins/data_source_management/public/plugin.ts index d0c900effce2..941107d74638 100644 --- a/src/plugins/data_source_management/public/plugin.ts +++ b/src/plugins/data_source_management/public/plugin.ts @@ -3,21 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - AppMountParameters, - CoreSetup, - CoreStart, - DEFAULT_APP_CATEGORIES, - Plugin, - StartServicesAccessor, -} from '../../../core/public'; +import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; import { PLUGIN_NAME } from '../common'; import { ManagementSetup } from '../../management/public'; import { IndexPatternManagementSetup } from '../../index_pattern_management/public'; import { DataSourceColumn } from './components/data_source_column/data_source_column'; -import { DataSourceManagementStartDependencies } from './types'; export interface DataSourceManagementSetupDependencies { management: ManagementSetup; @@ -28,7 +20,16 @@ const DSM_APP_ID = 'dataSources'; export class DataSourceManagementPlugin implements Plugin { - public setup(core: CoreSetup, { indexPatternManagement }: DataSourceManagementSetupDependencies) { + public setup( + core: CoreSetup, + { management, indexPatternManagement }: DataSourceManagementSetupDependencies + ) { + const opensearchDashboardsSection = management.sections.section.opensearchDashboards; + + if (!opensearchDashboardsSection) { + throw new Error('`opensearchDashboards` management section not found.'); + } + const savedObjectPromise = core .getStartServices() .then(([coreStart]) => coreStart.savedObjects); @@ -36,18 +37,14 @@ export class DataSourceManagementPlugin const column = new DataSourceColumn(savedObjectPromise, httpPromise); indexPatternManagement.columns.register(column); - core.application.register({ + opensearchDashboardsSection.registerApp({ id: DSM_APP_ID, title: PLUGIN_NAME, order: 1, - category: DEFAULT_APP_CATEGORIES.opensearchDashboards, - mount: async (params: AppMountParameters) => { - const { mountDataSourcesManagementSection } = await import('./management_app'); - - return mountDataSourcesManagementSection( - core.getStartServices as StartServicesAccessor, - params - ); + mount: async (params) => { + const { mountManagementSection } = await import('./management_app'); + + return mountManagementSection(core.getStartServices, params); }, }); } diff --git a/src/plugins/data_source_management/public/types.ts b/src/plugins/data_source_management/public/types.ts index be2b7725d7ed..1bede8fbfca9 100644 --- a/src/plugins/data_source_management/public/types.ts +++ b/src/plugins/data_source_management/public/types.ts @@ -14,7 +14,6 @@ import { HttpSetup, } from 'src/core/public'; import { ManagementAppMountParams } from 'src/plugins/management/public'; -import { DataPublicPluginStart } from 'src/plugins/data/public'; import { SavedObjectAttributes } from 'src/core/types'; import { i18n } from '@osd/i18n'; import { SigV4ServiceName } from '../../data_source/common/data_sources'; @@ -116,7 +115,3 @@ export interface SigV4Content extends SavedObjectAttributes { region: string; service?: SigV4ServiceName; } - -export interface DataSourceManagementStartDependencies { - data: DataPublicPluginStart; -} diff --git a/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap b/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap index 54c90bd3ce92..342dea206c30 100644 --- a/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap +++ b/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap @@ -53,7 +53,7 @@ exports[`render 1`] = ` > = ({ addBasePath, onClose }) => { modes. You or your administrator can change to the previous theme by visiting {advancedSettingsLink}." values={{ advancedSettingsLink: ( - + , - params: AppMountParameters, + params: ManagementAppMountParams, getMlCardState: () => MlCardState ) { const [ @@ -82,17 +74,6 @@ export async function mountManagementSection( chrome.setBadge(readOnlyBadge); } - const setBreadcrumbsScope = (crumbs: ChromeBreadcrumb[] = [], appHistory?: ScopedHistory) => { - const wrapBreadcrumb = (item: ChromeBreadcrumb, scopedHistory: ScopedHistory) => ({ - ...item, - ...(item.href ? reactRouterNavigate(scopedHistory, item.href) : {}), - }); - - chrome.setBreadcrumbs([ - ...crumbs.map((item) => wrapBreadcrumb(item, appHistory || params.history)), - ]); - }; - const deps: IndexPatternManagmentContext = { chrome, application, @@ -104,36 +85,32 @@ export async function mountManagementSection( docLinks, data, indexPatternManagementStart: indexPatternManagementStart as IndexPatternManagementStart, - setBreadcrumbs: setBreadcrumbsScope, + setBreadcrumbs: params.setBreadcrumbs, getMlCardState, dataSourceEnabled, }; ReactDOM.render( - - - - - - - - - - - - - - - - - - - - - - - - , + + + + + + + + + + + + + + + + + + + + , params.element ); diff --git a/src/plugins/index_pattern_management/public/plugin.ts b/src/plugins/index_pattern_management/public/plugin.ts index 862c9e3b5c06..cf68e043b76c 100644 --- a/src/plugins/index_pattern_management/public/plugin.ts +++ b/src/plugins/index_pattern_management/public/plugin.ts @@ -29,15 +29,7 @@ */ import { i18n } from '@osd/i18n'; -import { - PluginInitializerContext, - CoreSetup, - CoreStart, - Plugin, - AppMountParameters, - ChromeBreadcrumb, - ScopedHistory, -} from 'src/core/public'; +import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; import { DataSourcePluginStart } from 'src/plugins/data_source/public'; import { UrlForwardingSetup } from '../../url_forwarding/public'; @@ -47,11 +39,10 @@ import { IndexPatternManagementServiceStart, } from './service'; -import { ManagementAppMountParams } from '../../management/public'; -import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; -import { reactRouterNavigate } from '../../opensearch_dashboards_react/public'; +import { ManagementSetup } from '../../management/public'; export interface IndexPatternManagementSetupDependencies { + management: ManagementSetup; urlForwarding: UrlForwardingSetup; } @@ -84,9 +75,15 @@ export class IndexPatternManagementPlugin public setup( core: CoreSetup, - { urlForwarding }: IndexPatternManagementSetupDependencies + { management, urlForwarding }: IndexPatternManagementSetupDependencies ) { - const newAppPath = IPM_APP_ID; + const opensearchDashboardsSection = management.sections.section.opensearchDashboards; + + if (!opensearchDashboardsSection) { + throw new Error('`opensearchDashboards` management section not found.'); + } + + const newAppPath = `management/opensearch-dashboards/${IPM_APP_ID}`; const legacyPatternsPath = 'management/opensearch-dashboards/index_patterns'; urlForwarding.forwardApp( @@ -99,13 +96,11 @@ export class IndexPatternManagementPlugin return pathInApp && `/patterns${pathInApp}`; }); - // register it under Library - core.application.register({ + opensearchDashboardsSection.registerApp({ id: IPM_APP_ID, title: sectionsHeader, - order: 8100, - category: DEFAULT_APP_CATEGORIES.opensearchDashboards, - mount: async (params: AppMountParameters) => { + order: 0, + mount: async (params) => { const { mountManagementSection } = await import('./management_app'); return mountManagementSection(core.getStartServices, params, () => diff --git a/src/plugins/opensearch_dashboards_overview/public/components/getting_started/__snapshots__/getting_started.test.tsx.snap b/src/plugins/opensearch_dashboards_overview/public/components/getting_started/__snapshots__/getting_started.test.tsx.snap index db7484e21379..9df3bb12caec 100644 --- a/src/plugins/opensearch_dashboards_overview/public/components/getting_started/__snapshots__/getting_started.test.tsx.snap +++ b/src/plugins/opensearch_dashboards_overview/public/components/getting_started/__snapshots__/getting_started.test.tsx.snap @@ -171,7 +171,7 @@ exports[`GettingStarted dark mode on 1`] = ` = ({ addBasePath, isDarkTheme, apps }) => - + = ({ addBasePath, path }) => { diff --git a/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.test.tsx b/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.test.tsx index fcd417a42826..2e27ebd0cb6b 100644 --- a/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.test.tsx +++ b/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.test.tsx @@ -200,7 +200,7 @@ describe('OverviewPageHeader toolbar items - Management', () => { return component.find({ className: 'osdOverviewPageHeader__actionButton', - href: '/app/settings', + href: '/app/management', }); }; diff --git a/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.tsx b/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.tsx index e27a99fc4d44..a636f7ecdb7d 100644 --- a/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.tsx +++ b/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.tsx @@ -136,7 +136,7 @@ export const OverviewPageHeader: FC = ({ className="osdOverviewPageHeader__actionButton" flush="both" iconType="gear" - href={addBasePath('/app/settings')} + href={addBasePath('/app/management')} > {i18n.translate( 'opensearch-dashboards-react.osdOverviewPageHeader.stackManagementButtonLabel', diff --git a/src/plugins/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx b/src/plugins/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx index 0df7289caf75..438971862c79 100644 --- a/src/plugins/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx +++ b/src/plugins/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx @@ -315,7 +315,7 @@ class TableListView extends React.ComponentlistingLimit, advancedSettingsLink: ( - + ; serviceRegistry: ISavedObjectsManagementServiceRegistry; - appMountParams?: AppMountParameters; - title: string; - allowedObjectTypes?: string[]; + mountParams: ManagementAppMountParams; } +let allowedObjectTypes: string[] | undefined; + +const title = i18n.translate('savedObjectsManagement.objects.savedObjectsTitle', { + defaultMessage: 'Saved Objects', +}); + const SavedObjectsEditionPage = lazy(() => import('./saved_objects_edition_page')); const SavedObjectsTablePage = lazy(() => import('./saved_objects_table_page')); export const mountManagementSection = async ({ core, - appMountParams, + mountParams, serviceRegistry, - title, - allowedObjectTypes, }: MountParams) => { const [coreStart, { data, uiActions }, pluginStart] = await core.getStartServices(); - const usedMountParams = appMountParams || ({} as ManagementAppMountParams); - const { element, history } = usedMountParams; - const { chrome } = coreStart; - const setBreadcrumbs = chrome.setBreadcrumbs; + const { element, history, setBreadcrumbs } = mountParams; if (allowedObjectTypes === undefined) { allowedObjectTypes = await getAllowedTypes(coreStart.http); } @@ -87,34 +86,29 @@ export const mountManagementSection = async ({ }> - - - + }> - - - + diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap index b6e7a4379c58..fe237e8d8d0d 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap @@ -316,10 +316,10 @@ exports[`SavedObjectsTable should render normally 1`] = ` Object { "id": "1", "meta": Object { - "editUrl": "#/indexPatterns/patterns/1", + "editUrl": "#/management/opensearch-dashboards/indexPatterns/patterns/1", "icon": "indexPatternApp", "inAppUrl": Object { - "path": "/indexPatterns/patterns/1", + "path": "/management/opensearch-dashboards/indexPatterns/patterns/1", "uiCapabilitiesPath": "management.opensearchDashboards.indexPatterns", }, "title": "MyIndexPattern*", @@ -329,7 +329,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` Object { "id": "2", "meta": Object { - "editUrl": "/objects/savedSearches/2", + "editUrl": "/management/opensearch-dashboards/objects/savedSearches/2", "icon": "search", "inAppUrl": Object { "path": "/discover/2", @@ -342,7 +342,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` Object { "id": "3", "meta": Object { - "editUrl": "/objects/savedDashboards/3", + "editUrl": "/management/opensearch-dashboards/objects/savedDashboards/3", "icon": "dashboardApp", "inAppUrl": Object { "path": "/dashboard/3", @@ -355,7 +355,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` Object { "id": "4", "meta": Object { - "editUrl": "/objects/savedVisualizations/4", + "editUrl": "/management/opensearch-dashboards/objects/savedVisualizations/4", "icon": "visualizeApp", "inAppUrl": Object { "path": "/edit/4", diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap index dace178024f2..038e1aaf2d8f 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap @@ -10,7 +10,13 @@ exports[`Header should render normally 1`] = ` grow={false} > -

    +

    + +

    void; onImport: () => void; onRefresh: () => void; filteredCount: number; - title: string; }) => ( -

    {title}

    +

    + +

    diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx index 5afdbacf6dff..1f21e5990c74 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx @@ -53,7 +53,7 @@ describe('Relationships', () => { id: '1', relationship: 'parent', meta: { - editUrl: '/objects/savedSearches/1', + editUrl: '/management/opensearch-dashboards/objects/savedSearches/1', icon: 'search', inAppUrl: { path: '/app/discover#//1', @@ -67,7 +67,7 @@ describe('Relationships', () => { id: '2', relationship: 'parent', meta: { - editUrl: '/objects/savedVisualizations/2', + editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/2', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/2', @@ -85,9 +85,9 @@ describe('Relationships', () => { meta: { title: 'MyIndexPattern*', icon: 'indexPatternApp', - editUrl: '#/indexPatterns/patterns/1', + editUrl: '#/management/opensearch-dashboards/indexPatterns/patterns/1', inAppUrl: { - path: '/indexPatterns/patterns/1', + path: '/management/opensearch-dashboards/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, }, @@ -120,10 +120,10 @@ describe('Relationships', () => { id: '1', relationship: 'child', meta: { - editUrl: '/indexPatterns/patterns/1', + editUrl: '/management/opensearch-dashboards/indexPatterns/patterns/1', icon: 'indexPatternApp', inAppUrl: { - path: '/app/indexPatterns/patterns/1', + path: '/app/management/opensearch-dashboards/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, title: 'My Index Pattern', @@ -134,7 +134,7 @@ describe('Relationships', () => { id: '2', relationship: 'parent', meta: { - editUrl: '/objects/savedVisualizations/2', + editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/2', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/2', @@ -152,7 +152,7 @@ describe('Relationships', () => { meta: { title: 'MySearch', icon: 'search', - editUrl: '/objects/savedSearches/1', + editUrl: '/management/opensearch-dashboards/objects/savedSearches/1', inAppUrl: { path: '/discover/1', uiCapabilitiesPath: 'discover.show', @@ -187,7 +187,7 @@ describe('Relationships', () => { id: '1', relationship: 'parent', meta: { - editUrl: '/objects/savedDashboards/1', + editUrl: '/management/opensearch-dashboards/objects/savedDashboards/1', icon: 'dashboardApp', inAppUrl: { path: '/app/opensearch-dashboards#/dashboard/1', @@ -201,7 +201,7 @@ describe('Relationships', () => { id: '2', relationship: 'parent', meta: { - editUrl: '/objects/savedDashboards/2', + editUrl: '/management/opensearch-dashboards/objects/savedDashboards/2', icon: 'dashboardApp', inAppUrl: { path: '/app/opensearch-dashboards#/dashboard/2', @@ -219,7 +219,7 @@ describe('Relationships', () => { meta: { title: 'MyViz', icon: 'visualizeApp', - editUrl: '/objects/savedVisualizations/1', + editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/1', inAppUrl: { path: '/edit/1', uiCapabilitiesPath: 'visualize.show', @@ -256,7 +256,7 @@ describe('Relationships', () => { meta: { title: 'MyViz', icon: 'visualizeApp', - editUrl: '/objects/savedVisualizations/1', + editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/1', inAppUrl: { path: '/edit/1', uiCapabilitiesPath: 'visualize.show', @@ -272,7 +272,7 @@ describe('Relationships', () => { meta: { title: 'MyAugmentVisObject', icon: 'savedObject', - editUrl: '/objects/savedAugmentVis/1', + editUrl: '/management/opensearch-dashboards/objects/savedAugmentVis/1', }, }, close: jest.fn(), @@ -303,7 +303,7 @@ describe('Relationships', () => { id: '1', relationship: 'child', meta: { - editUrl: '/objects/savedVisualizations/1', + editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/1', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/1', @@ -317,7 +317,7 @@ describe('Relationships', () => { id: '2', relationship: 'child', meta: { - editUrl: '/objects/savedVisualizations/2', + editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/2', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/2', @@ -335,7 +335,7 @@ describe('Relationships', () => { meta: { title: 'MyDashboard', icon: 'dashboardApp', - editUrl: '/objects/savedDashboards/1', + editUrl: '/management/opensearch-dashboards/objects/savedDashboards/1', inAppUrl: { path: '/dashboard/1', uiCapabilitiesPath: 'dashboard.show', @@ -375,7 +375,7 @@ describe('Relationships', () => { meta: { title: 'MyDashboard', icon: 'dashboardApp', - editUrl: '/objects/savedDashboards/1', + editUrl: '/management/opensearch-dashboards/objects/savedDashboards/1', inAppUrl: { path: '/dashboard/1', uiCapabilitiesPath: 'dashboard.show', diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx index c8e378b93b92..7e5bb318f4d0 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx @@ -51,9 +51,9 @@ const defaultProps: TableProps = { meta: { title: `MyIndexPattern*`, icon: 'indexPatternApp', - editUrl: '#/indexPatterns/patterns/1', + editUrl: '#/management/opensearch-dashboards/indexPatterns/patterns/1', inAppUrl: { - path: '/indexPatterns/patterns/1', + path: '/management/opensearch-dashboards/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, }, @@ -91,9 +91,9 @@ const defaultProps: TableProps = { meta: { title: `MyIndexPattern*`, icon: 'indexPatternApp', - editUrl: '#/indexPatterns/patterns/1', + editUrl: '#/management/opensearch-dashboards/indexPatterns/patterns/1', inAppUrl: { - path: '/indexPatterns/patterns/1', + path: '/management/opensearch-dashboards/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, }, diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx index 443026e92964..5a6bf0713d95 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx @@ -172,9 +172,9 @@ describe('SavedObjectsTable', () => { meta: { title: `MyIndexPattern*`, icon: 'indexPatternApp', - editUrl: '#/indexPatterns/patterns/1', + editUrl: '#/management/opensearch-dashboards/indexPatterns/patterns/1', inAppUrl: { - path: '/indexPatterns/patterns/1', + path: '/management/opensearch-dashboards/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, }, @@ -185,7 +185,7 @@ describe('SavedObjectsTable', () => { meta: { title: `MySearch`, icon: 'search', - editUrl: '/objects/savedSearches/2', + editUrl: '/management/opensearch-dashboards/objects/savedSearches/2', inAppUrl: { path: '/discover/2', uiCapabilitiesPath: 'discover.show', @@ -198,7 +198,7 @@ describe('SavedObjectsTable', () => { meta: { title: `MyDashboard`, icon: 'dashboardApp', - editUrl: '/objects/savedDashboards/3', + editUrl: '/management/opensearch-dashboards/objects/savedDashboards/3', inAppUrl: { path: '/dashboard/3', uiCapabilitiesPath: 'dashboard.show', @@ -211,7 +211,7 @@ describe('SavedObjectsTable', () => { meta: { title: `MyViz`, icon: 'visualizeApp', - editUrl: '/objects/savedVisualizations/4', + editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/4', inAppUrl: { path: '/edit/4', uiCapabilitiesPath: 'visualize.show', @@ -460,7 +460,7 @@ describe('SavedObjectsTable', () => { meta: { title: `MySearch`, icon: 'search', - editUrl: '/objects/savedSearches/2', + editUrl: '/management/opensearch-dashboards/objects/savedSearches/2', inAppUrl: { path: '/discover/2', uiCapabilitiesPath: 'discover.show', @@ -475,7 +475,7 @@ describe('SavedObjectsTable', () => { type: 'search', meta: { title: 'MySearch', - editUrl: '/objects/savedSearches/2', + editUrl: '/management/opensearch-dashboards/objects/savedSearches/2', icon: 'search', inAppUrl: { path: '/discover/2', diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx index faf6c133ddc0..3dcf29e732c1 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx @@ -114,7 +114,6 @@ export interface SavedObjectsTableProps { goInspectObject: (obj: SavedObjectWithMetadata) => void; canGoInApp: (obj: SavedObjectWithMetadata) => boolean; dateFormat: string; - title: string; } export interface SavedObjectsTableState { @@ -544,7 +543,9 @@ export class SavedObjectsTable extends Component diff --git a/src/plugins/saved_objects_management/public/management_section/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap deleted file mode 100644 index 3c5257e2e8d1..000000000000 --- a/src/plugins/saved_objects_management/public/management_section/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap +++ /dev/null @@ -1,13 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PageWrapper should render normally 1`] = ` -
    -
    - Foo -
    -
    -`; diff --git a/src/plugins/saved_objects_management/public/management_section/page_wrapper/index.ts b/src/plugins/saved_objects_management/public/management_section/page_wrapper/index.ts deleted file mode 100644 index 3cf0cdd26c99..000000000000 --- a/src/plugins/saved_objects_management/public/management_section/page_wrapper/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -export { PageWrapper } from './page_wrapper'; diff --git a/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.test.tsx b/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.test.tsx deleted file mode 100644 index 550eb3ee1cae..000000000000 --- a/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { render } from '@testing-library/react'; -import { PageWrapper } from './page_wrapper'; - -describe('PageWrapper', () => { - it('should render normally', async () => { - const { findByText, container } = render(Foo); - await findByText('Foo'); - expect(container).toMatchSnapshot(); - }); -}); diff --git a/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.tsx b/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.tsx deleted file mode 100644 index 1b1949c334e4..000000000000 --- a/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { EuiPageContent } from '@elastic/eui'; -import React from 'react'; - -export const PageWrapper = (props: { children?: React.ReactChild }) => { - return ( - - ); -}; diff --git a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx index 4ba9575f9f62..09937388ba57 100644 --- a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx +++ b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx @@ -30,13 +30,13 @@ import React, { useEffect } from 'react'; import { get } from 'lodash'; +import { i18n } from '@osd/i18n'; import { CoreStart, ChromeBreadcrumb } from 'src/core/public'; import { DataPublicPluginStart } from '../../../data/public'; import { ISavedObjectsManagementServiceRegistry, SavedObjectsManagementActionServiceStart, SavedObjectsManagementColumnServiceStart, - SavedObjectsManagementNamespaceServiceStart, } from '../services'; import { SavedObjectsTable } from './objects_table'; @@ -49,7 +49,6 @@ const SavedObjectsTablePage = ({ columnRegistry, namespaceRegistry, setBreadcrumbs, - title, }: { coreStart: CoreStart; dataStart: DataPublicPluginStart; @@ -59,7 +58,6 @@ const SavedObjectsTablePage = ({ columnRegistry: SavedObjectsManagementColumnServiceStart; namespaceRegistry: SavedObjectsManagementNamespaceServiceStart; setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; - title: string; }) => { const capabilities = coreStart.application.capabilities; const itemsPerPage = coreStart.uiSettings.get('savedObjects:perPage', 50); @@ -68,11 +66,13 @@ const SavedObjectsTablePage = ({ useEffect(() => { setBreadcrumbs([ { - text: title, - href: undefined, + text: i18n.translate('savedObjectsManagement.breadcrumb.index', { + defaultMessage: 'Saved objects', + }), + href: '/', }, ]); - }, [setBreadcrumbs, title]); + }, [setBreadcrumbs]); return ( ); }; diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index dba28e05e98e..14beb73386a8 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -29,7 +29,7 @@ */ import { i18n } from '@osd/i18n'; -import { AppMountParameters, CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { VisBuilderStart } from '../../vis_builder/public'; import { ManagementSetup } from '../../management/public'; @@ -55,12 +55,6 @@ import { } from './services'; import { registerServices } from './register_services'; import { bootstrap } from './ui_actions_bootstrap'; -import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; -import { - MANAGE_LIBRARY_TITLE_WORDINGS, - SAVED_QUERIES_WORDINGS, - SAVED_SEARCHES_WORDINGS, -} from './constants'; export interface SavedObjectsManagementPluginSetup { actions: SavedObjectsManagementActionServiceSetup; @@ -104,66 +98,9 @@ export class SavedObjectsManagementPlugin private namespaceService = new SavedObjectsManagementNamespaceService(); private serviceRegistry = new SavedObjectsManagementServiceRegistry(); - private registerLibrarySubApp( - coreSetup: CoreSetup - ) { - const core = coreSetup; - const mountWrapper = ({ - title, - allowedObjectTypes, - }: { - title: string; - allowedObjectTypes?: string[]; - }) => async (appMountParams: AppMountParameters) => { - const { mountManagementSection } = await import('./management_section'); - return mountManagementSection({ - core, - serviceRegistry: this.serviceRegistry, - appMountParams, - title, - allowedObjectTypes, - }); - }; - - /** - * Register saved objects overview & saved search & saved query here - */ - core.application.register({ - id: 'objects', - title: MANAGE_LIBRARY_TITLE_WORDINGS, - order: 10000, - category: DEFAULT_APP_CATEGORIES.opensearchDashboards, - mount: mountWrapper({ - title: MANAGE_LIBRARY_TITLE_WORDINGS, - }), - }); - - core.application.register({ - id: 'objects_searches', - title: SAVED_SEARCHES_WORDINGS, - order: 8000, - category: DEFAULT_APP_CATEGORIES.opensearchDashboards, - mount: mountWrapper({ - title: SAVED_SEARCHES_WORDINGS, - allowedObjectTypes: ['search'], - }), - }); - - core.application.register({ - id: 'objects_query', - title: SAVED_QUERIES_WORDINGS, - order: 8001, - category: DEFAULT_APP_CATEGORIES.opensearchDashboards, - mount: mountWrapper({ - title: SAVED_QUERIES_WORDINGS, - allowedObjectTypes: ['query'], - }), - }); - } - public setup( core: CoreSetup, - { home, uiActions }: SetupDependencies + { home, management, uiActions }: SetupDependencies ): SavedObjectsManagementPluginSetup { const actionSetup = this.actionService.setup(); const columnSetup = this.columnService.setup(); @@ -180,20 +117,35 @@ export class SavedObjectsManagementPlugin 'Import, export, and manage your saved searches, visualizations, and dashboards.', }), icon: 'savedObjectsApp', - path: '/app/objects', + path: '/app/management/opensearch-dashboards/objects', showOnHomePage: false, category: FeatureCatalogueCategory.ADMIN, }); } + const opensearchDashboardsSection = management.sections.section.opensearchDashboards; + opensearchDashboardsSection.registerApp({ + id: 'objects', + title: i18n.translate('savedObjectsManagement.managementSectionLabel', { + defaultMessage: 'Saved objects', + }), + order: 1, + mount: async (mountParams) => { + const { mountManagementSection } = await import('./management_section'); + return mountManagementSection({ + core, + serviceRegistry: this.serviceRegistry, + mountParams, + }); + }, + }); + // sets up the context mappings and registers any triggers/actions for the plugin bootstrap(uiActions); // depends on `getStartServices`, should not be awaited registerServices(this.serviceRegistry, core.getStartServices); - this.registerLibrarySubApp(core); - return { actions: actionSetup, columns: columnSetup, diff --git a/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts b/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts index 558649f900bd..52188d52998a 100644 --- a/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts +++ b/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts @@ -15,7 +15,9 @@ export const augmentVisSavedObjectType: SavedObjectsType = { return `augment-vis-${obj?.attributes?.originPlugin}`; }, getEditUrl(obj) { - return `/objects/savedAugmentVis/${encodeURIComponent(obj.id)}`; + return `/management/opensearch-dashboards/objects/savedAugmentVis/${encodeURIComponent( + obj.id + )}`; }, }, mappings: { diff --git a/src/plugins/vis_builder/server/saved_objects/vis_builder_app.ts b/src/plugins/vis_builder/server/saved_objects/vis_builder_app.ts index 2d329227491c..029557010bee 100644 --- a/src/plugins/vis_builder/server/saved_objects/vis_builder_app.ts +++ b/src/plugins/vis_builder/server/saved_objects/vis_builder_app.ts @@ -20,7 +20,8 @@ export const visBuilderSavedObjectType: SavedObjectsType = { defaultSearchField: 'title', importableAndExportable: true, getTitle: ({ attributes: { title } }: SavedObject) => title, - getEditUrl: ({ id }: SavedObject) => `/objects/savedVisBuilder/${encodeURIComponent(id)}`, + getEditUrl: ({ id }: SavedObject) => + `/management/opensearch-dashboards/objects/savedVisBuilder/${encodeURIComponent(id)}`, getInAppUrl({ id }: SavedObject) { return { path: `/app/${PLUGIN_ID}${EDIT_PATH}/${encodeURIComponent(id)}`, diff --git a/src/plugins/visualizations/server/saved_objects/visualization.ts b/src/plugins/visualizations/server/saved_objects/visualization.ts index 4e46c83db157..15a926b3f81d 100644 --- a/src/plugins/visualizations/server/saved_objects/visualization.ts +++ b/src/plugins/visualizations/server/saved_objects/visualization.ts @@ -43,7 +43,9 @@ export const visualizationSavedObjectType: SavedObjectsType = { return obj.attributes.title; }, getEditUrl(obj) { - return `/objects/savedVisualizations/${encodeURIComponent(obj.id)}`; + return `/management/opensearch-dashboards/objects/savedVisualizations/${encodeURIComponent( + obj.id + )}`; }, getInAppUrl(obj) { return { diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index 065541a36d77..a82d4e792cdc 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -73,7 +73,8 @@ export default function ({ getService }: FtrProviderContext) { score: 0, updated_at: '2017-09-21T18:51:23.794Z', meta: { - editUrl: '/objects/savedVisualizations/dd7caf20-9efd-11e7-acb3-3dab96693fab', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/dd7caf20-9efd-11e7-acb3-3dab96693fab', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/dd7caf20-9efd-11e7-acb3-3dab96693fab', @@ -236,7 +237,8 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'discoverApp', title: 'OneRecord', - editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -254,7 +256,8 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'dashboardApp', title: 'Dashboard', - editUrl: '/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'dashboard.show', @@ -272,7 +275,8 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -282,7 +286,8 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[1].meta).to.eql({ icon: 'visualizeApp', title: 'Visualization', - editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -300,9 +305,11 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'indexPatternApp', title: 'saved_objects*', - editUrl: '/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: '/app/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + path: + '/app/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, namespaceType: 'single', diff --git a/test/api_integration/apis/saved_objects_management/relationships.ts b/test/api_integration/apis/saved_objects_management/relationships.ts index 77e838cfed42..f0af2d8d9e79 100644 --- a/test/api_integration/apis/saved_objects_management/relationships.ts +++ b/test/api_integration/apis/saved_objects_management/relationships.ts @@ -94,9 +94,11 @@ export default function ({ getService }: FtrProviderContext) { meta: { title: 'saved_objects*', icon: 'indexPatternApp', - editUrl: '/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: '/app/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + path: + '/app/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, namespaceType: 'single', @@ -109,7 +111,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { title: 'VisualizationFromSavedSearch', icon: 'visualizeApp', - editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -134,9 +137,11 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'indexPatternApp', title: 'saved_objects*', - editUrl: '/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: '/app/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + path: + '/app/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, namespaceType: 'single', @@ -149,7 +154,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -193,7 +199,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'Visualization', - editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -208,7 +215,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -231,7 +239,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'Visualization', - editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -246,7 +255,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -290,7 +300,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -305,7 +316,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'dashboardApp', title: 'Dashboard', - editUrl: '/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'dashboard.show', @@ -330,7 +342,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -373,7 +386,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -388,7 +402,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'Visualization', - editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -413,7 +428,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', diff --git a/test/functional/apps/dashboard/create_and_add_embeddables.js b/test/functional/apps/dashboard/create_and_add_embeddables.js index 6701ae0fc94c..3b6e8a243556 100644 --- a/test/functional/apps/dashboard/create_and_add_embeddables.js +++ b/test/functional/apps/dashboard/create_and_add_embeddables.js @@ -112,7 +112,8 @@ export default function ({ getService, getPageObjects }) { describe('is false', () => { before(async () => { - await PageObjects.common.navigateToApp('settings'); + await PageObjects.header.clickStackManagement(); + await PageObjects.settings.clickOpenSearchDashboardsSettings(); await PageObjects.settings.toggleAdvancedSettingCheckbox(VISUALIZE_ENABLE_LABS_SETTING); }); @@ -126,7 +127,8 @@ export default function ({ getService, getPageObjects }) { }); after(async () => { - await PageObjects.settings.navigateTo(); + await PageObjects.header.clickStackManagement(); + await PageObjects.settings.clickOpenSearchDashboardsSettings(); await PageObjects.settings.clearAdvancedSettings(VISUALIZE_ENABLE_LABS_SETTING); await PageObjects.header.clickDashboard(); }); diff --git a/test/functional/apps/dashboard/time_zones.js b/test/functional/apps/dashboard/time_zones.js index 7c3e2f162779..13a424bd7ea6 100644 --- a/test/functional/apps/dashboard/time_zones.js +++ b/test/functional/apps/dashboard/time_zones.js @@ -51,6 +51,7 @@ export default function ({ getService, getPageObjects }) { await opensearchDashboardsServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); + await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); await PageObjects.savedObjects.importFile( path.join(__dirname, 'exports', 'timezonetest_6_2_4.json') @@ -74,6 +75,7 @@ export default function ({ getService, getPageObjects }) { it('Changing timezone changes dashboard timestamp and shows the same data', async () => { await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickOpenSearchDashboardsSettings(); await PageObjects.settings.setAdvancedSettingsSelect('dateFormat:tz', 'Etc/GMT+5'); await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.loadSavedDashboard('time zone test'); diff --git a/test/functional/apps/management/_import_objects.js b/test/functional/apps/management/_import_objects.js index 50700a12b718..2c432964f309 100644 --- a/test/functional/apps/management/_import_objects.js +++ b/test/functional/apps/management/_import_objects.js @@ -46,6 +46,7 @@ export default function ({ getService, getPageObjects }) { beforeEach(async function () { // delete .kibana index and then wait for OpenSearch Dashboards to re-create it await opensearchDashboardsServer.uiSettings.replace({}); + await PageObjects.settings.navigateTo(); await opensearchArchiver.load('management'); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); }); @@ -214,6 +215,7 @@ export default function ({ getService, getPageObjects }) { beforeEach(async function () { // delete .kibana index and then wait for OpenSearch Dashboards to re-create it await opensearchDashboardsServer.uiSettings.replace({}); + await PageObjects.settings.navigateTo(); await opensearchArchiver.load('saved_objects_imports'); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); }); diff --git a/test/functional/apps/management/_index_pattern_create_delete.js b/test/functional/apps/management/_index_pattern_create_delete.js index 1d154718c26d..b7214590ebd4 100644 --- a/test/functional/apps/management/_index_pattern_create_delete.js +++ b/test/functional/apps/management/_index_pattern_create_delete.js @@ -129,7 +129,7 @@ export default function ({ getService, getPageObjects }) { return retry.try(function tryingForTime() { return browser.getCurrentUrl().then(function (currentUrl) { log.debug('currentUrl = ' + currentUrl); - expect(currentUrl).to.contain('indexPatterns'); + expect(currentUrl).to.contain('management/opensearch-dashboards/indexPatterns'); }); }); }); diff --git a/test/functional/apps/management/_mgmt_import_saved_objects.js b/test/functional/apps/management/_mgmt_import_saved_objects.js index fe19eb141b9c..631b4e85cb8b 100644 --- a/test/functional/apps/management/_mgmt_import_saved_objects.js +++ b/test/functional/apps/management/_mgmt_import_saved_objects.js @@ -42,6 +42,7 @@ export default function ({ getService, getPageObjects }) { beforeEach(async function () { await opensearchArchiver.load('empty_opensearch_dashboards'); await opensearchArchiver.load('discover'); + await PageObjects.settings.navigateTo(); }); afterEach(async function () { diff --git a/test/functional/apps/management/_opensearch_dashboards_settings.js b/test/functional/apps/management/_opensearch_dashboards_settings.js index 637f7073d517..98cda687e23b 100644 --- a/test/functional/apps/management/_opensearch_dashboards_settings.js +++ b/test/functional/apps/management/_opensearch_dashboards_settings.js @@ -40,9 +40,11 @@ export default function ({ getService, getPageObjects }) { // delete .kibana index and then wait for OpenSearch Dashboards to re-create it await opensearchDashboardsServer.uiSettings.replace({}); await PageObjects.settings.createIndexPattern('logstash-*'); + await PageObjects.settings.navigateTo(); }); after(async function afterAll() { + await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.removeLogstashIndexPatternIfExist(); }); @@ -88,6 +90,7 @@ export default function ({ getService, getPageObjects }) { }); it('setting to true change is preserved', async function () { + await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsSettings(); await PageObjects.settings.toggleAdvancedSettingCheckbox('state:storeInSessionStorage'); const storeInSessionStorage = await PageObjects.settings.getAdvancedSettingCheckbox( @@ -110,7 +113,8 @@ export default function ({ getService, getPageObjects }) { it("changing 'state:storeInSessionStorage' also takes effect without full page reload", async () => { await PageObjects.dashboard.preserveCrossAppState(); - await PageObjects.settings.navigateTo(); + await PageObjects.header.clickStackManagement(); + await PageObjects.settings.clickOpenSearchDashboardsSettings(); await PageObjects.settings.toggleAdvancedSettingCheckbox('state:storeInSessionStorage'); await PageObjects.header.clickDashboard(); const [globalState, appState] = await getStateFromUrl(); diff --git a/test/functional/apps/management/_scripted_fields.js b/test/functional/apps/management/_scripted_fields.js index f0b69344c472..fd290ce76b8a 100644 --- a/test/functional/apps/management/_scripted_fields.js +++ b/test/functional/apps/management/_scripted_fields.js @@ -78,11 +78,13 @@ export default function ({ getService, getPageObjects }) { }); after(async function afterAll() { + await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.removeLogstashIndexPatternIfExist(); }); it('should not allow saving of invalid scripts', async function () { + await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.clickScriptedFieldsTab(); @@ -100,6 +102,7 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName = 'ram_Pain_reg'; it('should create and edit scripted field', async function () { + await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); @@ -133,6 +136,7 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName = 'ram_Pain1'; it('should create scripted field', async function () { + await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); @@ -252,6 +256,7 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName2 = 'painString'; it('should create scripted field', async function () { + await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); @@ -346,6 +351,7 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName2 = 'painBool'; it('should create scripted field', async function () { + await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); @@ -441,6 +447,7 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName2 = 'painDate'; it('should create scripted field', async function () { + await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); diff --git a/test/functional/apps/management/_scripted_fields_filter.js b/test/functional/apps/management/_scripted_fields_filter.js index 55ec8895608c..b1714c425aac 100644 --- a/test/functional/apps/management/_scripted_fields_filter.js +++ b/test/functional/apps/management/_scripted_fields_filter.js @@ -58,6 +58,7 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName = 'ram_pain1'; it('should filter scripted fields', async function () { + await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.clickScriptedFieldsTab(); diff --git a/test/functional/apps/saved_objects_management/edit_saved_object.ts b/test/functional/apps/saved_objects_management/edit_saved_object.ts index 64fe2bf199b0..1534c710179b 100644 --- a/test/functional/apps/saved_objects_management/edit_saved_object.ts +++ b/test/functional/apps/saved_objects_management/edit_saved_object.ts @@ -88,6 +88,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); it('allows to update the saved object when submitting', async () => { + await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); let objects = await PageObjects.savedObjects.getRowTitles(); @@ -153,6 +154,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }, ]; + await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); const objects = await PageObjects.savedObjects.getRowTitles(); diff --git a/test/functional/apps/visualize/_custom_branding.ts b/test/functional/apps/visualize/_custom_branding.ts index 52cbc8e5fec9..37f07e932ee5 100644 --- a/test/functional/apps/visualize/_custom_branding.ts +++ b/test/functional/apps/visualize/_custom_branding.ts @@ -46,7 +46,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('with customized logo for opensearch overview header in dark mode', async () => { - await PageObjects.settings.navigateTo(); + await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); await PageObjects.settings.toggleAdvancedSettingCheckbox('theme:darkMode'); await PageObjects.common.navigateToApp('opensearch_dashboards_overview'); await testSubjects.existOrFail('osdOverviewPageHeaderLogo'); @@ -100,7 +100,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('with customized logo in dark mode', async () => { - await PageObjects.settings.navigateTo(); + await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); await PageObjects.settings.toggleAdvancedSettingCheckbox('theme:darkMode'); await PageObjects.common.navigateToApp('home'); await testSubjects.existOrFail('welcomeCustomLogo'); @@ -179,13 +179,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('in dark mode', async () => { before(async function () { - await PageObjects.settings.navigateTo(); + await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); await PageObjects.settings.toggleAdvancedSettingCheckbox('theme:darkMode'); await PageObjects.common.navigateToApp('home'); }); after(async function () { - await PageObjects.settings.navigateTo(); + await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); await PageObjects.settings.clearAdvancedSettings('theme:darkMode'); }); @@ -206,7 +206,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('with customized mark logo button that navigates to home page', async () => { - await PageObjects.settings.navigateTo(); + await PageObjects.common.navigateToApp('settings'); await globalNav.clickHomeButton(); await PageObjects.header.waitUntilLoadingHasFinished(); const url = await browser.getCurrentUrl(); diff --git a/test/functional/apps/visualize/_lab_mode.js b/test/functional/apps/visualize/_lab_mode.js index faca1ff394b1..d852ac484eaa 100644 --- a/test/functional/apps/visualize/_lab_mode.js +++ b/test/functional/apps/visualize/_lab_mode.js @@ -53,7 +53,8 @@ export default function ({ getService, getPageObjects }) { log.info('found saved search before toggling enableLabs mode'); // Navigate to advanced setting and disable lab mode - await PageObjects.settings.navigateTo(); + await PageObjects.header.clickStackManagement(); + await PageObjects.settings.clickOpenSearchDashboardsSettings(); await PageObjects.settings.toggleAdvancedSettingCheckbox(VISUALIZE_ENABLE_LABS_SETTING); // Expect the discover still to list that saved visualization in the open list @@ -66,7 +67,8 @@ export default function ({ getService, getPageObjects }) { after(async () => { await PageObjects.discover.closeLoadSaveSearchPanel(); - await PageObjects.settings.navigateTo(); + await PageObjects.header.clickStackManagement(); + await PageObjects.settings.clickOpenSearchDashboardsSettings(); await PageObjects.settings.clearAdvancedSettings(VISUALIZE_ENABLE_LABS_SETTING); }); }); diff --git a/test/functional/apps/visualize/_tag_cloud.js b/test/functional/apps/visualize/_tag_cloud.js index 075e7fa22907..a5123434115d 100644 --- a/test/functional/apps/visualize/_tag_cloud.js +++ b/test/functional/apps/visualize/_tag_cloud.js @@ -160,6 +160,7 @@ export default function ({ getService, getPageObjects }) { describe('formatted field', function () { before(async function () { + await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.filterField(termsField); @@ -177,6 +178,7 @@ export default function ({ getService, getPageObjects }) { after(async function () { await filterBar.removeFilter(termsField); + await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.filterField(termsField); diff --git a/test/functional/config.js b/test/functional/config.js index 75c9c3f9b6fe..b862208276bf 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -102,6 +102,10 @@ export default async function ({ readConfigFile }) { management: { pathname: '/app/management', }, + /** @obsolete "management" should be instead of "settings" **/ + settings: { + pathname: '/app/management', + }, console: { pathname: '/app/dev_tools', hash: '/console', diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index 1e0106229d3d..af2bf046e3a9 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -51,19 +51,19 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider await find.clickByDisplayedLinkText(text); } async clickOpenSearchDashboardsSettings() { - await PageObjects.common.navigateToApp('settings'); + await testSubjects.click('settings'); await PageObjects.header.waitUntilLoadingHasFinished(); await testSubjects.existOrFail('managementSettingsTitle'); } async clickOpenSearchDashboardsSavedObjects() { - await PageObjects.common.navigateToApp('objects'); + await testSubjects.click('objects'); await PageObjects.savedObjects.waitTableIsLoaded(); } async clickOpenSearchDashboardsIndexPatterns() { log.debug('clickOpenSearchDashboardsIndexPatterns link'); - await PageObjects.common.navigateToApp('indexPatterns'); + await testSubjects.click('indexPatterns'); await PageObjects.header.waitUntilLoadingHasFinished(); }