From 33bb32ca1f547d46ac9f8648b388d041884f2af8 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Wed, 7 Jun 2023 14:31:20 +0800 Subject: [PATCH 001/174] setup workspace plugin project skeleton Signed-off-by: Yulong Ruan --- src/plugins/workspace/common/constants.ts | 2 + .../workspace/opensearch_dashboards.json | 10 ++++ src/plugins/workspace/public/application.tsx | 32 ++++++++++ .../workspace/public/components/routes.ts | 20 +++++++ .../public/components/workspace_app.tsx | 59 +++++++++++++++++++ .../components/workspace_creator/index.tsx | 5 ++ src/plugins/workspace/public/index.ts | 5 ++ src/plugins/workspace/public/plugin.ts | 38 ++++++++++++ 8 files changed, 171 insertions(+) create mode 100644 src/plugins/workspace/common/constants.ts create mode 100644 src/plugins/workspace/opensearch_dashboards.json create mode 100644 src/plugins/workspace/public/application.tsx create mode 100644 src/plugins/workspace/public/components/routes.ts create mode 100644 src/plugins/workspace/public/components/workspace_app.tsx create mode 100644 src/plugins/workspace/public/components/workspace_creator/index.tsx create mode 100644 src/plugins/workspace/public/index.ts create mode 100644 src/plugins/workspace/public/plugin.ts diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts new file mode 100644 index 000000000000..306fc5f3bef8 --- /dev/null +++ b/src/plugins/workspace/common/constants.ts @@ -0,0 +1,2 @@ +export const WORKSPACE_APP_ID = 'workspace'; +export const WORKSPACE_APP_NAME = 'Workspace'; diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json new file mode 100644 index 000000000000..0273a579bfcb --- /dev/null +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -0,0 +1,10 @@ +{ + "id": "workspace", + "version": "opensearchDashboards", + "server": false, + "ui": true, + "requiredPlugins": ["savedObjects"], + "requiredBundles": [ + "opensearchDashboardsReact" + ] +} diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx new file mode 100644 index 000000000000..b005aa0e87ad --- /dev/null +++ b/src/plugins/workspace/public/application.tsx @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Router } from 'react-router-dom'; + +import { AppMountParameters, CoreStart } from '../../../core/public'; +import { OpenSearchDashboardsContextProvider } from '../../../plugins/opensearch_dashboards_react/public'; +import { WorkspaceApp } from './components/workspace_app'; + +interface Service extends CoreStart {} + +export const renderApp = ( + { element, history, appBasePath }: AppMountParameters, + services: Service +) => { + ReactDOM.render( + + + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; diff --git a/src/plugins/workspace/public/components/routes.ts b/src/plugins/workspace/public/components/routes.ts new file mode 100644 index 000000000000..5155fa9a1146 --- /dev/null +++ b/src/plugins/workspace/public/components/routes.ts @@ -0,0 +1,20 @@ +import { WorkspaceCreator } from './workspace_creator'; + +export const paths = { + create: '/create', +}; + +interface RouteConfig { + path: string; + Component: React.ComponentType; + label: string; + exact?: boolean; +} + +export const ROUTES: RouteConfig[] = [ + { + path: paths.create, + Component: WorkspaceCreator, + label: 'Create', + }, +]; diff --git a/src/plugins/workspace/public/components/workspace_app.tsx b/src/plugins/workspace/public/components/workspace_app.tsx new file mode 100644 index 000000000000..9b3e7974039d --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_app.tsx @@ -0,0 +1,59 @@ +import React, { useEffect } from 'react'; +import { EuiPage, EuiPageBody } from '@elastic/eui'; +import { I18nProvider } from '@osd/i18n/react'; +import { matchPath, Route, Switch, useLocation } from 'react-router-dom'; + +import { ROUTES } from './routes'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { ChromeBreadcrumb } from '../../../../core/public'; +import { WORKSPACE_APP_NAME } from '../../common/constants'; + +export const WorkspaceApp = ({ appBasePath }: { appBasePath: string }) => { + const { + services: { chrome }, + } = useOpenSearchDashboards(); + const location = useLocation(); + + /** + * map the current pathname to breadcrumbs + */ + useEffect(() => { + let pathname = location.pathname; + const breadcrumbs: ChromeBreadcrumb[] = []; + + while (pathname !== '/') { + const matchedRoute = ROUTES.find((route) => + matchPath(pathname, { path: route.path, exact: true }) + ); + if (matchedRoute) { + if (breadcrumbs.length === 0) { + breadcrumbs.unshift({ text: matchedRoute.label }); + } else { + breadcrumbs.unshift({ + text: matchedRoute.label, + href: `${appBasePath}${matchedRoute.path}`, + }); + } + } + const pathArr = pathname.split('/'); + pathArr.pop(); + pathname = pathArr.join('/') ? pathArr.join('/') : '/'; + } + breadcrumbs.unshift({ text: WORKSPACE_APP_NAME, href: appBasePath }); + chrome?.setBreadcrumbs(breadcrumbs); + }, [appBasePath, location.pathname, chrome?.setBreadcrumbs]); + + return ( + + + + + {ROUTES.map(({ path, Component, exact }) => ( + } exact={exact ?? false} /> + ))} + + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_creator/index.tsx b/src/plugins/workspace/public/components/workspace_creator/index.tsx new file mode 100644 index 000000000000..0a1601db8966 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/index.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export const WorkspaceCreator = () => { + return
TODO
; +}; diff --git a/src/plugins/workspace/public/index.ts b/src/plugins/workspace/public/index.ts new file mode 100644 index 000000000000..817ad3ef0e1a --- /dev/null +++ b/src/plugins/workspace/public/index.ts @@ -0,0 +1,5 @@ +import { WorkspacesPlugin } from './plugin'; + +export function plugin() { + return new WorkspacesPlugin(); +} diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts new file mode 100644 index 000000000000..48253ee79a4b --- /dev/null +++ b/src/plugins/workspace/public/plugin.ts @@ -0,0 +1,38 @@ +import { i18n } from '@osd/i18n'; +import { + CoreSetup, + CoreStart, + Plugin, + AppMountParameters, + AppNavLinkStatus, +} from '../../../core/public'; +import { WORKSPACE_APP_ID } from '../common/constants'; + +export class WorkspacesPlugin implements Plugin<{}, {}> { + public setup(core: CoreSetup) { + core.application.register({ + id: WORKSPACE_APP_ID, + title: i18n.translate('workspace.settings.title', { + defaultMessage: 'Workspace', + }), + // order: 6010, + navLinkStatus: AppNavLinkStatus.hidden, + // updater$: this.appUpdater, + async mount(params: AppMountParameters) { + const { renderApp } = await import('./application'); + const [coreStart] = await core.getStartServices(); + const services = { + ...coreStart, + }; + + return renderApp(params, services); + }, + }); + + return {}; + } + + public start(core: CoreStart) { + return {}; + } +} From f7c76ebdabceb15cc817b8f6290c9747243cd71b Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Fri, 9 Jun 2023 15:58:25 +0800 Subject: [PATCH 002/174] test: add unit tests add license header Signed-off-by: Yulong Ruan --- src/plugins/workspace/common/constants.ts | 5 ++ src/plugins/workspace/public/application.tsx | 4 +- .../workspace/public/components/routes.ts | 7 ++- .../components/utils/breadcrumbs.test.ts | 50 +++++++++++++++++++ .../public/components/utils/breadcrumbs.ts | 39 +++++++++++++++ .../public/components/utils/path.test.ts | 18 +++++++ .../workspace/public/components/utils/path.ts | 17 +++++++ .../public/components/workspace_app.tsx | 35 ++++--------- .../components/workspace_creator/index.tsx | 5 ++ src/plugins/workspace/public/index.ts | 5 ++ src/plugins/workspace/public/plugin.ts | 5 ++ 11 files changed, 160 insertions(+), 30 deletions(-) create mode 100644 src/plugins/workspace/public/components/utils/breadcrumbs.test.ts create mode 100644 src/plugins/workspace/public/components/utils/breadcrumbs.ts create mode 100644 src/plugins/workspace/public/components/utils/path.test.ts create mode 100644 src/plugins/workspace/public/components/utils/path.ts diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 306fc5f3bef8..5ccad2c6a2a9 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -1,2 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + export const WORKSPACE_APP_ID = 'workspace'; export const WORKSPACE_APP_NAME = 'Workspace'; diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx index b005aa0e87ad..6715450ca693 100644 --- a/src/plugins/workspace/public/application.tsx +++ b/src/plugins/workspace/public/application.tsx @@ -11,11 +11,9 @@ import { AppMountParameters, CoreStart } from '../../../core/public'; import { OpenSearchDashboardsContextProvider } from '../../../plugins/opensearch_dashboards_react/public'; import { WorkspaceApp } from './components/workspace_app'; -interface Service extends CoreStart {} - export const renderApp = ( { element, history, appBasePath }: AppMountParameters, - services: Service + services: CoreStart ) => { ReactDOM.render( diff --git a/src/plugins/workspace/public/components/routes.ts b/src/plugins/workspace/public/components/routes.ts index 5155fa9a1146..5e47465643f5 100644 --- a/src/plugins/workspace/public/components/routes.ts +++ b/src/plugins/workspace/public/components/routes.ts @@ -1,10 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + import { WorkspaceCreator } from './workspace_creator'; export const paths = { create: '/create', }; -interface RouteConfig { +export interface RouteConfig { path: string; Component: React.ComponentType; label: string; diff --git a/src/plugins/workspace/public/components/utils/breadcrumbs.test.ts b/src/plugins/workspace/public/components/utils/breadcrumbs.test.ts new file mode 100644 index 000000000000..229fcde96055 --- /dev/null +++ b/src/plugins/workspace/public/components/utils/breadcrumbs.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createBreadcrumbsFromPath } from './breadcrumbs'; + +describe('breadcrumbs utils', () => { + const ROUTES = [ + { + path: '/create', + Component: jest.fn(), + label: 'Create', + }, + { + path: '/manage', + Component: jest.fn(), + label: 'Manage Workspaces', + }, + { + path: '/manage/access', + Component: jest.fn(), + label: 'Manage Access', + }, + ]; + + it('should create breadcrumbs with matched route', () => { + const breadcrumbs = createBreadcrumbsFromPath('/create', ROUTES, '/'); + expect(breadcrumbs).toEqual([{ href: '/', text: 'Workspace' }, { text: 'Create' }]); + }); + + it('should create breadcrumbs with only root route if path did not match any route', () => { + const breadcrumbs = createBreadcrumbsFromPath('/unknown', ROUTES, '/'); + expect(breadcrumbs).toEqual([{ href: '/', text: 'Workspace' }]); + }); + + it('should create breadcrumbs with all matched routes', () => { + const breadcrumbs = createBreadcrumbsFromPath('/manage/access', ROUTES, '/'); + expect(breadcrumbs).toEqual([ + { href: '/', text: 'Workspace' }, + { href: '/manage', text: 'Manage Workspaces' }, + { text: 'Manage Access' }, + ]); + }); + + it('should create breadcrumbs with only matched routes', () => { + const breadcrumbs = createBreadcrumbsFromPath('/manage/not-matched', ROUTES, '/'); + expect(breadcrumbs).toEqual([{ href: '/', text: 'Workspace' }, { text: 'Manage Workspaces' }]); + }); +}); diff --git a/src/plugins/workspace/public/components/utils/breadcrumbs.ts b/src/plugins/workspace/public/components/utils/breadcrumbs.ts new file mode 100644 index 000000000000..d6d302a9c6fc --- /dev/null +++ b/src/plugins/workspace/public/components/utils/breadcrumbs.ts @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { matchPath } from 'react-router-dom'; + +import { RouteConfig } from '../routes'; +import { ChromeBreadcrumb } from '../../../../../core/public'; +import { WORKSPACE_APP_NAME } from '../../../common/constants'; +import { join } from './path'; + +export const createBreadcrumbsFromPath = ( + pathname: string, + routeConfig: RouteConfig[], + appBasePath: string +): ChromeBreadcrumb[] => { + const breadcrumbs: ChromeBreadcrumb[] = []; + while (pathname !== '/') { + const matchedRoute = routeConfig.find((route) => + matchPath(pathname, { path: route.path, exact: true }) + ); + if (matchedRoute) { + if (breadcrumbs.length === 0) { + breadcrumbs.unshift({ text: matchedRoute.label }); + } else { + breadcrumbs.unshift({ + text: matchedRoute.label, + href: join(appBasePath, matchedRoute.path), + }); + } + } + const pathArr = pathname.split('/'); + pathArr.pop(); + pathname = pathArr.join('/') ? pathArr.join('/') : '/'; + } + breadcrumbs.unshift({ text: WORKSPACE_APP_NAME, href: appBasePath }); + return breadcrumbs; +}; diff --git a/src/plugins/workspace/public/components/utils/path.test.ts b/src/plugins/workspace/public/components/utils/path.test.ts new file mode 100644 index 000000000000..d8bdf361d723 --- /dev/null +++ b/src/plugins/workspace/public/components/utils/path.test.ts @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { join } from './path'; + +describe('path utils', () => { + it('should join paths', () => { + expect(join('/', '/')).toBe('/'); + expect(join('/', '/foo')).toBe('/foo'); + expect(join('foo', '/bar')).toBe('foo/bar'); + expect(join('foo', 'bar')).toBe('foo/bar'); + expect(join('foo', 'bar/baz')).toBe('foo/bar/baz'); + expect(join('/foo', 'bar/baz')).toBe('/foo/bar/baz'); + expect(join('/foo/', 'bar/baz')).toBe('/foo/bar/baz'); + }); +}); diff --git a/src/plugins/workspace/public/components/utils/path.ts b/src/plugins/workspace/public/components/utils/path.ts new file mode 100644 index 000000000000..1086a84b6d05 --- /dev/null +++ b/src/plugins/workspace/public/components/utils/path.ts @@ -0,0 +1,17 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export function join(base: string, ...paths: string[]) { + const normalized = [base] + .concat(...paths) + .join('/') + .split('/') + .filter(Boolean) + .join('/'); + if (base.startsWith('/')) { + return `/${normalized}`; + } + return normalized; +} diff --git a/src/plugins/workspace/public/components/workspace_app.tsx b/src/plugins/workspace/public/components/workspace_app.tsx index 9b3e7974039d..54c326bc551f 100644 --- a/src/plugins/workspace/public/components/workspace_app.tsx +++ b/src/plugins/workspace/public/components/workspace_app.tsx @@ -1,12 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + import React, { useEffect } from 'react'; import { EuiPage, EuiPageBody } from '@elastic/eui'; import { I18nProvider } from '@osd/i18n/react'; -import { matchPath, Route, Switch, useLocation } from 'react-router-dom'; +import { Route, Switch, useLocation } from 'react-router-dom'; import { ROUTES } from './routes'; import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; -import { ChromeBreadcrumb } from '../../../../core/public'; -import { WORKSPACE_APP_NAME } from '../../common/constants'; +import { createBreadcrumbsFromPath } from './utils/breadcrumbs'; export const WorkspaceApp = ({ appBasePath }: { appBasePath: string }) => { const { @@ -18,30 +22,9 @@ export const WorkspaceApp = ({ appBasePath }: { appBasePath: string }) => { * map the current pathname to breadcrumbs */ useEffect(() => { - let pathname = location.pathname; - const breadcrumbs: ChromeBreadcrumb[] = []; - - while (pathname !== '/') { - const matchedRoute = ROUTES.find((route) => - matchPath(pathname, { path: route.path, exact: true }) - ); - if (matchedRoute) { - if (breadcrumbs.length === 0) { - breadcrumbs.unshift({ text: matchedRoute.label }); - } else { - breadcrumbs.unshift({ - text: matchedRoute.label, - href: `${appBasePath}${matchedRoute.path}`, - }); - } - } - const pathArr = pathname.split('/'); - pathArr.pop(); - pathname = pathArr.join('/') ? pathArr.join('/') : '/'; - } - breadcrumbs.unshift({ text: WORKSPACE_APP_NAME, href: appBasePath }); + const breadcrumbs = createBreadcrumbsFromPath(location.pathname, ROUTES, appBasePath); chrome?.setBreadcrumbs(breadcrumbs); - }, [appBasePath, location.pathname, chrome?.setBreadcrumbs]); + }, [appBasePath, location.pathname, chrome]); return ( diff --git a/src/plugins/workspace/public/components/workspace_creator/index.tsx b/src/plugins/workspace/public/components/workspace_creator/index.tsx index 0a1601db8966..11a3e0feaf92 100644 --- a/src/plugins/workspace/public/components/workspace_creator/index.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/index.tsx @@ -1,3 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + import React from 'react'; export const WorkspaceCreator = () => { diff --git a/src/plugins/workspace/public/index.ts b/src/plugins/workspace/public/index.ts index 817ad3ef0e1a..9f5c720fc9d5 100644 --- a/src/plugins/workspace/public/index.ts +++ b/src/plugins/workspace/public/index.ts @@ -1,3 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + import { WorkspacesPlugin } from './plugin'; export function plugin() { diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 48253ee79a4b..13017dab8835 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -1,3 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + import { i18n } from '@osd/i18n'; import { CoreSetup, From e7d4a6223cdda80cdc7be7c37b9d5391148e6ed9 Mon Sep 17 00:00:00 2001 From: Hailong Cui Date: Fri, 9 Jun 2023 11:13:00 +0800 Subject: [PATCH 003/174] workspace template init commit Signed-off-by: Hailong Cui --- src/core/public/application/types.ts | 8 +++- src/core/public/index.ts | 1 + src/core/types/index.ts | 1 + src/core/types/workspace_template.ts | 31 +++++++++++++++ src/core/utils/default_workspace_templates.ts | 38 +++++++++++++++++++ src/core/utils/index.ts | 1 + src/plugins/dashboard/public/plugin.tsx | 2 + 7 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 src/core/types/workspace_template.ts create mode 100644 src/core/utils/default_workspace_templates.ts diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 7398aad65009..66606775a772 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -45,7 +45,7 @@ import { OverlayStart } from '../overlays'; import { PluginOpaqueId } from '../plugins'; import { IUiSettingsClient } from '../ui_settings'; import { SavedObjectsStart } from '../saved_objects'; -import { AppCategory } from '../../types'; +import { AppCategory, WorkspaceTemplate } from '../../types'; import { ScopedHistory } from './scoped_history'; /** @@ -123,6 +123,12 @@ export interface App { */ category?: AppCategory; + /** + * The template definition of features belongs to + * See {@link WorkspaceTemplate} + */ + workspaceTemplate?: WorkspaceTemplate[]; + /** * The initial status of the application. * Defaulting to `accessible` diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 03ef6b6392f9..4e1b15a96efd 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -93,6 +93,7 @@ export { PackageInfo, EnvironmentMode } from '../server/types'; /** @interal */ export { CoreContext, CoreSystem } from './core_system'; export { DEFAULT_APP_CATEGORIES } from '../utils'; +export { DEFAULT_WORKSPACE_TEMPLATES } from '../utils'; export { AppCategory, UiSettingsParams, diff --git a/src/core/types/index.ts b/src/core/types/index.ts index 9f620273e3b2..e016d7ca7527 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -35,6 +35,7 @@ export * from './core_service'; export * from './capabilities'; export * from './app_category'; +export * from './workspace_template'; export * from './ui_settings'; export * from './saved_objects'; export * from './serializable'; diff --git a/src/core/types/workspace_template.ts b/src/core/types/workspace_template.ts new file mode 100644 index 000000000000..885c826120c5 --- /dev/null +++ b/src/core/types/workspace_template.ts @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface WorkspaceTemplate { + /** + * Unique identifier for the workspace template + */ + id: string; + + /** + * Label used for workspace template name. + */ + label: string; + + /** + * The order that workspace template will be sorted in + */ + order?: number; + + /** + * Introduction of the template + */ + description: string; + + /** + * Sample screenshot image location + */ + screenshot?: string; +} diff --git a/src/core/utils/default_workspace_templates.ts b/src/core/utils/default_workspace_templates.ts new file mode 100644 index 000000000000..153fc23f790a --- /dev/null +++ b/src/core/utils/default_workspace_templates.ts @@ -0,0 +1,38 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WorkspaceTemplate } from '../types'; + +/** @internal */ +export const DEFAULT_WORKSPACE_TEMPLATES: Record = Object.freeze({ + search: { + id: 'search', + label: 'Search', + order: 1000, + description: + "Intro paragraph blur about search, key features, and why you'd want to create ana search workspace", + }, + observability: { + id: 'observability', + label: 'Observability', + order: 2000, + description: + "Intro paragraph blur about observability, key features, and why you'd want to create ana observability workspace", + }, + security_analytics: { + id: 'security_analytics', + label: 'Security Analytics', + order: 3000, + description: + "Intro paragraph blur about security analytics, key features, and why you'd want to create ana security analytics workspace", + }, + general_analysis: { + id: 'general_analysis', + label: 'General Analytics', + order: 4000, + description: + "Intro paragraph blur about analytics, key features, and why you'd want to create ana analytics workspace", + }, +}); diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index b3b6ce4aab02..797d90c18130 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 { DEFAULT_WORKSPACE_TEMPLATES } from './default_workspace_templates'; diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 956bb5a7a836..5738f8554c37 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -86,6 +86,7 @@ import { } from '../../opensearch_dashboards_legacy/public'; import { FeatureCatalogueCategory, HomePublicPluginSetup } from '../../../plugins/home/public'; import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; +import { DEFAULT_WORKSPACE_TEMPLATES } from '../../../core/public'; import { ACTION_CLONE_PANEL, @@ -372,6 +373,7 @@ export class DashboardPlugin defaultPath: `#${DashboardConstants.LANDING_PAGE_PATH}`, updater$: this.appStateUpdater, category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + workspaceTemplate: [DEFAULT_WORKSPACE_TEMPLATES.search], mount: async (params: AppMountParameters) => { const [coreStart, pluginsStart, dashboardStart] = await core.getStartServices(); this.currentHistory = params.history; From dba3c122ae6a148b88ef76fd57c3663dbb67b020 Mon Sep 17 00:00:00 2001 From: Hailong Cui Date: Fri, 9 Jun 2023 12:07:30 +0800 Subject: [PATCH 004/174] refacter workspace template into hooks Signed-off-by: Hailong Cui --- src/plugins/workspace/public/hooks.ts | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/plugins/workspace/public/hooks.ts diff --git a/src/plugins/workspace/public/hooks.ts b/src/plugins/workspace/public/hooks.ts new file mode 100644 index 000000000000..a0b13ec911be --- /dev/null +++ b/src/plugins/workspace/public/hooks.ts @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApplicationStart, PublicAppInfo } from 'opensearch-dashboards/public'; +import { WorkspaceTemplate } from '../../../core/types'; + +export function useWorkspaceTemplate(application: ApplicationStart) { + let workspaceTemplates = [] as WorkspaceTemplate[]; + const templateFeatureMap = new Map(); + + application.applications$.subscribe((applications) => + applications.forEach((app) => { + const { workspaceTemplate: templates = [] } = app; + workspaceTemplates.push(...templates); + for (const template of templates) { + const features = templateFeatureMap.get(template.id) || []; + features.push(app); + templateFeatureMap.set(template.id, features); + } + }) + ); + + workspaceTemplates.sort((a, b) => (a.order || 0) - (b.order || 0)); + workspaceTemplates = [...new Set(workspaceTemplates)]; + + return { + workspaceTemplates, + templateFeatureMap, + }; +} From 010c62ca4221867ef230fb4254b41e1d57938ad2 Mon Sep 17 00:00:00 2001 From: Hailong Cui Date: Fri, 9 Jun 2023 17:33:48 +0800 Subject: [PATCH 005/174] refacter workspace template hooks Signed-off-by: Hailong Cui --- src/core/types/workspace_template.ts | 2 +- src/plugins/workspace/public/hooks.ts | 41 ++++++++++++++------------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/core/types/workspace_template.ts b/src/core/types/workspace_template.ts index 885c826120c5..8d8f91ca3ed8 100644 --- a/src/core/types/workspace_template.ts +++ b/src/core/types/workspace_template.ts @@ -27,5 +27,5 @@ export interface WorkspaceTemplate { /** * Sample screenshot image location */ - screenshot?: string; + coverImage?: string; } diff --git a/src/plugins/workspace/public/hooks.ts b/src/plugins/workspace/public/hooks.ts index a0b13ec911be..71019c5948a2 100644 --- a/src/plugins/workspace/public/hooks.ts +++ b/src/plugins/workspace/public/hooks.ts @@ -4,29 +4,32 @@ */ import { ApplicationStart, PublicAppInfo } from 'opensearch-dashboards/public'; +import { useObservable } from 'react-use'; +import { useMemo } from 'react'; import { WorkspaceTemplate } from '../../../core/types'; export function useWorkspaceTemplate(application: ApplicationStart) { - let workspaceTemplates = [] as WorkspaceTemplate[]; - const templateFeatureMap = new Map(); + const applications = useObservable(application.applications$); - application.applications$.subscribe((applications) => - applications.forEach((app) => { - const { workspaceTemplate: templates = [] } = app; - workspaceTemplates.push(...templates); - for (const template of templates) { - const features = templateFeatureMap.get(template.id) || []; - features.push(app); - templateFeatureMap.set(template.id, features); - } - }) - ); + return useMemo(() => { + let workspaceTemplates = [] as WorkspaceTemplate[]; + const templateFeatureMap = new Map(); - workspaceTemplates.sort((a, b) => (a.order || 0) - (b.order || 0)); - workspaceTemplates = [...new Set(workspaceTemplates)]; + if (applications) { + applications.forEach((app) => { + const { workspaceTemplate: templates = [] } = app; + workspaceTemplates.push(...templates); + for (const template of templates) { + const features = templateFeatureMap.get(template.id) || []; + features.push(app); + templateFeatureMap.set(template.id, features); + } + }); - return { - workspaceTemplates, - templateFeatureMap, - }; + workspaceTemplates = [...new Set(workspaceTemplates)]; + workspaceTemplates.sort((a, b) => (a.order || 0) - (b.order || 0)); + } + + return { workspaceTemplates, templateFeatureMap }; + }, [applications]); } From 378f0778ede2781afcf175fe4a5018ae3cc59973 Mon Sep 17 00:00:00 2001 From: Hailong Cui Date: Fri, 9 Jun 2023 17:40:05 +0800 Subject: [PATCH 006/174] update coverImage comments Signed-off-by: Hailong Cui --- src/core/types/workspace_template.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/types/workspace_template.ts b/src/core/types/workspace_template.ts index 8d8f91ca3ed8..4be8c4882bf0 100644 --- a/src/core/types/workspace_template.ts +++ b/src/core/types/workspace_template.ts @@ -25,7 +25,7 @@ export interface WorkspaceTemplate { description: string; /** - * Sample screenshot image location + * template coverage image location */ coverImage?: string; } From 8f9b956b1c08c4cf039a79f71673bc4ad1538a7e Mon Sep 17 00:00:00 2001 From: SuZhoue-Joe Date: Wed, 7 Jun 2023 15:32:24 +0800 Subject: [PATCH 007/174] feature: add public/workspaces service Signed-off-by: SuZhoue-Joe --- src/core/public/application/types.ts | 3 + src/core/public/core_system.ts | 6 + src/core/public/index.ts | 3 + src/core/public/plugins/plugin_context.ts | 1 + src/core/public/workspace/index.ts | 7 + .../public/workspace/workspaces_client.ts | 167 ++++++++++++++++++ .../public/workspace/workspaces_service.ts | 22 +++ 7 files changed, 209 insertions(+) create mode 100644 src/core/public/workspace/index.ts create mode 100644 src/core/public/workspace/workspaces_client.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 66606775a772..54ed7840596f 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, WorkspaceTemplate } from '../../types'; import { ScopedHistory } from './scoped_history'; +import { WorkspacesStart } from '../workspace'; /** * Accessibility status of an application. @@ -340,6 +341,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..27f39ea57c1b 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 }; @@ -199,6 +202,7 @@ export class CoreSystem { const uiSettings = await this.uiSettings.start(); const docLinks = this.docLinks.start({ injectedMetadata }); const http = await this.http.start(); + const workspaces = await this.workspaces.start({ http }); const savedObjects = await this.savedObjects.start({ http }); const i18n = await this.i18n.start(); const fatalErrors = await this.fatalErrors.start(); @@ -242,6 +246,7 @@ export class CoreSystem { overlays, savedObjects, uiSettings, + workspaces, })); const core: InternalCoreStart = { @@ -256,6 +261,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 4e1b15a96efd..fa17f1ccc3a2 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 } from './workspace'; export type { Logos } from '../common'; export { PackageInfo, EnvironmentMode } from '../server/types'; @@ -294,6 +295,8 @@ export interface CoreStart { getInjectedVar: (name: string, defaultValue?: any) => unknown; getBranding: () => Branding; }; + /** {@link WorkspacesStart} */ + workspaces: WorkspacesStart; } export { diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index 42c40e91183f..81fcb2b34dab 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -168,5 +168,6 @@ export function createPluginStartContext< getBranding: deps.injectedMetadata.getBranding, }, fatalErrors: deps.fatalErrors, + workspaces: deps.workspaces, }; } diff --git a/src/core/public/workspace/index.ts b/src/core/public/workspace/index.ts new file mode 100644 index 000000000000..a70d91733ab6 --- /dev/null +++ b/src/core/public/workspace/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +export { WorkspacesClientContract, WorkspacesClient } from './workspaces_client'; +export { WorkspacesStart, WorkspacesService } from './workspaces_service'; +export { WorkspaceAttribute, WorkspaceFindOptions } from '../../server/types'; diff --git a/src/core/public/workspace/workspaces_client.ts b/src/core/public/workspace/workspaces_client.ts new file mode 100644 index 000000000000..893546ef1f0b --- /dev/null +++ b/src/core/public/workspace/workspaces_client.ts @@ -0,0 +1,167 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { resolve as resolveUrl } from 'url'; +import type { PublicMethodsOf } from '@osd/utility-types'; +import { HttpStart } from '../http'; +import { WorkspaceAttribute, WorkspaceFindOptions } from '.'; + +/** + * WorkspacesClientContract as implemented by the {@link WorkspacesClient} + * + * @public + */ +export type WorkspacesClientContract = PublicMethodsOf; + +const API_BASE_URL = '/api/workspaces/'; + +const join = (...uriComponents: Array) => + uriComponents + .filter((comp): comp is string => Boolean(comp)) + .map(encodeURIComponent) + .join('/'); + +/** + * Workspaces is OpenSearchDashboards's visualize mechanism allowing admins to + * organize related features + * + * @public + */ +export class WorkspacesClient { + private http: HttpStart; + /** @internal */ + constructor(http: HttpStart) { + this.http = http; + } + + private async performBulkGet(objects: Array<{ id: string }>): Promise { + const path = this.getPath(['_bulk_get']); + return this.http.fetch(path, { + method: 'POST', + body: JSON.stringify(objects), + }); + } + + private getPath(path: Array): string { + return resolveUrl(API_BASE_URL, join(...path)); + } + + /** + * Persists an workspace + * + * @param attributes + * @returns + */ + public create = (attributes: Omit): Promise => { + if (!attributes) { + return Promise.reject(new Error('requires attributes')); + } + + const path = this.getPath([]); + + return this.http.fetch(path, { + method: 'POST', + body: JSON.stringify({ + attributes, + }), + }); + }; + + /** + * Deletes a workspace + * + * @param id + * @returns + */ + public delete = (id: string): Promise<{ success: boolean }> => { + if (!id) { + return Promise.reject(new Error('requires id')); + } + + return this.http.delete(this.getPath([id]), { method: 'DELETE' }); + }; + + /** + * Search for workspaces + * + * @param {object} [options={}] + * @property {string} options.search + * @property {string} options.search_fields - see OpenSearch Simple Query String + * Query field argument for more information + * @property {integer} [options.page=1] + * @property {integer} [options.per_page=20] + * @property {array} options.fields + * @returns A find result with workspaces matching the specified search. + */ + public list = ( + options?: WorkspaceFindOptions + ): Promise< + WorkspaceAttribute & { + total: number; + perPage: number; + page: number; + } + > => { + const path = this.getPath(['_list']); + return this.http.fetch(path, { + method: 'GET', + query: options, + }); + }; + + /** + * Fetches a single workspace + * + * @param {string} id + * @returns The workspace for the given id. + */ + public get = (id: string): Promise => { + if (!id) { + return Promise.reject(new Error('requires id')); + } + + return this.performBulkGet([{ id }]).then((res) => { + if (res.length) { + return res[0]; + } + + return Promise.reject('No workspace can be found'); + }); + }; + + /** + * Updates a workspace + * + * @param {string} type + * @param {string} id + * @param {object} attributes + * @returns + */ + public update( + id: string, + attributes: Partial + ): Promise<{ + success: boolean; + }> { + if (!id || !attributes) { + return Promise.reject(new Error('requires id and attributes')); + } + + const path = this.getPath([id]); + const body = { + attributes, + }; + + return this.http + .fetch(path, { + method: 'PUT', + body: JSON.stringify(body), + }) + .then((resp: WorkspaceAttribute) => { + return { + success: true, + }; + }); + } +} diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts new file mode 100644 index 000000000000..f691ebbe3e16 --- /dev/null +++ b/src/core/public/workspace/workspaces_service.ts @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { CoreService } from 'src/core/types'; +import { WorkspacesClient, WorkspacesClientContract } from './workspaces_client'; +import { HttpStart } from '..'; + +/** + * @public + */ +export interface WorkspacesStart { + client: WorkspacesClientContract; +} + +export class WorkspacesService implements CoreService { + public async setup() {} + public async start({ http }: { http: HttpStart }): Promise { + return { client: new WorkspacesClient(http) }; + } + public async stop() {} +} From 8a319752164ddece4bd9c8dad68c16d73a7fe963 Mon Sep 17 00:00:00 2001 From: SuZhoue-Joe Date: Thu, 8 Jun 2023 16:34:15 +0800 Subject: [PATCH 008/174] feat: add interfaces for workspaces client Signed-off-by: SuZhoue-Joe --- .../public/workspace/workspaces_client.ts | 50 +++++++++++++------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/src/core/public/workspace/workspaces_client.ts b/src/core/public/workspace/workspaces_client.ts index 893546ef1f0b..1ec4e657eba9 100644 --- a/src/core/public/workspace/workspaces_client.ts +++ b/src/core/public/workspace/workspaces_client.ts @@ -4,6 +4,7 @@ */ import { resolve as resolveUrl } from 'url'; import type { PublicMethodsOf } from '@osd/utility-types'; +import { WORKSPACES_API_BASE_URL } from '../../server/types'; import { HttpStart } from '../http'; import { WorkspaceAttribute, WorkspaceFindOptions } from '.'; @@ -14,14 +15,18 @@ import { WorkspaceAttribute, WorkspaceFindOptions } from '.'; */ export type WorkspacesClientContract = PublicMethodsOf; -const API_BASE_URL = '/api/workspaces/'; - const join = (...uriComponents: Array) => uriComponents .filter((comp): comp is string => Boolean(comp)) .map(encodeURIComponent) .join('/'); +interface IResponse { + result: T; + success: boolean; + error?: string; +} + /** * Workspaces is OpenSearchDashboards's visualize mechanism allowing admins to * organize related features @@ -35,16 +40,32 @@ export class WorkspacesClient { this.http = http; } - private async performBulkGet(objects: Array<{ id: string }>): Promise { - const path = this.getPath(['_bulk_get']); - return this.http.fetch(path, { - method: 'POST', - body: JSON.stringify(objects), - }); + private getPath(path: Array): string { + return resolveUrl(`${WORKSPACES_API_BASE_URL}/`, join(...path)); } - private getPath(path: Array): string { - return resolveUrl(API_BASE_URL, join(...path)); + public async enterWorkspace(id: string): Promise> { + return { + result: false, + success: false, + error: 'Unimplement', + }; + } + + public async exitWorkspace(): Promise> { + return { + result: false, + success: false, + error: 'Unimplement', + }; + } + + public async getCurrentWorkspace(): Promise> { + return { + result: false, + success: false, + error: 'Unimplement', + }; } /** @@ -121,12 +142,9 @@ export class WorkspacesClient { return Promise.reject(new Error('requires id')); } - return this.performBulkGet([{ id }]).then((res) => { - if (res.length) { - return res[0]; - } - - return Promise.reject('No workspace can be found'); + const path = this.getPath([id]); + return this.http.fetch(path, { + method: 'GET', }); }; From 08d22f8db5bc4b7946930e3ad4b0077eaecd8a74 Mon Sep 17 00:00:00 2001 From: SuZhoue-Joe Date: Thu, 8 Jun 2023 16:48:24 +0800 Subject: [PATCH 009/174] feat: add interfaces for workspaces client Signed-off-by: SuZhoue-Joe --- .../public/workspace/workspaces_client.ts | 53 +++++++++---------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/src/core/public/workspace/workspaces_client.ts b/src/core/public/workspace/workspaces_client.ts index 1ec4e657eba9..076f14d4696e 100644 --- a/src/core/public/workspace/workspaces_client.ts +++ b/src/core/public/workspace/workspaces_client.ts @@ -21,11 +21,15 @@ const join = (...uriComponents: Array) => .map(encodeURIComponent) .join('/'); -interface IResponse { - result: T; - success: boolean; - error?: string; -} +type IResponse = + | { + result: T; + success: true; + } + | { + success: false; + error?: string; + }; /** * Workspaces is OpenSearchDashboards's visualize mechanism allowing admins to @@ -35,7 +39,6 @@ interface IResponse { */ export class WorkspacesClient { private http: HttpStart; - /** @internal */ constructor(http: HttpStart) { this.http = http; } @@ -44,25 +47,22 @@ export class WorkspacesClient { return resolveUrl(`${WORKSPACES_API_BASE_URL}/`, join(...path)); } - public async enterWorkspace(id: string): Promise> { + public async enterWorkspace(id: string): Promise> { return { - result: false, success: false, error: 'Unimplement', }; } - public async exitWorkspace(): Promise> { + public async exitWorkspace(): Promise> { return { - result: false, success: false, error: 'Unimplement', }; } - public async getCurrentWorkspace(): Promise> { + public async getCurrentWorkspace(): Promise> { return { - result: false, success: false, error: 'Unimplement', }; @@ -74,7 +74,9 @@ export class WorkspacesClient { * @param attributes * @returns */ - public create = (attributes: Omit): Promise => { + public create = ( + attributes: Omit + ): Promise> => { if (!attributes) { return Promise.reject(new Error('requires attributes')); } @@ -95,7 +97,7 @@ export class WorkspacesClient { * @param id * @returns */ - public delete = (id: string): Promise<{ success: boolean }> => { + public delete = (id: string): Promise> => { if (!id) { return Promise.reject(new Error('requires id')); } @@ -118,11 +120,13 @@ export class WorkspacesClient { public list = ( options?: WorkspaceFindOptions ): Promise< - WorkspaceAttribute & { - total: number; - perPage: number; - page: number; - } + IResponse< + WorkspaceAttribute & { + total: number; + perPage: number; + page: number; + } + > > => { const path = this.getPath(['_list']); return this.http.fetch(path, { @@ -137,7 +141,7 @@ export class WorkspacesClient { * @param {string} id * @returns The workspace for the given id. */ - public get = (id: string): Promise => { + public get = (id: string): Promise> => { if (!id) { return Promise.reject(new Error('requires id')); } @@ -151,17 +155,11 @@ export class WorkspacesClient { /** * Updates a workspace * - * @param {string} type * @param {string} id * @param {object} attributes * @returns */ - public update( - id: string, - attributes: Partial - ): Promise<{ - success: boolean; - }> { + public update(id: string, attributes: Partial): Promise> { if (!id || !attributes) { return Promise.reject(new Error('requires id and attributes')); } @@ -178,6 +176,7 @@ export class WorkspacesClient { }) .then((resp: WorkspaceAttribute) => { return { + result: true, success: true, }; }); From 26d3783ab5c8500135f93cb97c00da43b0c34ffb Mon Sep 17 00:00:00 2001 From: SuZhoue-Joe Date: Thu, 8 Jun 2023 16:54:34 +0800 Subject: [PATCH 010/174] feat: add interfaces for workspaces client Signed-off-by: SuZhoue-Joe --- src/core/public/workspace/workspaces_client.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/core/public/workspace/workspaces_client.ts b/src/core/public/workspace/workspaces_client.ts index 076f14d4696e..110a61ca1189 100644 --- a/src/core/public/workspace/workspaces_client.ts +++ b/src/core/public/workspace/workspaces_client.ts @@ -61,6 +61,13 @@ export class WorkspacesClient { }; } + public async getCurrentWorkspaceId(): Promise> { + return { + success: false, + error: 'Unimplement', + }; + } + public async getCurrentWorkspace(): Promise> { return { success: false, From fc4437fba5b8961bc361b43f8126facc0a4ced5a Mon Sep 17 00:00:00 2001 From: SuZhoue-Joe Date: Mon, 12 Jun 2023 12:00:48 +0800 Subject: [PATCH 011/174] feat: implement workspaces service Signed-off-by: SuZhoue-Joe --- src/core/server/internal_types.ts | 3 + src/core/server/server.ts | 14 ++ src/core/server/types.ts | 1 + src/core/server/workspaces/index.ts | 13 ++ src/core/server/workspaces/routes/index.ts | 146 ++++++++++++++++ .../server/workspaces/saved_objects/index.ts | 6 + .../workspaces/saved_objects/workspace.ts | 46 +++++ src/core/server/workspaces/types.ts | 71 ++++++++ .../workspaces_client_with_saved_object.ts | 157 ++++++++++++++++++ .../server/workspaces/workspaces_service.ts | 74 +++++++++ 10 files changed, 531 insertions(+) create mode 100644 src/core/server/workspaces/index.ts create mode 100644 src/core/server/workspaces/routes/index.ts create mode 100644 src/core/server/workspaces/saved_objects/index.ts create mode 100644 src/core/server/workspaces/saved_objects/workspace.ts create mode 100644 src/core/server/workspaces/types.ts create mode 100644 src/core/server/workspaces/workspaces_client_with_saved_object.ts create mode 100644 src/core/server/workspaces/workspaces_service.ts diff --git a/src/core/server/internal_types.ts b/src/core/server/internal_types.ts index 2b4df7da68bf..0eb0b684d4f4 100644 --- a/src/core/server/internal_types.ts +++ b/src/core/server/internal_types.ts @@ -48,6 +48,7 @@ import { InternalStatusServiceSetup } from './status'; import { AuditTrailSetup, AuditTrailStart } from './audit_trail'; import { InternalLoggingServiceSetup } from './logging'; import { CoreUsageDataStart } from './core_usage_data'; +import { InternalWorkspacesServiceSetup, InternalWorkspacesServiceStart } from './workspaces'; /** @internal */ export interface InternalCoreSetup { @@ -64,6 +65,7 @@ export interface InternalCoreSetup { auditTrail: AuditTrailSetup; logging: InternalLoggingServiceSetup; metrics: InternalMetricsServiceSetup; + workspaces: InternalWorkspacesServiceSetup; } /** @@ -78,6 +80,7 @@ export interface InternalCoreStart { uiSettings: InternalUiSettingsServiceStart; auditTrail: AuditTrailStart; coreUsageData: CoreUsageDataStart; + workspaces: InternalWorkspacesServiceStart; } /** diff --git a/src/core/server/server.ts b/src/core/server/server.ts index d4c041725ac7..99c79351d861 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -62,6 +62,7 @@ import { RequestHandlerContext } from '.'; import { InternalCoreSetup, InternalCoreStart, ServiceConfigDescriptor } from './internal_types'; import { CoreUsageDataService } from './core_usage_data'; import { CoreRouteHandlerContext } from './core_route_handler_context'; +import { WorkspacesService } from './workspaces'; const coreId = Symbol('core'); const rootConfigPath = ''; @@ -86,6 +87,7 @@ export class Server { private readonly coreApp: CoreApp; private readonly auditTrail: AuditTrailService; private readonly coreUsageData: CoreUsageDataService; + private readonly workspaces: WorkspacesService; #pluginsInitialized?: boolean; private coreStart?: InternalCoreStart; @@ -118,6 +120,7 @@ export class Server { this.auditTrail = new AuditTrailService(core); this.logging = new LoggingService(core); this.coreUsageData = new CoreUsageDataService(core); + this.workspaces = new WorkspacesService(core); } public async setup() { @@ -172,6 +175,11 @@ export class Server { const metricsSetup = await this.metrics.setup({ http: httpSetup }); + const workspacesSetup = await this.workspaces.setup({ + http: httpSetup, + savedObject: savedObjectsSetup, + }); + const statusSetup = await this.status.setup({ opensearch: opensearchServiceSetup, pluginDependencies: pluginTree.asNames, @@ -212,6 +220,7 @@ export class Server { auditTrail: auditTrailSetup, logging: loggingSetup, metrics: metricsSetup, + workspaces: workspacesSetup, }; const pluginsSetup = await this.plugins.setup(coreSetup); @@ -253,6 +262,9 @@ export class Server { opensearch: opensearchStart, savedObjects: savedObjectsStart, }); + const workspacesStart = await this.workspaces.start({ + savedObjects: savedObjectsStart, + }); this.coreStart = { capabilities: capabilitiesStart, @@ -263,6 +275,7 @@ export class Server { uiSettings: uiSettingsStart, auditTrail: auditTrailStart, coreUsageData: coreUsageDataStart, + workspaces: workspacesStart, }; const pluginsStart = await this.plugins.start(this.coreStart); @@ -295,6 +308,7 @@ export class Server { await this.status.stop(); await this.logging.stop(); await this.auditTrail.stop(); + await this.workspaces.stop(); } private registerCoreContext(coreSetup: InternalCoreSetup) { diff --git a/src/core/server/types.ts b/src/core/server/types.ts index 90ccef575807..f6e54c201dae 100644 --- a/src/core/server/types.ts +++ b/src/core/server/types.ts @@ -35,3 +35,4 @@ export * from './ui_settings/types'; export * from './legacy/types'; export type { EnvironmentMode, PackageInfo } from '@osd/config'; export { Branding } from '../../core/types'; +export * from './workspaces/types'; diff --git a/src/core/server/workspaces/index.ts b/src/core/server/workspaces/index.ts new file mode 100644 index 000000000000..838f216bbd86 --- /dev/null +++ b/src/core/server/workspaces/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +export { + WorkspacesService, + InternalWorkspacesServiceSetup, + WorkspacesServiceStart, + WorkspacesServiceSetup, + InternalWorkspacesServiceStart, +} from './workspaces_service'; + +export { WorkspaceAttribute, WorkspaceFindOptions, WORKSPACES_API_BASE_URL } from './types'; diff --git a/src/core/server/workspaces/routes/index.ts b/src/core/server/workspaces/routes/index.ts new file mode 100644 index 000000000000..dbd7b20809e2 --- /dev/null +++ b/src/core/server/workspaces/routes/index.ts @@ -0,0 +1,146 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { schema } from '@osd/config-schema'; +import { InternalHttpServiceSetup } from '../../http'; +import { Logger } from '../../logging'; +import { IWorkspaceDBImpl, WORKSPACES_API_BASE_URL } from '../types'; + +export function registerRoutes({ + client, + logger, + http, +}: { + client: IWorkspaceDBImpl; + logger: Logger; + http: InternalHttpServiceSetup; +}) { + const router = http.createRouter(WORKSPACES_API_BASE_URL); + router.get( + { + path: '/_list', + validate: { + query: schema.object({ + per_page: schema.number({ min: 0, defaultValue: 20 }), + page: schema.number({ min: 0, defaultValue: 1 }), + sort_field: schema.maybe(schema.string()), + fields: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const result = await client.list( + { + context, + request: req, + logger, + }, + req.query + ); + return res.ok({ body: result }); + }) + ); + router.get( + { + path: '/{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 + ); + return res.ok({ body: result }); + }) + ); + router.post( + { + path: '/{id?}', + validate: { + body: schema.object({ + attributes: schema.object({ + description: schema.maybe(schema.string()), + name: schema.string(), + }), + }), + }, + }, + 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: '/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + attributes: schema.object({ + description: schema.maybe(schema.string()), + name: schema.string(), + }), + }), + }, + }, + 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: '/{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/core/server/workspaces/saved_objects/index.ts b/src/core/server/workspaces/saved_objects/index.ts new file mode 100644 index 000000000000..51653c50681e --- /dev/null +++ b/src/core/server/workspaces/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/core/server/workspaces/saved_objects/workspace.ts b/src/core/server/workspaces/saved_objects/workspace.ts new file mode 100644 index 000000000000..d211aaa3ea93 --- /dev/null +++ b/src/core/server/workspaces/saved_objects/workspace.ts @@ -0,0 +1,46 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsType } from 'opensearch-dashboards/server'; + +export const workspace: SavedObjectsType = { + name: 'workspace', + namespaceType: 'agnostic', + hidden: false, + management: { + icon: 'apps', // todo: pending ux #2034 + defaultSearchField: 'title', + importableAndExportable: true, + getTitle(obj) { + return obj.attributes.title; + }, + getEditUrl(obj) { + return `/management/opensearch-dashboards/dataSources/${encodeURIComponent(obj.id)}`; + }, + getInAppUrl(obj) { + return { + path: `/app/management/opensearch-dashboards/dataSources/${encodeURIComponent(obj.id)}`, + uiCapabilitiesPath: 'management.opensearchDashboards.dataSources', + }; + }, + }, + mappings: { + dynamic: false, + properties: { + title: { + type: 'text', + }, + description: { + type: 'text', + }, + /** + * In opensearch, string[] is also mapped to text + */ + features: { + type: 'text', + }, + }, + }, +}; diff --git a/src/core/server/workspaces/types.ts b/src/core/server/workspaces/types.ts new file mode 100644 index 000000000000..cc24797605e2 --- /dev/null +++ b/src/core/server/workspaces/types.ts @@ -0,0 +1,71 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { + Logger, + OpenSearchDashboardsRequest, + RequestHandlerContext, + SavedObjectsFindResponse, +} from '..'; +import { WorkspacesSetupDeps } from './workspaces_service'; + +export interface WorkspaceAttribute { + id: string; + name: string; + description?: string; + features?: string[]; +} + +export interface WorkspaceFindOptions { + page?: number; + per_page?: number; + search?: string; + search_fields?: string[]; + sort_field?: string; + sort_order?: string; +} + +export interface IRequestDetail { + request: OpenSearchDashboardsRequest; + context: RequestHandlerContext; + logger: Logger; +} + +export interface IWorkspaceDBImpl { + setup(dep: WorkspacesSetupDeps): Promise>; + 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; + }; + +export const WORKSPACES_API_BASE_URL = '/api/workspaces'; diff --git a/src/core/server/workspaces/workspaces_client_with_saved_object.ts b/src/core/server/workspaces/workspaces_client_with_saved_object.ts new file mode 100644 index 000000000000..058d7477efd4 --- /dev/null +++ b/src/core/server/workspaces/workspaces_client_with_saved_object.ts @@ -0,0 +1,157 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { SavedObject, SavedObjectError, SavedObjectsClientContract } from '../types'; +import { + IWorkspaceDBImpl, + WorkspaceAttribute, + WorkspaceFindOptions, + IResponse, + IRequestDetail, +} from './types'; +import { WorkspacesSetupDeps } from './workspaces_service'; +import { workspace } from './saved_objects'; + +export const WORKSPACES_TYPE_FOR_SAVED_OBJECT = 'workspace'; + +export class WorkspacesClientWithSavedObject implements IWorkspaceDBImpl { + private setupDep: WorkspacesSetupDeps; + constructor(dep: WorkspacesSetupDeps) { + this.setupDep = dep; + } + private getSavedObjectClientsFromRequestDetail( + requestDetail: IRequestDetail + ): SavedObjectsClientContract { + return requestDetail.context.core.savedObjects.client; + } + private getFlatternedResultWithSavedObject( + savedObject: SavedObject + ): WorkspaceAttribute { + return { + ...savedObject.attributes, + id: savedObject.id, + }; + } + private formatError(error: SavedObjectError | Error | any): string { + return error.message || error.error || 'Error'; + } + public async setup(dep: WorkspacesSetupDeps): Promise> { + this.setupDep.savedObject.registerType(workspace); + return { + success: true, + result: true, + }; + } + public async create( + requestDetail: IRequestDetail, + payload: Omit + ): ReturnType { + try { + const result = await this.getSavedObjectClientsFromRequestDetail(requestDetail).create< + Omit + >(WORKSPACES_TYPE_FOR_SAVED_OBJECT, payload); + 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: WORKSPACES_TYPE_FOR_SAVED_OBJECT, + } + ); + return { + success: true, + result: { + ...others, + workspaces: savedObjects.map((item) => this.getFlatternedResultWithSavedObject(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 + >(WORKSPACES_TYPE_FOR_SAVED_OBJECT, id); + return { + success: true, + result: this.getFlatternedResultWithSavedObject(result), + }; + } catch (e: unknown) { + return { + success: false, + error: this.formatError(e), + }; + } + } + public async update( + requestDetail: IRequestDetail, + id: string, + payload: Omit + ): Promise> { + try { + await this.getSavedObjectClientsFromRequestDetail(requestDetail).update< + Omit + >(WORKSPACES_TYPE_FOR_SAVED_OBJECT, id, payload); + 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( + WORKSPACES_TYPE_FOR_SAVED_OBJECT, + 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, + }; + } +} diff --git a/src/core/server/workspaces/workspaces_service.ts b/src/core/server/workspaces/workspaces_service.ts new file mode 100644 index 000000000000..15c150b7378a --- /dev/null +++ b/src/core/server/workspaces/workspaces_service.ts @@ -0,0 +1,74 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { CoreService } from '../../types'; +import { CoreContext } from '../core_context'; +import { InternalHttpServiceSetup } from '../http'; +import { Logger } from '../logging'; +import { registerRoutes } from './routes'; +import { + InternalSavedObjectsServiceSetup, + InternalSavedObjectsServiceStart, +} from '../saved_objects'; +import { IWorkspaceDBImpl } from './types'; +import { WorkspacesClientWithSavedObject } from './workspaces_client_with_saved_object'; + +export interface WorkspacesServiceSetup { + setWorkspacesClient: (client: IWorkspaceDBImpl) => void; +} + +export interface WorkspacesServiceStart { + client: IWorkspaceDBImpl; +} + +export interface WorkspacesSetupDeps { + http: InternalHttpServiceSetup; + savedObject: InternalSavedObjectsServiceSetup; +} + +export type InternalWorkspacesServiceSetup = WorkspacesServiceSetup; +export type InternalWorkspacesServiceStart = WorkspacesServiceStart; + +/** @internal */ +export interface WorkspacesStartDeps { + savedObjects: InternalSavedObjectsServiceStart; +} + +export class WorkspacesService + implements CoreService { + private logger: Logger; + private client?: IWorkspaceDBImpl; + constructor(private readonly coreContext: CoreContext) { + this.logger = coreContext.logger.get('workspaces-service'); + } + + public async setup(setupDeps: WorkspacesSetupDeps): Promise { + this.logger.debug('Setting up Workspaces service'); + + this.client = this.client || new WorkspacesClientWithSavedObject(setupDeps); + await this.client.setup(setupDeps); + + registerRoutes({ + http: setupDeps.http, + logger: this.logger, + client: this.client as IWorkspaceDBImpl, + }); + + return { + setWorkspacesClient: (client: IWorkspaceDBImpl) => { + this.client = client; + }, + }; + } + + public async start(deps: WorkspacesStartDeps): Promise { + this.logger.debug('Starting SavedObjects service'); + + return { + client: this.client as IWorkspaceDBImpl, + }; + } + + public async stop() {} +} From c96d139d2f18986326ef1dd121df4a0ef23e3ba9 Mon Sep 17 00:00:00 2001 From: SuZhoue-Joe Date: Tue, 13 Jun 2023 08:27:20 +0800 Subject: [PATCH 012/174] feat: changes to client type interface Signed-off-by: SuZhoue-Joe --- src/core/public/workspace/workspaces_client.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/core/public/workspace/workspaces_client.ts b/src/core/public/workspace/workspaces_client.ts index 110a61ca1189..fefdd261d544 100644 --- a/src/core/public/workspace/workspaces_client.ts +++ b/src/core/public/workspace/workspaces_client.ts @@ -127,13 +127,12 @@ export class WorkspacesClient { public list = ( options?: WorkspaceFindOptions ): Promise< - IResponse< - WorkspaceAttribute & { - total: number; - perPage: number; - page: number; - } - > + IResponse<{ + workspaces: WorkspaceAttribute[]; + total: number; + per_page: number; + page: number; + }> > => { const path = this.getPath(['_list']); return this.http.fetch(path, { From ba26ff1c2869d182073cd0677951c9819dbafee8 Mon Sep 17 00:00:00 2001 From: SuZhoue-Joe Date: Tue, 13 Jun 2023 08:47:25 +0800 Subject: [PATCH 013/174] feat: changes to client implement Signed-off-by: SuZhoue-Joe --- src/core/public/workspace/workspaces_client.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/core/public/workspace/workspaces_client.ts b/src/core/public/workspace/workspaces_client.ts index fefdd261d544..fbb8047c9b3b 100644 --- a/src/core/public/workspace/workspaces_client.ts +++ b/src/core/public/workspace/workspaces_client.ts @@ -175,16 +175,9 @@ export class WorkspacesClient { attributes, }; - return this.http - .fetch(path, { - method: 'PUT', - body: JSON.stringify(body), - }) - .then((resp: WorkspaceAttribute) => { - return { - result: true, - success: true, - }; - }); + return this.http.fetch(path, { + method: 'PUT', + body: JSON.stringify(body), + }); } } From 93882ad9c0b7505a15cec6922a4b0dbaeba4300b Mon Sep 17 00:00:00 2001 From: SuZhoue-Joe Date: Tue, 13 Jun 2023 13:43:59 +0800 Subject: [PATCH 014/174] feat: implement more for workspaces service Signed-off-by: SuZhoue-Joe --- .../public/workspace/workspaces_client.ts | 39 ++++++---- src/core/server/workspaces/routes/index.ts | 76 ++++++++++++++++++- src/core/server/workspaces/types.ts | 2 + 3 files changed, 100 insertions(+), 17 deletions(-) diff --git a/src/core/public/workspace/workspaces_client.ts b/src/core/public/workspace/workspaces_client.ts index fbb8047c9b3b..e275166f9139 100644 --- a/src/core/public/workspace/workspaces_client.ts +++ b/src/core/public/workspace/workspaces_client.ts @@ -48,31 +48,38 @@ export class WorkspacesClient { } public async enterWorkspace(id: string): Promise> { - return { - success: false, - error: 'Unimplement', - }; + return this.http.post(this.getPath(['_enter', id])); } public async exitWorkspace(): Promise> { - return { - success: false, - error: 'Unimplement', - }; + return this.http.post(this.getPath(['_exit'])); } public async getCurrentWorkspaceId(): Promise> { - return { - success: false, - error: 'Unimplement', - }; + const currentWorkspaceIdResp = await this.http.get(this.getPath(['_current'])); + if (currentWorkspaceIdResp.success) { + if (!currentWorkspaceIdResp.result) { + return { + success: false, + error: 'You are not in any workspace yet.', + }; + } + } + + return currentWorkspaceIdResp; } public async getCurrentWorkspace(): Promise> { - return { - success: false, - error: 'Unimplement', - }; + const currentWorkspaceIdResp = await this.getCurrentWorkspaceId(); + if (currentWorkspaceIdResp.success) { + const currentWorkspaceResp = await this.get(currentWorkspaceIdResp.result); + return currentWorkspaceResp; + } else { + return { + success: false, + error: currentWorkspaceIdResp.error || '', + }; + } } /** diff --git a/src/core/server/workspaces/routes/index.ts b/src/core/server/workspaces/routes/index.ts index dbd7b20809e2..6a0422e2aa30 100644 --- a/src/core/server/workspaces/routes/index.ts +++ b/src/core/server/workspaces/routes/index.ts @@ -5,7 +5,13 @@ import { schema } from '@osd/config-schema'; import { InternalHttpServiceSetup } from '../../http'; import { Logger } from '../../logging'; -import { IWorkspaceDBImpl, WORKSPACES_API_BASE_URL } from '../types'; +import { IWorkspaceDBImpl, WORKSPACES_API_BASE_URL, WORKSPACE_ID_COOKIE_NAME } from '../types'; + +function getCookieValue(cookieString: string, cookieName: string): string | null { + const regex = new RegExp(`(?:(?:^|.*;\\s*)${cookieName}\\s*\\=\\s*([^;]*).*$)|^.*$`); + const match = cookieString.match(regex); + return match ? match[1] : null; +} export function registerRoutes({ client, @@ -143,4 +149,72 @@ export function registerRoutes({ return res.ok({ body: result }); }) ); + router.post( + { + path: '/_enter/{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.custom({ + body: { + success: true, + }, + statusCode: 200, + headers: { + 'set-cookie': `${WORKSPACE_ID_COOKIE_NAME}=${id}; Path=/`, + }, + }); + } else { + return res.ok({ body: result }); + } + }) + ); + + router.post( + { + path: '/_exit', + validate: {}, + }, + router.handleLegacyErrors(async (context, req, res) => { + return res.custom({ + body: { + success: true, + }, + statusCode: 200, + headers: { + 'set-cookie': `${WORKSPACE_ID_COOKIE_NAME}=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/`, + }, + }); + }) + ); + + router.get( + { + path: '/_current', + validate: {}, + }, + router.handleLegacyErrors(async (context, req, res) => { + return res.ok({ + body: { + success: true, + result: getCookieValue(req.headers.cookie as string, WORKSPACE_ID_COOKIE_NAME), + }, + }); + }) + ); } diff --git a/src/core/server/workspaces/types.ts b/src/core/server/workspaces/types.ts index cc24797605e2..fc3cae861c77 100644 --- a/src/core/server/workspaces/types.ts +++ b/src/core/server/workspaces/types.ts @@ -69,3 +69,5 @@ export type IResponse = }; export const WORKSPACES_API_BASE_URL = '/api/workspaces'; + +export const WORKSPACE_ID_COOKIE_NAME = 'trinity_workspace_id'; From aa0d70acbda0ea53f16d1f986636eecdc18c176c Mon Sep 17 00:00:00 2001 From: SuZhoue-Joe Date: Tue, 13 Jun 2023 15:14:23 +0800 Subject: [PATCH 015/174] feat: implement more for workspaces service Signed-off-by: SuZhoue-Joe --- src/core/public/workspace/workspaces_client.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/core/public/workspace/workspaces_client.ts b/src/core/public/workspace/workspaces_client.ts index e275166f9139..6b369517b366 100644 --- a/src/core/public/workspace/workspaces_client.ts +++ b/src/core/public/workspace/workspaces_client.ts @@ -57,13 +57,11 @@ export class WorkspacesClient { public async getCurrentWorkspaceId(): Promise> { const currentWorkspaceIdResp = await this.http.get(this.getPath(['_current'])); - if (currentWorkspaceIdResp.success) { - if (!currentWorkspaceIdResp.result) { - return { - success: false, - error: 'You are not in any workspace yet.', - }; - } + if (currentWorkspaceIdResp.success && !currentWorkspaceIdResp.result) { + return { + success: false, + error: 'You are not in any workspace yet.', + }; } return currentWorkspaceIdResp; @@ -75,10 +73,7 @@ export class WorkspacesClient { const currentWorkspaceResp = await this.get(currentWorkspaceIdResp.result); return currentWorkspaceResp; } else { - return { - success: false, - error: currentWorkspaceIdResp.error || '', - }; + return currentWorkspaceIdResp; } } From 8fb8972976631a38c1966808049b09153cbebc8c Mon Sep 17 00:00:00 2001 From: SuZhoue-Joe Date: Tue, 13 Jun 2023 17:54:57 +0800 Subject: [PATCH 016/174] feat: implement more for workspaces service Signed-off-by: SuZhoue-Joe --- src/core/public/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/core/public/index.ts b/src/core/public/index.ts index fa17f1ccc3a2..4251219608f2 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -345,3 +345,12 @@ export { }; export { __osdBootstrap__ } from './osd_bootstrap'; + +export { + WorkspacesClientContract, + WorkspacesClient, + WorkspacesStart, + WorkspacesService, + WorkspaceAttribute, + WorkspaceFindOptions, +} from './workspace'; From c6c7cf5de779cf01a5aa456419fba7ca5521b12c Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Wed, 14 Jun 2023 14:43:01 +0800 Subject: [PATCH 017/174] feat: add workspace creator page (#5) * feat: add workspace creator page Signed-off-by: Lin Wang * feat: integrate with application workspace template Signed-off-by: Lin Wang * feat: add max-width and remove image wrapper if not exists Signed-off-by: Lin Wang * feat: update filter condition to align with collapsible nav Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang --- .../components/workspace_creator/index.tsx | 6 +- .../workspace_creator/workspace_creator.tsx | 39 ++ .../workspace_creator/workspace_form.tsx | 338 ++++++++++++++++++ src/plugins/workspace/public/hooks.ts | 11 + 4 files changed, 389 insertions(+), 5 deletions(-) create mode 100644 src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx create mode 100644 src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx diff --git a/src/plugins/workspace/public/components/workspace_creator/index.tsx b/src/plugins/workspace/public/components/workspace_creator/index.tsx index 11a3e0feaf92..c8cdbfab65be 100644 --- a/src/plugins/workspace/public/components/workspace_creator/index.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/index.tsx @@ -3,8 +3,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; - -export const WorkspaceCreator = () => { - return
TODO
; -}; +export { WorkspaceCreator } from './workspace_creator'; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx new file mode 100644 index 000000000000..e7006464ba7d --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; +import { EuiPage, EuiPageBody, EuiPageHeader, EuiPageContent } from '@elastic/eui'; + +import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public'; + +import { WorkspaceForm } from './workspace_form'; + +export const WorkspaceCreator = () => { + const { + services: { application }, + } = useOpenSearchDashboards(); + + const handleWorkspaceFormSubmit = useCallback(() => {}, []); + + return ( + + + + + {application && ( + + )} + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx new file mode 100644 index 000000000000..8cb3a1e3c39d --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx @@ -0,0 +1,338 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useState, FormEventHandler, useRef, useMemo } from 'react'; +import { groupBy } from 'lodash'; +import { + EuiPanel, + EuiSpacer, + EuiTitle, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiText, + EuiButton, + EuiFlexItem, + EuiCheckableCard, + htmlIdGenerator, + EuiFlexGrid, + EuiFlexGroup, + EuiImage, + EuiAccordion, + EuiCheckbox, + EuiCheckboxGroup, + EuiCheckableCardProps, + EuiCheckboxGroupProps, + EuiCheckboxProps, + EuiFieldTextProps, +} from '@elastic/eui'; + +import { WorkspaceTemplate } from '../../../../../core/types'; +import { AppNavLinkStatus, ApplicationStart } from '../../../../../core/public'; +import { useApplications, useWorkspaceTemplate } from '../../hooks'; + +interface WorkspaceFeature { + id: string; + name: string; + templates: WorkspaceTemplate[]; +} + +interface WorkspaceFeatureGroup { + name: string; + features: WorkspaceFeature[]; +} + +interface WorkspaceFormData { + name: string; + description?: string; + features: string[]; +} + +type WorkspaceFormErrors = { [key in keyof WorkspaceFormData]?: string }; + +const isWorkspaceFeatureGroup = ( + featureOrGroup: WorkspaceFeature | WorkspaceFeatureGroup +): featureOrGroup is WorkspaceFeatureGroup => 'features' in featureOrGroup; + +const workspaceHtmlIdGenerator = htmlIdGenerator(); + +interface WorkspaceFormProps { + application: ApplicationStart; + onSubmit?: (formData: WorkspaceFormData) => void; + defaultValues?: WorkspaceFormData; +} +export const WorkspaceForm = ({ application, onSubmit, defaultValues }: WorkspaceFormProps) => { + const { workspaceTemplates, templateFeatureMap } = useWorkspaceTemplate(application); + const applications = useApplications(application); + + const [name, setName] = useState(defaultValues?.name); + const [description, setDescription] = useState(defaultValues?.description); + const [selectedTemplateId, setSelectedTemplateId] = useState(); + const [selectedFeatureIds, setSelectedFeatureIds] = useState(defaultValues?.features || []); + const selectedTemplate = workspaceTemplates.find( + (template) => template.id === selectedTemplateId + ); + const [formErrors, setFormErrors] = useState({}); + const formIdRef = useRef(); + const getFormData = () => ({ + name, + description, + features: selectedFeatureIds, + }); + const getFormDataRef = useRef(getFormData); + getFormDataRef.current = getFormData; + + const featureOrGroups = useMemo(() => { + const category2Applications = groupBy(applications, 'category.label'); + return Object.keys(category2Applications).reduce< + Array + >((previousValue, currentKey) => { + const apps = category2Applications[currentKey]; + const features = apps + .filter( + ({ navLinkStatus, chromeless }) => + navLinkStatus !== AppNavLinkStatus.hidden && !chromeless + ) + .map(({ id, title, workspaceTemplate }) => ({ + id, + name: title, + templates: workspaceTemplate || [], + })); + if (features.length === 1 || currentKey === 'undefined') { + return [...previousValue, ...features]; + } + return [ + ...previousValue, + { + name: apps[0].category?.label || '', + features, + }, + ]; + }, []); + }, [applications]); + + if (!formIdRef.current) { + formIdRef.current = workspaceHtmlIdGenerator(); + } + + const handleTemplateCardChange = useCallback( + (e) => { + const templateId = e.target.value; + setSelectedTemplateId(templateId); + setSelectedFeatureIds( + featureOrGroups.reduce( + (previousData, currentData) => [ + ...previousData, + ...(isWorkspaceFeatureGroup(currentData) ? currentData.features : [currentData]) + .filter(({ templates }) => !!templates.find((template) => template.id === templateId)) + .map((feature) => feature.id), + ], + [] + ) + ); + }, + [featureOrGroups] + ); + + const handleFeatureChange = useCallback((featureId) => { + setSelectedFeatureIds((previousData) => + previousData.includes(featureId) + ? previousData.filter((id) => id !== featureId) + : [...previousData, featureId] + ); + }, []); + + const handleFeatureCheckboxChange = useCallback( + (e) => { + handleFeatureChange(e.target.id); + }, + [handleFeatureChange] + ); + + const handleFeatureGroupChange = useCallback( + (e) => { + for (const featureOrGroup of featureOrGroups) { + if (isWorkspaceFeatureGroup(featureOrGroup) && featureOrGroup.name === e.target.id) { + const groupFeatureIds = featureOrGroup.features.map((feature) => feature.id); + setSelectedFeatureIds((previousData) => { + const notExistsIds = groupFeatureIds.filter((id) => !previousData.includes(id)); + if (notExistsIds.length > 0) { + return [...previousData, ...notExistsIds]; + } + return previousData.filter((id) => !groupFeatureIds.includes(id)); + }); + } + } + }, + [featureOrGroups] + ); + + const handleFormSubmit = useCallback( + (e) => { + e.preventDefault(); + const formData = getFormDataRef.current(); + if (!formData.name) { + setFormErrors({ name: "Name can't be empty." }); + return; + } + setFormErrors({}); + onSubmit?.({ ...formData, name: formData.name }); + }, + [onSubmit] + ); + + const handleNameInputChange = useCallback['onChange']>((e) => { + setName(e.target.value); + }, []); + + const handleDescriptionInputChange = useCallback['onChange']>((e) => { + setDescription(e.target.value); + }, []); + + return ( + + + +

Workspace details

+
+ + + + + + Description - optional + + } + > + + +
+ + + +

Workspace Template

+
+ + + {workspaceTemplates.map((template) => ( + + + + ))} + + + {selectedTemplate && ( + <> + +

Features

+
+ + + {selectedTemplate.coverImage && ( + + + + )} + + {selectedTemplate.description} + +

Key Features:

+
+ + + {templateFeatureMap.get(selectedTemplate.id)?.map((feature) => ( + • {feature.title} + ))} + +
+
+ + + )} + + +

Advanced Options

+
+ + } + > + + {featureOrGroups.map((featureOrGroup) => { + const features = isWorkspaceFeatureGroup(featureOrGroup) + ? featureOrGroup.features + : []; + const selectedIds = selectedFeatureIds.filter((id) => + (isWorkspaceFeatureGroup(featureOrGroup) + ? featureOrGroup.features + : [featureOrGroup] + ).find((item) => item.id === id) + ); + return ( + + 0 ? `(${selectedIds.length}/${features.length})` : '' + }`} + checked={selectedIds.length > 0} + indeterminate={ + isWorkspaceFeatureGroup(featureOrGroup) && + selectedIds.length > 0 && + selectedIds.length < features.length + } + /> + {isWorkspaceFeatureGroup(featureOrGroup) && ( + ({ + id: item.id, + label: item.name, + }))} + idToSelectedMap={selectedIds.reduce( + (previousValue, currentValue) => ({ + ...previousValue, + [currentValue]: true, + }), + {} + )} + onChange={handleFeatureChange} + style={{ marginLeft: 40 }} + /> + )} + + ); + })} + +
+
+ + + + Create workspace + + +
+ ); +}; diff --git a/src/plugins/workspace/public/hooks.ts b/src/plugins/workspace/public/hooks.ts index 71019c5948a2..636a00742146 100644 --- a/src/plugins/workspace/public/hooks.ts +++ b/src/plugins/workspace/public/hooks.ts @@ -8,6 +8,17 @@ import { useObservable } from 'react-use'; import { useMemo } from 'react'; import { WorkspaceTemplate } from '../../../core/types'; +export function useApplications(application: ApplicationStart) { + const applications = useObservable(application.applications$); + return useMemo(() => { + const apps: PublicAppInfo[] = []; + applications?.forEach((app) => { + apps.push(app); + }); + return apps; + }, [applications]); +} + export function useWorkspaceTemplate(application: ApplicationStart) { const applications = useObservable(application.applications$); From 27198dc54860cca8e23c30e18e1bd2a21bf39d1a Mon Sep 17 00:00:00 2001 From: suzhou Date: Thu, 15 Jun 2023 10:28:09 +0800 Subject: [PATCH 018/174] Add validation when load page (#8) * fix: validation & query Signed-off-by: SuZhoue-Joe * feat: modify file name to reduce confusion Signed-off-by: SuZhoue-Joe * feat: add landing logic to retrive workspace id Signed-off-by: SuZhoue-Joe * feat: add worklist observable Signed-off-by: SuZhoue-Joe * feat: add worklist observable Signed-off-by: SuZhoue-Joe * feat: add worklist observable Signed-off-by: SuZhoue-Joe * fix: type error Signed-off-by: SuZhoue-Joe * fix: type error Signed-off-by: SuZhoue-Joe * feat: make client more robust Signed-off-by: SuZhoue-Joe * feat: use Subject Signed-off-by: SuZhoue-Joe --------- Signed-off-by: SuZhoue-Joe --- src/core/public/core_app/core_app.ts | 2 + src/core/public/core_system.ts | 6 +- .../fatal_errors/fatal_errors_service.mock.ts | 30 +++ src/core/public/index.ts | 5 +- src/core/public/plugins/plugin_context.ts | 1 + .../public/plugins/plugins_service.test.ts | 7 +- src/core/public/workspace/consts.ts | 8 + src/core/public/workspace/index.ts | 5 +- .../public/workspace/workspaces_client.ts | 173 +++++++++++++++--- .../public/workspace/workspaces_service.ts | 49 ++++- .../integration_tests/core_services.test.ts | 4 +- src/core/server/internal_types.ts | 3 - src/core/server/server.ts | 6 +- src/core/server/workspaces/index.ts | 2 +- src/core/server/workspaces/routes/index.ts | 96 ++-------- .../workspaces/saved_objects/workspace.ts | 4 +- src/core/server/workspaces/types.ts | 12 +- ...h_saved_object.ts => workspaces_client.ts} | 0 .../server/workspaces/workspaces_service.ts | 12 +- src/plugins/workspace/common/constants.ts | 1 + src/plugins/workspace/public/plugin.ts | 66 ++++++- 21 files changed, 340 insertions(+), 152 deletions(-) create mode 100644 src/core/public/workspace/consts.ts rename src/core/server/workspaces/{workspaces_client_with_saved_object.ts => workspaces_client.ts} (100%) diff --git a/src/core/public/core_app/core_app.ts b/src/core/public/core_app/core_app.ts index fcbcc5de5655..c4d359d58dc1 100644 --- a/src/core/public/core_app/core_app.ts +++ b/src/core/public/core_app/core_app.ts @@ -42,12 +42,14 @@ import type { IUiSettingsClient } from '../ui_settings'; import type { InjectedMetadataSetup } from '../injected_metadata'; import { renderApp as renderErrorApp, setupUrlOverflowDetection } from './errors'; import { renderApp as renderStatusApp } from './status'; +import { WorkspacesSetup } from '../workspace'; interface SetupDeps { application: InternalApplicationSetup; http: HttpSetup; injectedMetadata: InjectedMetadataSetup; notifications: NotificationsSetup; + workspaces: WorkspacesSetup; } interface StartDeps { diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 27f39ea57c1b..1c0286cbd03c 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -163,13 +163,14 @@ 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 = await this.workspaces.setup({ http }); const pluginDependencies = this.plugins.getOpaqueIds(); const context = this.context.setup({ pluginDependencies: new Map([...pluginDependencies]), }); const application = this.application.setup({ context, http }); - this.coreApp.setup({ application, http, injectedMetadata, notifications }); + this.coreApp.setup({ application, http, injectedMetadata, notifications, workspaces }); const core: InternalCoreSetup = { application, @@ -179,6 +180,7 @@ export class CoreSystem { injectedMetadata, notifications, uiSettings, + workspaces, }; // Services that do not expose contracts at setup @@ -202,7 +204,7 @@ export class CoreSystem { const uiSettings = await this.uiSettings.start(); const docLinks = this.docLinks.start({ injectedMetadata }); const http = await this.http.start(); - const workspaces = await this.workspaces.start({ http }); + const workspaces = await this.workspaces.start(); const savedObjects = await this.savedObjects.start({ http }); const i18n = await this.i18n.start(); const fatalErrors = await this.fatalErrors.start(); diff --git a/src/core/public/fatal_errors/fatal_errors_service.mock.ts b/src/core/public/fatal_errors/fatal_errors_service.mock.ts index ff1b252fc128..5079fc8f4b6a 100644 --- a/src/core/public/fatal_errors/fatal_errors_service.mock.ts +++ b/src/core/public/fatal_errors/fatal_errors_service.mock.ts @@ -30,6 +30,8 @@ import type { PublicMethodsOf } from '@osd/utility-types'; import { FatalErrorsService, FatalErrorsSetup } from './fatal_errors_service'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { WorkspaceAttribute } from '../workspace'; const createSetupContractMock = () => { const setupContract: jest.Mocked = { @@ -58,3 +60,31 @@ export const fatalErrorsServiceMock = { createSetupContract: createSetupContractMock, createStartContract: createStartContractMock, }; + +const currentWorkspaceId$ = new BehaviorSubject(''); +const workspaceList$ = new Subject(); + +const createWorkspacesSetupContractMock = () => ({ + client: { + currentWorkspaceId$, + workspaceList$, + stop: jest.fn(), + enterWorkspace: jest.fn(), + exitWorkspace: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + list: jest.fn(), + getCurrentWorkspace: jest.fn(), + getCurrentWorkspaceId: jest.fn(), + get: jest.fn(), + update: jest.fn(), + }, + formatUrlWithWorkspaceId: jest.fn(), +}); + +const createWorkspacesStartContractMock = createWorkspacesSetupContractMock; + +export const workspacesServiceMock = { + createSetupContractMock: createWorkspacesStartContractMock, + createStartContract: createWorkspacesStartContractMock, +}; diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 4251219608f2..bc2fb823ff9f 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -87,7 +87,7 @@ import { HandlerParameters, } from './context'; import { Branding } from '../types'; -import { WorkspacesStart } from './workspace'; +import { WorkspacesStart, WorkspacesSetup } from './workspace'; export type { Logos } from '../common'; export { PackageInfo, EnvironmentMode } from '../server/types'; @@ -241,6 +241,8 @@ export interface CoreSetup; + /** {@link WorkspacesSetup} */ + workspaces: WorkspacesSetup; } /** @@ -353,4 +355,5 @@ export { WorkspacesService, WorkspaceAttribute, WorkspaceFindOptions, + WORKSPACE_ID_QUERYSTRING_NAME, } from './workspace'; diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index 81fcb2b34dab..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, }; } diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts index b2cf4e8880cf..f4bd60800da4 100644 --- a/src/core/public/plugins/plugins_service.test.ts +++ b/src/core/public/plugins/plugins_service.test.ts @@ -50,7 +50,10 @@ import { applicationServiceMock } from '../application/application_service.mock' import { i18nServiceMock } from '../i18n/i18n_service.mock'; import { overlayServiceMock } from '../overlays/overlay_service.mock'; import { chromeServiceMock } from '../chrome/chrome_service.mock'; -import { fatalErrorsServiceMock } from '../fatal_errors/fatal_errors_service.mock'; +import { + fatalErrorsServiceMock, + workspacesServiceMock, +} from '../fatal_errors/fatal_errors_service.mock'; import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock'; import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; import { httpServiceMock } from '../http/http_service.mock'; @@ -108,6 +111,7 @@ describe('PluginsService', () => { injectedMetadata: injectedMetadataServiceMock.createStartContract(), notifications: notificationServiceMock.createSetupContract(), uiSettings: uiSettingsServiceMock.createSetupContract(), + workspaces: workspacesServiceMock.createSetupContractMock(), }; mockSetupContext = { ...mockSetupDeps, @@ -127,6 +131,7 @@ describe('PluginsService', () => { uiSettings: uiSettingsServiceMock.createStartContract(), savedObjects: savedObjectsServiceMock.createStartContract(), fatalErrors: fatalErrorsServiceMock.createStartContract(), + workspaces: workspacesServiceMock.createStartContract(), }; mockStartContext = { ...mockStartDeps, diff --git a/src/core/public/workspace/consts.ts b/src/core/public/workspace/consts.ts new file mode 100644 index 000000000000..77f9144a27a3 --- /dev/null +++ b/src/core/public/workspace/consts.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const WORKSPACES_API_BASE_URL = '/api/workspaces'; + +export const WORKSPACE_ID_QUERYSTRING_NAME = '_workspace_id_'; diff --git a/src/core/public/workspace/index.ts b/src/core/public/workspace/index.ts index a70d91733ab6..d0fb17ead0c1 100644 --- a/src/core/public/workspace/index.ts +++ b/src/core/public/workspace/index.ts @@ -3,5 +3,6 @@ * SPDX-License-Identifier: Apache-2.0 */ export { WorkspacesClientContract, WorkspacesClient } from './workspaces_client'; -export { WorkspacesStart, WorkspacesService } from './workspaces_service'; -export { WorkspaceAttribute, WorkspaceFindOptions } from '../../server/types'; +export { WorkspacesStart, WorkspacesService, WorkspacesSetup } from './workspaces_service'; +export type { WorkspaceAttribute, WorkspaceFindOptions } from '../../server/types'; +export { WORKSPACE_ID_QUERYSTRING_NAME } from './consts'; diff --git a/src/core/public/workspace/workspaces_client.ts b/src/core/public/workspace/workspaces_client.ts index 6b369517b366..8508921cbb0d 100644 --- a/src/core/public/workspace/workspaces_client.ts +++ b/src/core/public/workspace/workspaces_client.ts @@ -3,17 +3,18 @@ * SPDX-License-Identifier: Apache-2.0 */ import { resolve as resolveUrl } from 'url'; -import type { PublicMethodsOf } from '@osd/utility-types'; -import { WORKSPACES_API_BASE_URL } from '../../server/types'; -import { HttpStart } from '../http'; +import type { PublicContract } from '@osd/utility-types'; +import { Subject } from 'rxjs'; +import { HttpFetchError, HttpFetchOptions, HttpSetup } from '../http'; import { WorkspaceAttribute, WorkspaceFindOptions } from '.'; +import { WORKSPACES_API_BASE_URL } from './consts'; /** * WorkspacesClientContract as implemented by the {@link WorkspacesClient} * * @public */ -export type WorkspacesClientContract = PublicMethodsOf; +export type WorkspacesClientContract = PublicContract; const join = (...uriComponents: Array) => uriComponents @@ -38,33 +39,107 @@ type IResponse = * @public */ export class WorkspacesClient { - private http: HttpStart; - constructor(http: HttpStart) { + private http: HttpSetup; + private currentWorkspaceId = ''; + public currentWorkspaceId$ = new Subject(); + public workspaceList$ = new Subject(); + constructor(http: HttpSetup) { this.http = http; + this.currentWorkspaceId$.subscribe( + (currentWorkspaceId) => (this.currentWorkspaceId = currentWorkspaceId) + ); + /** + * Add logic to check if current workspace id is still valid + * If not, remove the current workspace id and notify other subscribers + */ + this.workspaceList$.subscribe(async (workspaceList) => { + const currentWorkspaceId = this.currentWorkspaceId; + if (currentWorkspaceId) { + const findItem = workspaceList.find((item) => item.id === currentWorkspaceId); + if (!findItem) { + /** + * Current workspace is staled + */ + this.currentWorkspaceId$.next(''); + } + } + }); + + /** + * Initialize workspace list + */ + this.updateWorkspaceListAndNotify(); } + private catchedFetch = async >( + path: string, + options: HttpFetchOptions + ) => { + try { + return await this.http.fetch(path, options); + } catch (error: unknown) { + if (error instanceof HttpFetchError || error instanceof Error) { + return { + success: false, + error: error.message, + } as T; + } + + return { + success: false, + error: 'Unknown error', + } as T; + } + }; + private getPath(path: Array): string { return resolveUrl(`${WORKSPACES_API_BASE_URL}/`, join(...path)); } + private async updateWorkspaceListAndNotify(): Promise { + const result = await this.list({ + perPage: 999, + }); + + if (result?.success) { + this.workspaceList$.next(result.result.workspaces); + } + } + public async enterWorkspace(id: string): Promise> { - return this.http.post(this.getPath(['_enter', id])); + const workspaceResp = await this.get(id); + if (workspaceResp.success) { + this.currentWorkspaceId$.next(id); + return { + success: true, + result: null, + }; + } else { + return workspaceResp; + } } public async exitWorkspace(): Promise> { - return this.http.post(this.getPath(['_exit'])); + this.currentWorkspaceId$.next(''); + return { + success: true, + result: null, + }; } public async getCurrentWorkspaceId(): Promise> { - const currentWorkspaceIdResp = await this.http.get(this.getPath(['_current'])); - if (currentWorkspaceIdResp.success && !currentWorkspaceIdResp.result) { + const currentWorkspaceId = this.currentWorkspaceId; + if (!currentWorkspaceId) { return { success: false, error: 'You are not in any workspace yet.', }; } - return currentWorkspaceIdResp; + return { + success: true, + result: currentWorkspaceId, + }; } public async getCurrentWorkspace(): Promise> { @@ -83,22 +158,31 @@ export class WorkspacesClient { * @param attributes * @returns */ - public create = ( + public async create( attributes: Omit - ): Promise> => { + ): Promise> { if (!attributes) { - return Promise.reject(new Error('requires attributes')); + return { + success: false, + error: 'Workspace attributes is required', + }; } const path = this.getPath([]); - return this.http.fetch(path, { + const result = await this.catchedFetch>(path, { method: 'POST', body: JSON.stringify({ attributes, }), }); - }; + + if (result.success) { + this.updateWorkspaceListAndNotify(); + } + + return result; + } /** * Deletes a workspace @@ -106,13 +190,22 @@ export class WorkspacesClient { * @param id * @returns */ - public delete = (id: string): Promise> => { + public async delete(id: string): Promise> { if (!id) { - return Promise.reject(new Error('requires id')); + return { + success: false, + error: 'Id is required.', + }; } - return this.http.delete(this.getPath([id]), { method: 'DELETE' }); - }; + const result = await this.catchedFetch(this.getPath([id]), { method: 'DELETE' }); + + if (result.success) { + this.updateWorkspaceListAndNotify(); + } + + return result; + } /** * Search for workspaces @@ -137,9 +230,9 @@ export class WorkspacesClient { }> > => { const path = this.getPath(['_list']); - return this.http.fetch(path, { - method: 'GET', - query: options, + return this.catchedFetch(path, { + method: 'POST', + body: JSON.stringify(options || {}), }); }; @@ -149,16 +242,19 @@ export class WorkspacesClient { * @param {string} id * @returns The workspace for the given id. */ - public get = (id: string): Promise> => { + public async get(id: string): Promise> { if (!id) { - return Promise.reject(new Error('requires id')); + return { + success: false, + error: 'Id is required.', + }; } const path = this.getPath([id]); - return this.http.fetch(path, { + return this.catchedFetch(path, { method: 'GET', }); - }; + } /** * Updates a workspace @@ -167,9 +263,15 @@ export class WorkspacesClient { * @param {object} attributes * @returns */ - public update(id: string, attributes: Partial): Promise> { + public async update( + id: string, + attributes: Partial + ): Promise> { if (!id || !attributes) { - return Promise.reject(new Error('requires id and attributes')); + return { + success: false, + error: 'Id and attributes are required.', + }; } const path = this.getPath([id]); @@ -177,9 +279,20 @@ export class WorkspacesClient { attributes, }; - return this.http.fetch(path, { + const result = await this.catchedFetch(path, { method: 'PUT', body: JSON.stringify(body), }); + + if (result.success) { + this.updateWorkspaceListAndNotify(); + } + + return result; + } + + public stop() { + this.workspaceList$.unsubscribe(); + this.currentWorkspaceId$.unsubscribe(); } } diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts index f691ebbe3e16..908530885760 100644 --- a/src/core/public/workspace/workspaces_service.ts +++ b/src/core/public/workspace/workspaces_service.ts @@ -4,19 +4,56 @@ */ import { CoreService } from 'src/core/types'; import { WorkspacesClient, WorkspacesClientContract } from './workspaces_client'; -import { HttpStart } from '..'; +import type { WorkspaceAttribute } from '../../server/types'; +import { WORKSPACE_ID_QUERYSTRING_NAME } from './consts'; +import { HttpSetup } from '../http'; /** * @public */ export interface WorkspacesStart { client: WorkspacesClientContract; + formatUrlWithWorkspaceId: (url: string, id: WorkspaceAttribute['id']) => string; } -export class WorkspacesService implements CoreService { - public async setup() {} - public async start({ http }: { http: HttpStart }): Promise { - return { client: new WorkspacesClient(http) }; +export type WorkspacesSetup = WorkspacesStart; + +function setQuerystring(url: string, params: Record): string { + const urlObj = new URL(url); + const searchParams = new URLSearchParams(urlObj.search); + + for (const key in params) { + if (params.hasOwnProperty(key)) { + const value = params[key]; + searchParams.set(key, value); + } + } + + urlObj.search = searchParams.toString(); + return urlObj.toString(); +} + +export class WorkspacesService implements CoreService { + private client?: WorkspacesClientContract; + private formatUrlWithWorkspaceId(url: string, id: string) { + return setQuerystring(url, { + [WORKSPACE_ID_QUERYSTRING_NAME]: id, + }); + } + public async setup({ http }: { http: HttpSetup }) { + this.client = new WorkspacesClient(http); + return { + client: this.client, + formatUrlWithWorkspaceId: this.formatUrlWithWorkspaceId, + }; + } + public async start(): Promise { + return { + client: this.client as WorkspacesClientContract, + formatUrlWithWorkspaceId: this.formatUrlWithWorkspaceId, + }; + } + public async stop() { + this.client?.stop(); } - public async stop() {} } diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts index c489d98cf708..b248a67ef50c 100644 --- a/src/core/server/http/integration_tests/core_services.test.ts +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -520,7 +520,7 @@ describe('http service', () => { }); const coreStart = await root.start(); - opensearch = coreStart.opensearch; + opensearch = coreStart?.opensearch; const { header } = await osdTestServer.request.get(root, '/new-platform/').expect(401); @@ -556,7 +556,7 @@ describe('http service', () => { }); const coreStart = await root.start(); - opensearch = coreStart.opensearch; + opensearch = coreStart?.opensearch; const { header } = await osdTestServer.request.get(root, '/new-platform/').expect(401); diff --git a/src/core/server/internal_types.ts b/src/core/server/internal_types.ts index 0eb0b684d4f4..2b4df7da68bf 100644 --- a/src/core/server/internal_types.ts +++ b/src/core/server/internal_types.ts @@ -48,7 +48,6 @@ import { InternalStatusServiceSetup } from './status'; import { AuditTrailSetup, AuditTrailStart } from './audit_trail'; import { InternalLoggingServiceSetup } from './logging'; import { CoreUsageDataStart } from './core_usage_data'; -import { InternalWorkspacesServiceSetup, InternalWorkspacesServiceStart } from './workspaces'; /** @internal */ export interface InternalCoreSetup { @@ -65,7 +64,6 @@ export interface InternalCoreSetup { auditTrail: AuditTrailSetup; logging: InternalLoggingServiceSetup; metrics: InternalMetricsServiceSetup; - workspaces: InternalWorkspacesServiceSetup; } /** @@ -80,7 +78,6 @@ export interface InternalCoreStart { uiSettings: InternalUiSettingsServiceStart; auditTrail: AuditTrailStart; coreUsageData: CoreUsageDataStart; - workspaces: InternalWorkspacesServiceStart; } /** diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 99c79351d861..f80b90ba6baa 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -175,7 +175,7 @@ export class Server { const metricsSetup = await this.metrics.setup({ http: httpSetup }); - const workspacesSetup = await this.workspaces.setup({ + await this.workspaces.setup({ http: httpSetup, savedObject: savedObjectsSetup, }); @@ -220,7 +220,6 @@ export class Server { auditTrail: auditTrailSetup, logging: loggingSetup, metrics: metricsSetup, - workspaces: workspacesSetup, }; const pluginsSetup = await this.plugins.setup(coreSetup); @@ -262,7 +261,7 @@ export class Server { opensearch: opensearchStart, savedObjects: savedObjectsStart, }); - const workspacesStart = await this.workspaces.start({ + await this.workspaces.start({ savedObjects: savedObjectsStart, }); @@ -275,7 +274,6 @@ export class Server { uiSettings: uiSettingsStart, auditTrail: auditTrailStart, coreUsageData: coreUsageDataStart, - workspaces: workspacesStart, }; const pluginsStart = await this.plugins.start(this.coreStart); diff --git a/src/core/server/workspaces/index.ts b/src/core/server/workspaces/index.ts index 838f216bbd86..b9f765e4bba3 100644 --- a/src/core/server/workspaces/index.ts +++ b/src/core/server/workspaces/index.ts @@ -10,4 +10,4 @@ export { InternalWorkspacesServiceStart, } from './workspaces_service'; -export { WorkspaceAttribute, WorkspaceFindOptions, WORKSPACES_API_BASE_URL } from './types'; +export { WorkspaceAttribute, WorkspaceFindOptions } from './types'; diff --git a/src/core/server/workspaces/routes/index.ts b/src/core/server/workspaces/routes/index.ts index 6a0422e2aa30..24345b6a34d9 100644 --- a/src/core/server/workspaces/routes/index.ts +++ b/src/core/server/workspaces/routes/index.ts @@ -5,13 +5,11 @@ import { schema } from '@osd/config-schema'; import { InternalHttpServiceSetup } from '../../http'; import { Logger } from '../../logging'; -import { IWorkspaceDBImpl, WORKSPACES_API_BASE_URL, WORKSPACE_ID_COOKIE_NAME } from '../types'; +import { IWorkspaceDBImpl } from '../types'; -function getCookieValue(cookieString: string, cookieName: string): string | null { - const regex = new RegExp(`(?:(?:^|.*;\\s*)${cookieName}\\s*\\=\\s*([^;]*).*$)|^.*$`); - const match = cookieString.match(regex); - return match ? match[1] : null; -} +export const WORKSPACES_API_BASE_URL = '/api/workspaces'; + +export const WORKSPACE_ID_QUERYSTRING_NAME = '_workspace_id_'; export function registerRoutes({ client, @@ -23,15 +21,17 @@ export function registerRoutes({ http: InternalHttpServiceSetup; }) { const router = http.createRouter(WORKSPACES_API_BASE_URL); - router.get( + router.post( { path: '/_list', validate: { - query: schema.object({ - per_page: schema.number({ min: 0, defaultValue: 20 }), + 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 }), - sort_field: schema.maybe(schema.string()), - fields: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), + sortField: schema.maybe(schema.string()), + searchFields: schema.maybe(schema.arrayOf(schema.string())), }), }, }, @@ -42,7 +42,7 @@ export function registerRoutes({ request: req, logger, }, - req.query + req.body ); return res.ok({ body: result }); }) @@ -71,12 +71,13 @@ export function registerRoutes({ ); router.post( { - path: '/{id?}', + path: '/', validate: { body: schema.object({ attributes: schema.object({ description: schema.maybe(schema.string()), name: schema.string(), + features: schema.maybe(schema.arrayOf(schema.string())), }), }), }, @@ -106,6 +107,7 @@ export function registerRoutes({ attributes: schema.object({ description: schema.maybe(schema.string()), name: schema.string(), + features: schema.maybe(schema.arrayOf(schema.string())), }), }), }, @@ -149,72 +151,4 @@ export function registerRoutes({ return res.ok({ body: result }); }) ); - router.post( - { - path: '/_enter/{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.custom({ - body: { - success: true, - }, - statusCode: 200, - headers: { - 'set-cookie': `${WORKSPACE_ID_COOKIE_NAME}=${id}; Path=/`, - }, - }); - } else { - return res.ok({ body: result }); - } - }) - ); - - router.post( - { - path: '/_exit', - validate: {}, - }, - router.handleLegacyErrors(async (context, req, res) => { - return res.custom({ - body: { - success: true, - }, - statusCode: 200, - headers: { - 'set-cookie': `${WORKSPACE_ID_COOKIE_NAME}=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/`, - }, - }); - }) - ); - - router.get( - { - path: '/_current', - validate: {}, - }, - router.handleLegacyErrors(async (context, req, res) => { - return res.ok({ - body: { - success: true, - result: getCookieValue(req.headers.cookie as string, WORKSPACE_ID_COOKIE_NAME), - }, - }); - }) - ); } diff --git a/src/core/server/workspaces/saved_objects/workspace.ts b/src/core/server/workspaces/saved_objects/workspace.ts index d211aaa3ea93..e3fbaa0dad6a 100644 --- a/src/core/server/workspaces/saved_objects/workspace.ts +++ b/src/core/server/workspaces/saved_objects/workspace.ts @@ -29,8 +29,8 @@ export const workspace: SavedObjectsType = { mappings: { dynamic: false, properties: { - title: { - type: 'text', + name: { + type: 'keyword', }, description: { type: 'text', diff --git a/src/core/server/workspaces/types.ts b/src/core/server/workspaces/types.ts index fc3cae861c77..e098b4905a1f 100644 --- a/src/core/server/workspaces/types.ts +++ b/src/core/server/workspaces/types.ts @@ -19,11 +19,11 @@ export interface WorkspaceAttribute { export interface WorkspaceFindOptions { page?: number; - per_page?: number; + perPage?: number; search?: string; - search_fields?: string[]; - sort_field?: string; - sort_order?: string; + searchFields?: string[]; + sortField?: string; + sortOrder?: string; } export interface IRequestDetail { @@ -67,7 +67,3 @@ export type IResponse = success: false; error?: string; }; - -export const WORKSPACES_API_BASE_URL = '/api/workspaces'; - -export const WORKSPACE_ID_COOKIE_NAME = 'trinity_workspace_id'; diff --git a/src/core/server/workspaces/workspaces_client_with_saved_object.ts b/src/core/server/workspaces/workspaces_client.ts similarity index 100% rename from src/core/server/workspaces/workspaces_client_with_saved_object.ts rename to src/core/server/workspaces/workspaces_client.ts diff --git a/src/core/server/workspaces/workspaces_service.ts b/src/core/server/workspaces/workspaces_service.ts index 15c150b7378a..6faec9a6496e 100644 --- a/src/core/server/workspaces/workspaces_service.ts +++ b/src/core/server/workspaces/workspaces_service.ts @@ -12,10 +12,10 @@ import { InternalSavedObjectsServiceStart, } from '../saved_objects'; import { IWorkspaceDBImpl } from './types'; -import { WorkspacesClientWithSavedObject } from './workspaces_client_with_saved_object'; +import { WorkspacesClientWithSavedObject } from './workspaces_client'; export interface WorkspacesServiceSetup { - setWorkspacesClient: (client: IWorkspaceDBImpl) => void; + client: IWorkspaceDBImpl; } export interface WorkspacesServiceStart { @@ -39,14 +39,14 @@ export class WorkspacesService implements CoreService { private logger: Logger; private client?: IWorkspaceDBImpl; - constructor(private readonly coreContext: CoreContext) { + constructor(coreContext: CoreContext) { this.logger = coreContext.logger.get('workspaces-service'); } public async setup(setupDeps: WorkspacesSetupDeps): Promise { this.logger.debug('Setting up Workspaces service'); - this.client = this.client || new WorkspacesClientWithSavedObject(setupDeps); + this.client = new WorkspacesClientWithSavedObject(setupDeps); await this.client.setup(setupDeps); registerRoutes({ @@ -56,9 +56,7 @@ export class WorkspacesService }); return { - setWorkspacesClient: (client: IWorkspaceDBImpl) => { - this.client = client; - }, + client: this.client, }; } diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 5ccad2c6a2a9..4ac1575c25f7 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -5,3 +5,4 @@ export const WORKSPACE_APP_ID = 'workspace'; export const WORKSPACE_APP_NAME = 'Workspace'; +export const WORKSPACE_ID_IN_SESSION_STORAGE = '_workspace_id_'; diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 13017dab8835..911c9a651137 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -4,6 +4,7 @@ */ import { i18n } from '@osd/i18n'; +import { parse } from 'querystring'; import { CoreSetup, CoreStart, @@ -11,10 +12,71 @@ import { AppMountParameters, AppNavLinkStatus, } from '../../../core/public'; -import { WORKSPACE_APP_ID } from '../common/constants'; +import { WORKSPACE_APP_ID, WORKSPACE_ID_IN_SESSION_STORAGE } from '../common/constants'; +import { WORKSPACE_ID_QUERYSTRING_NAME } from '../../../core/public'; export class WorkspacesPlugin implements Plugin<{}, {}> { - public setup(core: CoreSetup) { + private core?: CoreSetup; + private addWorkspaceListener() { + this.core?.workspaces.client.currentWorkspaceId$.subscribe((newWorkspaceId) => { + try { + sessionStorage.setItem(WORKSPACE_ID_IN_SESSION_STORAGE, newWorkspaceId); + } catch (e) { + /** + * in incognize mode, this method may throw an error + * */ + } + }); + } + private getWorkpsaceIdFromQueryString(): string { + const querystringObject = parse(window.location.search.replace(/^\??/, '')); + return querystringObject[WORKSPACE_ID_QUERYSTRING_NAME] as string; + } + private getWorkpsaceIdFromSessionStorage(): string { + try { + return sessionStorage.getItem(WORKSPACE_ID_IN_SESSION_STORAGE) || ''; + } catch (e) { + /** + * in incognize mode, this method may throw an error + * */ + return ''; + } + } + private clearWorkspaceIdFromSessionStorage(): void { + try { + sessionStorage.removeItem(WORKSPACE_ID_IN_SESSION_STORAGE); + } catch (e) { + /** + * in incognize mode, this method may throw an error + * */ + } + } + public async setup(core: CoreSetup) { + this.core = core; + /** + * register a listener + */ + this.addWorkspaceListener(); + + /** + * Retrive workspace id from url or sessionstorage + * url > sessionstorage + */ + const workspaceId = + this.getWorkpsaceIdFromQueryString() || this.getWorkpsaceIdFromSessionStorage(); + + if (workspaceId) { + const result = await core.workspaces.client.enterWorkspace(workspaceId); + if (!result.success) { + this.clearWorkspaceIdFromSessionStorage(); + core.fatalErrors.add( + result.error || + i18n.translate('workspace.error.setup', { + defaultMessage: 'Workspace init failed', + }) + ); + } + } core.application.register({ id: WORKSPACE_APP_ID, title: i18n.translate('workspace.settings.title', { From 702452599a4fbf4925b252c11fd851db7e146a89 Mon Sep 17 00:00:00 2001 From: suzhou Date: Thu, 15 Jun 2023 15:44:29 +0800 Subject: [PATCH 019/174] feat: use BehaviorObject and optimize code (#14) Signed-off-by: SuZhoue-Joe --- src/core/public/workspace/consts.ts | 4 + .../public/workspace/workspaces_client.ts | 83 +++++++------------ src/core/server/workspaces/routes/index.ts | 10 +-- src/plugins/workspace/public/plugin.ts | 18 ++-- 4 files changed, 48 insertions(+), 67 deletions(-) diff --git a/src/core/public/workspace/consts.ts b/src/core/public/workspace/consts.ts index 77f9144a27a3..662baeaa5d19 100644 --- a/src/core/public/workspace/consts.ts +++ b/src/core/public/workspace/consts.ts @@ -6,3 +6,7 @@ export const WORKSPACES_API_BASE_URL = '/api/workspaces'; export const WORKSPACE_ID_QUERYSTRING_NAME = '_workspace_id_'; + +export enum WORKSPACE_ERROR_REASON_MAP { + WORKSPACE_STALED = 'WORKSPACE_STALED', +} diff --git a/src/core/public/workspace/workspaces_client.ts b/src/core/public/workspace/workspaces_client.ts index 8508921cbb0d..91d83dd1639f 100644 --- a/src/core/public/workspace/workspaces_client.ts +++ b/src/core/public/workspace/workspaces_client.ts @@ -2,12 +2,11 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -import { resolve as resolveUrl } from 'url'; import type { PublicContract } from '@osd/utility-types'; -import { Subject } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { HttpFetchError, HttpFetchOptions, HttpSetup } from '../http'; import { WorkspaceAttribute, WorkspaceFindOptions } from '.'; -import { WORKSPACES_API_BASE_URL } from './consts'; +import { WORKSPACES_API_BASE_URL, WORKSPACE_ERROR_REASON_MAP } from './consts'; /** * WorkspacesClientContract as implemented by the {@link WorkspacesClient} @@ -40,27 +39,25 @@ type IResponse = */ export class WorkspacesClient { private http: HttpSetup; - private currentWorkspaceId = ''; - public currentWorkspaceId$ = new Subject(); - public workspaceList$ = new Subject(); + public currentWorkspaceId$ = new BehaviorSubject(''); + public workspaceList$ = new BehaviorSubject([]); constructor(http: HttpSetup) { this.http = http; - this.currentWorkspaceId$.subscribe( - (currentWorkspaceId) => (this.currentWorkspaceId = currentWorkspaceId) - ); /** * Add logic to check if current workspace id is still valid * If not, remove the current workspace id and notify other subscribers */ this.workspaceList$.subscribe(async (workspaceList) => { - const currentWorkspaceId = this.currentWorkspaceId; + const currentWorkspaceId = this.currentWorkspaceId$.getValue(); if (currentWorkspaceId) { const findItem = workspaceList.find((item) => item.id === currentWorkspaceId); if (!findItem) { /** * Current workspace is staled */ - this.currentWorkspaceId$.next(''); + this.currentWorkspaceId$.error({ + reason: WORKSPACE_ERROR_REASON_MAP.WORKSPACE_STALED, + }); } } }); @@ -71,29 +68,39 @@ export class WorkspacesClient { this.updateWorkspaceListAndNotify(); } - private catchedFetch = async >( + /** + * 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); + return await this.http.fetch>(path, options); } catch (error: unknown) { - if (error instanceof HttpFetchError || error instanceof Error) { + 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, - } as T; + }; } return { success: false, error: 'Unknown error', - } as T; + }; } }; private getPath(path: Array): string { - return resolveUrl(`${WORKSPACES_API_BASE_URL}/`, join(...path)); + return [WORKSPACES_API_BASE_URL, join(...path)].filter((item) => item).join('/'); } private async updateWorkspaceListAndNotify(): Promise { @@ -128,7 +135,7 @@ export class WorkspacesClient { } public async getCurrentWorkspaceId(): Promise> { - const currentWorkspaceId = this.currentWorkspaceId; + const currentWorkspaceId = this.currentWorkspaceId$.getValue(); if (!currentWorkspaceId) { return { success: false, @@ -161,16 +168,9 @@ export class WorkspacesClient { public async create( attributes: Omit ): Promise> { - if (!attributes) { - return { - success: false, - error: 'Workspace attributes is required', - }; - } - const path = this.getPath([]); - const result = await this.catchedFetch>(path, { + const result = await this.safeFetch(path, { method: 'POST', body: JSON.stringify({ attributes, @@ -191,14 +191,7 @@ export class WorkspacesClient { * @returns */ public async delete(id: string): Promise> { - if (!id) { - return { - success: false, - error: 'Id is required.', - }; - } - - const result = await this.catchedFetch(this.getPath([id]), { method: 'DELETE' }); + const result = await this.safeFetch(this.getPath([id]), { method: 'DELETE' }); if (result.success) { this.updateWorkspaceListAndNotify(); @@ -230,7 +223,7 @@ export class WorkspacesClient { }> > => { const path = this.getPath(['_list']); - return this.catchedFetch(path, { + return this.safeFetch(path, { method: 'POST', body: JSON.stringify(options || {}), }); @@ -243,15 +236,8 @@ export class WorkspacesClient { * @returns The workspace for the given id. */ public async get(id: string): Promise> { - if (!id) { - return { - success: false, - error: 'Id is required.', - }; - } - const path = this.getPath([id]); - return this.catchedFetch(path, { + return this.safeFetch(path, { method: 'GET', }); } @@ -267,19 +253,12 @@ export class WorkspacesClient { id: string, attributes: Partial ): Promise> { - if (!id || !attributes) { - return { - success: false, - error: 'Id and attributes are required.', - }; - } - const path = this.getPath([id]); const body = { attributes, }; - const result = await this.catchedFetch(path, { + const result = await this.safeFetch(path, { method: 'PUT', body: JSON.stringify(body), }); diff --git a/src/core/server/workspaces/routes/index.ts b/src/core/server/workspaces/routes/index.ts index 24345b6a34d9..980364103ba8 100644 --- a/src/core/server/workspaces/routes/index.ts +++ b/src/core/server/workspaces/routes/index.ts @@ -7,9 +7,7 @@ import { InternalHttpServiceSetup } from '../../http'; import { Logger } from '../../logging'; import { IWorkspaceDBImpl } from '../types'; -export const WORKSPACES_API_BASE_URL = '/api/workspaces'; - -export const WORKSPACE_ID_QUERYSTRING_NAME = '_workspace_id_'; +const WORKSPACES_API_BASE_URL = '/api/workspaces'; export function registerRoutes({ client, @@ -71,7 +69,7 @@ export function registerRoutes({ ); router.post( { - path: '/', + path: '', validate: { body: schema.object({ attributes: schema.object({ @@ -98,7 +96,7 @@ export function registerRoutes({ ); router.put( { - path: '/{id}', + path: '/{id?}', validate: { params: schema.object({ id: schema.string(), @@ -130,7 +128,7 @@ export function registerRoutes({ ); router.delete( { - path: '/{id}', + path: '/{id?}', validate: { params: schema.object({ id: schema.string(), diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 911c9a651137..39069ca09bb8 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -4,7 +4,6 @@ */ import { i18n } from '@osd/i18n'; -import { parse } from 'querystring'; import { CoreSetup, CoreStart, @@ -28,9 +27,9 @@ export class WorkspacesPlugin implements Plugin<{}, {}> { } }); } - private getWorkpsaceIdFromQueryString(): string { - const querystringObject = parse(window.location.search.replace(/^\??/, '')); - return querystringObject[WORKSPACE_ID_QUERYSTRING_NAME] as string; + private getWorkpsaceIdFromQueryString(): string | null { + const searchParams = new URLSearchParams(window.location.search); + return searchParams.get(WORKSPACE_ID_QUERYSTRING_NAME); } private getWorkpsaceIdFromSessionStorage(): string { try { @@ -53,11 +52,6 @@ export class WorkspacesPlugin implements Plugin<{}, {}> { } public async setup(core: CoreSetup) { this.core = core; - /** - * register a listener - */ - this.addWorkspaceListener(); - /** * Retrive workspace id from url or sessionstorage * url > sessionstorage @@ -77,6 +71,12 @@ export class WorkspacesPlugin implements Plugin<{}, {}> { ); } } + + /** + * register a listener + */ + this.addWorkspaceListener(); + core.application.register({ id: WORKSPACE_APP_ID, title: i18n.translate('workspace.settings.title', { From 5105abc730d67744edc4f00f7f57a2231c129f30 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Thu, 15 Jun 2023 16:06:06 +0800 Subject: [PATCH 020/174] feat: integrate with workspace create API (#13) * feat: integrate with workspace create API Signed-off-by: Lin Wang * feat: update to i18n text for toast Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang --- .../workspace_creator/workspace_creator.tsx | 37 +++++++++++++++++-- .../workspace_creator/workspace_form.tsx | 2 +- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx index e7006464ba7d..59c4ce0c444d 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -5,17 +5,48 @@ import React, { useCallback } from 'react'; import { EuiPage, EuiPageBody, EuiPageHeader, EuiPageContent } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public'; -import { WorkspaceForm } from './workspace_form'; +import { WorkspaceForm, WorkspaceFormData } from './workspace_form'; export const WorkspaceCreator = () => { const { - services: { application }, + services: { application, workspaces, notifications }, } = useOpenSearchDashboards(); - const handleWorkspaceFormSubmit = useCallback(() => {}, []); + const handleWorkspaceFormSubmit = useCallback( + async (data: WorkspaceFormData) => { + let result; + try { + result = await workspaces?.client.create(data); + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.create.failed', { + defaultMessage: 'Failed to create workspace', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + return; + } + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.create.success', { + defaultMessage: 'Create workspace successfully', + }), + }); + return; + } + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.create.failed', { + defaultMessage: 'Failed to create workspace', + }), + text: result?.error, + }); + }, + [notifications?.toasts, workspaces?.client] + ); return ( diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx index 8cb3a1e3c39d..41639701c435 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx @@ -44,7 +44,7 @@ interface WorkspaceFeatureGroup { features: WorkspaceFeature[]; } -interface WorkspaceFormData { +export interface WorkspaceFormData { name: string; description?: string; features: string[]; From cbebb5e58885e7a4ce8dd2e3f37ea7c43e0f7689 Mon Sep 17 00:00:00 2001 From: suzhou Date: Fri, 16 Jun 2023 09:16:49 +0800 Subject: [PATCH 021/174] Add currentWorkspace$ (#15) * feat: add currentWorkspace$ Signed-off-by: SuZhoue-Joe * fix: type error Signed-off-by: SuZhoue-Joe * feat: add emit on currentWorkspace$ Signed-off-by: SuZhoue-Joe --------- Signed-off-by: SuZhoue-Joe --- .../fatal_errors/fatal_errors_service.mock.ts | 6 ++- .../public/workspace/workspaces_client.ts | 46 ++++++++++++++----- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/core/public/fatal_errors/fatal_errors_service.mock.ts b/src/core/public/fatal_errors/fatal_errors_service.mock.ts index 5079fc8f4b6a..e495d66ae568 100644 --- a/src/core/public/fatal_errors/fatal_errors_service.mock.ts +++ b/src/core/public/fatal_errors/fatal_errors_service.mock.ts @@ -30,7 +30,7 @@ import type { PublicMethodsOf } from '@osd/utility-types'; import { FatalErrorsService, FatalErrorsSetup } from './fatal_errors_service'; -import { BehaviorSubject, Subject } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { WorkspaceAttribute } from '../workspace'; const createSetupContractMock = () => { @@ -62,12 +62,14 @@ export const fatalErrorsServiceMock = { }; const currentWorkspaceId$ = new BehaviorSubject(''); -const workspaceList$ = new Subject(); +const workspaceList$ = new BehaviorSubject([]); +const currentWorkspace$ = new BehaviorSubject(null); const createWorkspacesSetupContractMock = () => ({ client: { currentWorkspaceId$, workspaceList$, + currentWorkspace$, stop: jest.fn(), enterWorkspace: jest.fn(), exitWorkspace: jest.fn(), diff --git a/src/core/public/workspace/workspaces_client.ts b/src/core/public/workspace/workspaces_client.ts index 91d83dd1639f..f37fd89ae249 100644 --- a/src/core/public/workspace/workspaces_client.ts +++ b/src/core/public/workspace/workspaces_client.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ import type { PublicContract } from '@osd/utility-types'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, combineLatest } from 'rxjs'; +import { isEqual } from 'lodash'; import { HttpFetchError, HttpFetchOptions, HttpSetup } from '../http'; import { WorkspaceAttribute, WorkspaceFindOptions } from '.'; import { WORKSPACES_API_BASE_URL, WORKSPACE_ERROR_REASON_MAP } from './consts'; @@ -41,26 +42,34 @@ export class WorkspacesClient { private http: HttpSetup; public currentWorkspaceId$ = new BehaviorSubject(''); public workspaceList$ = new BehaviorSubject([]); + public currentWorkspace$ = new BehaviorSubject(null); constructor(http: HttpSetup) { this.http = http; - /** - * Add logic to check if current workspace id is still valid - * If not, remove the current workspace id and notify other subscribers - */ - this.workspaceList$.subscribe(async (workspaceList) => { - const currentWorkspaceId = this.currentWorkspaceId$.getValue(); - if (currentWorkspaceId) { - const findItem = workspaceList.find((item) => item.id === currentWorkspaceId); - if (!findItem) { + + combineLatest([this.workspaceList$, this.currentWorkspaceId$]).subscribe( + ([workspaceList, currentWorkspaceId]) => { + const currentWorkspace = this.findWorkspace([workspaceList, currentWorkspaceId]); + + /** + * Do a simple idempotent verification here + */ + if (!isEqual(currentWorkspace, this.currentWorkspace$.getValue())) { + this.currentWorkspace$.next(currentWorkspace); + } + + if (currentWorkspaceId && !currentWorkspace?.id) { /** * Current workspace is staled */ this.currentWorkspaceId$.error({ reason: WORKSPACE_ERROR_REASON_MAP.WORKSPACE_STALED, }); + this.currentWorkspace$.error({ + reason: WORKSPACE_ERROR_REASON_MAP.WORKSPACE_STALED, + }); } } - }); + ); /** * Initialize workspace list @@ -68,6 +77,21 @@ export class WorkspacesClient { this.updateWorkspaceListAndNotify(); } + private findWorkspace(payload: [WorkspaceAttribute[], string]): WorkspaceAttribute | null { + const [workspaceList, currentWorkspaceId] = payload; + if (!currentWorkspaceId || !workspaceList || !workspaceList.length) { + return null; + } + + const findItem = workspaceList.find((item) => item?.id === currentWorkspaceId); + + if (!findItem) { + return null; + } + + return findItem; + } + /** * Add a non-throw-error fetch method for internal use. */ From b56094549e51e644086d853214f716f8b85bc967 Mon Sep 17 00:00:00 2001 From: Hailong Cui Date: Fri, 16 Jun 2023 09:36:37 +0800 Subject: [PATCH 022/174] register plugin with workspace template (#16) Signed-off-by: Hailong Cui --- src/plugins/dashboard/public/plugin.tsx | 7 ++++++- src/plugins/discover/public/plugin.ts | 5 +++++ src/plugins/visualize/public/plugin.ts | 5 +++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 5738f8554c37..789de5867c62 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -373,7 +373,12 @@ export class DashboardPlugin defaultPath: `#${DashboardConstants.LANDING_PAGE_PATH}`, updater$: this.appStateUpdater, category: DEFAULT_APP_CATEGORIES.opensearchDashboards, - workspaceTemplate: [DEFAULT_WORKSPACE_TEMPLATES.search], + workspaceTemplate: [ + DEFAULT_WORKSPACE_TEMPLATES.search, + DEFAULT_WORKSPACE_TEMPLATES.general_analysis, + DEFAULT_WORKSPACE_TEMPLATES.observability, + DEFAULT_WORKSPACE_TEMPLATES.security_analytics, + ], mount: async (params: AppMountParameters) => { const [coreStart, pluginsStart, dashboardStart] = await core.getStartServices(); this.currentHistory = params.history; diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index f1532b6f776b..ce15dcff4342 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -11,6 +11,7 @@ import { AppUpdater, CoreSetup, CoreStart, + DEFAULT_WORKSPACE_TEMPLATES, Plugin, PluginInitializerContext, } from 'opensearch-dashboards/public'; @@ -251,6 +252,10 @@ export class DiscoverPlugin euiIconType: 'inputOutput', defaultPath: '#/', category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + workspaceTemplate: [ + DEFAULT_WORKSPACE_TEMPLATES.search, + DEFAULT_WORKSPACE_TEMPLATES.general_analysis, + ], mount: async (params: AppMountParameters) => { if (!this.initializeServices) { throw Error('Discover plugin method initializeServices is undefined'); diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index c146efef1fab..dfb521961293 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -37,6 +37,7 @@ import { AppUpdater, CoreSetup, CoreStart, + DEFAULT_WORKSPACE_TEMPLATES, Plugin, PluginInitializerContext, ScopedHistory, @@ -157,6 +158,10 @@ export class VisualizePlugin euiIconType: 'inputOutput', defaultPath: '#/', category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + workspaceTemplate: [ + DEFAULT_WORKSPACE_TEMPLATES.search, + DEFAULT_WORKSPACE_TEMPLATES.general_analysis, + ], updater$: this.appStateUpdater.asObservable(), // remove all references to visualize mount: async (params: AppMountParameters) => { From 0ed84c10842c2419c12b3089f8c0edbeeb41897b Mon Sep 17 00:00:00 2001 From: zhichao-aws Date: Fri, 16 Jun 2023 14:16:53 +0800 Subject: [PATCH 023/174] workspace dropdown list (#9) Add workspace dropdown list --------- Signed-off-by: zhichao-aws Signed-off-by: SuZhoue-Joe Signed-off-by: suzhou Co-authored-by: SuZhoue-Joe --- .../workspace_dropdown_list/index.ts | 8 ++ .../workspace_dropdown_list.tsx | 83 +++++++++++++++++++ src/plugins/workspace/public/mount.tsx | 32 +++++++ src/plugins/workspace/public/plugin.ts | 2 + 4 files changed, 125 insertions(+) create mode 100644 src/plugins/workspace/public/containers/workspace_dropdown_list/index.ts create mode 100644 src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx create mode 100644 src/plugins/workspace/public/mount.tsx diff --git a/src/plugins/workspace/public/containers/workspace_dropdown_list/index.ts b/src/plugins/workspace/public/containers/workspace_dropdown_list/index.ts new file mode 100644 index 000000000000..b68e1f9131e1 --- /dev/null +++ b/src/plugins/workspace/public/containers/workspace_dropdown_list/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WorkspaceDropdownList } from './workspace_dropdown_list'; + +export { WorkspaceDropdownList }; diff --git a/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx new file mode 100644 index 000000000000..276a5872473e --- /dev/null +++ b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx @@ -0,0 +1,83 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useCallback, useMemo, useEffect } from 'react'; + +import { EuiButton, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import useObservable from 'react-use/lib/useObservable'; +import { CoreStart, WorkspaceAttribute } from '../../../../../core/public'; + +type WorkspaceOption = EuiComboBoxOptionOption; + +interface WorkspaceDropdownListProps { + coreStart: CoreStart; + onCreateWorkspace: () => void; + onSwitchWorkspace: (workspaceId: string) => Promise; +} + +function workspaceToOption(workspace: WorkspaceAttribute): WorkspaceOption { + return { label: workspace.name, key: workspace.id, value: workspace }; +} + +export function WorkspaceDropdownList(props: WorkspaceDropdownListProps) { + const { coreStart, onCreateWorkspace, onSwitchWorkspace } = props; + const workspaceList = useObservable(coreStart.workspaces.client.workspaceList$, []); + const currentWorkspaceId = useObservable(coreStart.workspaces.client.currentWorkspaceId$, ''); + + const [loading, setLoading] = useState(false); + const [workspaceOptions, setWorkspaceOptions] = useState([] as WorkspaceOption[]); + + const currentWorkspaceOption = useMemo(() => { + const workspace = workspaceList.find((item) => item.id === currentWorkspaceId); + if (!workspace) { + coreStart.notifications.toasts.addDanger( + `can not get current workspace of id [${currentWorkspaceId}]` + ); + return [workspaceToOption({ id: currentWorkspaceId, name: '' })]; + } + return [workspaceToOption(workspace)]; + }, [workspaceList, currentWorkspaceId, coreStart]); + const allWorkspaceOptions = useMemo(() => { + return workspaceList.map(workspaceToOption); + }, [workspaceList]); + + const onSearchChange = useCallback( + (searchValue: string) => { + setWorkspaceOptions(allWorkspaceOptions.filter((item) => item.label.includes(searchValue))); + }, + [allWorkspaceOptions] + ); + + const onChange = (workspaceOption: WorkspaceOption[]) => { + /** switch the workspace */ + setLoading(true); + onSwitchWorkspace(workspaceOption[0].key!) + .catch((err) => + coreStart.notifications.toasts.addDanger('some error happens in workspace service') + ) + .finally(() => { + setLoading(false); + }); + }; + + useEffect(() => { + onSearchChange(''); + }, [onSearchChange]); + + return ( + <> + Create workspace} + /> + + ); +} diff --git a/src/plugins/workspace/public/mount.tsx b/src/plugins/workspace/public/mount.tsx new file mode 100644 index 000000000000..17646ebecd28 --- /dev/null +++ b/src/plugins/workspace/public/mount.tsx @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { CoreStart } from '../../../core/public'; +import { WorkspaceDropdownList } from './containers/workspace_dropdown_list'; + +export const mountDropdownList = (core: CoreStart) => { + core.chrome.navControls.registerLeft({ + order: 0, + mount: (element) => { + ReactDOM.render( + alert('create')} + onSwitchWorkspace={async (id: string) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + alert(`switch to workspace ${id}`); + }} + // onSwitchWorkspace={(id: string) => alert(`switch to workspace ${id}`)} + />, + element + ); + return () => { + ReactDOM.unmountComponentAtNode(element); + }; + }, + }); +}; diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 39069ca09bb8..5a70235373b7 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -13,6 +13,7 @@ import { } from '../../../core/public'; import { WORKSPACE_APP_ID, WORKSPACE_ID_IN_SESSION_STORAGE } from '../common/constants'; import { WORKSPACE_ID_QUERYSTRING_NAME } from '../../../core/public'; +import { mountDropdownList } from './mount'; export class WorkspacesPlugin implements Plugin<{}, {}> { private core?: CoreSetup; @@ -100,6 +101,7 @@ export class WorkspacesPlugin implements Plugin<{}, {}> { } public start(core: CoreStart) { + mountDropdownList(core); return {}; } } From 2b143cd22a3f98cbae2d048f7ed6c2b351bfbd64 Mon Sep 17 00:00:00 2001 From: raintygao Date: Fri, 16 Jun 2023 14:17:10 +0800 Subject: [PATCH 024/174] init workspace menu stage 1 (#12) * feat: init workspace menu stage 1 Signed-off-by: tygao * fix: remove port diff Signed-off-by: tygao * feat: update menu logic Signed-off-by: tygao --------- Signed-off-by: tygao --- src/core/public/chrome/chrome_service.tsx | 5 +++- .../chrome/ui/header/collapsible_nav.tsx | 27 +++++++++++++++++-- src/core/public/chrome/ui/header/header.tsx | 5 +++- src/core/public/core_system.ts | 1 + 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index d094d86360ef..44dc4eee20af 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -48,7 +48,7 @@ import { ChromeNavLinks, NavLinksService, ChromeNavLink } from './nav_links'; import { ChromeRecentlyAccessed, RecentlyAccessedService } from './recently_accessed'; import { Header } from './ui'; import { ChromeHelpExtensionMenuLink } from './ui/header/header_help_menu'; -import { Branding } from '../'; +import { Branding, WorkspacesStart } from '../'; import { getLogos } from '../../common'; import type { Logos } from '../../common/types'; @@ -96,6 +96,7 @@ interface StartDeps { injectedMetadata: InjectedMetadataStart; notifications: NotificationsStart; uiSettings: IUiSettingsClient; + workspaces: WorkspacesStart; } /** @internal */ @@ -149,6 +150,7 @@ export class ChromeService { injectedMetadata, notifications, uiSettings, + workspaces, }: StartDeps): Promise { this.initVisibility(application); @@ -262,6 +264,7 @@ export class ChromeService { branding={injectedMetadata.getBranding()} logos={logos} survey={injectedMetadata.getSurvey()} + currentWorkspace$={workspaces.client.currentWorkspace$} /> ), diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index 8b178200114a..18aeb9f9bb6f 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -51,6 +51,7 @@ import { HttpStart } from '../../../http'; import { OnIsLockedUpdate } from './'; import { createEuiListItem, createRecentNavLink, isModifiedOrPrevented } from './nav_link'; import type { Logos } from '../../../../common/types'; +import { WorkspaceAttribute } from '../../../workspace'; function getAllCategories(allCategorizedLinks: Record) { const allCategories = {} as Record; @@ -102,6 +103,7 @@ interface Props { navigateToUrl: InternalApplicationStart['navigateToUrl']; customNavLink$: Rx.Observable; logos: Logos; + currentWorkspace$: Rx.BehaviorSubject; } export function CollapsibleNav({ @@ -122,11 +124,14 @@ export function CollapsibleNav({ const recentlyAccessed = useObservable(observables.recentlyAccessed$, []); const customNavLink = useObservable(observables.customNavLink$, undefined); const appId = useObservable(observables.appId$, ''); + const currentWorkspace = useObservable(observables.currentWorkspace$); const lockRef = useRef(null); const groupedNavLinks = groupBy(navLinks, (link) => link?.category?.id); const { undefined: unknowns = [], ...allCategorizedLinks } = groupedNavLinks; - const categoryDictionary = getAllCategories(allCategorizedLinks); - const orderedCategories = getOrderedCategories(allCategorizedLinks, categoryDictionary); + const filterdLinks = getFilterLinks(currentWorkspace, allCategorizedLinks); + const categoryDictionary = getAllCategories(filterdLinks); + const orderedCategories = getOrderedCategories(filterdLinks, categoryDictionary); + const readyForEUI = (link: ChromeNavLink, needsIcon: boolean = false) => { return createEuiListItem({ link, @@ -138,6 +143,24 @@ export function CollapsibleNav({ }); }; + function getFilterLinks( + workspace: WorkspaceAttribute | null | undefined, + categorizedLinks: Record + ) { + // plugins are in this dictionary + const pluginsDictionary = categorizedLinks.opensearch; + if (!pluginsDictionary) return categorizedLinks; + + const features = workspace?.features ?? []; + const newPluginsDictionary = pluginsDictionary.filter((item) => features.indexOf(item.id) > -1); + if (newPluginsDictionary.length === 0) { + delete categorizedLinks.opensearch; + } else { + categorizedLinks.opensearch = newPluginsDictionary; + } + return categorizedLinks; + } + return ( ; } export function Header({ @@ -264,6 +266,7 @@ export function Header({ }} customNavLink$={observables.customNavLink$} logos={logos} + currentWorkspace$={observables.currentWorkspace$} /> diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 1c0286cbd03c..9512560112f7 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -233,6 +233,7 @@ export class CoreSystem { injectedMetadata, notifications, uiSettings, + workspaces, }); this.coreApp.start({ application, http, notifications, uiSettings }); From c596448d4ab9088dcf2818bf8cb3850eccca223f Mon Sep 17 00:00:00 2001 From: Hailong Cui Date: Fri, 16 Jun 2023 17:35:17 +0800 Subject: [PATCH 025/174] Fix template registration import error (#21) * fix import error Signed-off-by: Hailong Cui * fix osd bootstrap failure Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui --- src/core/public/chrome/chrome_service.test.ts | 2 ++ src/core/public/chrome/ui/header/collapsible_nav.test.tsx | 2 ++ src/core/public/chrome/ui/header/header.test.tsx | 2 ++ src/plugins/discover/public/plugin.ts | 3 +-- src/plugins/visualize/public/plugin.ts | 4 +--- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index f11b0f3965e6..00f196cdb3cb 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -41,6 +41,7 @@ import { notificationServiceMock } from '../notifications/notifications_service. import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock'; import { ChromeService } from './chrome_service'; import { getAppInfo } from '../application/utils'; +import { workspacesServiceMock } from '../fatal_errors/fatal_errors_service.mock'; class FakeApp implements App { public title = `${this.id} App`; @@ -67,6 +68,7 @@ function defaultStartDeps(availableApps?: App[]) { injectedMetadata: injectedMetadataServiceMock.createStartContract(), notifications: notificationServiceMock.createStartContract(), uiSettings: uiSettingsServiceMock.createStartContract(), + workspaces: workspacesServiceMock.createStartContract(), }; if (availableApps) { diff --git a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx index 955b7d7ca242..9f1f856d486b 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx @@ -38,6 +38,7 @@ import { httpServiceMock } from '../../../http/http_service.mock'; import { ChromeRecentlyAccessedHistoryItem } from '../../recently_accessed'; import { CollapsibleNav } from './collapsible_nav'; import { getLogos } from '../../../../common'; +import { workspacesServiceMock } from '../../../fatal_errors/fatal_errors_service.mock'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => () => 'mockId', @@ -95,6 +96,7 @@ function mockProps(branding = {}) { customNavLink$: new BehaviorSubject(undefined), branding, logos: getLogos(branding, mockBasePath.serverBasePath), + currentWorkspace$: workspacesServiceMock.createStartContract().client.currentWorkspace$, }; } diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx index c829361ff5c1..b1396f338880 100644 --- a/src/core/public/chrome/ui/header/header.test.tsx +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -36,6 +36,7 @@ import { httpServiceMock } from '../../../http/http_service.mock'; import { applicationServiceMock, chromeServiceMock } from '../../../mocks'; import { Header } from './header'; import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; +import { workspacesServiceMock } from '../../../fatal_errors/fatal_errors_service.mock'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => () => 'mockId', @@ -72,6 +73,7 @@ function mockProps() { branding: {}, survey: '/', logos: chromeServiceMock.createStartContract().logos, + currentWorkspace$: workspacesServiceMock.createStartContract().client.currentWorkspace$, }; } diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index ce15dcff4342..2d7a237a0db3 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -11,7 +11,6 @@ import { AppUpdater, CoreSetup, CoreStart, - DEFAULT_WORKSPACE_TEMPLATES, Plugin, PluginInitializerContext, } from 'opensearch-dashboards/public'; @@ -34,7 +33,7 @@ import { lazy } from 'react'; import { DataPublicPluginStart, DataPublicPluginSetup, opensearchFilters } from '../../data/public'; import { SavedObjectLoader } from '../../saved_objects/public'; import { url } from '../../opensearch_dashboards_utils/public'; -import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; +import { DEFAULT_APP_CATEGORIES, DEFAULT_WORKSPACE_TEMPLATES } from '../../../core/public'; import { UrlGeneratorState } from '../../share/public'; import { DocViewInput, DocViewInputFn } from './application/doc_views/doc_views_types'; import { generateDocViewsUrl } from './application/components/doc_views/generate_doc_views_url'; diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index dfb521961293..f66f5751023a 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -37,12 +37,10 @@ import { AppUpdater, CoreSetup, CoreStart, - DEFAULT_WORKSPACE_TEMPLATES, Plugin, PluginInitializerContext, ScopedHistory, } from 'opensearch-dashboards/public'; - import { Storage, createOsdUrlTracker, @@ -57,7 +55,7 @@ import { VisualizationsStart } from '../../visualizations/public'; import { VisualizeConstants } from './application/visualize_constants'; import { FeatureCatalogueCategory, HomePublicPluginSetup } from '../../home/public'; import { VisualizeServices } from './application/types'; -import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; +import { DEFAULT_APP_CATEGORIES, DEFAULT_WORKSPACE_TEMPLATES } from '../../../core/public'; import { SavedObjectsStart } from '../../saved_objects/public'; import { EmbeddableStart } from '../../embeddable/public'; import { DashboardStart } from '../../dashboard/public'; From 309efba863ba86ebfcbac6e2bbf2ae795caeafe9 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Mon, 19 Jun 2023 13:45:14 +0800 Subject: [PATCH 026/174] Add workspace overview page (#19) * feat: add workspace overview page Signed-off-by: Lin Wang * refactor: move paths to common constants Signed-off-by: Lin Wang * feat: add workspace overview item by custom nav in start phase Signed-off-by: Lin Wang * refactor: change to currentWorkspace$ in workspace client Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang --- src/plugins/workspace/common/constants.ts | 5 +++ .../workspace/public/components/routes.ts | 14 ++++--- .../public/components/workspace_overview.tsx | 40 +++++++++++++++++++ src/plugins/workspace/public/plugin.ts | 8 +++- 4 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 src/plugins/workspace/public/components/workspace_overview.tsx diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 4ac1575c25f7..633c402ffa86 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -6,3 +6,8 @@ export const WORKSPACE_APP_ID = 'workspace'; export const WORKSPACE_APP_NAME = 'Workspace'; export const WORKSPACE_ID_IN_SESSION_STORAGE = '_workspace_id_'; + +export const PATHS = { + create: '/create', + overview: '/overview', +}; diff --git a/src/plugins/workspace/public/components/routes.ts b/src/plugins/workspace/public/components/routes.ts index 5e47465643f5..99a0a1402afa 100644 --- a/src/plugins/workspace/public/components/routes.ts +++ b/src/plugins/workspace/public/components/routes.ts @@ -3,11 +3,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { WorkspaceCreator } from './workspace_creator'; +import { PATHS } from '../../common/constants'; -export const paths = { - create: '/create', -}; +import { WorkspaceCreator } from './workspace_creator'; +import { WorkspaceOverview } from './workspace_overview'; export interface RouteConfig { path: string; @@ -18,8 +17,13 @@ export interface RouteConfig { export const ROUTES: RouteConfig[] = [ { - path: paths.create, + path: PATHS.create, Component: WorkspaceCreator, label: 'Create', }, + { + path: PATHS.overview, + Component: WorkspaceOverview, + label: 'Overview', + }, ]; diff --git a/src/plugins/workspace/public/components/workspace_overview.tsx b/src/plugins/workspace/public/components/workspace_overview.tsx new file mode 100644 index 000000000000..0da5b7cd1e66 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_overview.tsx @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiPageHeader, EuiButton, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { useObservable } from 'react-use'; +import { of } from 'rxjs'; + +import { useOpenSearchDashboards } from '../../../../../src/plugins/opensearch_dashboards_react/public'; + +export const WorkspaceOverview = () => { + const { + services: { workspaces }, + } = useOpenSearchDashboards(); + + const currentWorkspace = useObservable( + workspaces ? workspaces.client.currentWorkspace$ : of(null) + ); + + return ( + <> + Delete, + Update, + ]} + /> + + +

Workspace

+
+ + {JSON.stringify(currentWorkspace)} +
+ + ); +}; diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 5a70235373b7..df601a3f1a29 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -11,7 +11,7 @@ import { AppMountParameters, AppNavLinkStatus, } from '../../../core/public'; -import { WORKSPACE_APP_ID, WORKSPACE_ID_IN_SESSION_STORAGE } from '../common/constants'; +import { WORKSPACE_APP_ID, WORKSPACE_ID_IN_SESSION_STORAGE, PATHS } from '../common/constants'; import { WORKSPACE_ID_QUERYSTRING_NAME } from '../../../core/public'; import { mountDropdownList } from './mount'; @@ -102,6 +102,12 @@ export class WorkspacesPlugin implements Plugin<{}, {}> { public start(core: CoreStart) { mountDropdownList(core); + + core.chrome.setCustomNavLink({ + title: i18n.translate('workspace.nav.title', { defaultMessage: 'Workspace Overview' }), + baseUrl: core.http.basePath.get(), + href: core.application.getUrlForApp(WORKSPACE_APP_ID, { path: PATHS.overview }), + }); return {}; } } From 61b29c3b468efd26a433d0d1e873bbc27b5f9382 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Mon, 19 Jun 2023 16:55:39 +0800 Subject: [PATCH 027/174] feat: navigate to workspace create page after button clicked (#23) Signed-off-by: Lin Wang --- .../workspace_dropdown_list.tsx | 10 +++++++--- src/plugins/workspace/public/mount.tsx | 1 - 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx index 276a5872473e..4285f7b2fa49 100644 --- a/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx +++ b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx @@ -8,12 +8,12 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { EuiButton, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import useObservable from 'react-use/lib/useObservable'; import { CoreStart, WorkspaceAttribute } from '../../../../../core/public'; +import { WORKSPACE_APP_ID, PATHS } from '../../../common/constants'; type WorkspaceOption = EuiComboBoxOptionOption; interface WorkspaceDropdownListProps { coreStart: CoreStart; - onCreateWorkspace: () => void; onSwitchWorkspace: (workspaceId: string) => Promise; } @@ -22,7 +22,7 @@ function workspaceToOption(workspace: WorkspaceAttribute): WorkspaceOption { } export function WorkspaceDropdownList(props: WorkspaceDropdownListProps) { - const { coreStart, onCreateWorkspace, onSwitchWorkspace } = props; + const { coreStart, onSwitchWorkspace } = props; const workspaceList = useObservable(coreStart.workspaces.client.workspaceList$, []); const currentWorkspaceId = useObservable(coreStart.workspaces.client.currentWorkspaceId$, ''); @@ -62,6 +62,10 @@ export function WorkspaceDropdownList(props: WorkspaceDropdownListProps) { }); }; + const onCreateWorkspaceClick = () => { + coreStart.application.navigateToApp(WORKSPACE_APP_ID, { path: PATHS.create }); + }; + useEffect(() => { onSearchChange(''); }, [onSearchChange]); @@ -76,7 +80,7 @@ export function WorkspaceDropdownList(props: WorkspaceDropdownListProps) { selectedOptions={currentWorkspaceOption} singleSelection={{ asPlainText: true }} onSearchChange={onSearchChange} - append={Create workspace} + append={Create workspace} /> ); diff --git a/src/plugins/workspace/public/mount.tsx b/src/plugins/workspace/public/mount.tsx index 17646ebecd28..03e4cb38a3a9 100644 --- a/src/plugins/workspace/public/mount.tsx +++ b/src/plugins/workspace/public/mount.tsx @@ -15,7 +15,6 @@ export const mountDropdownList = (core: CoreStart) => { ReactDOM.render( alert('create')} onSwitchWorkspace={async (id: string) => { await new Promise((resolve) => setTimeout(resolve, 1000)); alert(`switch to workspace ${id}`); From 0f92bdbc723b9dbabd6711ce17f98948253b4de0 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Mon, 19 Jun 2023 21:08:35 +0800 Subject: [PATCH 028/174] fix failed test snapshots (#22) fix failed test snapshots temporary fix: fetch functional test from main branch fixed git error which cannot find ref due to feature branch `workspace` not exists on repo opensearch-dashboards-functional-test Signed-off-by: Yulong Ruan --------- Signed-off-by: Yulong Ruan --- .github/workflows/cypress_workflow.yml | 7 +- .../collapsible_nav.test.tsx.snap | 710 +++++++++++++++++- .../header/__snapshots__/header.test.tsx.snap | 270 +++++++ 3 files changed, 961 insertions(+), 26 deletions(-) diff --git a/.github/workflows/cypress_workflow.yml b/.github/workflows/cypress_workflow.yml index 5e78785f9b88..edfcc288fad7 100644 --- a/.github/workflows/cypress_workflow.yml +++ b/.github/workflows/cypress_workflow.yml @@ -55,7 +55,8 @@ jobs: with: path: ${{ env.FTR_PATH }} repository: opensearch-project/opensearch-dashboards-functional-test - ref: '${{ github.base_ref }}' + # revert this to '${{ github.base_ref }}' + ref: 'main' - name: Get Cypress version id: cypress_version @@ -88,7 +89,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 +102,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 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..f781daf9983f 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 @@ -62,6 +62,92 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` } branding={Object {}} closeNav={[Function]} + currentWorkspace$={ + BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } customNavLink$={ BehaviorSubject { "_isScalar": false, @@ -2013,6 +2099,55 @@ exports[`CollapsibleNav renders the default nav 1`] = ` } branding={Object {}} closeNav={[Function]} + currentWorkspace$={ + BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } customNavLink$={ BehaviorSubject { "_isScalar": false, @@ -2315,6 +2450,55 @@ exports[`CollapsibleNav renders the default nav 2`] = ` } branding={Object {}} closeNav={[Function]} + currentWorkspace$={ + BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } customNavLink$={ BehaviorSubject { "_isScalar": false, @@ -2618,6 +2802,55 @@ exports[`CollapsibleNav renders the default nav 3`] = ` } branding={Object {}} closeNav={[Function]} + currentWorkspace$={ + BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } customNavLink$={ BehaviorSubject { "_isScalar": false, @@ -3201,29 +3434,263 @@ exports[`CollapsibleNav with custom branding renders the nav bar in dark mode 1` "thrownError": null, } } - basePath={ - BasePath { - "basePath": "/test", - "get": [Function], - "prepend": [Function], - "remove": [Function], - "serverBasePath": "/test", - } - } - branding={ - Object { - "darkMode": true, - "logo": Object { - "darkModeUrl": "/custom/branded/logo-darkmode.svg", - "defaultUrl": "/custom/branded/logo.svg", - }, - "mark": Object { - "darkModeUrl": "/custom/branded/mark-darkmode.svg", - "defaultUrl": "/custom/branded/mark.svg", - }, - } - } - closeNav={[Function]} + basePath={ + BasePath { + "basePath": "/test", + "get": [Function], + "prepend": [Function], + "remove": [Function], + "serverBasePath": "/test", + } + } + branding={ + Object { + "darkMode": true, + "logo": Object { + "darkModeUrl": "/custom/branded/logo-darkmode.svg", + "defaultUrl": "/custom/branded/logo.svg", + }, + "mark": Object { + "darkModeUrl": "/custom/branded/mark-darkmode.svg", + "defaultUrl": "/custom/branded/mark.svg", + }, + } + } + closeNav={[Function]} + currentWorkspace$={ + BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } customNavLink$={ BehaviorSubject { "_isScalar": false, @@ -6548,6 +7015,203 @@ exports[`CollapsibleNav without custom branding renders the nav bar in default m } branding={Object {}} closeNav={[Function]} + currentWorkspace$={ + BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } customNavLink$={ BehaviorSubject { "_isScalar": false, 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..86b8b2aa3318 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 @@ -301,6 +301,55 @@ exports[`Header handles visibility and lock changes 1`] = ` "thrownError": null, } } + currentWorkspace$={ + BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } customNavLink$={ BehaviorSubject { "_isScalar": false, @@ -5878,6 +5927,55 @@ exports[`Header handles visibility and lock changes 1`] = ` } } closeNav={[Function]} + currentWorkspace$={ + BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } customNavLink$={ BehaviorSubject { "_isScalar": false, @@ -6991,6 +7089,92 @@ exports[`Header renders condensed header 1`] = ` "thrownError": null, } } + currentWorkspace$={ + BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } customNavLink$={ BehaviorSubject { "_isScalar": false, @@ -11349,6 +11533,92 @@ exports[`Header renders condensed header 1`] = ` } } closeNav={[Function]} + currentWorkspace$={ + BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } customNavLink$={ BehaviorSubject { "_isScalar": false, From ec02b387064fe4bf029e56f6b268994c6579252c Mon Sep 17 00:00:00 2001 From: zhichao-aws Date: Tue, 20 Jun 2023 12:11:39 +0800 Subject: [PATCH 029/174] change to currentWorkspace, wrap title using i18n (#20) * change to currentWorkspace, wrap title using i18n Signed-off-by: zhichao-aws * change import Signed-off-by: zhichao-aws * directly return [] if currentWorkspace is null Signed-off-by: zhichao-aws --------- Signed-off-by: zhichao-aws --- .../workspace_dropdown_list.tsx | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx index 4285f7b2fa49..cb411daee599 100644 --- a/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx +++ b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx @@ -7,6 +7,7 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { EuiButton, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import useObservable from 'react-use/lib/useObservable'; +import { i18n } from '@osd/i18n'; import { CoreStart, WorkspaceAttribute } from '../../../../../core/public'; import { WORKSPACE_APP_ID, PATHS } from '../../../common/constants'; @@ -21,24 +22,26 @@ function workspaceToOption(workspace: WorkspaceAttribute): WorkspaceOption { return { label: workspace.name, key: workspace.id, value: workspace }; } +export function getErrorMessage(err: any) { + if (err && err.message) return err.message; + return ''; +} + export function WorkspaceDropdownList(props: WorkspaceDropdownListProps) { const { coreStart, onSwitchWorkspace } = props; const workspaceList = useObservable(coreStart.workspaces.client.workspaceList$, []); - const currentWorkspaceId = useObservable(coreStart.workspaces.client.currentWorkspaceId$, ''); + const currentWorkspace = useObservable(coreStart.workspaces.client.currentWorkspace$, null); const [loading, setLoading] = useState(false); const [workspaceOptions, setWorkspaceOptions] = useState([] as WorkspaceOption[]); const currentWorkspaceOption = useMemo(() => { - const workspace = workspaceList.find((item) => item.id === currentWorkspaceId); - if (!workspace) { - coreStart.notifications.toasts.addDanger( - `can not get current workspace of id [${currentWorkspaceId}]` - ); - return [workspaceToOption({ id: currentWorkspaceId, name: '' })]; + if (!currentWorkspace) { + return []; + } else { + return [workspaceToOption(currentWorkspace)]; } - return [workspaceToOption(workspace)]; - }, [workspaceList, currentWorkspaceId, coreStart]); + }, [currentWorkspace]); const allWorkspaceOptions = useMemo(() => { return workspaceList.map(workspaceToOption); }, [workspaceList]); @@ -55,7 +58,12 @@ export function WorkspaceDropdownList(props: WorkspaceDropdownListProps) { setLoading(true); onSwitchWorkspace(workspaceOption[0].key!) .catch((err) => - coreStart.notifications.toasts.addDanger('some error happens in workspace service') + coreStart.notifications.toasts.addDanger({ + title: i18n.translate('workspace.dropdownList.switchWorkspaceErrorTitle', { + defaultMessage: 'some error happens when switching workspace', + }), + text: getErrorMessage(err), + }) ) .finally(() => { setLoading(false); From 6aa12fa50b43013e012ed03c019cb025c1333183 Mon Sep 17 00:00:00 2001 From: raintygao Date: Tue, 20 Jun 2023 15:24:19 +0800 Subject: [PATCH 030/174] add workspace switch (#17) * feat: update workspace switch Signed-off-by: tygao * fix: fix switch error Signed-off-by: tygao * fix: fix prettier after merge Signed-off-by: tygao * chore: remove extra code after merge Signed-off-by: tygao --------- Signed-off-by: tygao --- .../workspace_dropdown_list.tsx | 34 ++++++++----------- src/plugins/workspace/public/mount.tsx | 12 +------ 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx index cb411daee599..f7e0113e2c4a 100644 --- a/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx +++ b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx @@ -7,7 +7,6 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { EuiButton, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import useObservable from 'react-use/lib/useObservable'; -import { i18n } from '@osd/i18n'; import { CoreStart, WorkspaceAttribute } from '../../../../../core/public'; import { WORKSPACE_APP_ID, PATHS } from '../../../common/constants'; @@ -15,7 +14,6 @@ type WorkspaceOption = EuiComboBoxOptionOption; interface WorkspaceDropdownListProps { coreStart: CoreStart; - onSwitchWorkspace: (workspaceId: string) => Promise; } function workspaceToOption(workspace: WorkspaceAttribute): WorkspaceOption { @@ -28,7 +26,8 @@ export function getErrorMessage(err: any) { } export function WorkspaceDropdownList(props: WorkspaceDropdownListProps) { - const { coreStart, onSwitchWorkspace } = props; + const { coreStart } = props; + const workspaceList = useObservable(coreStart.workspaces.client.workspaceList$, []); const currentWorkspace = useObservable(coreStart.workspaces.client.currentWorkspace$, null); @@ -53,22 +52,19 @@ export function WorkspaceDropdownList(props: WorkspaceDropdownListProps) { [allWorkspaceOptions] ); - const onChange = (workspaceOption: WorkspaceOption[]) => { - /** switch the workspace */ - setLoading(true); - onSwitchWorkspace(workspaceOption[0].key!) - .catch((err) => - coreStart.notifications.toasts.addDanger({ - title: i18n.translate('workspace.dropdownList.switchWorkspaceErrorTitle', { - defaultMessage: 'some error happens when switching workspace', - }), - text: getErrorMessage(err), - }) - ) - .finally(() => { - setLoading(false); - }); - }; + const onChange = useCallback( + (workspaceOption: WorkspaceOption[]) => { + /** switch the workspace */ + setLoading(true); + const id = workspaceOption[0].key!; + const newUrl = coreStart.workspaces?.formatUrlWithWorkspaceId(window.location.href, id); + if (newUrl) { + window.location.href = newUrl; + } + setLoading(false); + }, + [coreStart.workspaces] + ); const onCreateWorkspaceClick = () => { coreStart.application.navigateToApp(WORKSPACE_APP_ID, { path: PATHS.create }); diff --git a/src/plugins/workspace/public/mount.tsx b/src/plugins/workspace/public/mount.tsx index 03e4cb38a3a9..c4ca29479d23 100644 --- a/src/plugins/workspace/public/mount.tsx +++ b/src/plugins/workspace/public/mount.tsx @@ -12,17 +12,7 @@ export const mountDropdownList = (core: CoreStart) => { core.chrome.navControls.registerLeft({ order: 0, mount: (element) => { - ReactDOM.render( - { - await new Promise((resolve) => setTimeout(resolve, 1000)); - alert(`switch to workspace ${id}`); - }} - // onSwitchWorkspace={(id: string) => alert(`switch to workspace ${id}`)} - />, - element - ); + ReactDOM.render(, element); return () => { ReactDOM.unmountComponentAtNode(element); }; From 138e338e5a0521967c3536f2d7b80fcceb39988e Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Wed, 21 Jun 2023 10:46:57 +0800 Subject: [PATCH 031/174] Add update workspace page (#25) Signed-off-by: gaobinlong --- src/plugins/workspace/common/constants.ts | 3 + .../workspace/public/components/routes.ts | 6 + .../workspace_creator/workspace_creator.tsx | 7 +- .../workspace_creator/workspace_form.tsx | 26 +++- .../public/components/workspace_overview.tsx | 24 +++- .../components/workspace_updater/index.tsx | 6 + .../workspace_updater/workspace_updater.tsx | 117 ++++++++++++++++++ 7 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 src/plugins/workspace/public/components/workspace_updater/index.tsx create mode 100644 src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 633c402ffa86..903f028539dd 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -10,4 +10,7 @@ export const WORKSPACE_ID_IN_SESSION_STORAGE = '_workspace_id_'; export const PATHS = { create: '/create', overview: '/overview', + update: '/update', }; +export const WORKSPACE_OP_TYPE_CREATE = 'create'; +export const WORKSPACE_OP_TYPE_UPDATE = 'update'; diff --git a/src/plugins/workspace/public/components/routes.ts b/src/plugins/workspace/public/components/routes.ts index 99a0a1402afa..33f7b4774713 100644 --- a/src/plugins/workspace/public/components/routes.ts +++ b/src/plugins/workspace/public/components/routes.ts @@ -6,6 +6,7 @@ import { PATHS } from '../../common/constants'; import { WorkspaceCreator } from './workspace_creator'; +import { WorkspaceUpdater } from './workspace_updater'; import { WorkspaceOverview } from './workspace_overview'; export interface RouteConfig { @@ -26,4 +27,9 @@ export const ROUTES: RouteConfig[] = [ Component: WorkspaceOverview, label: 'Overview', }, + { + path: PATHS.update, + Component: WorkspaceUpdater, + label: 'Update', + }, ]; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx index 59c4ce0c444d..e4b5a4cf7693 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -10,6 +10,7 @@ import { i18n } from '@osd/i18n'; import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public'; import { WorkspaceForm, WorkspaceFormData } from './workspace_form'; +import { WORKSPACE_OP_TYPE_CREATE } from '../../../common/constants'; export const WorkspaceCreator = () => { const { @@ -61,7 +62,11 @@ export const WorkspaceCreator = () => { style={{ width: '100%', maxWidth: 1000 }} > {application && ( - + )} diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx index 41639701c435..e561c3850827 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx @@ -32,6 +32,7 @@ import { import { WorkspaceTemplate } from '../../../../../core/types'; import { AppNavLinkStatus, ApplicationStart } from '../../../../../core/public'; import { useApplications, useWorkspaceTemplate } from '../../hooks'; +import { WORKSPACE_OP_TYPE_CREATE, WORKSPACE_OP_TYPE_UPDATE } from '../../../common/constants'; interface WorkspaceFeature { id: string; @@ -62,8 +63,14 @@ interface WorkspaceFormProps { application: ApplicationStart; onSubmit?: (formData: WorkspaceFormData) => void; defaultValues?: WorkspaceFormData; + opType?: string; } -export const WorkspaceForm = ({ application, onSubmit, defaultValues }: WorkspaceFormProps) => { +export const WorkspaceForm = ({ + application, + onSubmit, + defaultValues, + opType, +}: WorkspaceFormProps) => { const { workspaceTemplates, templateFeatureMap } = useWorkspaceTemplate(application); const applications = useApplications(application); @@ -199,7 +206,7 @@ export const WorkspaceForm = ({ application, onSubmit, defaultValues }: Workspac - + } > - + @@ -329,9 +336,16 @@ export const WorkspaceForm = ({ application, onSubmit, defaultValues }: Workspac - - Create workspace - + {opType === WORKSPACE_OP_TYPE_CREATE && ( + + Create workspace + + )} + {opType === WORKSPACE_OP_TYPE_UPDATE && ( + + Update workspace + + )} ); diff --git a/src/plugins/workspace/public/components/workspace_overview.tsx b/src/plugins/workspace/public/components/workspace_overview.tsx index 0da5b7cd1e66..76986f141784 100644 --- a/src/plugins/workspace/public/components/workspace_overview.tsx +++ b/src/plugins/workspace/public/components/workspace_overview.tsx @@ -7,25 +7,43 @@ import React from 'react'; import { EuiPageHeader, EuiButton, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; import { useObservable } from 'react-use'; import { of } from 'rxjs'; +import { i18n } from '@osd/i18n'; +import { PATHS } from '../../common/constants'; +import { ApplicationStart } from '../../../../core/public'; +import { WORKSPACE_APP_ID, WORKSPACE_ID_IN_SESSION_STORAGE } from '../../common/constants'; import { useOpenSearchDashboards } from '../../../../../src/plugins/opensearch_dashboards_react/public'; export const WorkspaceOverview = () => { const { - services: { workspaces }, - } = useOpenSearchDashboards(); + services: { workspaces, application, notifications }, + } = useOpenSearchDashboards<{ application: ApplicationStart }>(); const currentWorkspace = useObservable( workspaces ? workspaces.client.currentWorkspace$ : of(null) ); + const onUpdateWorkspaceClick = () => { + if (!currentWorkspace || !currentWorkspace.id) { + notifications?.toasts.addDanger({ + title: i18n.translate('Cannot find current workspace', { + defaultMessage: 'Cannot update workspace', + }), + }); + return; + } + application.navigateToApp(WORKSPACE_APP_ID, { + path: PATHS.update + '?' + WORKSPACE_ID_IN_SESSION_STORAGE + '=' + currentWorkspace.id, + }); + }; + return ( <> Delete, - Update, + Update, ]} /> diff --git a/src/plugins/workspace/public/components/workspace_updater/index.tsx b/src/plugins/workspace/public/components/workspace_updater/index.tsx new file mode 100644 index 000000000000..711f19fd25f6 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_updater/index.tsx @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspaceUpdater } from './workspace_updater'; diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx new file mode 100644 index 000000000000..2706ee5363d5 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx @@ -0,0 +1,117 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { EuiPage, EuiPageBody, EuiPageHeader, EuiPageContent } from '@elastic/eui'; +import { useObservable } from 'react-use'; +import { i18n } from '@osd/i18n'; +import { of } from 'rxjs'; + +import { WorkspaceAttribute } from 'opensearch-dashboards/public'; +import { useOpenSearchDashboards } from '../../../../../../src/plugins/opensearch_dashboards_react/public'; + +import { PATHS } from '../../../common/constants'; +import { WorkspaceForm, WorkspaceFormData } from '../workspace_creator/workspace_form'; +import { + WORKSPACE_APP_ID, + WORKSPACE_ID_IN_SESSION_STORAGE, + WORKSPACE_OP_TYPE_UPDATE, +} from '../../../common/constants'; +import { ApplicationStart } from '../../../../../core/public'; + +export const WorkspaceUpdater = () => { + const { + services: { application, workspaces, notifications }, + } = useOpenSearchDashboards<{ application: ApplicationStart }>(); + + const currentWorkspace = useObservable( + workspaces ? workspaces.client.currentWorkspace$ : of(null) + ); + + const excludedAttribute = 'id'; + const { [excludedAttribute]: removedProperty, ...otherAttributes } = + currentWorkspace || ({} as WorkspaceAttribute); + + const [currentWorkspaceFormData, setCurrentWorkspaceFormData] = useState< + Omit + >(otherAttributes); + + useEffect(() => { + const { id, ...others } = currentWorkspace || ({} as WorkspaceAttribute); + setCurrentWorkspaceFormData(others); + }, [workspaces, currentWorkspace, excludedAttribute]); + + const handleWorkspaceFormSubmit = useCallback( + async (data: WorkspaceFormData) => { + let result; + if (!currentWorkspace) { + notifications?.toasts.addDanger({ + title: i18n.translate('Cannot find current workspace', { + defaultMessage: 'Cannot update workspace', + }), + }); + return; + } + try { + result = await workspaces?.client.update(currentWorkspace?.id, data); + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.update.failed', { + defaultMessage: 'Failed to update workspace', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + return; + } + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.update.success', { + defaultMessage: 'Update workspace successfully', + }), + }); + application.navigateToApp(WORKSPACE_APP_ID, { + path: PATHS.overview + '?' + WORKSPACE_ID_IN_SESSION_STORAGE + '=' + currentWorkspace.id, + }); + return; + } + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.update.failed', { + defaultMessage: 'Failed to update workspace', + }), + text: result?.error, + }); + }, + [notifications?.toasts, workspaces?.client, currentWorkspace, application] + ); + + if (!currentWorkspaceFormData.name) { + return null; + } + + return ( + + + + + {application && ( + + )} + + + + ); +}; From 8fcd9665c29cdfe9bc0771ff5b407e151d858002 Mon Sep 17 00:00:00 2001 From: Yuye Zhu Date: Wed, 21 Jun 2023 14:59:55 +0800 Subject: [PATCH 032/174] Delete Workspace (#24) * add delete workspace modal Signed-off-by: yuye-aws * implement delete on workspace overview page Signed-off-by: yuye-aws * fix export on delete workspace modal Signed-off-by: yuye-aws * add try catch to handle errors for workspace delete Signed-off-by: yuye-aws * move visibility control to workspace overview page exlusively Signed-off-by: yuye-aws * remove unused import Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws --- .../delete_workspace_modal.tsx | 71 +++++++++++++++++++ .../delete_workspace_modal/index.ts | 6 ++ .../public/components/workspace_overview.tsx | 56 +++++++++++++-- 3 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.tsx create mode 100644 src/plugins/workspace/public/components/delete_workspace_modal/index.ts diff --git a/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.tsx b/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.tsx new file mode 100644 index 000000000000..d1f29a140718 --- /dev/null +++ b/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.tsx @@ -0,0 +1,71 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, + EuiText, +} from '@elastic/eui'; + +interface DeleteWorkspaceModalProps { + selectedItems: string[]; + onClose: () => void; + onConfirm: () => void; +} + +export function DeleteWorkspaceModal(props: DeleteWorkspaceModalProps) { + const [value, setValue] = useState(''); + const { onClose, onConfirm, selectedItems } = props; + + return ( + + + Delete workspace + + + +
+

The following workspace will be permanently deleted. This action cannot be undone.

+
    + {selectedItems.map((item) => ( +
  • {item}
  • + ))} +
+ + + To confirm your action, type delete. + + setValue(e.target.value)} + /> +
+
+ + + Cancel + + Delete + + +
+ ); +} diff --git a/src/plugins/workspace/public/components/delete_workspace_modal/index.ts b/src/plugins/workspace/public/components/delete_workspace_modal/index.ts new file mode 100644 index 000000000000..3466e180c54a --- /dev/null +++ b/src/plugins/workspace/public/components/delete_workspace_modal/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './delete_workspace_modal'; diff --git a/src/plugins/workspace/public/components/workspace_overview.tsx b/src/plugins/workspace/public/components/workspace_overview.tsx index 76986f141784..b0eabce71555 100644 --- a/src/plugins/workspace/public/components/workspace_overview.tsx +++ b/src/plugins/workspace/public/components/workspace_overview.tsx @@ -3,17 +3,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import React, { useState } from 'react'; import { EuiPageHeader, EuiButton, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; import { useObservable } from 'react-use'; import { of } from 'rxjs'; import { i18n } from '@osd/i18n'; -import { PATHS } from '../../common/constants'; import { ApplicationStart } from '../../../../core/public'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { DeleteWorkspaceModal } from './delete_workspace_modal'; +import { PATHS } from '../../common/constants'; import { WORKSPACE_APP_ID, WORKSPACE_ID_IN_SESSION_STORAGE } from '../../common/constants'; -import { useOpenSearchDashboards } from '../../../../../src/plugins/opensearch_dashboards_react/public'; - export const WorkspaceOverview = () => { const { services: { workspaces, application, notifications }, @@ -23,6 +23,43 @@ export const WorkspaceOverview = () => { workspaces ? workspaces.client.currentWorkspace$ : of(null) ); + const workspaceId = currentWorkspace?.id; + const workspaceName = currentWorkspace?.name; + const [deleteWorkspaceModalVisible, setDeleteWorkspaceModalVisible] = useState(false); + + const deleteWorkspace = async () => { + if (workspaceId) { + let result; + try { + result = await workspaces?.client.delete(workspaceId); + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.delete.failed', { + defaultMessage: 'Failed to delete workspace', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + return setDeleteWorkspaceModalVisible(false); + } + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.delete.success', { + defaultMessage: 'Delete workspace successfully', + }), + }); + } else { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.delete.failed', { + defaultMessage: 'Failed to delete workspace', + }), + text: result?.error, + }); + } + } + setDeleteWorkspaceModalVisible(false); + await application.navigateToApp('home'); + }; + const onUpdateWorkspaceClick = () => { if (!currentWorkspace || !currentWorkspace.id) { notifications?.toasts.addDanger({ @@ -42,11 +79,22 @@ export const WorkspaceOverview = () => { setDeleteWorkspaceModalVisible(true)}> + Delete + , + Update, Delete, Update, ]} /> + {deleteWorkspaceModalVisible && ( + setDeleteWorkspaceModalVisible(false)} + selectedItems={workspaceName ? [workspaceName] : []} + /> + )}

Workspace

From beaebd95800ab7d7be3cbfba07bb3242c51a931a Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Sun, 25 Jun 2023 10:17:23 +0800 Subject: [PATCH 033/174] feat: redirect to overview page after workspace switch (#26) Signed-off-by: Lin Wang --- .../workspace_dropdown_list.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx index f7e0113e2c4a..9ad6b7b27e7b 100644 --- a/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx +++ b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx @@ -57,13 +57,19 @@ export function WorkspaceDropdownList(props: WorkspaceDropdownListProps) { /** switch the workspace */ setLoading(true); const id = workspaceOption[0].key!; - const newUrl = coreStart.workspaces?.formatUrlWithWorkspaceId(window.location.href, id); + const newUrl = coreStart.workspaces?.formatUrlWithWorkspaceId( + coreStart.application.getUrlForApp(WORKSPACE_APP_ID, { + path: PATHS.overview, + absolute: true, + }), + id + ); if (newUrl) { window.location.href = newUrl; } setLoading(false); }, - [coreStart.workspaces] + [coreStart.workspaces, coreStart.application] ); const onCreateWorkspaceClick = () => { From fffd0732b1f9b61a718dd5483afb2ca8a11589ba Mon Sep 17 00:00:00 2001 From: raintygao Date: Sun, 25 Jun 2023 11:37:27 +0800 Subject: [PATCH 034/174] update menu filter logic (#28) * feat: update menu logic Signed-off-by: tygao * fix: use navLinks to filter Signed-off-by: tygao --------- Signed-off-by: tygao --- .../chrome/ui/header/collapsible_nav.tsx | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index 18aeb9f9bb6f..5c7a6dab1cbb 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -126,11 +126,11 @@ export function CollapsibleNav({ const appId = useObservable(observables.appId$, ''); const currentWorkspace = useObservable(observables.currentWorkspace$); const lockRef = useRef(null); - const groupedNavLinks = groupBy(navLinks, (link) => link?.category?.id); + const filterdLinks = getFilterLinks(currentWorkspace, navLinks); + const groupedNavLinks = groupBy(filterdLinks, (link) => link?.category?.id); const { undefined: unknowns = [], ...allCategorizedLinks } = groupedNavLinks; - const filterdLinks = getFilterLinks(currentWorkspace, allCategorizedLinks); - const categoryDictionary = getAllCategories(filterdLinks); - const orderedCategories = getOrderedCategories(filterdLinks, categoryDictionary); + const categoryDictionary = getAllCategories(allCategorizedLinks); + const orderedCategories = getOrderedCategories(allCategorizedLinks, categoryDictionary); const readyForEUI = (link: ChromeNavLink, needsIcon: boolean = false) => { return createEuiListItem({ @@ -145,20 +145,13 @@ export function CollapsibleNav({ function getFilterLinks( workspace: WorkspaceAttribute | null | undefined, - categorizedLinks: Record + allNavLinks: ChromeNavLink[] ) { - // plugins are in this dictionary - const pluginsDictionary = categorizedLinks.opensearch; - if (!pluginsDictionary) return categorizedLinks; + if (!workspace) return allNavLinks; - const features = workspace?.features ?? []; - const newPluginsDictionary = pluginsDictionary.filter((item) => features.indexOf(item.id) > -1); - if (newPluginsDictionary.length === 0) { - delete categorizedLinks.opensearch; - } else { - categorizedLinks.opensearch = newPluginsDictionary; - } - return categorizedLinks; + const features = workspace.features ?? []; + const links = allNavLinks.filter((item) => features.indexOf(item.id) > -1); + return links; } return ( From d9371383a076734b9fc1bad8c6a6e82af43cc956 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Sun, 25 Jun 2023 15:36:28 +0800 Subject: [PATCH 035/174] feat: redirect to workspace overview page after created success (#29) Signed-off-by: Lin Wang --- .../workspace_creator/workspace_creator.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx index e4b5a4cf7693..bbea2d2aa0a2 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -10,7 +10,7 @@ import { i18n } from '@osd/i18n'; import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public'; import { WorkspaceForm, WorkspaceFormData } from './workspace_form'; -import { WORKSPACE_OP_TYPE_CREATE } from '../../../common/constants'; +import { PATHS, WORKSPACE_APP_ID, WORKSPACE_OP_TYPE_CREATE } from '../../../common/constants'; export const WorkspaceCreator = () => { const { @@ -37,6 +37,15 @@ export const WorkspaceCreator = () => { defaultMessage: 'Create workspace successfully', }), }); + if (application && workspaces) { + window.location.href = workspaces.formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_APP_ID, { + path: PATHS.overview, + absolute: true, + }), + result.result.id + ); + } return; } notifications?.toasts.addDanger({ @@ -46,7 +55,7 @@ export const WorkspaceCreator = () => { text: result?.error, }); }, - [notifications?.toasts, workspaces?.client] + [notifications?.toasts, workspaces, application] ); return ( From d0d795d342afb16aad6a3ecaebbbc930c8191ff5 Mon Sep 17 00:00:00 2001 From: suzhou Date: Sun, 25 Jun 2023 16:27:57 +0800 Subject: [PATCH 036/174] [Feature] Complied saved_objects create/find (#18) * temp: save Signed-off-by: SuZhoue-Joe * feat: make create/find support workspaces Signed-off-by: SuZhoue-Joe * feat: extract management code Signed-off-by: SuZhoue-Joe * fix: type check Signed-off-by: SuZhoue-Joe * fix: build error Signed-off-by: SuZhoue-Joe * feat: enable workspaces on saved client server side Signed-off-by: SuZhoue-Joe * feat: some optimization Signed-off-by: SuZhoue-Joe * feat: extract management code Signed-off-by: SuZhoue-Joe * feat: merge fix Signed-off-by: SuZhoue-Joe * feat: optimize code Signed-off-by: SuZhoue-Joe * feat: reuse common function Signed-off-by: SuZhoue-Joe * feat: optimize code when create Signed-off-by: SuZhoue-Joe * feat: remove useless test code Signed-off-by: SuZhoue-Joe --------- Signed-off-by: SuZhoue-Joe --- .../saved_objects/saved_objects_client.ts | 33 +++++++++++++-- .../export/get_sorted_objects_for_export.ts | 9 +++- .../import/create_saved_objects.ts | 5 ++- .../import/import_saved_objects.ts | 2 + src/core/server/saved_objects/import/types.ts | 2 + .../migrations/core/build_active_mappings.ts | 3 ++ .../saved_objects/routes/bulk_create.ts | 8 +++- .../server/saved_objects/routes/create.ts | 12 +++++- .../server/saved_objects/routes/export.ts | 11 ++++- src/core/server/saved_objects/routes/find.ts | 4 ++ .../server/saved_objects/routes/import.ts | 6 ++- src/core/server/saved_objects/routes/utils.ts | 18 +++++++- .../saved_objects/serialization/serializer.ts | 5 ++- .../saved_objects/serialization/types.ts | 2 + .../saved_objects/service/lib/repository.ts | 27 ++++++++++-- .../service/lib/search_dsl/query_params.ts | 42 +++++++++++++++++++ .../service/lib/search_dsl/search_dsl.ts | 3 ++ src/core/server/saved_objects/types.ts | 2 + src/plugins/workspace/public/plugin.ts | 11 +++++ 19 files changed, 189 insertions(+), 16 deletions(-) diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 6e5482614e40..da42bfc8c968 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -42,6 +42,7 @@ import { import { SimpleSavedObject } from './simple_saved_object'; import { HttpFetchOptions, HttpSetup } from '../http'; +import { WorkspacesStart } from '../workspace'; type SavedObjectsFindOptions = Omit< SavedObjectFindOptionsServer, @@ -61,6 +62,7 @@ export interface SavedObjectsCreateOptions { /** {@inheritDoc SavedObjectsMigrationVersion} */ migrationVersion?: SavedObjectsMigrationVersion; references?: SavedObjectReference[]; + workspaces?: string[]; } /** @@ -183,6 +185,7 @@ const getObjectsToFetch = (queue: BatchQueueEntry[]): ObjectTypeAndId[] => { export class SavedObjectsClient { private http: HttpSetup; private batchQueue: BatchQueueEntry[]; + private currentWorkspaceId?: string; /** * Throttled processing of get requests into bulk requests at 100ms interval @@ -227,6 +230,15 @@ export class SavedObjectsClient { this.batchQueue = []; } + private async _getCurrentWorkspace(): Promise { + return this.currentWorkspaceId || null; + } + + public async setCurrentWorkspace(workspaceId: string): Promise { + this.currentWorkspaceId = workspaceId; + return true; + } + /** * Persists an object * @@ -235,7 +247,7 @@ export class SavedObjectsClient { * @param options * @returns */ - public create = ( + public create = async ( type: string, attributes: T, options: SavedObjectsCreateOptions = {} @@ -248,6 +260,7 @@ export class SavedObjectsClient { const query = { overwrite: options.overwrite, }; + const currentWorkspaceId = await this._getCurrentWorkspace(); const createRequest: Promise> = this.savedObjectsFetch(path, { method: 'POST', @@ -256,6 +269,11 @@ export class SavedObjectsClient { attributes, migrationVersion: options.migrationVersion, references: options.references, + ...(options.workspaces || currentWorkspaceId + ? { + workspaces: options.workspaces || [currentWorkspaceId], + } + : {}), }), }); @@ -328,7 +346,7 @@ export class SavedObjectsClient { * @property {object} [options.hasReference] - { type, id } * @returns A find result with objects matching the specified search. */ - public find = ( + public find = async ( options: SavedObjectsFindOptions ): Promise> => { const path = this.getPath(['_find']); @@ -345,9 +363,18 @@ export class SavedObjectsClient { filter: 'filter', namespaces: 'namespaces', preference: 'preference', + workspaces: 'workspaces', }; - const renamedQuery = renameKeys(renameMap, options); + const workspaces = [ + ...(options.workspaces || [await this._getCurrentWorkspace()]), + 'public', + ].filter((item) => item); + + const renamedQuery = renameKeys(renameMap, { + ...options, + workspaces, + }); const query = pick.apply(null, [renamedQuery, ...Object.values(renameMap)]) as Partial< Record >; 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..8ca085639f10 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,7 @@ 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, workspaces }); const erroredObjects = bulkGetResult.saved_objects.filter((obj) => !!obj.error); if (erroredObjects.length) { const err = Boom.badRequest(); @@ -121,6 +125,7 @@ async function fetchObjectsToExport({ search, perPage: exportSizeLimit, namespaces: namespace ? [namespace] : undefined, + workspaces, }); if (findResponse.total > exportSizeLimit) { throw Boom.badRequest(`Can't export more than ${exportSizeLimit} objects`); @@ -153,6 +158,7 @@ export async function exportSavedObjectsToStream({ includeReferencesDeep = false, excludeExportDetails = false, namespace, + workspaces, }: SavedObjectsExportOptions) { const rootObjects = await fetchObjectsToExport({ types, @@ -161,6 +167,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/create_saved_objects.ts b/src/core/server/saved_objects/import/create_saved_objects.ts index a3a1eebbd2ab..b67cffce1e96 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; } @@ -110,7 +113,7 @@ export const createSavedObjects = async ({ // remap results to reflect the object IDs that were submitted for import // this ensures that consumers understand the results const remappedResults = expectedResults.map>((result) => { - const { id } = objectIdMap.get(`${result.type}:${result.id}`)!; + const { id } = objectIdMap.get(`${result.type}:${result.id}`) || ({} as SavedObject); // also, include a `destinationId` field if the object create attempt was made with a different ID return { ...result, id, ...(id !== result.id && { destinationId: result.id }) }; }); 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..68104db85e6f 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.ts @@ -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); @@ -118,6 +119,7 @@ export async function importSavedObjectsFromStream({ importIdMap, overwrite, namespace, + workspaces, }; const createSavedObjectsResult = await createSavedObjects(createSavedObjectsParams); errorAccumulator = [...errorAccumulator, ...createSavedObjectsResult.errors]; diff --git a/src/core/server/saved_objects/import/types.ts b/src/core/server/saved_objects/import/types.ts index 88beacb9d2fd..ab13fbfe4658 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[]; } /** 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..812cc1fd5eb1 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 @@ -175,6 +175,9 @@ function defaultMapping(): IndexMapping { }, }, }, + workspaces: { + 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..61a458d9a618 100644 --- a/src/core/server/saved_objects/routes/bulk_create.ts +++ b/src/core/server/saved_objects/routes/bulk_create.ts @@ -30,6 +30,7 @@ import { schema } from '@osd/config-schema'; import { IRouter } from '../../http'; +import { formatWorkspaces, workspacesValidator } from './utils'; export const registerBulkCreateRoute = (router: IRouter) => { router.post( @@ -38,6 +39,7 @@ export const registerBulkCreateRoute = (router: IRouter) => { validate: { query: schema.object({ overwrite: schema.boolean({ defaultValue: false }), + workspaces: workspacesValidator, }), body: schema.arrayOf( schema.object({ @@ -62,7 +64,11 @@ 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 }); + const workspaces = formatWorkspaces(req.query.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..447ec8f6d7de 100644 --- a/src/core/server/saved_objects/routes/find.ts +++ b/src/core/server/saved_objects/routes/find.ts @@ -30,6 +30,7 @@ import { schema } from '@osd/config-schema'; import { IRouter } from '../../http'; +import { formatWorkspaces, workspacesValidator } from './utils'; export const registerFindRoute = (router: IRouter) => { router.get( @@ -59,6 +60,7 @@ export const registerFindRoute = (router: IRouter) => { namespaces: schema.maybe( schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) ), + workspaces: workspacesValidator, }), }, }, @@ -67,6 +69,7 @@ export const registerFindRoute = (router: IRouter) => { const namespaces = typeof req.query.namespaces === 'string' ? [req.query.namespaces] : req.query.namespaces; + const workspaces = formatWorkspaces(query.workspaces); const result = await context.core.savedObjects.client.find({ perPage: query.per_page, @@ -81,6 +84,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..794f8ef84a79 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -34,7 +34,7 @@ import { schema } from '@osd/config-schema'; import { IRouter } from '../../http'; import { importSavedObjectsFromStream } from '../import'; import { SavedObjectConfig } from '../saved_objects_config'; -import { createSavedObjectsStreamFromNdJson } from './utils'; +import { createSavedObjectsStreamFromNdJson, formatWorkspaces, workspacesValidator } from './utils'; interface FileStream extends Readable { hapi: { @@ -60,6 +60,7 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) { overwrite: schema.boolean({ defaultValue: false }), createNewCopies: schema.boolean({ defaultValue: false }), + workspaces: workspacesValidator, }, { validate: (object) => { @@ -91,6 +92,8 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) }); } + const workspaces = formatWorkspaces(req.query.workspaces); + const result = await importSavedObjectsFromStream({ savedObjectsClient: context.core.savedObjects.client, typeRegistry: context.core.savedObjects.typeRegistry, @@ -98,6 +101,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/utils.ts b/src/core/server/saved_objects/routes/utils.ts index a4c9375e4716..c2b77655ff18 100644 --- a/src/core/server/saved_objects/routes/utils.ts +++ b/src/core/server/saved_objects/routes/utils.ts @@ -27,7 +27,7 @@ * specific language governing permissions and limitations * under the License. */ - +import { schema } from '@osd/config-schema'; import { Readable } from 'stream'; import { SavedObject, SavedObjectsExportResultDetails } from 'src/core/server'; import { @@ -74,3 +74,19 @@ export function validateObjects( .join(', ')}`; } } + +export const workspacesValidator = schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) +); + +export function formatWorkspaces(workspaces?: string | string[]): string[] | undefined { + if (Array.isArray(workspaces)) { + return workspaces; + } + + if (!workspaces) { + return undefined; + } + + return [workspaces]; +} 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/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index bccfd8ff2265..4a8ceb5e0b3b 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); @@ -289,6 +290,7 @@ export class SavedObjectsRepository { migrationVersion, updated_at: time, ...(Array.isArray(references) && { references }), + ...(Array.isArray(workspaces) && { workspaces }), }); const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); @@ -402,6 +404,16 @@ export class SavedObjectsRepository { object: { initialNamespaces, version, ...object }, method, } = expectedBulkGetResult.value; + let savedObjectWorkspaces: string[] | undefined; + if (expectedBulkGetResult.value.method === 'create') { + if (options.workspaces) { + savedObjectWorkspaces = Array.from(new Set([...(options.workspaces || [])])); + } + } else if (object.workspaces) { + savedObjectWorkspaces = Array.from( + new Set([...object.workspaces, ...(options.workspaces || [])]) + ); + } if (opensearchRequestIndex !== undefined) { const indexFound = bulkGetResponse?.statusCode !== 404; const actualResult = indexFound @@ -452,6 +464,7 @@ export class SavedObjectsRepository { updated_at: time, references: object.references || [], originId: object.originId, + workspaces: savedObjectWorkspaces, }) as SavedObjectSanitizedDoc ), }; @@ -736,6 +749,7 @@ export class SavedObjectsRepository { typeToNamespacesMap, filter, preference, + workspaces, } = options; if (!type && !typeToNamespacesMap) { @@ -809,6 +823,7 @@ export class SavedObjectsRepository { typeToNamespacesMap, hasReference, kueryNode, + workspaces, }), }, }; @@ -976,7 +991,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 +1006,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 +1071,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 +1086,7 @@ export class SavedObjectsRepository { version: encodeHitVersion(body), namespaces, ...(originId && { originId }), + ...(workspaces && { workspaces }), references, attributes, }; @@ -1452,12 +1469,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 +1772,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 +1787,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.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index 5bbb0a1fe24f..5a2aae5943a6 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 @@ -128,6 +128,35 @@ function getClauseForType( }; } +/** + * Gets the clause that will filter for the workspace. + */ +function getClauseForWorkspace(workspace: string) { + if (workspace === '*') { + return { + bool: { + must: { + match_all: {}, + }, + }, + }; + } + + if (workspace === 'public') { + return { + bool: { + must_not: [{ exists: { field: 'workspaces' } }], + }, + }; + } + + return { + bool: { + must: [{ term: { workspaces: workspace } }], + }, + }; +} + interface HasReferenceQueryParams { type: string; id: string; @@ -144,6 +173,7 @@ interface QueryParams { defaultSearchOperator?: string; hasReference?: HasReferenceQueryParams; kueryNode?: KueryNode; + workspaces?: string[]; } export function getClauseForReference(reference: HasReferenceQueryParams) { @@ -200,6 +230,7 @@ export function getQueryParams({ defaultSearchOperator, hasReference, kueryNode, + workspaces, }: QueryParams) { const types = getTypes( registry, @@ -224,6 +255,17 @@ export function getQueryParams({ ], }; + if (workspaces) { + bool.filter.push({ + bool: { + should: workspaces.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/types.ts b/src/core/server/saved_objects/types.ts index 3e2553b8ce51..33862cb149fb 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -110,6 +110,7 @@ export interface SavedObjectsFindOptions { typeToNamespacesMap?: Map; /** An optional OpenSearch preference value to be used for the query **/ preference?: string; + workspaces?: string[]; } /** @@ -119,6 +120,7 @@ export interface SavedObjectsFindOptions { export interface SavedObjectsBaseOptions { /** Specify the namespace for this operation */ namespace?: string; + workspaces?: string[]; } /** diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index df601a3f1a29..f570ac9c5ec9 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -100,6 +100,16 @@ export class WorkspacesPlugin implements Plugin<{}, {}> { return {}; } + private async _changeSavedObjectCurrentWorkspace() { + const startServices = await this.core?.getStartServices(); + if (startServices) { + const coreStart = startServices[0]; + coreStart.workspaces.client.currentWorkspaceId$.subscribe((currentWorkspaceId) => { + coreStart.savedObjects.client.setCurrentWorkspace(currentWorkspaceId); + }); + } + } + public start(core: CoreStart) { mountDropdownList(core); @@ -108,6 +118,7 @@ export class WorkspacesPlugin implements Plugin<{}, {}> { baseUrl: core.http.basePath.get(), href: core.application.getUrlForApp(WORKSPACE_APP_ID, { path: PATHS.overview }), }); + this._changeSavedObjectCurrentWorkspace(); return {}; } } From 82f776ad42783c2dfda575a4fe0d21914435472a Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Sun, 25 Jun 2023 16:45:34 +0800 Subject: [PATCH 037/174] feat: redirect to workspace update page after workspace switch (#30) --- .../workspace_dropdown_list/workspace_dropdown_list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx index 9ad6b7b27e7b..cd9e7b829d69 100644 --- a/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx +++ b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx @@ -59,7 +59,7 @@ export function WorkspaceDropdownList(props: WorkspaceDropdownListProps) { const id = workspaceOption[0].key!; const newUrl = coreStart.workspaces?.formatUrlWithWorkspaceId( coreStart.application.getUrlForApp(WORKSPACE_APP_ID, { - path: PATHS.overview, + path: PATHS.update, absolute: true, }), id From 18a5a09f7c7e3fbe797b5845c1ecdf8cc6fdb183 Mon Sep 17 00:00:00 2001 From: Yuye Zhu Date: Sun, 25 Jun 2023 16:49:08 +0800 Subject: [PATCH 038/174] Move delete button to update page (#27) * add delete workspace modal Signed-off-by: yuye-aws * implement delete on workspace overview page Signed-off-by: yuye-aws * fix export on delete workspace modal Signed-off-by: yuye-aws * add try catch to handle errors for workspace delete Signed-off-by: yuye-aws * move visibility control to workspace overview page exlusively Signed-off-by: yuye-aws * remove unused import Signed-off-by: yuye-aws * change workspace overview route to workspace update Signed-off-by: yuye-aws * move delete button from workspace overview page to update page Signed-off-by: yuye-aws * remove update button from workspace overview page Signed-off-by: yuye-aws * recover router to workspace overview page Signed-off-by: yuye-aws * change navigation url for workspace overview button on left side panel Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws --- .../public/components/workspace_overview.tsx | 57 +---------------- .../workspace_updater/workspace_updater.tsx | 64 ++++++++++++++++++- src/plugins/workspace/public/plugin.ts | 2 +- 3 files changed, 63 insertions(+), 60 deletions(-) diff --git a/src/plugins/workspace/public/components/workspace_overview.tsx b/src/plugins/workspace/public/components/workspace_overview.tsx index b0eabce71555..97c9a07092d9 100644 --- a/src/plugins/workspace/public/components/workspace_overview.tsx +++ b/src/plugins/workspace/public/components/workspace_overview.tsx @@ -10,7 +10,6 @@ import { of } from 'rxjs'; import { i18n } from '@osd/i18n'; import { ApplicationStart } from '../../../../core/public'; import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; -import { DeleteWorkspaceModal } from './delete_workspace_modal'; import { PATHS } from '../../common/constants'; import { WORKSPACE_APP_ID, WORKSPACE_ID_IN_SESSION_STORAGE } from '../../common/constants'; @@ -23,43 +22,6 @@ export const WorkspaceOverview = () => { workspaces ? workspaces.client.currentWorkspace$ : of(null) ); - const workspaceId = currentWorkspace?.id; - const workspaceName = currentWorkspace?.name; - const [deleteWorkspaceModalVisible, setDeleteWorkspaceModalVisible] = useState(false); - - const deleteWorkspace = async () => { - if (workspaceId) { - let result; - try { - result = await workspaces?.client.delete(workspaceId); - } catch (error) { - notifications?.toasts.addDanger({ - title: i18n.translate('workspace.delete.failed', { - defaultMessage: 'Failed to delete workspace', - }), - text: error instanceof Error ? error.message : JSON.stringify(error), - }); - return setDeleteWorkspaceModalVisible(false); - } - if (result?.success) { - notifications?.toasts.addSuccess({ - title: i18n.translate('workspace.delete.success', { - defaultMessage: 'Delete workspace successfully', - }), - }); - } else { - notifications?.toasts.addDanger({ - title: i18n.translate('workspace.delete.failed', { - defaultMessage: 'Failed to delete workspace', - }), - text: result?.error, - }); - } - } - setDeleteWorkspaceModalVisible(false); - await application.navigateToApp('home'); - }; - const onUpdateWorkspaceClick = () => { if (!currentWorkspace || !currentWorkspace.id) { notifications?.toasts.addDanger({ @@ -76,25 +38,8 @@ export const WorkspaceOverview = () => { return ( <> - setDeleteWorkspaceModalVisible(true)}> - Delete - , - Update, - Delete, - Update, - ]} - /> + - {deleteWorkspaceModalVisible && ( - setDeleteWorkspaceModalVisible(false)} - selectedItems={workspaceName ? [workspaceName] : []} - /> - )}

Workspace

diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx index 2706ee5363d5..f3c8be0abb48 100644 --- a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx @@ -4,7 +4,14 @@ */ import React, { useCallback, useEffect, useState } from 'react'; -import { EuiPage, EuiPageBody, EuiPageHeader, EuiPageContent } from '@elastic/eui'; +import { + EuiPage, + EuiPageBody, + EuiPageHeader, + EuiPageContent, + EuiButton, + EuiPanel, +} from '@elastic/eui'; import { useObservable } from 'react-use'; import { i18n } from '@osd/i18n'; import { of } from 'rxjs'; @@ -20,6 +27,7 @@ import { WORKSPACE_OP_TYPE_UPDATE, } from '../../../common/constants'; import { ApplicationStart } from '../../../../../core/public'; +import { DeleteWorkspaceModal } from '../delete_workspace_modal'; export const WorkspaceUpdater = () => { const { @@ -34,6 +42,7 @@ export const WorkspaceUpdater = () => { const { [excludedAttribute]: removedProperty, ...otherAttributes } = currentWorkspace || ({} as WorkspaceAttribute); + const [deleteWorkspaceModalVisible, setDeleteWorkspaceModalVisible] = useState(false); const [currentWorkspaceFormData, setCurrentWorkspaceFormData] = useState< Omit >(otherAttributes); @@ -71,7 +80,7 @@ export const WorkspaceUpdater = () => { defaultMessage: 'Update workspace successfully', }), }); - application.navigateToApp(WORKSPACE_APP_ID, { + await application.navigateToApp(WORKSPACE_APP_ID, { path: PATHS.overview + '?' + WORKSPACE_ID_IN_SESSION_STORAGE + '=' + currentWorkspace.id, }); return; @@ -89,11 +98,51 @@ export const WorkspaceUpdater = () => { if (!currentWorkspaceFormData.name) { return null; } + const deleteWorkspace = async () => { + if (currentWorkspace?.id) { + let result; + try { + result = await workspaces?.client.delete(currentWorkspace?.id); + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.delete.failed', { + defaultMessage: 'Failed to delete workspace', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + return setDeleteWorkspaceModalVisible(false); + } + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.delete.success', { + defaultMessage: 'Delete workspace successfully', + }), + }); + } else { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.delete.failed', { + defaultMessage: 'Failed to delete workspace', + }), + text: result?.error, + }); + } + } + setDeleteWorkspaceModalVisible(false); + await application.navigateToApp('home'); + }; return ( - + setDeleteWorkspaceModalVisible(true)}> + Delete + , + ]} + /> { hasShadow={false} style={{ width: '100%', maxWidth: 1000 }} > + {deleteWorkspaceModalVisible && ( + + setDeleteWorkspaceModalVisible(false)} + selectedItems={currentWorkspace?.name ? [currentWorkspace.name] : []} + /> + + )} {application && ( { core.chrome.setCustomNavLink({ title: i18n.translate('workspace.nav.title', { defaultMessage: 'Workspace Overview' }), baseUrl: core.http.basePath.get(), - href: core.application.getUrlForApp(WORKSPACE_APP_ID, { path: PATHS.overview }), + href: core.application.getUrlForApp(WORKSPACE_APP_ID, { path: PATHS.update }), }); this._changeSavedObjectCurrentWorkspace(); return {}; From 7b052873fe48126a35960f136c0af5dc22bd99a0 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Mon, 26 Jun 2023 09:21:23 +0800 Subject: [PATCH 039/174] fix: linting error Signed-off-by: Yulong Ruan --- src/core/public/saved_objects/saved_objects_client.ts | 1 - src/core/public/saved_objects/saved_objects_service.mock.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index da42bfc8c968..64f74255e2ad 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -42,7 +42,6 @@ import { import { SimpleSavedObject } from './simple_saved_object'; import { HttpFetchOptions, HttpSetup } from '../http'; -import { WorkspacesStart } from '../workspace'; type SavedObjectsFindOptions = Omit< SavedObjectFindOptionsServer, diff --git a/src/core/public/saved_objects/saved_objects_service.mock.ts b/src/core/public/saved_objects/saved_objects_service.mock.ts index 47bd146058f7..00ca44072958 100644 --- a/src/core/public/saved_objects/saved_objects_service.mock.ts +++ b/src/core/public/saved_objects/saved_objects_service.mock.ts @@ -41,6 +41,7 @@ const createStartContractMock = () => { find: jest.fn(), get: jest.fn(), update: jest.fn(), + setCurrentWorkspace: jest.fn(), }, }; return mock; From e9e05921094b65b29c3d55d7ff35723e98654876 Mon Sep 17 00:00:00 2001 From: Hailong Cui Date: Mon, 26 Jun 2023 15:51:38 +0800 Subject: [PATCH 040/174] remove duplicate EuiPage (#34) * remove duplicate EuiPage Signed-off-by: Hailong Cui * fix: remove duplicate workspace template Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui --- .../workspace/public/components/workspace_app.tsx | 15 +++++---------- src/plugins/workspace/public/hooks.ts | 10 ++++++++-- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/plugins/workspace/public/components/workspace_app.tsx b/src/plugins/workspace/public/components/workspace_app.tsx index 54c326bc551f..ae2720d75b30 100644 --- a/src/plugins/workspace/public/components/workspace_app.tsx +++ b/src/plugins/workspace/public/components/workspace_app.tsx @@ -4,7 +4,6 @@ */ import React, { useEffect } from 'react'; -import { EuiPage, EuiPageBody } from '@elastic/eui'; import { I18nProvider } from '@osd/i18n/react'; import { Route, Switch, useLocation } from 'react-router-dom'; @@ -28,15 +27,11 @@ export const WorkspaceApp = ({ appBasePath }: { appBasePath: string }) => { return ( - - - - {ROUTES.map(({ path, Component, exact }) => ( - } exact={exact ?? false} /> - ))} - - - + + {ROUTES.map(({ path, Component, exact }) => ( + } exact={exact ?? false} /> + ))} + ); }; diff --git a/src/plugins/workspace/public/hooks.ts b/src/plugins/workspace/public/hooks.ts index 636a00742146..1132aac04e73 100644 --- a/src/plugins/workspace/public/hooks.ts +++ b/src/plugins/workspace/public/hooks.ts @@ -23,13 +23,14 @@ export function useWorkspaceTemplate(application: ApplicationStart) { const applications = useObservable(application.applications$); return useMemo(() => { + const tempWsTemplates = [] as WorkspaceTemplate[]; let workspaceTemplates = [] as WorkspaceTemplate[]; const templateFeatureMap = new Map(); if (applications) { applications.forEach((app) => { const { workspaceTemplate: templates = [] } = app; - workspaceTemplates.push(...templates); + tempWsTemplates.push(...templates); for (const template of templates) { const features = templateFeatureMap.get(template.id) || []; features.push(app); @@ -37,7 +38,12 @@ export function useWorkspaceTemplate(application: ApplicationStart) { } }); - workspaceTemplates = [...new Set(workspaceTemplates)]; + workspaceTemplates = tempWsTemplates.reduce((list, curr) => { + if (!list.find((ws) => ws.id === curr.id)) { + list.push(curr); + } + return list; + }, [] as WorkspaceTemplate[]); workspaceTemplates.sort((a, b) => (a.order || 0) - (b.order || 0)); } From 751da2e6c014ab6e1cebeac5ed6236fa0f4b2294 Mon Sep 17 00:00:00 2001 From: zhichao-aws Date: Mon, 26 Jun 2023 15:51:56 +0800 Subject: [PATCH 041/174] remove clear button, add the width of create button (#33) Signed-off-by: zhichao-aws --- .../workspace_dropdown_list/workspace_dropdown_list.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx index cd9e7b829d69..ce1b8a6053d5 100644 --- a/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx +++ b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx @@ -90,7 +90,12 @@ export function WorkspaceDropdownList(props: WorkspaceDropdownListProps) { selectedOptions={currentWorkspaceOption} singleSelection={{ asPlainText: true }} onSearchChange={onSearchChange} - append={Create workspace} + isClearable={false} + append={ + + Create workspace + + } /> ); From c57d78e7f10f0e642907dece3fde7e1963db4b40 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Tue, 27 Jun 2023 22:30:28 +0800 Subject: [PATCH 042/174] rename OpenSearch Plugins to OpenSearch Features this is a temporary fix just for demo, should be reverted later Signed-off-by: Yulong Ruan --- .../components/workspace_creator/workspace_form.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx index e561c3850827..98273625d17a 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx @@ -301,9 +301,11 @@ export const WorkspaceForm = ({ ? handleFeatureGroupChange : handleFeatureCheckboxChange } - label={`${featureOrGroup.name}${ - features.length > 0 ? `(${selectedIds.length}/${features.length})` : '' - }`} + label={`${ + featureOrGroup.name === 'OpenSearch Plugins' + ? 'OpenSearch Features' + : featureOrGroup.name + }${features.length > 0 ? `(${selectedIds.length}/${features.length})` : ''}`} checked={selectedIds.length > 0} indeterminate={ isWorkspaceFeatureGroup(featureOrGroup) && From f80d94c6909d92781135db2ed8155049022a6969 Mon Sep 17 00:00:00 2001 From: suzhou Date: Thu, 29 Jun 2023 15:46:56 +0800 Subject: [PATCH 043/174] Add some logic check when overwrite a saved object (#32) * feat: add some logic check when overwrite a saved object Signed-off-by: SuZhoue-Joe * fix: type check Signed-off-by: SuZhoue-Joe * feat: update Signed-off-by: SuZhoue-Joe --------- Signed-off-by: SuZhoue-Joe --- .../server/saved_objects/service/lib/repository.ts | 14 +++++++++++++- src/core/types/saved_objects.ts | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 4a8ceb5e0b3b..b992adc0f24e 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -280,6 +280,18 @@ export class SavedObjectsRepository { } } + let savedObjectWorkspaces; + + if (id && overwrite) { + // do not overwrite workspaces + const currentItem = await this.get(type, id); + if (currentItem && currentItem.workspaces) { + savedObjectWorkspaces = currentItem.workspaces; + } + } else { + savedObjectWorkspaces = workspaces; + } + const migrated = this._migrator.migrateDocument({ id, type, @@ -290,7 +302,7 @@ export class SavedObjectsRepository { migrationVersion, updated_at: time, ...(Array.isArray(references) && { references }), - ...(Array.isArray(workspaces) && { workspaces }), + ...(Array.isArray(savedObjectWorkspaces) && { workspaces: savedObjectWorkspaces }), }); const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index 81e1ed029ddc..47faffb0b922 100644 --- a/src/core/types/saved_objects.ts +++ b/src/core/types/saved_objects.ts @@ -113,6 +113,7 @@ export interface SavedObject { * space. */ originId?: string; + workspaces?: string[]; } export interface SavedObjectError { From 2e1a5600dc4172e0f89334bf695455fad61560f2 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Wed, 5 Jul 2023 10:47:18 +0800 Subject: [PATCH 044/174] Add color, icon and defaultVISTheme for workspace (#36) * feat: add color, icon and defaultVISTheme field for workspace saved object Signed-off-by: Lin Wang * add new fields to workspace form Signed-off-by: Lin Wang * feat: remove feature or group name hack Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang --- src/core/server/workspaces/routes/index.ts | 21 +- .../workspaces/saved_objects/workspace.ts | 9 + src/core/server/workspaces/types.ts | 3 + .../workspace_creator/workspace_form.tsx | 179 +++++++++++------- .../workspace_icon_selector.tsx | 36 ++++ 5 files changed, 171 insertions(+), 77 deletions(-) create mode 100644 src/plugins/workspace/public/components/workspace_creator/workspace_icon_selector.tsx diff --git a/src/core/server/workspaces/routes/index.ts b/src/core/server/workspaces/routes/index.ts index 980364103ba8..b00da3c3f38c 100644 --- a/src/core/server/workspaces/routes/index.ts +++ b/src/core/server/workspaces/routes/index.ts @@ -9,6 +9,15 @@ 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, @@ -72,11 +81,7 @@ export function registerRoutes({ path: '', validate: { body: schema.object({ - attributes: schema.object({ - description: schema.maybe(schema.string()), - name: schema.string(), - features: schema.maybe(schema.arrayOf(schema.string())), - }), + attributes: workspaceAttributesSchema, }), }, }, @@ -102,11 +107,7 @@ export function registerRoutes({ id: schema.string(), }), body: schema.object({ - attributes: schema.object({ - description: schema.maybe(schema.string()), - name: schema.string(), - features: schema.maybe(schema.arrayOf(schema.string())), - }), + attributes: workspaceAttributesSchema, }), }, }, diff --git a/src/core/server/workspaces/saved_objects/workspace.ts b/src/core/server/workspaces/saved_objects/workspace.ts index e3fbaa0dad6a..d73a9e3b7605 100644 --- a/src/core/server/workspaces/saved_objects/workspace.ts +++ b/src/core/server/workspaces/saved_objects/workspace.ts @@ -41,6 +41,15 @@ export const workspace: SavedObjectsType = { features: { type: 'text', }, + color: { + type: 'text', + }, + icon: { + type: 'text', + }, + defaultVISTheme: { + type: 'text', + }, }, }, }; diff --git a/src/core/server/workspaces/types.ts b/src/core/server/workspaces/types.ts index e098b4905a1f..532a69ab9ce9 100644 --- a/src/core/server/workspaces/types.ts +++ b/src/core/server/workspaces/types.ts @@ -15,6 +15,9 @@ export interface WorkspaceAttribute { name: string; description?: string; features?: string[]; + color?: string; + icon?: string; + defaultVISTheme?: string; } export interface WorkspaceFindOptions { diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx index 98273625d17a..8e83b7057245 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx @@ -20,19 +20,23 @@ import { EuiFlexGrid, EuiFlexGroup, EuiImage, - EuiAccordion, EuiCheckbox, EuiCheckboxGroup, EuiCheckableCardProps, EuiCheckboxGroupProps, EuiCheckboxProps, EuiFieldTextProps, + EuiColorPicker, + EuiColorPickerProps, + EuiComboBox, + EuiComboBoxProps, } from '@elastic/eui'; import { WorkspaceTemplate } from '../../../../../core/types'; import { AppNavLinkStatus, ApplicationStart } from '../../../../../core/public'; import { useApplications, useWorkspaceTemplate } from '../../hooks'; import { WORKSPACE_OP_TYPE_CREATE, WORKSPACE_OP_TYPE_UPDATE } from '../../../common/constants'; +import { WorkspaceIconSelector } from './workspace_icon_selector'; interface WorkspaceFeature { id: string; @@ -49,6 +53,9 @@ export interface WorkspaceFormData { name: string; description?: string; features: string[]; + color?: string; + icon?: string; + defaultVISTheme?: string; } type WorkspaceFormErrors = { [key in keyof WorkspaceFormData]?: string }; @@ -59,6 +66,8 @@ const isWorkspaceFeatureGroup = ( const workspaceHtmlIdGenerator = htmlIdGenerator(); +const defaultVISThemeOptions = [{ label: 'Categorical', value: 'categorical' }]; + interface WorkspaceFormProps { application: ApplicationStart; onSubmit?: (formData: WorkspaceFormData) => void; @@ -76,6 +85,10 @@ export const WorkspaceForm = ({ const [name, setName] = useState(defaultValues?.name); const [description, setDescription] = useState(defaultValues?.description); + const [color, setColor] = useState(defaultValues?.color); + const [icon, setIcon] = useState(defaultValues?.icon); + const [defaultVISTheme, setDefaultVISTheme] = useState(defaultValues?.defaultVISTheme); + const [selectedTemplateId, setSelectedTemplateId] = useState(); const [selectedFeatureIds, setSelectedFeatureIds] = useState(defaultValues?.features || []); const selectedTemplate = workspaceTemplates.find( @@ -87,6 +100,9 @@ export const WorkspaceForm = ({ name, description, features: selectedFeatureIds, + color, + icon, + defaultVISTheme, }); const getFormDataRef = useRef(getFormData); getFormDataRef.current = getFormData; @@ -120,6 +136,11 @@ export const WorkspaceForm = ({ }, []); }, [applications]); + const selectedDefaultVISThemeOptions = useMemo( + () => defaultVISThemeOptions.filter((item) => item.value === defaultVISTheme), + [defaultVISTheme] + ); + if (!formIdRef.current) { formIdRef.current = workspaceHtmlIdGenerator(); } @@ -198,6 +219,20 @@ export const WorkspaceForm = ({ setDescription(e.target.value); }, []); + const handleColorChange = useCallback['onChange']>((text) => { + setColor(text); + }, []); + + const handleIconChange = useCallback((newIcon: string) => { + setIcon(newIcon); + }, []); + + const handleDefaultVISThemeInputChange = useCallback< + Required>['onChange'] + >((options) => { + setDefaultVISTheme(options[0]?.value); + }, []); + return ( @@ -217,6 +252,25 @@ export const WorkspaceForm = ({ > + + + + + + + + + @@ -267,74 +321,65 @@ export const WorkspaceForm = ({ )} - - -

Advanced Options

-
- - } - > - - {featureOrGroups.map((featureOrGroup) => { - const features = isWorkspaceFeatureGroup(featureOrGroup) +
+ + + +

Workspace features

+
+ + {featureOrGroups.map((featureOrGroup) => { + const features = isWorkspaceFeatureGroup(featureOrGroup) ? featureOrGroup.features : []; + const selectedIds = selectedFeatureIds.filter((id) => + (isWorkspaceFeatureGroup(featureOrGroup) ? featureOrGroup.features - : []; - const selectedIds = selectedFeatureIds.filter((id) => - (isWorkspaceFeatureGroup(featureOrGroup) - ? featureOrGroup.features - : [featureOrGroup] - ).find((item) => item.id === id) - ); - return ( - - 0 ? `(${selectedIds.length}/${features.length})` : ''}`} - checked={selectedIds.length > 0} - indeterminate={ - isWorkspaceFeatureGroup(featureOrGroup) && - selectedIds.length > 0 && - selectedIds.length < features.length - } + : [featureOrGroup] + ).find((item) => item.id === id) + ); + return ( + + 0 ? `(${selectedIds.length}/${features.length})` : '' + }`} + checked={selectedIds.length > 0} + indeterminate={ + isWorkspaceFeatureGroup(featureOrGroup) && + selectedIds.length > 0 && + selectedIds.length < features.length + } + /> + {isWorkspaceFeatureGroup(featureOrGroup) && ( + ({ + id: item.id, + label: item.name, + }))} + idToSelectedMap={selectedIds.reduce( + (previousValue, currentValue) => ({ + ...previousValue, + [currentValue]: true, + }), + {} + )} + onChange={handleFeatureChange} + style={{ marginLeft: 40 }} /> - {isWorkspaceFeatureGroup(featureOrGroup) && ( - ({ - id: item.id, - label: item.name, - }))} - idToSelectedMap={selectedIds.reduce( - (previousValue, currentValue) => ({ - ...previousValue, - [currentValue]: true, - }), - {} - )} - onChange={handleFeatureChange} - style={{ marginLeft: 40 }} - /> - )} - - ); - })} - - + )} + + ); + })} +
diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_icon_selector.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_icon_selector.tsx new file mode 100644 index 000000000000..80e08d8e2e98 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_icon_selector.tsx @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; + +import { EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; + +const icons = ['glasses', 'search', 'bell']; + +export const WorkspaceIconSelector = ({ + color, + value, + onChange, +}: { + color?: string; + value?: string; + onChange: (value: string) => void; +}) => { + return ( + + {icons.map((item) => ( + { + onChange(item); + }} + grow={false} + > + + + ))} + + ); +}; From f2d76076f827001c6a402f9f0254839b9b6279d2 Mon Sep 17 00:00:00 2001 From: raintygao Date: Thu, 6 Jul 2023 16:05:19 +0800 Subject: [PATCH 045/174] feat: add workspace list (#39) Signed-off-by: tygao --- src/plugins/workspace/common/constants.ts | 1 + .../workspace/public/components/routes.ts | 6 + .../public/components/utils/workspace.ts | 22 +++ .../components/workspace_list/index.tsx | 127 ++++++++++++++++++ .../workspace_dropdown_list.tsx | 14 +- 5 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 src/plugins/workspace/public/components/utils/workspace.ts create mode 100644 src/plugins/workspace/public/components/workspace_list/index.tsx diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 903f028539dd..2f67640bcd3f 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -11,6 +11,7 @@ export const PATHS = { create: '/create', overview: '/overview', update: '/update', + list: '/list', }; export const WORKSPACE_OP_TYPE_CREATE = 'create'; export const WORKSPACE_OP_TYPE_UPDATE = 'update'; diff --git a/src/plugins/workspace/public/components/routes.ts b/src/plugins/workspace/public/components/routes.ts index 33f7b4774713..9c2d568db021 100644 --- a/src/plugins/workspace/public/components/routes.ts +++ b/src/plugins/workspace/public/components/routes.ts @@ -8,6 +8,7 @@ import { PATHS } from '../../common/constants'; import { WorkspaceCreator } from './workspace_creator'; import { WorkspaceUpdater } from './workspace_updater'; import { WorkspaceOverview } from './workspace_overview'; +import { WorkspaceList } from './workspace_list'; export interface RouteConfig { path: string; @@ -32,4 +33,9 @@ export const ROUTES: RouteConfig[] = [ Component: WorkspaceUpdater, label: 'Update', }, + { + path: PATHS.list, + Component: WorkspaceList, + label: 'List', + }, ]; diff --git a/src/plugins/workspace/public/components/utils/workspace.ts b/src/plugins/workspace/public/components/utils/workspace.ts new file mode 100644 index 000000000000..7ad9a43bf72c --- /dev/null +++ b/src/plugins/workspace/public/components/utils/workspace.ts @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WORKSPACE_APP_ID, PATHS } from '../../../common/constants'; +import { CoreStart } from '../../../../../core/public'; + +type Core = Pick; + +export const switchWorkspace = ({ workspaces, application }: Core, id: string) => { + const newUrl = workspaces?.formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_APP_ID, { + path: PATHS.update, + absolute: true, + }), + id + ); + if (newUrl) { + window.location.href = newUrl; + } +}; diff --git a/src/plugins/workspace/public/components/workspace_list/index.tsx b/src/plugins/workspace/public/components/workspace_list/index.tsx new file mode 100644 index 000000000000..4568836b87af --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_list/index.tsx @@ -0,0 +1,127 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { + EuiPage, + EuiPageBody, + EuiPageHeader, + EuiPageContent, + EuiBasicTable, + EuiLink, + Direction, + CriteriaWithPagination, +} from '@elastic/eui'; +import useObservable from 'react-use/lib/useObservable'; +import { useMemo } from 'react'; +import { useCallback } from 'react'; +import { WorkspaceAttribute } from '../../../../../core/public'; + +import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public'; +import { switchWorkspace } from '../utils/workspace'; + +export const WorkspaceList = () => { + const { + services: { workspaces, application }, + } = useOpenSearchDashboards(); + + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(5); + const [sortField, setSortField] = useState<'name' | 'id'>('name'); + const [sortDirection, setSortDirection] = useState('asc'); + + const workspaceList = useObservable(workspaces!.client.workspaceList$, []); + + const pageOfItems = useMemo(() => { + return workspaceList + .sort((a, b) => { + const compare = a[sortField].localeCompare(b[sortField]); + return sortDirection === 'asc' ? compare : -compare; + }) + .slice(pageIndex * pageSize, (pageIndex + 1) * pageSize); + }, [workspaceList, pageIndex, pageSize, sortField, sortDirection]); + + const handleSwitchWorkspace = useCallback( + (id: string) => { + if (workspaces && application) { + switchWorkspace({ workspaces, application }, id); + } + }, + [workspaces, application] + ); + + const columns = [ + { + field: 'name', + name: 'Name', + sortable: true, + render: (name: string, item: WorkspaceAttribute) => ( + + handleSwitchWorkspace(item.id)}>{name} + + ), + }, + { + field: 'id', + name: 'ID', + sortable: true, + }, + { + field: 'description', + name: 'Description', + truncateText: true, + }, + { + field: 'features', + name: 'Features', + isExpander: true, + hasActions: true, + }, + ]; + + const onTableChange = ({ page, sort }: CriteriaWithPagination) => { + const { field, direction } = sort!; + const { index, size } = page; + + setPageIndex(index); + setPageSize(size); + setSortField(field as 'name' | 'id'); + setSortDirection(direction); + }; + + return ( + + + + + + + + + ); +}; diff --git a/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx index ce1b8a6053d5..3dd50bb5886f 100644 --- a/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx +++ b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx @@ -9,6 +9,7 @@ import { EuiButton, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import useObservable from 'react-use/lib/useObservable'; import { CoreStart, WorkspaceAttribute } from '../../../../../core/public'; import { WORKSPACE_APP_ID, PATHS } from '../../../common/constants'; +import { switchWorkspace } from '../../components/utils/workspace'; type WorkspaceOption = EuiComboBoxOptionOption; @@ -57,19 +58,10 @@ export function WorkspaceDropdownList(props: WorkspaceDropdownListProps) { /** switch the workspace */ setLoading(true); const id = workspaceOption[0].key!; - const newUrl = coreStart.workspaces?.formatUrlWithWorkspaceId( - coreStart.application.getUrlForApp(WORKSPACE_APP_ID, { - path: PATHS.update, - absolute: true, - }), - id - ); - if (newUrl) { - window.location.href = newUrl; - } + switchWorkspace(coreStart, id); setLoading(false); }, - [coreStart.workspaces, coreStart.application] + [coreStart] ); const onCreateWorkspaceClick = () => { From 9c3871d94dd151b6de0942a6eeb72a5e2d0be2a2 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Fri, 7 Jul 2023 10:59:18 +0800 Subject: [PATCH 046/174] Feature/menu change (#37) * feat: register library menus Signed-off-by: SuZhoue-Joe * feat: some update Signed-off-by: SuZhoue-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhoue-Joe Signed-off-by: SuZhou-Joe --- src/core/utils/default_app_categories.ts | 7 +- .../public/constants.ts | 31 +++++++++ .../management_section/mount_section.tsx | 35 ++++++---- .../objects_table/components/header.tsx | 9 +-- .../objects_table/saved_objects_table.tsx | 8 ++- .../saved_objects_table_page.tsx | 18 +++-- .../saved_objects_management/public/plugin.ts | 68 ++++++++++++++++++- 7 files changed, 144 insertions(+), 32 deletions(-) create mode 100644 src/plugins/saved_objects_management/public/constants.ts diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts index 3c0920624e1b..61cb1e250863 100644 --- a/src/core/utils/default_app_categories.ts +++ b/src/core/utils/default_app_categories.ts @@ -34,11 +34,10 @@ import { AppCategory } from '../types'; /** @internal */ export const DEFAULT_APP_CATEGORIES: Record = Object.freeze({ opensearchDashboards: { - id: 'opensearchDashboards', - label: i18n.translate('core.ui.opensearchDashboardsNavList.label', { - defaultMessage: 'OpenSearch Dashboards', + id: 'library', + label: i18n.translate('core.ui.libraryNavList.label', { + defaultMessage: 'Library', }), - euiIconType: 'inputOutput', order: 1000, }, enterpriseSearch: { diff --git a/src/plugins/saved_objects_management/public/constants.ts b/src/plugins/saved_objects_management/public/constants.ts new file mode 100644 index 000000000000..dec0d4e7be68 --- /dev/null +++ b/src/plugins/saved_objects_management/public/constants.ts @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; + +export const LIBRARY_OVERVIEW_WORDINGS = i18n.translate('savedObjectsManagement.libraryOverview', { + defaultMessage: 'Overview', +}); + +export const SAVED_OBJECT_MANAGEMENT_TITLE_WORDINGS = i18n.translate( + 'savedObjectsManagement.objectsTable.header.savedObjectsTitle', + { + defaultMessage: 'Saved Objects', + } +); + +export const SAVED_SEARCHES_WORDINGS = i18n.translate( + 'savedObjectsManagement.SearchesManagementSectionLabel', + { + defaultMessage: 'Saved searches', + } +); + +export const SAVED_QUERIES_WORDINGS = i18n.translate( + 'savedObjectsManagement.QueriesManagementSectionLabel', + { + defaultMessage: 'Saved filters', + } +); diff --git a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx index a1c7b5343eb1..bcd947c45663 100644 --- a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx +++ b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx @@ -32,10 +32,9 @@ import React, { lazy, Suspense } from 'react'; import ReactDOM from 'react-dom'; import { Router, Switch, Route } from 'react-router-dom'; import { I18nProvider } from '@osd/i18n/react'; -import { i18n } from '@osd/i18n'; import { EuiLoadingSpinner } from '@elastic/eui'; -import { CoreSetup } from 'src/core/public'; -import { ManagementAppMountParams } from '../../../management/public'; +import { AppMountParameters, CoreSetup } from 'src/core/public'; +import { ManagementAppMountParams } from 'src/plugins/management/public'; import { StartDependencies, SavedObjectsManagementPluginStart } from '../plugin'; import { ISavedObjectsManagementServiceRegistry } from '../services'; import { getAllowedTypes } from './../lib'; @@ -43,26 +42,32 @@ import { getAllowedTypes } from './../lib'; interface MountParams { core: CoreSetup; serviceRegistry: ISavedObjectsManagementServiceRegistry; - mountParams: ManagementAppMountParams; + mountParams?: ManagementAppMountParams; + appMountParams?: AppMountParameters; + title: string; + allowedObjectTypes?: string[]; + fullWidth?: boolean; } -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, + fullWidth = true, }: MountParams) => { const [coreStart, { data, uiActions }, pluginStart] = await core.getStartServices(); - const { element, history, setBreadcrumbs } = mountParams; - if (allowedObjectTypes === undefined) { - allowedObjectTypes = await getAllowedTypes(coreStart.http); + const usedMountParams = mountParams || appMountParams || ({} as ManagementAppMountParams); + const { element, history } = usedMountParams; + const { chrome } = coreStart; + const setBreadcrumbs = mountParams?.setBreadcrumbs || chrome.setBreadcrumbs; + let finalAllowedObjectTypes = allowedObjectTypes; + if (finalAllowedObjectTypes === undefined) { + finalAllowedObjectTypes = await getAllowedTypes(coreStart.http); } coreStart.chrome.docTitle.change(title); @@ -106,8 +111,10 @@ export const mountManagementSection = async ({ actionRegistry={pluginStart.actions} columnRegistry={pluginStart.columns} namespaceRegistry={pluginStart.namespaces} - allowedTypes={allowedObjectTypes} + allowedTypes={finalAllowedObjectTypes} setBreadcrumbs={setBreadcrumbs} + title={title} + fullWidth={fullWidth} /> diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx index a22e349d5240..9d46f1cca67c 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx @@ -45,22 +45,19 @@ export const Header = ({ onImport, onRefresh, filteredCount, + title, }: { onExportAll: () => void; onImport: () => void; onRefresh: () => void; filteredCount: number; + title: string; }) => ( -

- -

+

{title}

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..412047ba66f0 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,8 @@ export interface SavedObjectsTableProps { goInspectObject: (obj: SavedObjectWithMetadata) => void; canGoInApp: (obj: SavedObjectWithMetadata) => boolean; dateFormat: string; + title: string; + fullWidth: boolean; } export interface SavedObjectsTableState { @@ -847,7 +849,10 @@ export class SavedObjectsTable extends Component + {this.renderFlyout()} {this.renderRelationships()} {this.renderDeleteConfirmModal()} @@ -857,6 +862,7 @@ export class SavedObjectsTable extends Component 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..ec3837762317 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,7 +30,6 @@ 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 { @@ -49,6 +48,8 @@ const SavedObjectsTablePage = ({ columnRegistry, namespaceRegistry, setBreadcrumbs, + title, + fullWidth, }: { coreStart: CoreStart; dataStart: DataPublicPluginStart; @@ -58,6 +59,8 @@ const SavedObjectsTablePage = ({ columnRegistry: SavedObjectsManagementColumnServiceStart; namespaceRegistry: SavedObjectsManagementNamespaceServiceStart; setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; + title: string; + fullWidth: boolean; }) => { const capabilities = coreStart.application.capabilities; const itemsPerPage = coreStart.uiSettings.get('savedObjects:perPage', 50); @@ -66,13 +69,14 @@ const SavedObjectsTablePage = ({ useEffect(() => { setBreadcrumbs([ { - text: i18n.translate('savedObjectsManagement.breadcrumb.index', { - defaultMessage: 'Saved objects', - }), - href: '/', + text: title, + /** + * There is no need to set a link for current bread crumb + */ + 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..157e1ccf33a6 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,13 @@ import { } from './services'; import { registerServices } from './register_services'; import { bootstrap } from './ui_actions_bootstrap'; +import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; +import { + LIBRARY_OVERVIEW_WORDINGS, + SAVED_OBJECT_MANAGEMENT_TITLE_WORDINGS, + SAVED_QUERIES_WORDINGS, + SAVED_SEARCHES_WORDINGS, +} from './constants'; export interface SavedObjectsManagementPluginSetup { actions: SavedObjectsManagementActionServiceSetup; @@ -136,12 +143,71 @@ export class SavedObjectsManagementPlugin core, serviceRegistry: this.serviceRegistry, mountParams, + title: i18n.translate('savedObjectsManagement.managementSectionLabel', { + defaultMessage: 'Saved Objects', + }), }); }, }); // sets up the context mappings and registers any triggers/actions for the plugin bootstrap(uiActions); + 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, + fullWidth: false, + }); + }; + + /** + * Register saved objects overview & saved search & saved query here + */ + core.application.register({ + id: 'objects_overview', + appRoute: '/app/objects', + exactRoute: true, + title: LIBRARY_OVERVIEW_WORDINGS, + order: 10000, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + mount: mountWrapper({ + title: SAVED_OBJECT_MANAGEMENT_TITLE_WORDINGS, + }), + }); + + core.application.register({ + id: 'objects_searches', + appRoute: '/app/objects/search', + 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', + appRoute: '/app/objects/query', + title: SAVED_QUERIES_WORDINGS, + order: 8001, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + mount: mountWrapper({ + title: SAVED_QUERIES_WORDINGS, + allowedObjectTypes: ['query'], + }), + }); // depends on `getStartServices`, should not be awaited registerServices(this.serviceRegistry, core.getStartServices); From b1c9361badeedb3b7dc2fe01f23b82435ad9abdf Mon Sep 17 00:00:00 2001 From: Yuye Zhu Date: Fri, 7 Jul 2023 11:08:01 +0800 Subject: [PATCH 047/174] feat: different left menu and exit workspace (#38) * Exit workspace from left menu Signed-off-by: yuye-aws * Show exit workspace button with small window size Signed-off-by: yuye-aws * Remove recently viewed and workspace overview on left menu Signed-off-by: yuye-aws * Add buttons for outside, inside workspace case Signed-off-by: yuye-aws * Implement home button and workspace over view button on left menu Signed-off-by: yuye-aws * Implement workspace dropdown list in left menu Signed-off-by: yuye-aws * Add props on recently accessed and custom nav link Signed-off-by: yuye-aws * Add three props to mock props for collapsible nav: exitWorkspace, getWorkspaceUrl, workspaceList$ Signed-off-by: yuye-aws * Add three props to mock props for header: exitWorkspace, getWorkspaceUrl, workspaceList$ Signed-off-by: yuye-aws * Fix bugs for function createWorkspaceNavLink Signed-off-by: yuye-aws * Remove unused constants Signed-off-by: yuye-aws * Reuse method getWorkspaceUrl Signed-off-by: yuye-aws * Remove recently accessed and custom nav props in test Signed-off-by: yuye-aws * Revert "Remove recently accessed and custom nav props in test" This reverts commit 7895e5c5dcde9e134f26b2d6a3df54a2d62e9274. * Wrap title with i18n Signed-off-by: yuye-aws * Add redirect for workspace app Signed-off-by: yuye-aws * Enable users to go to workspace lists page via see more under workspaces in left menu Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws --- src/core/public/chrome/chrome_service.tsx | 43 +- src/core/public/chrome/constants.ts | 6 + .../chrome/ui/header/collapsible_nav.test.tsx | 3 + .../chrome/ui/header/collapsible_nav.tsx | 383 ++++++++++-------- .../public/chrome/ui/header/header.test.tsx | 3 + src/core/public/chrome/ui/header/header.tsx | 8 + src/core/public/chrome/ui/header/nav_link.tsx | 38 +- .../public/components/workspace_app.tsx | 5 +- src/plugins/workspace/public/plugin.ts | 6 - 9 files changed, 326 insertions(+), 169 deletions(-) diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 44dc4eee20af..5d6f5f6b458f 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -34,6 +34,7 @@ import { FormattedMessage } from '@osd/i18n/react'; import { BehaviorSubject, combineLatest, merge, Observable, of, ReplaySubject } from 'rxjs'; import { flatMap, map, takeUntil } from 'rxjs/operators'; import { EuiLink } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; import { mountReactNode } from '../utils/mount'; import { InternalApplicationStart } from '../application'; import { DocLinksStart } from '../doc_links'; @@ -41,7 +42,7 @@ import { HttpStart } from '../http'; import { InjectedMetadataStart } from '../injected_metadata'; import { NotificationsStart } from '../notifications'; import { IUiSettingsClient } from '../ui_settings'; -import { OPENSEARCH_DASHBOARDS_ASK_OPENSEARCH_LINK } from './constants'; +import { OPENSEARCH_DASHBOARDS_ASK_OPENSEARCH_LINK, WORKSPACE_APP_ID } from './constants'; import { ChromeDocTitle, DocTitleService } from './doc_title'; import { ChromeNavControls, NavControlsService } from './nav_controls'; import { ChromeNavLinks, NavLinksService, ChromeNavLink } from './nav_links'; @@ -176,6 +177,41 @@ export class ChromeService { docTitle.reset(); }); + const getWorkspaceUrl = (id: string) => { + return workspaces?.formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_APP_ID, { + path: '/', + absolute: true, + }), + id + ); + }; + + const exitWorkspace = async () => { + let result; + try { + result = await workspaces?.client.exitWorkspace(); + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.exit.failed', { + defaultMessage: 'Failed to exit workspace', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + return; + } + if (!result?.success) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.exit.failed', { + defaultMessage: 'Failed to exit workspace', + }), + text: result?.error, + }); + return; + } + await application.navigateToApp('home'); + }; + const setIsNavDrawerLocked = (isLocked: boolean) => { isNavDrawerLocked$.next(isLocked); localStorage.setItem(IS_LOCKED_KEY, `${isLocked}`); @@ -244,7 +280,6 @@ export class ChromeService { badge$={badge$.pipe(takeUntil(this.stop$))} basePath={http.basePath} breadcrumbs$={breadcrumbs$.pipe(takeUntil(this.stop$))} - customNavLink$={customNavLink$.pipe(takeUntil(this.stop$))} opensearchDashboardsDocLink={docLinks.links.opensearchDashboards.introduction} forceAppSwitcherNavigation$={navLinks.getForceAppSwitcherNavigation$()} helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))} @@ -253,6 +288,7 @@ export class ChromeService { isVisible$={this.isVisible$} opensearchDashboardsVersion={injectedMetadata.getOpenSearchDashboardsVersion()} navLinks$={navLinks.getNavLinks$()} + customNavLink$={customNavLink$.pipe(takeUntil(this.stop$))} recentlyAccessed$={recentlyAccessed.get$()} navControlsLeft$={navControls.getLeft$()} navControlsCenter$={navControls.getCenter$()} @@ -260,11 +296,14 @@ export class ChromeService { navControlsExpandedCenter$={navControls.getExpandedCenter$()} navControlsExpandedRight$={navControls.getExpandedRight$()} onIsLockedUpdate={setIsNavDrawerLocked} + exitWorkspace={exitWorkspace} + getWorkspaceUrl={getWorkspaceUrl} isLocked$={getIsNavDrawerLocked$} branding={injectedMetadata.getBranding()} logos={logos} survey={injectedMetadata.getSurvey()} currentWorkspace$={workspaces.client.currentWorkspace$} + workspaceList$={workspaces.client.workspaceList$} /> ), diff --git a/src/core/public/chrome/constants.ts b/src/core/public/chrome/constants.ts index 5008f8b4a69a..6de7c01f1d13 100644 --- a/src/core/public/chrome/constants.ts +++ b/src/core/public/chrome/constants.ts @@ -31,3 +31,9 @@ export const OPENSEARCH_DASHBOARDS_ASK_OPENSEARCH_LINK = 'https://forum.opensearch.org/'; export const GITHUB_CREATE_ISSUE_LINK = 'https://github.com/opensearch-project/OpenSearch-Dashboards/issues/new/choose'; + +export const WORKSPACE_APP_ID = 'workspace'; + +export const PATHS = { + list: '/list', +}; diff --git a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx index 9f1f856d486b..a29fa10e2c64 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx @@ -93,10 +93,13 @@ function mockProps(branding = {}) { closeNav: () => {}, navigateToApp: () => Promise.resolve(), navigateToUrl: () => Promise.resolve(), + exitWorkspace: () => {}, + getWorkspaceUrl: (id: string) => '', customNavLink$: new BehaviorSubject(undefined), branding, logos: getLogos(branding, mockBasePath.serverBasePath), currentWorkspace$: workspacesServiceMock.createStartContract().client.currentWorkspace$, + workspaceList$: workspacesServiceMock.createStartContract().client.workspaceList$, }; } diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index 5c7a6dab1cbb..12841ab980bb 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -33,7 +33,6 @@ import { EuiCollapsibleNav, EuiCollapsibleNavGroup, EuiFlexItem, - EuiHorizontalRule, EuiListGroup, EuiListGroupItem, EuiShowFor, @@ -41,17 +40,18 @@ import { } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { groupBy, sortBy } from 'lodash'; -import React, { Fragment, useRef } from 'react'; +import React, { useRef } from 'react'; import useObservable from 'react-use/lib/useObservable'; import * as Rx from 'rxjs'; 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 { createEuiListItem, isModifiedOrPrevented, createWorkspaceNavLink } from './nav_link'; import { WorkspaceAttribute } from '../../../workspace'; +import { WORKSPACE_APP_ID, PATHS } from '../../constants'; function getAllCategories(allCategorizedLinks: Record) { const allCategories = {} as Record; @@ -103,7 +103,10 @@ interface Props { navigateToUrl: InternalApplicationStart['navigateToUrl']; customNavLink$: Rx.Observable; logos: Logos; + exitWorkspace: () => void; + getWorkspaceUrl: (id: string) => string; currentWorkspace$: Rx.BehaviorSubject; + workspaceList$: Rx.BehaviorSubject; } export function CollapsibleNav({ @@ -114,6 +117,8 @@ export function CollapsibleNav({ homeHref, storage = window.localStorage, onIsLockedUpdate, + exitWorkspace, + getWorkspaceUrl, closeNav, navigateToApp, navigateToUrl, @@ -121,13 +126,12 @@ export function CollapsibleNav({ ...observables }: Props) { const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); - const recentlyAccessed = useObservable(observables.recentlyAccessed$, []); - const customNavLink = useObservable(observables.customNavLink$, undefined); const appId = useObservable(observables.appId$, ''); const currentWorkspace = useObservable(observables.currentWorkspace$); + const workspaceList = useObservable(observables.workspaceList$, []).slice(0, 5); const lockRef = useRef(null); - const filterdLinks = getFilterLinks(currentWorkspace, navLinks); - const groupedNavLinks = groupBy(filterdLinks, (link) => link?.category?.id); + const filteredLinks = getFilterLinks(currentWorkspace, navLinks); + const groupedNavLinks = groupBy(filteredLinks, (link) => link?.category?.id); const { undefined: unknowns = [], ...allCategorizedLinks } = groupedNavLinks; const categoryDictionary = getAllCategories(allCategorizedLinks); const orderedCategories = getOrderedCategories(allCategorizedLinks, categoryDictionary); @@ -166,172 +170,235 @@ export function CollapsibleNav({ onClose={closeNav} outsideClickCloses={false} > - {customNavLink && ( - - + + {/* Home, Alerts, Favorites, Projects and Admin outside workspace */} + {!currentWorkspace && ( + <> + { + closeNav(); + await navigateToApp('home'); + }} + iconType={'logoOpenSearch'} + title={i18n.translate('core.ui.primaryNavSection.home', { + defaultMessage: 'Home', + })} + /> + + + +

+ {i18n.translate('core.ui.EmptyFavoriteList', { + defaultMessage: 'No Favorites', + })} +

+
+ +

+ {i18n.translate('core.ui.SeeMoreFavorite', { + defaultMessage: 'SEE MORE', + })} +

+
+
- 0 ? ( + { + const href = getWorkspaceUrl(workspace.id); + const hydratedLink = createWorkspaceNavLink(href, workspace, navLinks); + return { + href, + ...hydratedLink, + 'data-test-subj': 'collapsibleNavAppLink--workspace', + onClick: async (event) => { + if (!isModifiedOrPrevented(event)) { + closeNav(); + } + }, + }; + })} + maxWidth="none" + color="subdued" + gutterSize="none" + size="s" + /> + ) : ( + +

+ {i18n.translate('core.ui.EmptyWorkspaceList', { + defaultMessage: 'No Workspaces', + })} +

+
+ )} + + color="subdued" + style={{ padding: '0 8px 8px' }} + onClick={async () => { + await navigateToApp(WORKSPACE_APP_ID, { + path: PATHS.list, + }); + }} + > +

+ {i18n.translate('core.ui.SeeMoreWorkspace', { + defaultMessage: 'SEE MORE', + })} +

+
-
- - -
- )} - - {/* 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', + {/* Workspace name and Overview inside workspace */} + {currentWorkspace && ( + <> + + { + window.location.href = getWorkspaceUrl(currentWorkspace.id); + }} + iconType={'grid'} + title={i18n.translate('core.ui.primaryNavSection.overview', { + defaultMessage: 'Overview', })} -

-
+ /> + )} -
- + {/* OpenSearchDashboards, Observability, Security, and Management sections inside workspace */} + {currentWorkspace && + orderedCategories.map((categoryName) => { + const category = categoryDictionary[categoryName]!; + const opensearchLinkLogo = + category.id === 'opensearchDashboards' ? logos.Mark.url : category.euiIconType; - - {/* OpenSearchDashboards, Observability, Security, and Management sections */} - {orderedCategories.map((categoryName) => { - const category = categoryDictionary[categoryName]!; - const opensearchLinkLogo = - category.id === 'opensearchDashboards' ? 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" - /> + {/* Things with no category (largely for custom plugins) inside workspace */} + {currentWorkspace && + unknowns.map((link, i) => ( + + + + - ); - })} - - {/* Things with no category (largely for custom plugins) */} - {unknowns.map((link, i) => ( - - - - - - ))} + ))} - {/* Docking button only for larger screens that can support it*/} - - - + + + {/* Exit workspace button only within a workspace*/} + {currentWorkspace && ( { - onIsLockedUpdate(!isLocked); - if (lockRef.current) { - lockRef.current.focus(); - } - }} - iconType={isLocked ? 'lock' : 'lockOpen'} + label={i18n.translate('core.ui.primaryNavSection.exitWorkspaceLabel', { + defaultMessage: 'Exit workspace', + })} + aria-label={i18n.translate('core.ui.primaryNavSection.exitWorkspaceLabel', { + defaultMessage: 'Exit workspace', + })} + onClick={exitWorkspace} + iconType={'exit'} /> - - - + )} + {/* Docking button only for larger screens that can support it*/} + { + + { + onIsLockedUpdate(!isLocked); + if (lockRef.current) { + lockRef.current.focus(); + } + }} + iconType={isLocked ? 'lock' : 'lockOpen'} + /> + + } + +
); diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx index b1396f338880..75955dfad4cd 100644 --- a/src/core/public/chrome/ui/header/header.test.tsx +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -71,9 +71,12 @@ function mockProps() { loadingCount$: new BehaviorSubject(0), onIsLockedUpdate: () => {}, branding: {}, + exitWorkspace: () => {}, + getWorkspaceUrl: (id: string) => '', survey: '/', logos: chromeServiceMock.createStartContract().logos, currentWorkspace$: workspacesServiceMock.createStartContract().client.currentWorkspace$, + workspaceList$: workspacesServiceMock.createStartContract().client.workspaceList$, }; } diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index d002dd9955b0..46d313af2b39 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -91,10 +91,13 @@ export interface HeaderProps { isLocked$: Observable; loadingCount$: ReturnType; onIsLockedUpdate: OnIsLockedUpdate; + exitWorkspace: () => void; + getWorkspaceUrl: (id: string) => string; branding: ChromeBranding; logos: Logos; survey: string | undefined; currentWorkspace$: BehaviorSubject; + workspaceList$: BehaviorSubject; } export function Header({ @@ -103,6 +106,8 @@ export function Header({ application, basePath, onIsLockedUpdate, + exitWorkspace, + getWorkspaceUrl, homeHref, branding, survey, @@ -258,6 +263,8 @@ export function Header({ navigateToApp={application.navigateToApp} navigateToUrl={application.navigateToUrl} onIsLockedUpdate={onIsLockedUpdate} + exitWorkspace={exitWorkspace} + getWorkspaceUrl={getWorkspaceUrl} closeNav={() => { setIsNavOpen(false); if (toggleCollapsibleNavRef.current) { @@ -267,6 +274,7 @@ export function Header({ customNavLink$={observables.customNavLink$} logos={logos} currentWorkspace$={observables.currentWorkspace$} + workspaceList$={observables.workspaceList$} /> diff --git a/src/core/public/chrome/ui/header/nav_link.tsx b/src/core/public/chrome/ui/header/nav_link.tsx index 11ff0b472bd0..8281b1ee2f96 100644 --- a/src/core/public/chrome/ui/header/nav_link.tsx +++ b/src/core/public/chrome/ui/header/nav_link.tsx @@ -31,7 +31,12 @@ import { EuiIcon } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import React from 'react'; -import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem, CoreStart } from '../../..'; +import { + ChromeNavLink, + ChromeRecentlyAccessedHistoryItem, + CoreStart, + WorkspaceAttribute, +} from '../../..'; import { HttpStart } from '../../../http'; import { InternalApplicationStart } from '../../../application/types'; import { relativeToAbsolute } from '../../nav_links/to_nav_link'; @@ -148,3 +153,34 @@ export function createRecentNavLink( }, }; } + +export interface WorkspaceNavLink { + label: string; + title: string; + 'aria-label': string; +} + +export function createWorkspaceNavLink( + href: string, + workspace: WorkspaceAttribute, + navLinks: ChromeNavLink[] +): WorkspaceNavLink { + const label = workspace.name; + let titleAndAriaLabel = label; + const navLink = navLinks.find((nl) => href.startsWith(nl.baseUrl)); + if (navLink) { + titleAndAriaLabel = i18n.translate('core.ui.workspaceLinks.linkItem.screenReaderLabel', { + defaultMessage: '{workspaceItemLinkName}, type: {pageType}', + values: { + workspaceItemLinkName: label, + pageType: navLink.title, + }, + }); + } + + return { + label, + title: titleAndAriaLabel, + 'aria-label': titleAndAriaLabel, + }; +} diff --git a/src/plugins/workspace/public/components/workspace_app.tsx b/src/plugins/workspace/public/components/workspace_app.tsx index ae2720d75b30..ec31f511da96 100644 --- a/src/plugins/workspace/public/components/workspace_app.tsx +++ b/src/plugins/workspace/public/components/workspace_app.tsx @@ -5,11 +5,11 @@ import React, { useEffect } from 'react'; import { I18nProvider } from '@osd/i18n/react'; -import { Route, Switch, useLocation } from 'react-router-dom'; - +import { Route, Switch, Redirect, useLocation } from 'react-router-dom'; import { ROUTES } from './routes'; import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; import { createBreadcrumbsFromPath } from './utils/breadcrumbs'; +import { PATHS } from '../../common/constants'; export const WorkspaceApp = ({ appBasePath }: { appBasePath: string }) => { const { @@ -31,6 +31,7 @@ export const WorkspaceApp = ({ appBasePath }: { appBasePath: string }) => { {ROUTES.map(({ path, Component, exact }) => ( } exact={exact ?? false} /> ))} +
); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 4925015306f9..4933cda2a43a 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -112,12 +112,6 @@ export class WorkspacesPlugin implements Plugin<{}, {}> { public start(core: CoreStart) { mountDropdownList(core); - - core.chrome.setCustomNavLink({ - title: i18n.translate('workspace.nav.title', { defaultMessage: 'Workspace Overview' }), - baseUrl: core.http.basePath.get(), - href: core.application.getUrlForApp(WORKSPACE_APP_ID, { path: PATHS.update }), - }); this._changeSavedObjectCurrentWorkspace(); return {}; } From 5ac2f3d90b82b08b5fe7d22f63eeef0ac3adb27b Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Mon, 10 Jul 2023 10:12:01 +0800 Subject: [PATCH 048/174] feat: make url stateful (#35) * feat: make url stateful Signed-off-by: SuZhoue-Joe * feat: optimize code Signed-off-by: SuZhoue-Joe * feat: remove useless change Signed-off-by: SuZhoue-Joe * feat: optimize url listener Signed-off-by: SuZhoue-Joe * feat: make formatUrlWithWorkspaceId extensible Signed-off-by: SuZhoue-Joe * feat: modify to related components Signed-off-by: SuZhoue-Joe * feat: modify the async format to be sync function Signed-off-by: SuZhoue-Joe * feat: modify the async format to be sync function Signed-off-by: SuZhoue-Joe * fix: type check Signed-off-by: SuZhoue-Joe * feat: use path to maintain workspace info Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhoue-Joe Signed-off-by: SuZhou-Joe --- .../fatal_errors/fatal_errors_service.mock.ts | 1 + src/core/public/http/base_path.ts | 24 +++++-- src/core/public/http/http_service.ts | 3 +- src/core/public/http/types.ts | 11 +++- src/core/public/index.ts | 1 - .../injected_metadata_service.ts | 11 ++++ src/core/public/utils/index.ts | 1 + src/core/public/utils/workspace.ts | 15 +++++ src/core/public/workspace/consts.ts | 2 - src/core/public/workspace/index.ts | 1 - .../public/workspace/workspaces_service.ts | 30 +++------ .../server/workspaces/workspaces_service.ts | 19 ++++++ src/plugins/workspace/common/constants.ts | 1 - .../public/components/workspace_overview.tsx | 17 ----- .../workspace_updater/workspace_updater.tsx | 19 +++--- src/plugins/workspace/public/plugin.ts | 66 +++++++------------ 16 files changed, 118 insertions(+), 104 deletions(-) create mode 100644 src/core/public/utils/workspace.ts diff --git a/src/core/public/fatal_errors/fatal_errors_service.mock.ts b/src/core/public/fatal_errors/fatal_errors_service.mock.ts index e495d66ae568..a28547bf88ed 100644 --- a/src/core/public/fatal_errors/fatal_errors_service.mock.ts +++ b/src/core/public/fatal_errors/fatal_errors_service.mock.ts @@ -82,6 +82,7 @@ const createWorkspacesSetupContractMock = () => ({ update: jest.fn(), }, formatUrlWithWorkspaceId: jest.fn(), + setFormatUrlWithWorkspaceId: jest.fn(), }); const createWorkspacesStartContractMock = createWorkspacesSetupContractMock; diff --git a/src/core/public/http/base_path.ts b/src/core/public/http/base_path.ts index b31504676dba..8c45d707cf26 100644 --- a/src/core/public/http/base_path.ts +++ b/src/core/public/http/base_path.ts @@ -33,14 +33,28 @@ import { modifyUrl } from '@osd/std'; 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.get()) return path; + return modifyUrl(path, (parts) => { + if (!parts.hostname && parts.pathname && parts.pathname.startsWith('/')) { + parts.pathname = `${this.get()}${parts.pathname}`; + } + }); + }; + + public prependWithoutWorkspacePath = (path: string): string => { if (!this.basePath) return path; return modifyUrl(path, (parts) => { if (!parts.hostname && parts.pathname && parts.pathname.startsWith('/')) { @@ -50,16 +64,16 @@ export class BasePath { }; public remove = (path: string): string => { - if (!this.basePath) { + if (!this.get()) { return path; } - if (path === this.basePath) { + if (path === this.get()) { return '/'; } - if (path.startsWith(`${this.basePath}/`)) { - return path.slice(this.basePath.length); + if (path.startsWith(`${this.get()}/`)) { + return path.slice(this.get().length); } return path; diff --git a/src/core/public/http/http_service.ts b/src/core/public/http/http_service.ts index f26323f261aa..10d51bb2de7d 100644 --- a/src/core/public/http/http_service.ts +++ b/src/core/public/http/http_service.ts @@ -52,7 +52,8 @@ export class HttpService implements CoreService { const opensearchDashboardsVersion = injectedMetadata.getOpenSearchDashboardsVersion(); const basePath = new BasePath( injectedMetadata.getBasePath(), - injectedMetadata.getServerBasePath() + injectedMetadata.getServerBasePath(), + injectedMetadata.getWorkspaceBasePath() ); 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..ce56cfc3c826 100644 --- a/src/core/public/http/types.ts +++ b/src/core/public/http/types.ts @@ -93,17 +93,17 @@ export type HttpStart = HttpSetup; */ export interface IBasePath { /** - * Gets the `basePath` string. + * Gets the `basePath + workspace` string. */ get: () => string; /** - * Prepends `path` with the basePath. + * Prepends `path` with the basePath + workspace. */ prepend: (url: string) => string; /** - * Removes the prepended basePath from the `path`. + * Removes the prepended basePath + workspace from the `path`. */ remove: (url: string) => string; @@ -113,6 +113,11 @@ export interface IBasePath { * See {@link BasePath.get} for getting the basePath value for a specific request */ readonly serverBasePath: string; + + /** + * Prepends `path` with the basePath. + */ + prependWithoutWorkspacePath: (url: string) => string; } /** diff --git a/src/core/public/index.ts b/src/core/public/index.ts index bc2fb823ff9f..90a04d979da2 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -355,5 +355,4 @@ export { WorkspacesService, WorkspaceAttribute, WorkspaceFindOptions, - WORKSPACE_ID_QUERYSTRING_NAME, } from './workspace'; diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index f4c6a7f7b91a..ccda2fbc925a 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -38,6 +38,7 @@ import { UserProvidedValues, } from '../../server/types'; import { AppCategory, Branding } from '../'; +import { getWorkspaceIdFromUrl } from '../utils'; export interface InjectedPluginMetadata { id: PluginName; @@ -151,6 +152,15 @@ export class InjectedMetadataService { getSurvey: () => { return this.state.survey; }, + + getWorkspaceBasePath: () => { + const workspaceId = getWorkspaceIdFromUrl(window.location.href); + if (workspaceId) { + return `/w/${workspaceId}`; + } + + return ''; + }, }; } } @@ -186,6 +196,7 @@ export interface InjectedMetadataSetup { }; getBranding: () => Branding; getSurvey: () => string | undefined; + getWorkspaceBasePath: () => string; } /** @internal */ diff --git a/src/core/public/utils/index.ts b/src/core/public/utils/index.ts index 7676b9482aac..0719f5e83c53 100644 --- a/src/core/public/utils/index.ts +++ b/src/core/public/utils/index.ts @@ -31,3 +31,4 @@ export { shareWeakReplay } from './share_weak_replay'; export { Sha256 } from './crypto'; export { MountWrapper, mountReactNode } from './mount'; +export { getWorkspaceIdFromUrl } from './workspace'; diff --git a/src/core/public/utils/workspace.ts b/src/core/public/utils/workspace.ts new file mode 100644 index 000000000000..e93355aa00e3 --- /dev/null +++ b/src/core/public/utils/workspace.ts @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +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 ''; +}; diff --git a/src/core/public/workspace/consts.ts b/src/core/public/workspace/consts.ts index 662baeaa5d19..b02fa29f1013 100644 --- a/src/core/public/workspace/consts.ts +++ b/src/core/public/workspace/consts.ts @@ -5,8 +5,6 @@ export const WORKSPACES_API_BASE_URL = '/api/workspaces'; -export const WORKSPACE_ID_QUERYSTRING_NAME = '_workspace_id_'; - export enum WORKSPACE_ERROR_REASON_MAP { WORKSPACE_STALED = 'WORKSPACE_STALED', } diff --git a/src/core/public/workspace/index.ts b/src/core/public/workspace/index.ts index d0fb17ead0c1..359eee93f664 100644 --- a/src/core/public/workspace/index.ts +++ b/src/core/public/workspace/index.ts @@ -5,4 +5,3 @@ export { WorkspacesClientContract, WorkspacesClient } from './workspaces_client'; export { WorkspacesStart, WorkspacesService, WorkspacesSetup } from './workspaces_service'; export type { WorkspaceAttribute, WorkspaceFindOptions } from '../../server/types'; -export { WORKSPACE_ID_QUERYSTRING_NAME } from './consts'; diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts index 908530885760..7d30ac52f49f 100644 --- a/src/core/public/workspace/workspaces_service.ts +++ b/src/core/public/workspace/workspaces_service.ts @@ -5,7 +5,6 @@ import { CoreService } from 'src/core/types'; import { WorkspacesClient, WorkspacesClientContract } from './workspaces_client'; import type { WorkspaceAttribute } from '../../server/types'; -import { WORKSPACE_ID_QUERYSTRING_NAME } from './consts'; import { HttpSetup } from '../http'; /** @@ -14,43 +13,34 @@ import { HttpSetup } from '../http'; export interface WorkspacesStart { client: WorkspacesClientContract; formatUrlWithWorkspaceId: (url: string, id: WorkspaceAttribute['id']) => string; + setFormatUrlWithWorkspaceId: (formatFn: WorkspacesStart['formatUrlWithWorkspaceId']) => void; } export type WorkspacesSetup = WorkspacesStart; -function setQuerystring(url: string, params: Record): string { - const urlObj = new URL(url); - const searchParams = new URLSearchParams(urlObj.search); - - for (const key in params) { - if (params.hasOwnProperty(key)) { - const value = params[key]; - searchParams.set(key, value); - } - } - - urlObj.search = searchParams.toString(); - return urlObj.toString(); -} - export class WorkspacesService implements CoreService { private client?: WorkspacesClientContract; private formatUrlWithWorkspaceId(url: string, id: string) { - return setQuerystring(url, { - [WORKSPACE_ID_QUERYSTRING_NAME]: id, - }); + return url; + } + private setFormatUrlWithWorkspaceId(formatFn: WorkspacesStart['formatUrlWithWorkspaceId']) { + this.formatUrlWithWorkspaceId = formatFn; } public async setup({ http }: { http: HttpSetup }) { this.client = new WorkspacesClient(http); return { client: this.client, - formatUrlWithWorkspaceId: this.formatUrlWithWorkspaceId, + formatUrlWithWorkspaceId: (url: string, id: string) => this.formatUrlWithWorkspaceId(url, id), + setFormatUrlWithWorkspaceId: (fn: WorkspacesStart['formatUrlWithWorkspaceId']) => + this.setFormatUrlWithWorkspaceId(fn), }; } public async start(): Promise { return { client: this.client as WorkspacesClientContract, formatUrlWithWorkspaceId: this.formatUrlWithWorkspaceId, + setFormatUrlWithWorkspaceId: (fn: WorkspacesStart['formatUrlWithWorkspaceId']) => + this.setFormatUrlWithWorkspaceId(fn), }; } public async stop() { diff --git a/src/core/server/workspaces/workspaces_service.ts b/src/core/server/workspaces/workspaces_service.ts index 6faec9a6496e..7aa01db34beb 100644 --- a/src/core/server/workspaces/workspaces_service.ts +++ b/src/core/server/workspaces/workspaces_service.ts @@ -2,6 +2,7 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ +import { URL } from 'node:url'; import { CoreService } from '../../types'; import { CoreContext } from '../core_context'; import { InternalHttpServiceSetup } from '../http'; @@ -43,11 +44,29 @@ export class WorkspacesService this.logger = coreContext.logger.get('workspaces-service'); } + private proxyWorkspaceTrafficToRealHandler(setupDeps: WorkspacesSetupDeps) { + /** + * Proxy all {basePath}/w/{workspaceId}{osdPath*} paths to + * {basePath}{osdPath*} + */ + setupDeps.http.registerOnPreRouting((request, response, toolkit) => { + const regexp = /\/w\/([^\/]*)/; + const matchedResult = request.url.pathname.match(regexp); + if (matchedResult) { + const requestUrl = new URL(request.url.toString()); + requestUrl.pathname = requestUrl.pathname.replace(regexp, ''); + return toolkit.rewriteUrl(requestUrl.toString()); + } + return toolkit.next(); + }); + } + public async setup(setupDeps: WorkspacesSetupDeps): Promise { this.logger.debug('Setting up Workspaces service'); this.client = new WorkspacesClientWithSavedObject(setupDeps); await this.client.setup(setupDeps); + this.proxyWorkspaceTrafficToRealHandler(setupDeps); registerRoutes({ http: setupDeps.http, diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 2f67640bcd3f..557b889d6111 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -5,7 +5,6 @@ export const WORKSPACE_APP_ID = 'workspace'; export const WORKSPACE_APP_NAME = 'Workspace'; -export const WORKSPACE_ID_IN_SESSION_STORAGE = '_workspace_id_'; export const PATHS = { create: '/create', diff --git a/src/plugins/workspace/public/components/workspace_overview.tsx b/src/plugins/workspace/public/components/workspace_overview.tsx index 97c9a07092d9..55de87d20b66 100644 --- a/src/plugins/workspace/public/components/workspace_overview.tsx +++ b/src/plugins/workspace/public/components/workspace_overview.tsx @@ -7,11 +7,8 @@ import React, { useState } from 'react'; import { EuiPageHeader, EuiButton, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; import { useObservable } from 'react-use'; import { of } from 'rxjs'; -import { i18n } from '@osd/i18n'; import { ApplicationStart } from '../../../../core/public'; import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; -import { PATHS } from '../../common/constants'; -import { WORKSPACE_APP_ID, WORKSPACE_ID_IN_SESSION_STORAGE } from '../../common/constants'; export const WorkspaceOverview = () => { const { @@ -22,20 +19,6 @@ export const WorkspaceOverview = () => { workspaces ? workspaces.client.currentWorkspace$ : of(null) ); - const onUpdateWorkspaceClick = () => { - if (!currentWorkspace || !currentWorkspace.id) { - notifications?.toasts.addDanger({ - title: i18n.translate('Cannot find current workspace', { - defaultMessage: 'Cannot update workspace', - }), - }); - return; - } - application.navigateToApp(WORKSPACE_APP_ID, { - path: PATHS.update + '?' + WORKSPACE_ID_IN_SESSION_STORAGE + '=' + currentWorkspace.id, - }); - }; - return ( <> diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx index f3c8be0abb48..a3dc973ee095 100644 --- a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx @@ -21,11 +21,7 @@ import { useOpenSearchDashboards } from '../../../../../../src/plugins/opensearc import { PATHS } from '../../../common/constants'; import { WorkspaceForm, WorkspaceFormData } from '../workspace_creator/workspace_form'; -import { - WORKSPACE_APP_ID, - WORKSPACE_ID_IN_SESSION_STORAGE, - WORKSPACE_OP_TYPE_UPDATE, -} from '../../../common/constants'; +import { WORKSPACE_APP_ID, WORKSPACE_OP_TYPE_UPDATE } from '../../../common/constants'; import { ApplicationStart } from '../../../../../core/public'; import { DeleteWorkspaceModal } from '../delete_workspace_modal'; @@ -80,9 +76,14 @@ export const WorkspaceUpdater = () => { defaultMessage: 'Update workspace successfully', }), }); - await application.navigateToApp(WORKSPACE_APP_ID, { - path: PATHS.overview + '?' + WORKSPACE_ID_IN_SESSION_STORAGE + '=' + currentWorkspace.id, - }); + window.location.href = + workspaces?.formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_APP_ID, { + path: PATHS.overview, + absolute: true, + }), + currentWorkspace.id + ) || ''; return; } notifications?.toasts.addDanger({ @@ -92,7 +93,7 @@ export const WorkspaceUpdater = () => { text: result?.error, }); }, - [notifications?.toasts, workspaces?.client, currentWorkspace, application] + [notifications?.toasts, workspaces, currentWorkspace, application] ); if (!currentWorkspaceFormData.name) { diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 4933cda2a43a..07f1b84f32fe 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -11,59 +11,42 @@ import { AppMountParameters, AppNavLinkStatus, } from '../../../core/public'; -import { WORKSPACE_APP_ID, WORKSPACE_ID_IN_SESSION_STORAGE, PATHS } from '../common/constants'; -import { WORKSPACE_ID_QUERYSTRING_NAME } from '../../../core/public'; +import { WORKSPACE_APP_ID, PATHS } from '../common/constants'; import { mountDropdownList } from './mount'; +import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; export class WorkspacesPlugin implements Plugin<{}, {}> { private core?: CoreSetup; - private addWorkspaceListener() { - this.core?.workspaces.client.currentWorkspaceId$.subscribe((newWorkspaceId) => { - try { - sessionStorage.setItem(WORKSPACE_ID_IN_SESSION_STORAGE, newWorkspaceId); - } catch (e) { - /** - * in incognize mode, this method may throw an error - * */ - } - }); + private getWorkpsaceIdFromURL(): string | null { + return getWorkspaceIdFromUrl(window.location.href); } - private getWorkpsaceIdFromQueryString(): string | null { - const searchParams = new URLSearchParams(window.location.search); - return searchParams.get(WORKSPACE_ID_QUERYSTRING_NAME); - } - private getWorkpsaceIdFromSessionStorage(): string { - try { - return sessionStorage.getItem(WORKSPACE_ID_IN_SESSION_STORAGE) || ''; - } catch (e) { - /** - * in incognize mode, this method may throw an error - * */ - return ''; - } - } - private clearWorkspaceIdFromSessionStorage(): void { - try { - sessionStorage.removeItem(WORKSPACE_ID_IN_SESSION_STORAGE); - } catch (e) { - /** - * in incognize mode, this method may throw an error - * */ + private getPatchedUrl = (url: string, workspaceId: string) => { + const newUrl = new URL(url, window.location.href); + /** + * Patch workspace id into path + */ + newUrl.pathname = this.core?.http.basePath.remove(newUrl.pathname) || ''; + if (workspaceId) { + newUrl.pathname = `${this.core?.http.basePath.serverBasePath || ''}/w/${workspaceId}${ + newUrl.pathname + }`; + } else { + newUrl.pathname = `${this.core?.http.basePath.serverBasePath || ''}${newUrl.pathname}`; } - } + + return newUrl.toString(); + }; public async setup(core: CoreSetup) { this.core = core; + this.core?.workspaces.setFormatUrlWithWorkspaceId((url, id) => this.getPatchedUrl(url, id)); /** - * Retrive workspace id from url or sessionstorage - * url > sessionstorage + * Retrive workspace id from url */ - const workspaceId = - this.getWorkpsaceIdFromQueryString() || this.getWorkpsaceIdFromSessionStorage(); + const workspaceId = this.getWorkpsaceIdFromURL(); if (workspaceId) { const result = await core.workspaces.client.enterWorkspace(workspaceId); if (!result.success) { - this.clearWorkspaceIdFromSessionStorage(); core.fatalErrors.add( result.error || i18n.translate('workspace.error.setup', { @@ -73,11 +56,6 @@ export class WorkspacesPlugin implements Plugin<{}, {}> { } } - /** - * register a listener - */ - this.addWorkspaceListener(); - core.application.register({ id: WORKSPACE_APP_ID, title: i18n.translate('workspace.settings.title', { From 526d7452b25731662d2458ed67ddd5b434828682 Mon Sep 17 00:00:00 2001 From: raintygao Date: Tue, 11 Jul 2023 15:10:03 +0800 Subject: [PATCH 049/174] Fix build error and part of test error (#42) * fix: fix build error and some ut Signed-off-by: tygao * chore: remove saved object client test diff Signed-off-by: tygao --------- Signed-off-by: tygao --- .../collapsible_nav.test.tsx.snap | 5461 +++++++++-------- .../header/__snapshots__/header.test.tsx.snap | 1215 ++-- .../chrome/ui/header/collapsible_nav.test.tsx | 2 +- .../injected_metadata_service.mock.ts | 1 + .../get_sorted_objects_for_export.test.ts | 6 + .../build_active_mappings.test.ts.snap | 8 + .../migrations/core/index_migrator.test.ts | 12 + ...pensearch_dashboards_migrator.test.ts.snap | 4 + .../dashboard_empty_screen.test.tsx.snap | 9 + .../saved_objects_table.test.tsx.snap | 9 + .../__snapshots__/flyout.test.tsx.snap | 3 + .../__snapshots__/header.test.tsx.snap | 8 +- ...telemetry_management_section.test.tsx.snap | 3 + 13 files changed, 3787 insertions(+), 2954 deletions(-) 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 f781daf9983f..35a26a92185e 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,12 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], + "prependWithoutWorkspacePath": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} @@ -163,48 +166,12 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "closed": false, "hasError": false, "isStopped": false, - "observers": Array [ - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], + "observers": Array [], "thrownError": null, } } + exitWorkspace={[Function]} + getWorkspaceUrl={[Function]} homeHref="/" id="collapsibe-nav" isLocked={false} @@ -281,9 +248,8 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` Object { "baseUrl": "/", "category": Object { - "euiIconType": "inputOutput", - "id": "opensearchDashboards", - "label": "OpenSearch Dashboards", + "id": "library", + "label": "Library", "order": 1000, }, "data-test-subj": "discover", @@ -337,9 +303,8 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` Object { "baseUrl": "/", "category": Object { - "euiIconType": "inputOutput", - "id": "opensearchDashboards", - "label": "OpenSearch Dashboards", + "id": "library", + "label": "Library", "order": 1000, }, "data-test-subj": "visualize", @@ -351,9 +316,8 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` Object { "baseUrl": "/", "category": Object { - "euiIconType": "inputOutput", - "id": "opensearchDashboards", - "label": "OpenSearch Dashboards", + "id": "library", + "label": "Library", "order": 1000, }, "data-test-subj": "dashboard", @@ -452,6 +416,25 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "closed": false, "hasError": false, "isStopped": false, + "observers": Array [], + "thrownError": null, + } + } + storage={ + StubBrowserStorage { + "keys": Array [], + "size": 0, + "sizeLimit": 5000000, + "values": Array [], + } + } + workspaceList$={ + BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, "observers": Array [ Subscriber { "_parentOrParents": null, @@ -490,18 +473,47 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, ], "thrownError": null, } } - storage={ - StubBrowserStorage { - "keys": Array [], - "size": 0, - "sizeLimit": 5000000, - "values": Array [], - } - } >
-
+ + + + + +

+ OpenSearch Dashboards +

+
+
+ } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + data-test-opensearch-logo="/test/ui/logos/opensearch_mark_on_light.svg" + data-test-subj="collapsibleNavGroup-opensearchDashboards" + 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" - > -
-
-
- - - -
-
- -
-
+ + +
+
+ -
- +
- - -
-
-
- -
-
-
-
- -
-
- -
- - - - - - - -

- OpenSearch Dashboards -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-opensearch-logo="/test/ui/logos/opensearch_mark_on_light.svg" - data-test-subj="collapsibleNavGroup-opensearchDashboards" - id="mockId" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" - > -
-
- -
-
- -
- +
- - +
+ +
+ +
+ + + +
+
+ +
+ +

+ Alerts +

+
+
+
+
+
+
+
+
+ + + + + + + +

+ Favorites +

+
+
+ + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + id="mockId" + initialIsOpen={true} + isLoading={false} + isLoadingMessage={false} + paddingSize="none" + > +
- -
    - + + + + + +
    + +
    - - - Dock navigation - - , - } - } - color="subdued" - data-test-subj="collapsible-nav-lock" - iconType="lockOpen" - label="Dock navigation" - onClick={[Function]} - size="xs" + + +
    +
    + +
    + +

    + Favorites +

    +
    +
    +
    +
    +
    +
    + +
+
+ +
+
-
  • -
  • + + +
    - Dock navigation - - - - - - + +
    +

    + SEE MORE +

    +
    +
    +
    +
    +
    +
    +
    +
    - - - -
    - - -
    + + + + + + + + + +

    + Workspaces +

    +
    +
    + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + id="mockId" + initialIsOpen={true} + isLoading={false} + isLoadingMessage={false} + paddingSize="none" + > +
    +
    + +
    +
    + +
    +
    +
    + +
    + +
    +

    + No Workspaces +

    +
    +
    +
    +
    + +
    + +
    +

    + SEE MORE +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    + +
    + + + +
    +
    + +
    + +

    + Admin +

    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
      + + + + + Dock navigation + + , + } + } + color="subdued" + data-test-subj="collapsible-nav-lock" + iconType="lockOpen" + label="Dock navigation" + onClick={[Function]} + size="xs" + > +
    • + +
    • +
      +
      +
    +
    +
    +
    +
    + + + + + `; @@ -2092,9 +2296,12 @@ exports[`CollapsibleNav renders the default nav 1`] = ` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], + "prependWithoutWorkspacePath": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} @@ -2155,48 +2362,12 @@ exports[`CollapsibleNav renders the default nav 1`] = ` "closed": false, "hasError": false, "isStopped": false, - "observers": Array [ - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], + "observers": Array [], "thrownError": null, } } + exitWorkspace={[Function]} + getWorkspaceUrl={[Function]} homeHref="/" id="collapsibe-nav" isLocked={false} @@ -2319,6 +2490,25 @@ exports[`CollapsibleNav renders the default nav 1`] = ` navigateToUrl={[Function]} onIsLockedUpdate={[Function]} recentlyAccessed$={ + BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + } + } + storage={ + StubBrowserStorage { + "keys": Array [], + "size": 0, + "sizeLimit": 5000000, + "values": Array [], + } + } + workspaceList$={ BehaviorSubject { "_isScalar": false, "_value": Array [], @@ -2367,14 +2557,6 @@ exports[`CollapsibleNav renders the default nav 1`] = ` "thrownError": null, } } - storage={ - StubBrowserStorage { - "keys": Array [], - "size": 0, - "sizeLimit": 5000000, - "values": Array [], - } - } > - - +
    + +
    - - + -

    - 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" - > -
    +
    + + + +
    + + +
    + +

    + Home +

    +
    +
    +
    +
    + +
    +
    +
    + +
    +
    + +
    + +
    + + + +
    +
    + +
    + +

    + Alerts +

    +
    +
    +
    +
    +
    +
    +
    +
    + + + + + + + +

    + Favorites +

    +
    +
    + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + id="mockId" + initialIsOpen={true} + isLoading={false} + isLoadingMessage={false} + paddingSize="none" + > +
    +
    + +
    +
    + +
    +
    +
    + +
    + +
    +

    + No Favorites +

    +
    +
    +
    +
    + +
    + +
    +

    + SEE MORE +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + -
    - -
    -
    - -
    -
    + + +
    +
    + -
    - +
    - +
    + +
    +

    + No Workspaces +

    +
    +
    +
    + +
    -

    - No recently viewed items -

    + +
    +

    + SEE MORE +

    +
    +
    -
    +
    - +
    -
    +
    -
    -
    - - -
    - -
    -
    - -
    - + + + - +
    -
    - -
      +
      + + + +
      + + +
      + +

      + Admin +

      +
      +
      +
      +
    + +
    +
    +
    + +
    +
    + +
      + -
    -
    -
    + + +
    -
    -
    +
    +
    @@ -3438,9 +4006,12 @@ exports[`CollapsibleNav with custom branding renders the nav bar in dark mode 1` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], + "prependWithoutWorkspacePath": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={ @@ -3698,48 +4269,12 @@ exports[`CollapsibleNav with custom branding renders the nav bar in dark mode 1` "closed": false, "hasError": false, "isStopped": false, - "observers": Array [ - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], + "observers": Array [], "thrownError": null, } } + exitWorkspace={[Function]} + getWorkspaceUrl={[Function]} homeHref="/" id="collapsibe-nav" isLocked={false} @@ -3775,423 +4310,417 @@ exports[`CollapsibleNav with custom branding renders the nav bar in dark mode 1` "type": "custom", "url": "/custom/branded/mark-darkmode.svg", }, - "light": Object { - "type": "custom", - "url": "/custom/branded/mark.svg", - }, - "type": "custom", - "url": "/custom/branded/mark-darkmode.svg", - }, - "Mark": Object { - "dark": Object { - "type": "custom", - "url": "/custom/branded/mark-darkmode.svg", - }, - "light": Object { - "type": "custom", - "url": "/custom/branded/mark.svg", - }, - "type": "custom", - "url": "/custom/branded/mark-darkmode.svg", - }, - "OpenSearch": Object { - "dark": Object { - "type": "custom", - "url": "/custom/branded/logo-darkmode.svg", - }, - "light": Object { - "type": "custom", - "url": "/custom/branded/logo.svg", - }, - "type": "custom", - "url": "/custom/branded/logo-darkmode.svg", - }, - "colorScheme": "dark", - } - } - navLinks$={ - BehaviorSubject { - "_isScalar": false, - "_value": Array [ - Object { - "baseUrl": "/", - "category": Object { - "euiIconType": "inputOutput", - "id": "opensearchDashboards", - "label": "OpenSearch Dashboards", - "order": 1000, - }, - "data-test-subj": "discover", - "href": "discover", - "id": "discover", - "isActive": true, - "title": "discover", - }, - Object { - "baseUrl": "/", - "category": Object { - "euiIconType": "logoObservability", - "id": "observability", - "label": "Observability", - "order": 3000, - }, - "data-test-subj": "discover", - "href": "discover", - "id": "discover", - "isActive": true, - "title": "discover", - }, - ], - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [ - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], - "thrownError": null, - } - } - navigateToApp={[Function]} - navigateToUrl={[Function]} - onIsLockedUpdate={[Function]} - recentlyAccessed$={ - BehaviorSubject { - "_isScalar": false, - "_value": Array [ - Object { - "id": "recent", - "label": "recent", - "link": "recent", - }, - ], - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [ - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, + "light": Object { + "type": "custom", + "url": "/custom/branded/mark.svg", }, - ], - "thrownError": null, - } - } - storage={ - StubBrowserStorage { - "keys": Array [], - "size": 0, - "sizeLimit": 5000000, - "values": Array [], - } - } -> - - - @@ -6551,25 +6481,6 @@ exports[`CollapsibleNav without custom branding renders the nav bar in dark mode "closed": false, "hasError": false, "isStopped": false, - "observers": Array [], - "thrownError": null, - } - } - storage={ - StubBrowserStorage { - "keys": Array [], - "size": 0, - "sizeLimit": 5000000, - "values": Array [], - } - } - workspaceList$={ - BehaviorSubject { - "_isScalar": false, - "_value": Array [], - "closed": false, - "hasError": false, - "isStopped": false, "observers": Array [ Subscriber { "_parentOrParents": null, @@ -6608,84 +6519,18 @@ exports[`CollapsibleNav without custom branding renders the nav bar in dark mode "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, } } + storage={ + StubBrowserStorage { + "keys": Array [], + "size": 0, + "sizeLimit": 5000000, + "values": Array [], + } + } > + + + +

    + 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" + > +
    +
    + +
    +
    + +
    +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    + - Favorites + Library @@ -6776,6 +6818,7 @@ exports[`CollapsibleNav without custom branding renders the nav bar in dark mode initialIsOpen={true} isLoading={false} isLoadingMessage={false} + onToggle={[Function]} paddingSize="none" >
    - Favorites + Library
    @@ -6876,68 +6919,67 @@ exports[`CollapsibleNav without custom branding renders the nav bar in dark mode
    - -
    - -
    -

    - No Favorites -

    -
    -
    -
    -
    - -
    - -
    -

    - SEE MORE -

    -
    -
    -
    -
    + + + discover + + + + + +
    @@ -6947,10 +6989,14 @@ exports[`CollapsibleNav without custom branding renders the nav bar in dark mode
    @@ -6977,21 +7023,27 @@ exports[`CollapsibleNav without custom branding renders the nav bar in dark mode className="euiCollapsibleNavGroup__title" id="mockId__title" > - Workspaces + Observability } className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + data-test-opensearch-logo="logoObservability" + data-test-subj="collapsibleNavGroup-observability" id="mockId" initialIsOpen={true} isLoading={false} isLoadingMessage={false} + onToggle={[Function]} paddingSize="none" >
    @@ -7058,7 +7110,7 @@ exports[`CollapsibleNav without custom branding renders the nav bar in dark mode className="euiTitle euiTitle--xxsmall euiCollapsibleNavGroup__title" id="mockId__title" > - Workspaces + Observability
    @@ -7069,86 +7121,83 @@ exports[`CollapsibleNav without custom branding renders the nav bar in dark mode
    - -
    -
    -
    - -
    - -
    -

    - No Workspaces -

    -
    -
    -
    -
    - + +
    +
    +
    + -
    - -
    -

    - SEE MORE -

    -
    -
    -
    - + + + discover + + + + + +
    @@ -7157,86 +7206,27 @@ exports[`CollapsibleNav without custom branding renders the nav bar in dark mode
    - -
    -
    - -
    - -
    - - - -
    -
    - -
    - -

    - Admin -

    -
    -
    -
    -
    -
    -
    -
    -
    - -
    +
    - -
      - - - - - Dock navigation - - - - - -
    -
    -
    -
    -
    -
    - - - - - -`; - -exports[`CollapsibleNav without custom branding renders the nav bar in default mode 1`] = ` - + + + Dock navigation + + + + + + +
    +
    +
    + + + + +
    +
    + +`; + +exports[`CollapsibleNav without custom branding renders the nav bar in default mode 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" + > +
    +
    + +
    +
    + +
    +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    + + - Favorites + Library @@ -7901,6 +7886,7 @@ exports[`CollapsibleNav without custom branding renders the nav bar in default m initialIsOpen={true} isLoading={false} isLoadingMessage={false} + onToggle={[Function]} paddingSize="none" >
    - Favorites + Library
    @@ -8001,68 +7987,67 @@ exports[`CollapsibleNav without custom branding renders the nav bar in default m
    - -
    - -
    -

    - No Favorites -

    -
    -
    -
    -
    - -
    - -
    -

    - SEE MORE -

    -
    -
    -
    -
    + + + discover + + + + + +
    @@ -8072,10 +8057,14 @@ exports[`CollapsibleNav without custom branding renders the nav bar in default m
    @@ -8102,21 +8091,27 @@ exports[`CollapsibleNav without custom branding renders the nav bar in default m className="euiCollapsibleNavGroup__title" id="mockId__title" > - Workspaces + Observability } className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + data-test-opensearch-logo="logoObservability" + data-test-subj="collapsibleNavGroup-observability" id="mockId" initialIsOpen={true} isLoading={false} isLoadingMessage={false} + onToggle={[Function]} paddingSize="none" >
    @@ -8183,7 +8178,7 @@ exports[`CollapsibleNav without custom branding renders the nav bar in default m className="euiTitle euiTitle--xxsmall euiCollapsibleNavGroup__title" id="mockId__title" > - Workspaces + Observability
    @@ -8210,70 +8205,67 @@ exports[`CollapsibleNav without custom branding renders the nav bar in default m
    - -
    - -
    -

    - No Workspaces -

    -
    -
    -
    -
    - -
    - -
    -

    - SEE MORE -

    -
    -
    -
    -
    + + + discover + + + + + +
    @@ -8282,86 +8274,27 @@ exports[`CollapsibleNav without custom branding renders the nav bar in default m
    - -
    -
    - -
    - -
    - - - -
    -
    - -
    - -

    - Admin -

    -
    -
    -
    -
    -
    -
    -
    -
    - -
    +
    - -
      - - -
    -
    + + +
    - -
    + + 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 9b99f35ad63f..cefa87860d76 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 @@ -303,55 +303,6 @@ exports[`Header handles visibility and lock changes 1`] = ` "thrownError": null, } } - currentWorkspace$={ - BehaviorSubject { - "_isScalar": false, - "_value": null, - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [ - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], - "thrownError": null, - } - } customNavLink$={ BehaviorSubject { "_isScalar": false, @@ -368,7 +319,6 @@ exports[`Header handles visibility and lock changes 1`] = ` "thrownError": null, } } - exitWorkspace={[Function]} forceAppSwitcherNavigation$={ BehaviorSubject { "_isScalar": false, @@ -455,7 +405,6 @@ exports[`Header handles visibility and lock changes 1`] = ` "thrownError": null, } } - getWorkspaceUrl={[Function]} helpExtension$={ BehaviorSubject { "_isScalar": false, @@ -1897,18 +1846,6 @@ exports[`Header handles visibility and lock changes 1`] = ` "closed": false, "hasError": false, "isStopped": false, - "observers": Array [], - "thrownError": null, - } - } - survey="/" - workspaceList$={ - BehaviorSubject { - "_isScalar": false, - "_value": Array [], - "closed": false, - "hasError": false, - "isStopped": false, "observers": Array [ Subscriber { "_parentOrParents": null, @@ -1951,6 +1888,7 @@ exports[`Header handles visibility and lock changes 1`] = ` "thrownError": null, } } + survey="/" >
    -
    -
    - -
    - -
    - - - -
    -
    - -
    - -

    - Home -

    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    - -
    - -
    - - - -
    -
    - -
    - -

    - Alerts -

    -
    -
    -
    -
    -
    -
    -
    -
    - - - - - Favorites + Recently viewed } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" + data-test-subj="collapsibleNavGroup-recentlyViewed" id="mockId" initialIsOpen={true} isLoading={false} isLoadingMessage={false} + onToggle={[Function]} paddingSize="none" >
    - -
    - - - -
    -
    - Favorites + Recently viewed
    @@ -6537,68 +6277,71 @@ exports[`Header handles visibility and lock changes 1`] = `
    - -
    - -
    -

    - No Favorites -

    -
    -
    -
    -
    - -
    - -
    -

    - SEE MORE -

    -
    -
    -
    -
    + + + dashboard + + + + + +
    @@ -6608,296 +6351,77 @@ exports[`Header handles visibility and lock changes 1`] = `
    - - - - - - -

    - Workspaces -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" +
    -
    - -
    -
    - -
    -
    -
    - -
    - -
    -

    - No Workspaces -

    -
    -
    -
    -
    - -
    - -
    -

    - SEE MORE -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - - - -
    -
    - -
    - -
    - - -
    -
    - -
    - -

    - Admin -

    -
    -
    -
    -
    -
    + opensearchDashboards + + + + + +
    - -
    + +
    - -
      - - -
    -
    + + +
    -
    -
    + + @@ -7280,92 +6804,6 @@ exports[`Header renders condensed header 1`] = ` "thrownError": null, } } - currentWorkspace$={ - BehaviorSubject { - "_isScalar": false, - "_value": null, - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [ - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], - "thrownError": null, - } - } customNavLink$={ BehaviorSubject { "_isScalar": false, @@ -7377,7 +6815,6 @@ exports[`Header renders condensed header 1`] = ` "thrownError": null, } } - exitWorkspace={[Function]} forceAppSwitcherNavigation$={ BehaviorSubject { "_isScalar": false, @@ -7427,7 +6864,6 @@ exports[`Header renders condensed header 1`] = ` "thrownError": null, } } - getWorkspaceUrl={[Function]} helpExtension$={ BehaviorSubject { "_isScalar": false, @@ -8737,18 +8173,6 @@ exports[`Header renders condensed header 1`] = ` opensearchDashboardsDocLink="/docs" opensearchDashboardsVersion="1.0.0" recentlyAccessed$={ - BehaviorSubject { - "_isScalar": false, - "_value": Array [], - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [], - "thrownError": null, - } - } - survey="/" - workspaceList$={ BehaviorSubject { "_isScalar": false, "_value": Array [], @@ -8793,47 +8217,11 @@ exports[`Header renders condensed header 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, ], "thrownError": null, } } + survey="/" >
    {}, navigateToApp: () => Promise.resolve(), navigateToUrl: () => Promise.resolve(), - exitWorkspace: () => {}, - getWorkspaceUrl: (id: string) => '', customNavLink$: new BehaviorSubject(undefined), branding, logos: getLogos(branding, mockBasePath.serverBasePath), diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index 12841ab980bb..c1c4e508e917 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -49,9 +49,8 @@ import { InternalApplicationStart } from '../../../application'; import { HttpStart } from '../../../http'; import { OnIsLockedUpdate } from './'; import type { Logos } from '../../../../common/types'; -import { createEuiListItem, isModifiedOrPrevented, createWorkspaceNavLink } from './nav_link'; import { WorkspaceAttribute } from '../../../workspace'; -import { WORKSPACE_APP_ID, PATHS } from '../../constants'; +import { createEuiListItem, isModifiedOrPrevented, createRecentNavLink } from './nav_link'; function getAllCategories(allCategorizedLinks: Record) { const allCategories = {} as Record; @@ -117,8 +116,6 @@ export function CollapsibleNav({ homeHref, storage = window.localStorage, onIsLockedUpdate, - exitWorkspace, - getWorkspaceUrl, closeNav, navigateToApp, navigateToUrl, @@ -126,12 +123,10 @@ export function CollapsibleNav({ ...observables }: Props) { const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); + const recentlyAccessed = useObservable(observables.recentlyAccessed$, []); const appId = useObservable(observables.appId$, ''); - const currentWorkspace = useObservable(observables.currentWorkspace$); - const workspaceList = useObservable(observables.workspaceList$, []).slice(0, 5); const lockRef = useRef(null); - const filteredLinks = getFilterLinks(currentWorkspace, navLinks); - const groupedNavLinks = groupBy(filteredLinks, (link) => link?.category?.id); + const groupedNavLinks = groupBy(navLinks, (link) => link?.category?.id); const { undefined: unknowns = [], ...allCategorizedLinks } = groupedNavLinks; const categoryDictionary = getAllCategories(allCategorizedLinks); const orderedCategories = getOrderedCategories(allCategorizedLinks, categoryDictionary); @@ -147,17 +142,6 @@ export function CollapsibleNav({ }); }; - function getFilterLinks( - workspace: WorkspaceAttribute | null | undefined, - allNavLinks: ChromeNavLink[] - ) { - if (!workspace) return allNavLinks; - - const features = workspace.features ?? []; - const links = allNavLinks.filter((item) => features.indexOf(item.id) > -1); - return links; - } - return ( - {/* Home, Alerts, Favorites, Projects and Admin outside workspace */} - {!currentWorkspace && ( - <> - { - closeNav(); - await navigateToApp('home'); - }} - iconType={'logoOpenSearch'} - title={i18n.translate('core.ui.primaryNavSection.home', { - defaultMessage: 'Home', + {/* 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', + })} +

    +
    + )} +
    + + {/* Alerts and Favorites */} + {/* ( + <> + + ) */} + + {/* OpenSearchDashboards, Observability, Security, and Management sections */} + {orderedCategories.map((categoryName) => { + const category = categoryDictionary[categoryName]!; + const opensearchLinkLogo = + category.id === 'opensearchDashboards' ? logos.Mark.url : category.euiIconType; + + return ( setIsCategoryOpen(category.id, isCategoryOpen, storage)} + data-test-subj={`collapsibleNavGroup-${category.id}`} + data-test-opensearch-logo={opensearchLinkLogo} > - {workspaceList?.length > 0 ? ( - { - const href = getWorkspaceUrl(workspace.id); - const hydratedLink = createWorkspaceNavLink(href, workspace, navLinks); - return { - href, - ...hydratedLink, - 'data-test-subj': 'collapsibleNavAppLink--workspace', - onClick: async (event) => { - if (!isModifiedOrPrevented(event)) { - closeNav(); - } - }, - }; - })} - maxWidth="none" - color="subdued" - gutterSize="none" - size="s" - /> - ) : ( - -

    - {i18n.translate('core.ui.EmptyWorkspaceList', { - defaultMessage: 'No Workspaces', - })} -

    -
    - )} - readyForEUI(link))} + maxWidth="none" color="subdued" - style={{ padding: '0 8px 8px' }} - onClick={async () => { - await navigateToApp(WORKSPACE_APP_ID, { - path: PATHS.list, - }); - }} - > -

    - {i18n.translate('core.ui.SeeMoreWorkspace', { - defaultMessage: 'SEE MORE', - })} -

    -
    + gutterSize="none" + size="s" + />
    - - - )} - - {/* Workspace name and Overview inside workspace */} - {currentWorkspace && ( - <> - - { - window.location.href = getWorkspaceUrl(currentWorkspace.id); - }} - iconType={'grid'} - title={i18n.translate('core.ui.primaryNavSection.overview', { - defaultMessage: 'Overview', - })} - /> - - )} + ); + })} - {/* OpenSearchDashboards, Observability, Security, and Management sections inside workspace */} - {currentWorkspace && - orderedCategories.map((categoryName) => { - const category = categoryDictionary[categoryName]!; - const opensearchLinkLogo = - category.id === 'opensearchDashboards' ? logos.Mark.url : category.euiIconType; + {/* Things with no category (largely for custom plugins) */} + {unknowns.map((link, i) => ( + + + + + + ))} - 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" - /> - - ); - })} - - {/* Things with no category (largely for custom plugins) inside workspace */} - {currentWorkspace && - unknowns.map((link, i) => ( - - - - - - ))} - - - - {/* Exit workspace button only within a workspace*/} - {currentWorkspace && ( + {/* Docking button only for larger screens that can support it*/} + + + - )} - {/* Docking button only for larger screens that can support it*/} - { - - { + onIsLockedUpdate(!isLocked); + if (lockRef.current) { + lockRef.current.focus(); } - onClick={() => { - onIsLockedUpdate(!isLocked); - if (lockRef.current) { - lockRef.current.focus(); - } - }} - iconType={isLocked ? 'lock' : 'lockOpen'} - /> - - } - - + }} + iconType={isLocked ? 'lock' : 'lockOpen'} + /> + + +
    ); diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx index 711d4d0bd55e..97c13aff36a2 100644 --- a/src/core/public/chrome/ui/header/header.test.tsx +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -36,7 +36,6 @@ import { httpServiceMock } from '../../../http/http_service.mock'; import { applicationServiceMock, chromeServiceMock } from '../../../mocks'; import { Header } from './header'; import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; -import { workspacesServiceMock } from '../../../workspace/workspaces_service.mock'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => () => 'mockId', @@ -75,8 +74,6 @@ function mockProps() { getWorkspaceUrl: (id: string) => '', survey: '/', logos: chromeServiceMock.createStartContract().logos, - currentWorkspace$: workspacesServiceMock.createStartContract().client.currentWorkspace$, - workspaceList$: workspacesServiceMock.createStartContract().client.workspaceList$, }; } diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 46d313af2b39..acc7c6869145 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -43,7 +43,7 @@ import { i18n } from '@osd/i18n'; import classnames from 'classnames'; import React, { createRef, useState } from 'react'; import useObservable from 'react-use/lib/useObservable'; -import { Observable, BehaviorSubject } from 'rxjs'; +import { Observable } from 'rxjs'; import { LoadingIndicator } from '../'; import { ChromeBadge, @@ -65,7 +65,6 @@ import { HeaderNavControls } from './header_nav_controls'; import { HeaderActionMenu } from './header_action_menu'; import { HeaderLogo } from './header_logo'; import type { Logos } from '../../../../common/types'; -import { WorkspaceAttribute } from '../../../workspace'; export interface HeaderProps { opensearchDashboardsVersion: string; @@ -91,13 +90,9 @@ export interface HeaderProps { isLocked$: Observable; loadingCount$: ReturnType; onIsLockedUpdate: OnIsLockedUpdate; - exitWorkspace: () => void; - getWorkspaceUrl: (id: string) => string; branding: ChromeBranding; logos: Logos; survey: string | undefined; - currentWorkspace$: BehaviorSubject; - workspaceList$: BehaviorSubject; } export function Header({ @@ -106,8 +101,6 @@ export function Header({ application, basePath, onIsLockedUpdate, - exitWorkspace, - getWorkspaceUrl, homeHref, branding, survey, @@ -263,8 +256,6 @@ export function Header({ navigateToApp={application.navigateToApp} navigateToUrl={application.navigateToUrl} onIsLockedUpdate={onIsLockedUpdate} - exitWorkspace={exitWorkspace} - getWorkspaceUrl={getWorkspaceUrl} closeNav={() => { setIsNavOpen(false); if (toggleCollapsibleNavRef.current) { @@ -273,8 +264,6 @@ export function Header({ }} customNavLink$={observables.customNavLink$} logos={logos} - currentWorkspace$={observables.currentWorkspace$} - workspaceList$={observables.workspaceList$} />
    diff --git a/src/core/public/chrome/ui/header/nav_link.tsx b/src/core/public/chrome/ui/header/nav_link.tsx index 8281b1ee2f96..832708122d5e 100644 --- a/src/core/public/chrome/ui/header/nav_link.tsx +++ b/src/core/public/chrome/ui/header/nav_link.tsx @@ -31,12 +31,7 @@ import { EuiIcon } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import React from 'react'; -import { - ChromeNavLink, - ChromeRecentlyAccessedHistoryItem, - CoreStart, - WorkspaceAttribute, -} from '../../..'; +import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem, CoreStart } from '../../..'; import { HttpStart } from '../../../http'; import { InternalApplicationStart } from '../../../application/types'; import { relativeToAbsolute } from '../../nav_links/to_nav_link'; @@ -65,7 +60,6 @@ export function createEuiListItem({ onClick = () => {}, navigateToApp, dataTestSubj, - externalLink = false, }: Props) { const { href, id, title, disabled, euiIconType, icon, tooltip } = link; @@ -79,7 +73,7 @@ export function createEuiListItem({ } if ( - !externalLink && // ignore external links + !link.externalLink && // ignore external links event.button === 0 && // ignore everything but left clicks !isModifiedOrPrevented(event) ) { @@ -153,34 +147,3 @@ export function createRecentNavLink( }, }; } - -export interface WorkspaceNavLink { - label: string; - title: string; - 'aria-label': string; -} - -export function createWorkspaceNavLink( - href: string, - workspace: WorkspaceAttribute, - navLinks: ChromeNavLink[] -): WorkspaceNavLink { - const label = workspace.name; - let titleAndAriaLabel = label; - const navLink = navLinks.find((nl) => href.startsWith(nl.baseUrl)); - if (navLink) { - titleAndAriaLabel = i18n.translate('core.ui.workspaceLinks.linkItem.screenReaderLabel', { - defaultMessage: '{workspaceItemLinkName}, type: {pageType}', - values: { - workspaceItemLinkName: label, - pageType: navLink.title, - }, - }); - } - - return { - label, - title: titleAndAriaLabel, - 'aria-label': titleAndAriaLabel, - }; -} diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 1e756ddcf8c9..0f1d1c267899 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -31,7 +31,7 @@ import { pick } from '@osd/std'; import { CoreId } from '../server'; import { PackageInfo, EnvironmentMode } from '../server/types'; -import { CoreSetup, CoreStart } from '.'; +import { ChromeNavLink, CoreSetup, CoreStart } from '.'; import { ChromeService } from './chrome'; import { FatalErrorsService, FatalErrorsSetup } from './fatal_errors'; import { HttpService } from './http'; @@ -233,7 +233,6 @@ export class CoreSystem { injectedMetadata, notifications, uiSettings, - workspaces, }); this.coreApp.start({ application, http, notifications, uiSettings }); diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 557b889d6111..878a0ca9441e 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -3,6 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { i18n } from '@osd/i18n'; +import { AppCategory } from '../../../core/types'; + export const WORKSPACE_APP_ID = 'workspace'; export const WORKSPACE_APP_NAME = 'Workspace'; @@ -14,3 +17,12 @@ export const PATHS = { }; export const WORKSPACE_OP_TYPE_CREATE = 'create'; export const WORKSPACE_OP_TYPE_UPDATE = 'update'; + +export const WORKSPACE_NAV_CATEGORY: AppCategory = { + id: 'workspace', + label: i18n.translate('core.ui.workspaceNavList.label', { + defaultMessage: 'Workspaces', + }), + euiIconType: 'folderClosed', + order: 2000, +}; diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx index a3dc973ee095..278ed962966f 100644 --- a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx @@ -15,10 +15,8 @@ import { import { useObservable } from 'react-use'; import { i18n } from '@osd/i18n'; import { of } from 'rxjs'; - import { WorkspaceAttribute } from 'opensearch-dashboards/public'; -import { useOpenSearchDashboards } from '../../../../../../src/plugins/opensearch_dashboards_react/public'; - +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { PATHS } from '../../../common/constants'; import { WorkspaceForm, WorkspaceFormData } from '../workspace_creator/workspace_form'; import { WORKSPACE_APP_ID, WORKSPACE_OP_TYPE_UPDATE } from '../../../common/constants'; @@ -27,7 +25,7 @@ import { DeleteWorkspaceModal } from '../delete_workspace_modal'; export const WorkspaceUpdater = () => { const { - services: { application, workspaces, notifications }, + services: { application, workspaces, notifications, http }, } = useOpenSearchDashboards<{ application: ApplicationStart }>(); const currentWorkspace = useObservable( @@ -129,7 +127,50 @@ export const WorkspaceUpdater = () => { } } setDeleteWorkspaceModalVisible(false); - await application.navigateToApp('home'); + if (http) { + const homeUrl = application.getUrlForApp('home', { + path: '/', + absolute: false, + }); + const targetUrl = http.basePath.prepend(http.basePath.remove(homeUrl), { + withoutWorkspace: true, + }); + await application.navigateToUrl(targetUrl); + } + }; + + const exitWorkspace = async () => { + let result; + try { + result = await workspaces?.client.exitWorkspace(); + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.exit.failed', { + defaultMessage: 'Failed to exit workspace', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + return; + } + if (!result?.success) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.exit.failed', { + defaultMessage: 'Failed to exit workspace', + }), + text: result?.error, + }); + return; + } + if (http) { + const homeUrl = application.getUrlForApp('home', { + path: '/', + absolute: false, + }); + const targetUrl = http.basePath.prepend(http.basePath.remove(homeUrl), { + withoutWorkspace: true, + }); + await application.navigateToUrl(targetUrl); + } }; return ( @@ -139,6 +180,7 @@ export const WorkspaceUpdater = () => { restrictWidth pageTitle="Update Workspace" rightSideItems={[ + Exit, setDeleteWorkspaceModalVisible(true)}> Delete , diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index f79eb6d9bf50..e154796fe783 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -5,14 +5,19 @@ import { i18n } from '@osd/i18n'; import type { Subscription } from 'rxjs'; +import { combineLatest } from 'rxjs'; import { + ApplicationStart, + AppMountParameters, + AppNavLinkStatus, + ChromeNavLink, CoreSetup, CoreStart, Plugin, - AppMountParameters, - AppNavLinkStatus, + WorkspaceAttribute, + WorkspacesStart, } from '../../../core/public'; -import { WORKSPACE_APP_ID } from '../common/constants'; +import { PATHS, WORKSPACE_APP_ID, WORKSPACE_NAV_CATEGORY } from '../common/constants'; import { mountDropdownList } from './mount'; import { SavedObjectsManagementPluginSetup } from '../../saved_objects_management/public'; import { getWorkspaceColumn } from './components/utils/workspace_column'; @@ -99,7 +104,35 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep return {}; } - private _changeSavedObjectCurrentWorkspace() { + private workspaceToChromeNavLink( + workspace: WorkspaceAttribute, + workspacesStart: WorkspacesStart, + application: ApplicationStart + ): ChromeNavLink { + const id = WORKSPACE_APP_ID + '/' + workspace.id; + const url = workspacesStart?.formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_APP_ID, { + path: '/', + absolute: true, + }), + workspace.id + ); + return { + id, + url, + hidden: false, + disabled: false, + baseUrl: url, + href: url, + category: WORKSPACE_NAV_CATEGORY, + title: i18n.translate('core.ui.workspaceNavList.workspaceName', { + defaultMessage: workspace.name, + }), + externalLink: true, + }; + } + + private async _changeSavedObjectCurrentWorkspace() { if (this.coreStart) { return this.coreStart.workspaces.client.currentWorkspaceId$.subscribe( (currentWorkspaceId) => { @@ -109,9 +142,121 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep } } + private filterByWorkspace( + workspace: WorkspaceAttribute | null | undefined, + allNavLinks: ChromeNavLink[] + ) { + if (!workspace) return allNavLinks; + const features = workspace.features ?? []; + return allNavLinks.filter((item) => features.includes(item.id)); + } + + private filterNavLinks(core: CoreStart, workspaceEnabled: boolean) { + const navLinksService = core.chrome.navLinks; + const chromeNavLinks$ = navLinksService.getNavLinks$(); + if (workspaceEnabled) { + const workspaceList$ = core.workspaces.client.workspaceList$; + const currentWorkspace$ = core.workspaces.client.currentWorkspace$; + combineLatest([workspaceList$, chromeNavLinks$, currentWorkspace$]).subscribe( + ([workspaceList, chromeNavLinks, currentWorkspace]) => { + const filteredNavLinks = new Map(); + chromeNavLinks = this.filterByWorkspace(currentWorkspace, chromeNavLinks); + chromeNavLinks.forEach((chromeNavLink) => { + if (chromeNavLink.id === 'home') { + // set hidden, icon and order for home nav link + const homeNavLink: ChromeNavLink = { + ...chromeNavLink, + hidden: currentWorkspace !== null, + euiIconType: 'logoOpenSearch', + order: 1000, + }; + filteredNavLinks.set(chromeNavLink.id, homeNavLink); + } else { + filteredNavLinks.set(chromeNavLink.id, chromeNavLink); + } + }); + workspaceList + .filter((value, index) => !currentWorkspace && index < 5) + .map((workspace) => + this.workspaceToChromeNavLink(workspace, core.workspaces, core.application) + ) + .forEach((workspaceNavLink) => + filteredNavLinks.set(workspaceNavLink.id, workspaceNavLink) + ); + // See more + const seeMoreId = WORKSPACE_APP_ID + PATHS.list; + const seeMoreUrl = WORKSPACE_APP_ID + PATHS.list; + const seeMoreNavLink: ChromeNavLink = { + id: seeMoreId, + title: i18n.translate('core.ui.workspaceNavList.seeMore', { + defaultMessage: 'SEE MORE', + }), + hidden: currentWorkspace !== null, + disabled: false, + baseUrl: seeMoreUrl, + href: seeMoreUrl, + category: WORKSPACE_NAV_CATEGORY, + }; + filteredNavLinks.set(seeMoreId, seeMoreNavLink); + // Admin + const adminId = 'admin'; + const adminUrl = '/app/admin'; + const adminNavLink: ChromeNavLink = { + id: adminId, + title: i18n.translate('core.ui.workspaceNavList.admin', { + defaultMessage: 'Admin', + }), + hidden: currentWorkspace !== null, + disabled: true, + baseUrl: adminUrl, + href: adminUrl, + euiIconType: 'managementApp', + order: 9000, + }; + filteredNavLinks.set(adminId, adminNavLink); + // Overview only inside workspace + if (currentWorkspace) { + const overviewId = WORKSPACE_APP_ID + PATHS.update; + const overviewUrl = core.workspaces.formatUrlWithWorkspaceId( + core.application.getUrlForApp(WORKSPACE_APP_ID, { + path: PATHS.update, + absolute: true, + }), + currentWorkspace.id + ); + const overviewNavLink: ChromeNavLink = { + id: overviewId, + title: i18n.translate('core.ui.workspaceNavList.overview', { + defaultMessage: 'Overview', + }), + hidden: false, + disabled: false, + baseUrl: overviewUrl, + href: overviewUrl, + euiIconType: 'grid', + order: 1000, + }; + filteredNavLinks.set(overviewId, overviewNavLink); + } + navLinksService.setFilteredNavLinks(filteredNavLinks); + } + ); + } else { + chromeNavLinks$.subscribe((chromeNavLinks) => { + const filteredNavLinks = new Map(); + chromeNavLinks.forEach((chromeNavLink) => + filteredNavLinks.set(chromeNavLink.id, chromeNavLink) + ); + navLinksService.setFilteredNavLinks(filteredNavLinks); + }); + } + } + public start(core: CoreStart) { // If workspace feature is disabled, it will not load the workspace plugin if (core.uiSettings.get('workspace:enabled') === false) { + // set default value for filtered nav links + this.filterNavLinks(core, false); return {}; } @@ -123,6 +268,9 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep chrome: core.chrome, }); this.currentWorkspaceSubscription = this._changeSavedObjectCurrentWorkspace(); + if (core) { + this.filterNavLinks(core, true); + } return {}; } From 3a859b329d2b13f12245aa3b774ad4893ee5104c Mon Sep 17 00:00:00 2001 From: Yuye Zhu Date: Mon, 24 Jul 2023 11:32:27 +0800 Subject: [PATCH 062/174] fix: osd bootstrap error (#57) * fix osd bootstrap error Signed-off-by: yuye-aws * fix build plugins error Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws --- src/core/public/core_system.ts | 2 +- .../management_section/objects_table/saved_objects_table.tsx | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 0f1d1c267899..73536b76d76b 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -31,7 +31,7 @@ import { pick } from '@osd/std'; import { CoreId } from '../server'; import { PackageInfo, EnvironmentMode } from '../server/types'; -import { ChromeNavLink, CoreSetup, CoreStart } from '.'; +import { CoreSetup, CoreStart } from '.'; import { ChromeService } from './chrome'; import { FatalErrorsService, FatalErrorsSetup } from './fatal_errors'; import { HttpService } from './http'; 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 d300d489a86e..ca0aad55a0cb 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 @@ -66,7 +66,6 @@ import { OverlayStart, NotificationsStart, ApplicationStart, - WorkspacesStart, } from 'src/core/public'; import { RedirectAppLinks } from '../../../../opensearch_dashboards_react/public'; import { IndexPatternsContract } from '../../../../data/public'; @@ -121,7 +120,6 @@ export interface SavedObjectsTableProps { dateFormat: string; title: string; fullWidth: boolean; - workspaces: WorkspacesStart; } export interface SavedObjectsTableState { From aac424df5bd7dba284b3eae6bb87cbad999f9695 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Mon, 24 Jul 2023 17:38:38 +0800 Subject: [PATCH 063/174] feat: filter out ADMIN application and add feature dependency logic (#49) * feat: filter out ADMIN application and add feature dependency logic Signed-off-by: Lin Wang * feat: separate feature utils function Signed-off-by: Lin Wang * feat: rename isFeatureDependBySelectedFeatures, separate generateFeatureDependencyMap and add annotation Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang --- src/core/public/application/types.ts | 15 +++ .../public/components/utils/feature.test.ts | 53 +++++++++ .../public/components/utils/feature.ts | 60 ++++++++++ .../workspace_creator/workspace_form.tsx | 103 ++++++++++++++---- 4 files changed, 207 insertions(+), 24 deletions(-) create mode 100644 src/plugins/workspace/public/components/utils/feature.test.ts create mode 100644 src/plugins/workspace/public/components/utils/feature.ts diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 54ed7840596f..e284e80297f0 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -251,6 +251,21 @@ export interface App { * ``` */ exactRoute?: boolean; + + /** + * The feature group of workspace, won't be displayed as feature if feature set is ADMIN. + */ + featureGroup?: Array<'WORKSPACE' | 'ADMIN'>; + + /** + * The dependencies of one application, required feature will be automatic select and can't + * be unselect in the workspace configuration. + */ + dependencies?: { + [key: string]: { + type: 'required' | 'optional'; + }; + }; } /** diff --git a/src/plugins/workspace/public/components/utils/feature.test.ts b/src/plugins/workspace/public/components/utils/feature.test.ts new file mode 100644 index 000000000000..87554ef54ecb --- /dev/null +++ b/src/plugins/workspace/public/components/utils/feature.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + isFeatureDependBySelectedFeatures, + getFinalFeatureIdsByDependency, + generateFeatureDependencyMap, +} from './feature'; + +describe('feature utils', () => { + describe('isFeatureDependBySelectedFeatures', () => { + it('should return true', () => { + expect(isFeatureDependBySelectedFeatures('a', ['b'], { b: ['a'] })).toBe(true); + expect(isFeatureDependBySelectedFeatures('a', ['b'], { b: ['a', 'c'] })).toBe(true); + }); + it('should return false', () => { + expect(isFeatureDependBySelectedFeatures('a', ['b'], { b: ['c'] })).toBe(false); + expect(isFeatureDependBySelectedFeatures('a', ['b'], {})).toBe(false); + }); + }); + + describe('getFinalFeatureIdsByDependency', () => { + it('should return consistent feature ids', () => { + expect(getFinalFeatureIdsByDependency(['a'], { a: ['b'] }, ['c', 'd'])).toStrictEqual([ + 'c', + 'd', + 'a', + 'b', + ]); + expect(getFinalFeatureIdsByDependency(['a'], { a: ['b', 'e'] }, ['c', 'd'])).toStrictEqual([ + 'c', + 'd', + 'a', + 'b', + 'e', + ]); + }); + }); + + it('should generate consistent features dependency map', () => { + expect( + generateFeatureDependencyMap([ + { id: 'a', dependencies: { b: { type: 'required' }, c: { type: 'optional' } } }, + { id: 'b', dependencies: { c: { type: 'required' } } }, + ]) + ).toEqual({ + a: ['b'], + b: ['c'], + }); + }); +}); diff --git a/src/plugins/workspace/public/components/utils/feature.ts b/src/plugins/workspace/public/components/utils/feature.ts new file mode 100644 index 000000000000..3da6027e83d3 --- /dev/null +++ b/src/plugins/workspace/public/components/utils/feature.ts @@ -0,0 +1,60 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { App } from '../../../../../core/public'; + +export const isFeatureDependBySelectedFeatures = ( + featureId: string, + selectedFeatureIds: string[], + featureDependencies: { [key: string]: string[] } +) => + selectedFeatureIds.some((selectedFeatureId) => + (featureDependencies[selectedFeatureId] || []).some((dependencies) => + dependencies.includes(featureId) + ) + ); + +/** + * + * Generate new feature id list based the old feature id list + * and feature dependencies map. The feature dependency map may + * has duplicate ids with old feature id list. Use set here to + * get the unique feature ids. + * + * @param featureIds a feature id list need to add based old feature id list + * @param featureDependencies a feature dependencies map to get depended feature ids + * @param oldFeatureIds a feature id list that represent current feature id selection states + */ +export const getFinalFeatureIdsByDependency = ( + featureIds: string[], + featureDependencies: { [key: string]: string[] }, + oldFeatureIds: string[] = [] +) => + Array.from( + new Set([ + ...oldFeatureIds, + ...featureIds.reduce( + (pValue, featureId) => [...pValue, ...(featureDependencies[featureId] || [])], + featureIds + ), + ]) + ); + +export const generateFeatureDependencyMap = ( + allFeatures: Array> +) => + allFeatures.reduce<{ [key: string]: string[] }>( + (pValue, { id, dependencies }) => + dependencies + ? { + ...pValue, + [id]: [ + ...(pValue[id] || []), + ...Object.keys(dependencies).filter((key) => dependencies[key].type === 'required'), + ], + } + : pValue, + {} + ); diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx index 8e83b7057245..29e4d66edd2a 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx @@ -33,12 +33,18 @@ import { } from '@elastic/eui'; import { WorkspaceTemplate } from '../../../../../core/types'; -import { AppNavLinkStatus, ApplicationStart } from '../../../../../core/public'; +import { App, AppNavLinkStatus, ApplicationStart } from '../../../../../core/public'; import { useApplications, useWorkspaceTemplate } from '../../hooks'; import { WORKSPACE_OP_TYPE_CREATE, WORKSPACE_OP_TYPE_UPDATE } from '../../../common/constants'; +import { + isFeatureDependBySelectedFeatures, + getFinalFeatureIdsByDependency, + generateFeatureDependencyMap, +} from '../utils/feature'; + import { WorkspaceIconSelector } from './workspace_icon_selector'; -interface WorkspaceFeature { +interface WorkspaceFeature extends Pick { id: string; name: string; templates: WorkspaceTemplate[]; @@ -74,6 +80,7 @@ interface WorkspaceFormProps { defaultValues?: WorkspaceFormData; opType?: string; } + export const WorkspaceForm = ({ application, onSubmit, @@ -115,13 +122,16 @@ export const WorkspaceForm = ({ const apps = category2Applications[currentKey]; const features = apps .filter( - ({ navLinkStatus, chromeless }) => - navLinkStatus !== AppNavLinkStatus.hidden && !chromeless + ({ navLinkStatus, chromeless, featureGroup }) => + navLinkStatus !== AppNavLinkStatus.hidden && + !chromeless && + featureGroup?.includes('WORKSPACE') ) - .map(({ id, title, workspaceTemplate }) => ({ + .map(({ id, title, workspaceTemplate, dependencies }) => ({ id, name: title, templates: workspaceTemplate || [], + dependencies, })); if (features.length === 1 || currentKey === 'undefined') { return [...previousValue, ...features]; @@ -141,6 +151,22 @@ export const WorkspaceForm = ({ [defaultVISTheme] ); + const allFeatures = useMemo( + () => + featureOrGroups.reduce( + (previousData, currentData) => [ + ...previousData, + ...(isWorkspaceFeatureGroup(currentData) ? currentData.features : [currentData]), + ], + [] + ), + [featureOrGroups] + ); + + const featureDependencies = useMemo(() => generateFeatureDependencyMap(allFeatures), [ + allFeatures, + ]); + if (!formIdRef.current) { formIdRef.current = workspaceHtmlIdGenerator(); } @@ -150,27 +176,33 @@ export const WorkspaceForm = ({ const templateId = e.target.value; setSelectedTemplateId(templateId); setSelectedFeatureIds( - featureOrGroups.reduce( - (previousData, currentData) => [ - ...previousData, - ...(isWorkspaceFeatureGroup(currentData) ? currentData.features : [currentData]) - .filter(({ templates }) => !!templates.find((template) => template.id === templateId)) - .map((feature) => feature.id), - ], - [] + getFinalFeatureIdsByDependency( + allFeatures + .filter(({ templates }) => !!templates.find((template) => template.id === templateId)) + .map((feature) => feature.id), + featureDependencies ) ); }, - [featureOrGroups] + [allFeatures, featureDependencies] ); - const handleFeatureChange = useCallback((featureId) => { - setSelectedFeatureIds((previousData) => - previousData.includes(featureId) - ? previousData.filter((id) => id !== featureId) - : [...previousData, featureId] - ); - }, []); + const handleFeatureChange = useCallback( + (featureId) => { + setSelectedFeatureIds((previousData) => { + if (!previousData.includes(featureId)) { + return getFinalFeatureIdsByDependency([featureId], featureDependencies, previousData); + } + + if (isFeatureDependBySelectedFeatures(featureId, previousData, featureDependencies)) { + return previousData; + } + + return previousData.filter((selectedId) => selectedId !== featureId); + }); + }, + [featureDependencies] + ); const handleFeatureCheckboxChange = useCallback( (e) => { @@ -187,14 +219,37 @@ export const WorkspaceForm = ({ setSelectedFeatureIds((previousData) => { const notExistsIds = groupFeatureIds.filter((id) => !previousData.includes(id)); if (notExistsIds.length > 0) { - return [...previousData, ...notExistsIds]; + return getFinalFeatureIdsByDependency( + notExistsIds, + featureDependencies, + previousData + ); } - return previousData.filter((id) => !groupFeatureIds.includes(id)); + let groupRemainFeatureIds = groupFeatureIds; + const outGroupFeatureIds = previousData.filter( + (featureId) => !groupFeatureIds.includes(featureId) + ); + + while (true) { + const lastRemainFeatures = groupRemainFeatureIds.length; + groupRemainFeatureIds = groupRemainFeatureIds.filter((featureId) => + isFeatureDependBySelectedFeatures( + featureId, + [...outGroupFeatureIds, ...groupRemainFeatureIds], + featureDependencies + ) + ); + if (lastRemainFeatures === groupRemainFeatureIds.length) { + break; + } + } + + return [...outGroupFeatureIds, ...groupRemainFeatureIds]; }); } } }, - [featureOrGroups] + [featureOrGroups, featureDependencies] ); const handleFormSubmit = useCallback( From 110b3b14120d32b9362618cd6b8ac205598f2cf2 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Tue, 25 Jul 2023 07:54:21 +0800 Subject: [PATCH 064/174] feat: do not register app when feature flag is off (#56) * feat: do not register app when feature flag is off Signed-off-by: SuZhou-Joe * feat: comply with the category name Signed-off-by: SuZhou-Joe * feat: opt according to PR Signed-off-by: SuZhou-Joe * feat: optimize the comment Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe --- src/core/utils/default_app_categories.ts | 4 +- .../saved_objects_management/public/plugin.ts | 102 +++++----- src/plugins/workspace/public/plugin.ts | 186 ++++++++++-------- 3 files changed, 163 insertions(+), 129 deletions(-) diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts index 0b9e5d8b2817..68531107a5f1 100644 --- a/src/core/utils/default_app_categories.ts +++ b/src/core/utils/default_app_categories.ts @@ -35,8 +35,8 @@ import { AppCategory } from '../types'; export const DEFAULT_APP_CATEGORIES: Record = Object.freeze({ opensearchDashboards: { id: 'opensearchDashboards', - label: i18n.translate('core.ui.libraryNavList.label', { - defaultMessage: 'Library', + label: i18n.translate('core.ui.opensearchDashboardsNavList.label', { + defaultMessage: 'OpenSearch Dashboards', }), order: 1000, }, diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index 157e1ccf33a6..e3263f396512 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -105,53 +105,10 @@ export class SavedObjectsManagementPlugin private namespaceService = new SavedObjectsManagementNamespaceService(); private serviceRegistry = new SavedObjectsManagementServiceRegistry(); - public setup( - core: CoreSetup, - { home, management, uiActions }: SetupDependencies - ): SavedObjectsManagementPluginSetup { - const actionSetup = this.actionService.setup(); - const columnSetup = this.columnService.setup(); - const namespaceSetup = this.namespaceService.setup(); - - if (home) { - home.featureCatalogue.register({ - id: 'saved_objects', - title: i18n.translate('savedObjectsManagement.objects.savedObjectsTitle', { - defaultMessage: 'Saved Objects', - }), - description: i18n.translate('savedObjectsManagement.objects.savedObjectsDescription', { - defaultMessage: - 'Import, export, and manage your saved searches, visualizations, and dashboards.', - }), - icon: 'savedObjectsApp', - 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, - title: i18n.translate('savedObjectsManagement.managementSectionLabel', { - defaultMessage: 'Saved Objects', - }), - }); - }, - }); - - // sets up the context mappings and registers any triggers/actions for the plugin - bootstrap(uiActions); + private registerLibrarySubApp( + coreSetup: CoreSetup + ) { + const core = coreSetup; const mountWrapper = ({ title, allowedObjectTypes, @@ -208,6 +165,57 @@ export class SavedObjectsManagementPlugin allowedObjectTypes: ['query'], }), }); + } + + public setup( + core: CoreSetup, + { home, management, uiActions }: SetupDependencies + ): SavedObjectsManagementPluginSetup { + const actionSetup = this.actionService.setup(); + const columnSetup = this.columnService.setup(); + const namespaceSetup = this.namespaceService.setup(); + const isWorkspaceEnabled = core.uiSettings.get('workspace:enabled'); + + if (home) { + home.featureCatalogue.register({ + id: 'saved_objects', + title: i18n.translate('savedObjectsManagement.objects.savedObjectsTitle', { + defaultMessage: 'Saved Objects', + }), + description: i18n.translate('savedObjectsManagement.objects.savedObjectsDescription', { + defaultMessage: + 'Import, export, and manage your saved searches, visualizations, and dashboards.', + }), + icon: 'savedObjectsApp', + 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, + title: SAVED_OBJECT_MANAGEMENT_TITLE_WORDINGS, + }); + }, + }); + + // sets up the context mappings and registers any triggers/actions for the plugin + bootstrap(uiActions); + if (isWorkspaceEnabled) { + this.registerLibrarySubApp(core); + } // depends on `getStartServices`, should not be awaited registerServices(this.serviceRegistry, core.getStartServices); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index e154796fe783..70f0cbfc4cc9 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -6,6 +6,7 @@ import { i18n } from '@osd/i18n'; import type { Subscription } from 'rxjs'; import { combineLatest } from 'rxjs'; +import { map } from 'rxjs/operators'; import { ApplicationStart, AppMountParameters, @@ -16,6 +17,7 @@ import { Plugin, WorkspaceAttribute, WorkspacesStart, + DEFAULT_APP_CATEGORIES, } from '../../../core/public'; import { PATHS, WORKSPACE_APP_ID, WORKSPACE_NAV_CATEGORY } from '../common/constants'; import { mountDropdownList } from './mount'; @@ -157,90 +159,92 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep if (workspaceEnabled) { const workspaceList$ = core.workspaces.client.workspaceList$; const currentWorkspace$ = core.workspaces.client.currentWorkspace$; - combineLatest([workspaceList$, chromeNavLinks$, currentWorkspace$]).subscribe( - ([workspaceList, chromeNavLinks, currentWorkspace]) => { - const filteredNavLinks = new Map(); - chromeNavLinks = this.filterByWorkspace(currentWorkspace, chromeNavLinks); - chromeNavLinks.forEach((chromeNavLink) => { - if (chromeNavLink.id === 'home') { - // set hidden, icon and order for home nav link - const homeNavLink: ChromeNavLink = { - ...chromeNavLink, - hidden: currentWorkspace !== null, - euiIconType: 'logoOpenSearch', - order: 1000, - }; - filteredNavLinks.set(chromeNavLink.id, homeNavLink); - } else { - filteredNavLinks.set(chromeNavLink.id, chromeNavLink); - } - }); - workspaceList - .filter((value, index) => !currentWorkspace && index < 5) - .map((workspace) => - this.workspaceToChromeNavLink(workspace, core.workspaces, core.application) - ) - .forEach((workspaceNavLink) => - filteredNavLinks.set(workspaceNavLink.id, workspaceNavLink) - ); - // See more - const seeMoreId = WORKSPACE_APP_ID + PATHS.list; - const seeMoreUrl = WORKSPACE_APP_ID + PATHS.list; - const seeMoreNavLink: ChromeNavLink = { - id: seeMoreId, - title: i18n.translate('core.ui.workspaceNavList.seeMore', { - defaultMessage: 'SEE MORE', - }), - hidden: currentWorkspace !== null, - disabled: false, - baseUrl: seeMoreUrl, - href: seeMoreUrl, - category: WORKSPACE_NAV_CATEGORY, - }; - filteredNavLinks.set(seeMoreId, seeMoreNavLink); - // Admin - const adminId = 'admin'; - const adminUrl = '/app/admin'; - const adminNavLink: ChromeNavLink = { - id: adminId, - title: i18n.translate('core.ui.workspaceNavList.admin', { - defaultMessage: 'Admin', - }), - hidden: currentWorkspace !== null, - disabled: true, - baseUrl: adminUrl, - href: adminUrl, - euiIconType: 'managementApp', - order: 9000, - }; - filteredNavLinks.set(adminId, adminNavLink); - // Overview only inside workspace - if (currentWorkspace) { - const overviewId = WORKSPACE_APP_ID + PATHS.update; - const overviewUrl = core.workspaces.formatUrlWithWorkspaceId( - core.application.getUrlForApp(WORKSPACE_APP_ID, { - path: PATHS.update, - absolute: true, - }), - currentWorkspace.id - ); - const overviewNavLink: ChromeNavLink = { - id: overviewId, - title: i18n.translate('core.ui.workspaceNavList.overview', { - defaultMessage: 'Overview', - }), - hidden: false, - disabled: false, - baseUrl: overviewUrl, - href: overviewUrl, - euiIconType: 'grid', + combineLatest([ + workspaceList$, + chromeNavLinks$.pipe(map(this.changeCategoryNameByWorkspaceFeatureFlag)), + currentWorkspace$, + ]).subscribe(([workspaceList, chromeNavLinks, currentWorkspace]) => { + const filteredNavLinks = new Map(); + chromeNavLinks = this.filterByWorkspace(currentWorkspace, chromeNavLinks); + chromeNavLinks.forEach((chromeNavLink) => { + if (chromeNavLink.id === 'home') { + // set hidden, icon and order for home nav link + const homeNavLink: ChromeNavLink = { + ...chromeNavLink, + hidden: currentWorkspace !== null, + euiIconType: 'logoOpenSearch', order: 1000, }; - filteredNavLinks.set(overviewId, overviewNavLink); + filteredNavLinks.set(chromeNavLink.id, homeNavLink); + } else { + filteredNavLinks.set(chromeNavLink.id, chromeNavLink); } - navLinksService.setFilteredNavLinks(filteredNavLinks); + }); + workspaceList + .filter((value, index) => !currentWorkspace && index < 5) + .map((workspace) => + this.workspaceToChromeNavLink(workspace, core.workspaces, core.application) + ) + .forEach((workspaceNavLink) => + filteredNavLinks.set(workspaceNavLink.id, workspaceNavLink) + ); + // See more + const seeMoreId = WORKSPACE_APP_ID + PATHS.list; + const seeMoreUrl = WORKSPACE_APP_ID + PATHS.list; + const seeMoreNavLink: ChromeNavLink = { + id: seeMoreId, + title: i18n.translate('core.ui.workspaceNavList.seeMore', { + defaultMessage: 'SEE MORE', + }), + hidden: currentWorkspace !== null, + disabled: false, + baseUrl: seeMoreUrl, + href: seeMoreUrl, + category: WORKSPACE_NAV_CATEGORY, + }; + filteredNavLinks.set(seeMoreId, seeMoreNavLink); + // Admin + const adminId = 'admin'; + const adminUrl = '/app/admin'; + const adminNavLink: ChromeNavLink = { + id: adminId, + title: i18n.translate('core.ui.workspaceNavList.admin', { + defaultMessage: 'Admin', + }), + hidden: currentWorkspace !== null, + disabled: true, + baseUrl: adminUrl, + href: adminUrl, + euiIconType: 'managementApp', + order: 9000, + }; + filteredNavLinks.set(adminId, adminNavLink); + // Overview only inside workspace + if (currentWorkspace) { + const overviewId = WORKSPACE_APP_ID + PATHS.update; + const overviewUrl = core.workspaces.formatUrlWithWorkspaceId( + core.application.getUrlForApp(WORKSPACE_APP_ID, { + path: PATHS.update, + absolute: true, + }), + currentWorkspace.id + ); + const overviewNavLink: ChromeNavLink = { + id: overviewId, + title: i18n.translate('core.ui.workspaceNavList.overview', { + defaultMessage: 'Overview', + }), + hidden: false, + disabled: false, + baseUrl: overviewUrl, + href: overviewUrl, + euiIconType: 'grid', + order: 1000, + }; + filteredNavLinks.set(overviewId, overviewNavLink); } - ); + navLinksService.setFilteredNavLinks(filteredNavLinks); + }); } else { chromeNavLinks$.subscribe((chromeNavLinks) => { const filteredNavLinks = new Map(); @@ -252,6 +256,28 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep } } + /** + * 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 start(core: CoreStart) { // If workspace feature is disabled, it will not load the workspace plugin if (core.uiSettings.get('workspace:enabled') === false) { From 3f83418ddf7055afc5c66c653f672403fa4a48f5 Mon Sep 17 00:00:00 2001 From: Yuye Zhu Date: Wed, 26 Jul 2023 16:36:31 +0800 Subject: [PATCH 065/174] Sort category and non-category nav link according to order in left menu (#60) * change order for home Signed-off-by: yuye-aws * Sort category and non-category navlink types in left menu Signed-off-by: yuye-aws * change order for overview when inside workspace Signed-off-by: yuye-aws * assign sorted unknowns to another variable Signed-off-by: yuye-aws * change annotation Signed-off-by: yuye-aws * refactor function getMergedNavLinks in left menu Signed-off-by: yuye-aws * fix zero order bug Signed-off-by: yuye-aws * add annotation Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws --- .../chrome/ui/header/collapsible_nav.tsx | 110 +++++++++++------- src/plugins/workspace/public/plugin.ts | 4 +- 2 files changed, 73 insertions(+), 41 deletions(-) diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index c1c4e508e917..d78c2e58ed24 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -72,6 +72,30 @@ function getOrderedCategories( ); } +function getMergedNavLinks( + orderedCategories: string[], + uncategorizedLinks: ChromeNavLink[], + categoryDictionary: ReturnType +): Array { + const uncategorizedLinksWithOrder = sortBy( + uncategorizedLinks.filter((link) => link.order !== null), + 'order' + ); + const uncategorizedLinksWithoutOrder = uncategorizedLinks.filter((link) => link.order === null); + const orderedCategoryWithOrder = orderedCategories + .filter((categoryName) => categoryDictionary[categoryName]?.order !== null) + .map((categoryName) => ({ categoryName, order: categoryDictionary[categoryName]?.order })); + const orderedCategoryWithoutOrder = orderedCategories.filter( + (categoryName) => categoryDictionary[categoryName]?.order === null + ); + const mergedNavLinks = sortBy( + [...uncategorizedLinksWithOrder, ...orderedCategoryWithOrder], + 'order' + ).map((navLink) => ('categoryName' in navLink ? navLink.categoryName : navLink)); + // if order is not defined , categorized links will be placed before uncategorized links + return [...mergedNavLinks, ...orderedCategoryWithoutOrder, ...uncategorizedLinksWithoutOrder]; +} + function getCategoryLocalStorageKey(id: string) { return `core.navGroup.${id}`; } @@ -127,9 +151,14 @@ export function CollapsibleNav({ const appId = useObservable(observables.appId$, ''); const lockRef = useRef(null); const groupedNavLinks = groupBy(navLinks, (link) => link?.category?.id); - const { undefined: unknowns = [], ...allCategorizedLinks } = groupedNavLinks; + const { undefined: uncategorizedLinks = [], ...allCategorizedLinks } = groupedNavLinks; const categoryDictionary = getAllCategories(allCategorizedLinks); const orderedCategories = getOrderedCategories(allCategorizedLinks, categoryDictionary); + const mergedNavLinks = getMergedNavLinks( + orderedCategories, + uncategorizedLinks, + categoryDictionary + ); const readyForEUI = (link: ChromeNavLink, needsIcon: boolean = false) => { return createEuiListItem({ @@ -246,47 +275,50 @@ export function CollapsibleNav({ ) */} - {/* OpenSearchDashboards, Observability, Security, and Management sections */} - {orderedCategories.map((categoryName) => { - const category = categoryDictionary[categoryName]!; - const opensearchLinkLogo = - category.id === 'opensearchDashboards' ? logos.Mark.url : category.euiIconType; + {/* merged NavLinks */} + {mergedNavLinks.map((item, i) => { + if (typeof item === 'string') { + const category = categoryDictionary[item]!; + const opensearchLinkLogo = + category.id === 'opensearchDashboards' ? 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/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 70f0cbfc4cc9..c445346cd6f8 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -173,7 +173,7 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep ...chromeNavLink, hidden: currentWorkspace !== null, euiIconType: 'logoOpenSearch', - order: 1000, + order: 0, }; filteredNavLinks.set(chromeNavLink.id, homeNavLink); } else { @@ -239,7 +239,7 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep baseUrl: overviewUrl, href: overviewUrl, euiIconType: 'grid', - order: 1000, + order: 0, }; filteredNavLinks.set(overviewId, overviewNavLink); } From b12e203622f2d20bc8fa48de1f20f672b9d5d0a7 Mon Sep 17 00:00:00 2001 From: Yuye Zhu Date: Mon, 31 Jul 2023 16:07:48 +0800 Subject: [PATCH 066/174] Refactor: Assign default value for filtered nav links (#64) * remove default filtered nav link value set Signed-off-by: yuye-aws * default value for nav link Signed-off-by: yuye-aws * refactor currentworkspace logic Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws --- .../chrome/nav_links/nav_links_service.ts | 7 +- src/plugins/workspace/public/plugin.ts | 121 ++++++++---------- 2 files changed, 61 insertions(+), 67 deletions(-) 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 1d3dc7994445..3094cdccd7c4 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.ts @@ -167,7 +167,12 @@ export class NavLinksService { }, getFilteredNavLinks$: () => { - return this.filteredNavLinks$.pipe(map(sortChromeNavLinks), takeUntil(this.stop$)); + return combineLatest([navLinks$, this.filteredNavLinks$]).pipe( + map(([navLinks, filteredNavLinks]) => + filteredNavLinks.size ? sortChromeNavLinks(filteredNavLinks) : sortNavLinks(navLinks) + ), + takeUntil(this.stop$) + ); }, get(id: string) { diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index c445346cd6f8..0bd94c897fbc 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -153,35 +153,58 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep return allNavLinks.filter((item) => features.includes(item.id)); } - private filterNavLinks(core: CoreStart, workspaceEnabled: boolean) { + private filterNavLinks(core: CoreStart) { const navLinksService = core.chrome.navLinks; const chromeNavLinks$ = navLinksService.getNavLinks$(); - if (workspaceEnabled) { - const workspaceList$ = core.workspaces.client.workspaceList$; - const currentWorkspace$ = core.workspaces.client.currentWorkspace$; - combineLatest([ - workspaceList$, - chromeNavLinks$.pipe(map(this.changeCategoryNameByWorkspaceFeatureFlag)), - currentWorkspace$, - ]).subscribe(([workspaceList, chromeNavLinks, currentWorkspace]) => { - const filteredNavLinks = new Map(); - chromeNavLinks = this.filterByWorkspace(currentWorkspace, chromeNavLinks); - chromeNavLinks.forEach((chromeNavLink) => { - if (chromeNavLink.id === 'home') { - // set hidden, icon and order for home nav link - const homeNavLink: ChromeNavLink = { - ...chromeNavLink, - hidden: currentWorkspace !== null, - euiIconType: 'logoOpenSearch', - order: 0, - }; - filteredNavLinks.set(chromeNavLink.id, homeNavLink); - } else { - filteredNavLinks.set(chromeNavLink.id, chromeNavLink); - } - }); + const workspaceList$ = core.workspaces.client.workspaceList$; + const currentWorkspace$ = core.workspaces.client.currentWorkspace$; + combineLatest([ + workspaceList$, + chromeNavLinks$.pipe(map(this.changeCategoryNameByWorkspaceFeatureFlag)), + currentWorkspace$, + ]).subscribe(([workspaceList, chromeNavLinks, currentWorkspace]) => { + const filteredNavLinks = new Map(); + chromeNavLinks = this.filterByWorkspace(currentWorkspace, chromeNavLinks); + chromeNavLinks.forEach((chromeNavLink) => { + if (chromeNavLink.id === 'home') { + // set hidden, icon and order for home nav link + const homeNavLink: ChromeNavLink = { + ...chromeNavLink, + hidden: currentWorkspace !== null, + euiIconType: 'logoOpenSearch', + order: 0, + }; + filteredNavLinks.set(chromeNavLink.id, homeNavLink); + } else { + filteredNavLinks.set(chromeNavLink.id, chromeNavLink); + } + }); + if (currentWorkspace) { + // Overview only inside workspace + const overviewId = WORKSPACE_APP_ID + PATHS.update; + const overviewUrl = core.workspaces.formatUrlWithWorkspaceId( + core.application.getUrlForApp(WORKSPACE_APP_ID, { + path: PATHS.update, + absolute: true, + }), + currentWorkspace.id + ); + const overviewNavLink: ChromeNavLink = { + id: overviewId, + title: i18n.translate('core.ui.workspaceNavList.overview', { + defaultMessage: 'Overview', + }), + hidden: false, + disabled: false, + baseUrl: overviewUrl, + href: overviewUrl, + euiIconType: 'grid', + order: 0, + }; + filteredNavLinks.set(overviewId, overviewNavLink); + } else { workspaceList - .filter((value, index) => !currentWorkspace && index < 5) + .filter((workspace, index) => index < 5) .map((workspace) => this.workspaceToChromeNavLink(workspace, core.workspaces, core.application) ) @@ -196,7 +219,7 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep title: i18n.translate('core.ui.workspaceNavList.seeMore', { defaultMessage: 'SEE MORE', }), - hidden: currentWorkspace !== null, + hidden: false, disabled: false, baseUrl: seeMoreUrl, href: seeMoreUrl, @@ -211,7 +234,7 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep title: i18n.translate('core.ui.workspaceNavList.admin', { defaultMessage: 'Admin', }), - hidden: currentWorkspace !== null, + hidden: false, disabled: true, baseUrl: adminUrl, href: adminUrl, @@ -219,41 +242,9 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep order: 9000, }; filteredNavLinks.set(adminId, adminNavLink); - // Overview only inside workspace - if (currentWorkspace) { - const overviewId = WORKSPACE_APP_ID + PATHS.update; - const overviewUrl = core.workspaces.formatUrlWithWorkspaceId( - core.application.getUrlForApp(WORKSPACE_APP_ID, { - path: PATHS.update, - absolute: true, - }), - currentWorkspace.id - ); - const overviewNavLink: ChromeNavLink = { - id: overviewId, - title: i18n.translate('core.ui.workspaceNavList.overview', { - defaultMessage: 'Overview', - }), - hidden: false, - disabled: false, - baseUrl: overviewUrl, - href: overviewUrl, - euiIconType: 'grid', - order: 0, - }; - filteredNavLinks.set(overviewId, overviewNavLink); - } - navLinksService.setFilteredNavLinks(filteredNavLinks); - }); - } else { - chromeNavLinks$.subscribe((chromeNavLinks) => { - const filteredNavLinks = new Map(); - chromeNavLinks.forEach((chromeNavLink) => - filteredNavLinks.set(chromeNavLink.id, chromeNavLink) - ); - navLinksService.setFilteredNavLinks(filteredNavLinks); - }); - } + } + navLinksService.setFilteredNavLinks(filteredNavLinks); + }); } /** @@ -281,8 +272,6 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep public start(core: CoreStart) { // If workspace feature is disabled, it will not load the workspace plugin if (core.uiSettings.get('workspace:enabled') === false) { - // set default value for filtered nav links - this.filterNavLinks(core, false); return {}; } @@ -295,7 +284,7 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep }); this.currentWorkspaceSubscription = this._changeSavedObjectCurrentWorkspace(); if (core) { - this.filterNavLinks(core, true); + this.filterNavLinks(core); } return {}; } From 3bfab75bac5b13b40e957ffcec9b27858500e441 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Mon, 31 Jul 2023 17:59:27 +0800 Subject: [PATCH 067/174] Permission control service for saved objects (#63) * feat: move permission control to saved objects directory Signed-off-by: SuZhou-Joe * feat: use bulkGetObjects and fix unit test Signed-off-by: SuZhou-Joe * feat: add http routes for validate & list Signed-off-by: SuZhou-Joe * feat: move permissionModes to common place Signed-off-by: SuZhou-Joe * feat: rename routes Signed-off-by: SuZhou-Joe * feat: some side effects Signed-off-by: SuZhou-Joe * feat: some side effects Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe --- src/core/server/legacy/legacy_service.ts | 1 + src/core/server/plugins/plugin_context.ts | 1 + .../permission_control/client.mock.ts | 15 ++++ .../permission_control/client.ts | 83 +++++++++++++++++++ .../permission_control/routes/index.ts | 20 +++++ .../permission_control/routes/principles.ts | 33 ++++++++ .../permission_control/routes/validate.ts | 40 +++++++++ .../saved_objects_service.mock.ts | 2 + .../saved_objects/saved_objects_service.ts | 21 ++++- src/core/server/workspaces/index.ts | 1 - .../workspace_saved_objects_client_wrapper.ts | 68 ++++++++++----- .../workspace_permission_control.ts | 30 ------- .../server/workspaces/workspaces_service.ts | 10 +-- src/core/utils/constants.ts | 8 ++ src/core/utils/index.ts | 2 +- 15 files changed, 273 insertions(+), 62 deletions(-) create mode 100644 src/core/server/saved_objects/permission_control/client.mock.ts create mode 100644 src/core/server/saved_objects/permission_control/client.ts create mode 100644 src/core/server/saved_objects/permission_control/routes/index.ts create mode 100644 src/core/server/saved_objects/permission_control/routes/principles.ts create mode 100644 src/core/server/saved_objects/permission_control/routes/validate.ts delete mode 100644 src/core/server/workspaces/workspace_permission_control.ts diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 165a67aa1f83..609a724ff9fc 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -279,6 +279,7 @@ export class LegacyService implements CoreService { setStatus: () => { throw new Error(`core.savedObjects.setStatus is unsupported in legacy`); }, + permissionControl: setupDeps.core.savedObjects.permissionControl, }, status: { isStatusPageAnonymous: setupDeps.core.status.isStatusPageAnonymous, diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index ab028e169a71..3e0584a961df 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -206,6 +206,7 @@ export function createPluginSetupContext( getImportExportObjectLimit: deps.savedObjects.getImportExportObjectLimit, setRepositoryFactoryProvider: deps.savedObjects.setRepositoryFactoryProvider, setStatus: deps.savedObjects.setStatus, + permissionControl: deps.savedObjects.permissionControl, }, status: { core$: deps.status.core$, diff --git a/src/core/server/saved_objects/permission_control/client.mock.ts b/src/core/server/saved_objects/permission_control/client.mock.ts new file mode 100644 index 000000000000..eeafc83995b6 --- /dev/null +++ b/src/core/server/saved_objects/permission_control/client.mock.ts @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsPermissionControlContract } from './client'; + +export const savedObjectsPermissionControlMock: SavedObjectsPermissionControlContract = { + setup: jest.fn(), + validate: jest.fn(), + addPrinciplesToObjects: jest.fn(), + removePrinciplesFromObjects: jest.fn(), + getPrinciplesOfObjects: jest.fn(), + getPermittedWorkspaceIds: jest.fn(), +}; diff --git a/src/core/server/saved_objects/permission_control/client.ts b/src/core/server/saved_objects/permission_control/client.ts new file mode 100644 index 000000000000..9bd41a8c7a30 --- /dev/null +++ b/src/core/server/saved_objects/permission_control/client.ts @@ -0,0 +1,83 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpenSearchDashboardsRequest } from '../../http'; +import { SavedObjectsServiceStart } from '../saved_objects_service'; +import { SavedObjectsBulkGetObject } from '../service'; + +export type SavedObjectsPermissionControlContract = Pick< + SavedObjectsPermissionControl, + keyof SavedObjectsPermissionControl +>; + +export type SavedObjectsPermissionModes = string[]; + +export class SavedObjectsPermissionControl { + private getScopedClient?: SavedObjectsServiceStart['getScopedClient']; + private getScopedSavedObjectsClient(request: OpenSearchDashboardsRequest) { + return this.getScopedClient?.(request); + } + private async bulkGetSavedObjects( + request: OpenSearchDashboardsRequest, + savedObjects: SavedObjectsBulkGetObject[] + ) { + return ( + (await this.getScopedSavedObjectsClient(request)?.bulkGet(savedObjects))?.saved_objects || [] + ); + } + public async setup(getScopedClient: SavedObjectsServiceStart['getScopedClient']) { + this.getScopedClient = getScopedClient; + } + public async validate( + request: OpenSearchDashboardsRequest, + savedObject: SavedObjectsBulkGetObject, + permissionModeOrModes: SavedObjectsPermissionModes + ) { + const savedObjectsGet = await this.bulkGetSavedObjects(request, [savedObject]); + if (savedObjectsGet) { + return { + success: true, + result: true, + }; + } + + return { + success: true, + result: false, + }; + } + + public async addPrinciplesToObjects( + request: OpenSearchDashboardsRequest, + savedObjects: SavedObjectsBulkGetObject[], + personas: string[], + permissionModeOrModes: SavedObjectsPermissionModes + ): Promise { + return true; + } + + public async removePrinciplesFromObjects( + request: OpenSearchDashboardsRequest, + savedObjects: SavedObjectsBulkGetObject[], + personas: string[], + permissionModeOrModes: SavedObjectsPermissionModes + ): Promise { + return true; + } + + public async getPrinciplesOfObjects( + request: OpenSearchDashboardsRequest, + savedObjects: SavedObjectsBulkGetObject[] + ): Promise> { + return {}; + } + + public async getPermittedWorkspaceIds( + request: OpenSearchDashboardsRequest, + permissionModeOrModes: SavedObjectsPermissionModes + ) { + return []; + } +} diff --git a/src/core/server/saved_objects/permission_control/routes/index.ts b/src/core/server/saved_objects/permission_control/routes/index.ts new file mode 100644 index 000000000000..edd694b0ada0 --- /dev/null +++ b/src/core/server/saved_objects/permission_control/routes/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { InternalHttpServiceSetup } from '../../../http'; +import { SavedObjectsPermissionControlContract } from '../client'; +import { registerValidateRoute } from './validate'; + +export function registerPermissionCheckRoutes({ + http, + permissionControl, +}: { + http: InternalHttpServiceSetup; + permissionControl: SavedObjectsPermissionControlContract; +}) { + const router = http.createRouter('/api/saved_objects_permission_control/'); + + registerValidateRoute(router, permissionControl); +} diff --git a/src/core/server/saved_objects/permission_control/routes/principles.ts b/src/core/server/saved_objects/permission_control/routes/principles.ts new file mode 100644 index 000000000000..986bf46ed967 --- /dev/null +++ b/src/core/server/saved_objects/permission_control/routes/principles.ts @@ -0,0 +1,33 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; +import { IRouter } from '../../../http'; +import { SavedObjectsPermissionControlContract } from '../client'; + +export const registerListRoute = ( + router: IRouter, + permissionControl: SavedObjectsPermissionControlContract +) => { + router.post( + { + path: '/principles', + validate: { + body: schema.object({ + objects: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }) + ), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const result = await permissionControl.getPrinciplesOfObjects(req, req.body.objects); + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/core/server/saved_objects/permission_control/routes/validate.ts b/src/core/server/saved_objects/permission_control/routes/validate.ts new file mode 100644 index 000000000000..746608d2a74c --- /dev/null +++ b/src/core/server/saved_objects/permission_control/routes/validate.ts @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; +import { IRouter } from '../../../http'; +import { SavedObjectsPermissionControlContract } from '../client'; + +export const registerValidateRoute = ( + router: IRouter, + permissionControl: SavedObjectsPermissionControlContract +) => { + router.post( + { + path: '/validate/{type}/{id}', + validate: { + params: schema.object({ + type: schema.string(), + id: schema.string(), + }), + body: schema.object({ + permissionModes: schema.arrayOf(schema.string()), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { type, id } = req.params; + const result = await permissionControl.validate( + req, + { + type, + id, + }, + req.body.permissionModes + ); + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/core/server/saved_objects/saved_objects_service.mock.ts b/src/core/server/saved_objects/saved_objects_service.mock.ts index 257b5048fc4a..4b06827d2ad6 100644 --- a/src/core/server/saved_objects/saved_objects_service.mock.ts +++ b/src/core/server/saved_objects/saved_objects_service.mock.ts @@ -45,6 +45,7 @@ import { typeRegistryMock } from './saved_objects_type_registry.mock'; import { migrationMocks } from './migrations/mocks'; import { ServiceStatusLevels } from '../status'; import { ISavedObjectTypeRegistry } from './saved_objects_type_registry'; +import { savedObjectsPermissionControlMock } from './permission_control/client.mock'; type SavedObjectsServiceContract = PublicMethodsOf; @@ -81,6 +82,7 @@ const createSetupContractMock = () => { getImportExportObjectLimit: jest.fn(), setRepositoryFactoryProvider: jest.fn(), setStatus: jest.fn(), + permissionControl: savedObjectsPermissionControlMock, }; setupContract.getImportExportObjectLimit.mockReturnValue(100); diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index 43296f340d85..01738ac5a149 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -67,6 +67,11 @@ import { registerRoutes } from './routes'; import { ServiceStatus, ServiceStatusLevels } from '../status'; import { calculateStatus$ } from './status'; import { createMigrationOpenSearchClient } from './migrations/core/'; +import { + SavedObjectsPermissionControl, + SavedObjectsPermissionControlContract, +} from './permission_control/client'; +import { registerPermissionCheckRoutes } from './permission_control/routes'; /** * Saved Objects is OpenSearchDashboards's data persistence mechanism allowing plugins to * use OpenSearch for storing and querying state. The SavedObjectsServiceSetup API exposes methods @@ -183,6 +188,8 @@ export interface SavedObjectsServiceSetup { * Completely overrides the default status. */ setStatus(status$: Observable>): void; + + permissionControl: SavedObjectsPermissionControlContract; } /** @@ -314,6 +321,7 @@ export class SavedObjectsService level: ServiceStatusLevels.unavailable, summary: `waiting`, }); + private permissionControl?: SavedObjectsPermissionControlContract; constructor(private readonly coreContext: CoreContext) { this.logger = coreContext.logger.get('savedobjects-service'); @@ -341,6 +349,13 @@ export class SavedObjectsService migratorPromise: this.migrator$.pipe(first()).toPromise(), }); + this.permissionControl = new SavedObjectsPermissionControl(); + + registerPermissionCheckRoutes({ + http: setupDeps.http, + permissionControl: this.permissionControl, + }); + return { status$: this.savedObjectServiceStatus$.asObservable(), setClientFactoryProvider: (provider) => { @@ -389,6 +404,7 @@ export class SavedObjectsService } this.savedObjectServiceCustomStatus$ = status$; }, + permissionControl: this.permissionControl, }; } @@ -527,8 +543,11 @@ export class SavedObjectsService this.started = true; + const getScopedClient = clientProvider.getClient.bind(clientProvider); + this.permissionControl?.setup(getScopedClient); + return { - getScopedClient: clientProvider.getClient.bind(clientProvider), + getScopedClient, createScopedRepository: repositoryFactory.createScopedRepository, createInternalRepository: repositoryFactory.createInternalRepository, createSerializer: () => new SavedObjectsSerializer(this.typeRegistry), diff --git a/src/core/server/workspaces/index.ts b/src/core/server/workspaces/index.ts index c1f88784aa0e..6a312f00484d 100644 --- a/src/core/server/workspaces/index.ts +++ b/src/core/server/workspaces/index.ts @@ -12,6 +12,5 @@ export { export { WorkspaceAttribute, WorkspaceFindOptions } from './types'; -export { WorkspacePermissionControl } from './workspace_permission_control'; export { workspacesValidator, formatWorkspaces } from './utils'; export { WORKSPACE_TYPE } from './constants'; diff --git a/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts index 25c3aa157d9f..0e4452dc23e3 100644 --- a/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts +++ b/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -18,10 +18,9 @@ import { SavedObjectsDeleteOptions, SavedObjectsFindOptions, } from 'opensearch-dashboards/server'; -import { - WorkspacePermissionControl, - WorkspacePermissionMode, -} from '../workspace_permission_control'; +import { SavedObjectsPermissionControlContract } from '../../saved_objects/permission_control/client'; +import { WORKSPACE_TYPE } from '../constants'; +import { PermissionMode } from '../../../utils'; // Can't throw unauthorized for now, the page will be refreshed if unauthorized const generateWorkspacePermissionError = () => @@ -42,16 +41,34 @@ const isWorkspacesLikeAttributes = (attributes: unknown): attributes is Attribut Array.isArray((attributes as { workspaces: unknown }).workspaces); export class WorkspaceSavedObjectsClientWrapper { + private formatPermissionModeToStringArray( + permission: PermissionMode | PermissionMode[] + ): string[] { + if (Array.isArray(permission)) { + return permission; + } + + return [permission]; + } private async validateMultiWorkspacesPermissions( workspaces: string[] | undefined, request: OpenSearchDashboardsRequest, - permissionMode: WorkspacePermissionMode | WorkspacePermissionMode[] + permissionMode: PermissionMode | PermissionMode[] ) { if (!workspaces) { return; } for (const workspaceId of workspaces) { - if (!(await this.permissionControl.validate(workspaceId, permissionMode, request))) { + if ( + !(await this.permissionControl.validate( + request, + { + type: WORKSPACE_TYPE, + id: workspaceId, + }, + this.formatPermissionModeToStringArray(permissionMode) + )) + ) { throw generateWorkspacePermissionError(); } } @@ -60,14 +77,23 @@ export class WorkspaceSavedObjectsClientWrapper { private async validateAtLeastOnePermittedWorkspaces( workspaces: string[] | undefined, request: OpenSearchDashboardsRequest, - permissionMode: WorkspacePermissionMode | WorkspacePermissionMode[] + permissionMode: PermissionMode | PermissionMode[] ) { if (!workspaces) { return; } let permitted = false; for (const workspaceId of workspaces) { - if (await this.permissionControl.validate(workspaceId, permissionMode, request)) { + if ( + await this.permissionControl.validate( + request, + { + type: WORKSPACE_TYPE, + id: workspaceId, + }, + this.formatPermissionModeToStringArray(permissionMode) + ) + ) { permitted = true; break; } @@ -87,7 +113,7 @@ export class WorkspaceSavedObjectsClientWrapper { await this.validateMultiWorkspacesPermissions( objectToDeleted.workspaces, wrapperOptions.request, - WorkspacePermissionMode.Admin + PermissionMode.Management ); return await wrapperOptions.client.delete(type, id, options); }; @@ -108,7 +134,7 @@ export class WorkspaceSavedObjectsClientWrapper { await this.validateMultiWorkspacesPermissions( attributes.workspaces, wrapperOptions.request, - WorkspacePermissionMode.Admin + PermissionMode.Management ); } return await wrapperOptions.client.create(type, attributes, options); @@ -123,7 +149,7 @@ export class WorkspaceSavedObjectsClientWrapper { await this.validateAtLeastOnePermittedWorkspaces( objectToGet.workspaces, wrapperOptions.request, - WorkspacePermissionMode.Read + PermissionMode.Read ); return objectToGet; }; @@ -137,7 +163,7 @@ export class WorkspaceSavedObjectsClientWrapper { await this.validateAtLeastOnePermittedWorkspaces( object.workspaces, wrapperOptions.request, - WorkspacePermissionMode.Read + PermissionMode.Read ); } return objectToBulkGet; @@ -150,18 +176,20 @@ export class WorkspaceSavedObjectsClientWrapper { options.workspaces = options.workspaces.filter( async (workspaceId) => await this.permissionControl.validate( - workspaceId, - WorkspacePermissionMode.Read, - wrapperOptions.request + wrapperOptions.request, + { + type: WORKSPACE_TYPE, + id: workspaceId, + }, + [PermissionMode.Read] ) ); } else { options.workspaces = [ 'public', - ...(await this.permissionControl.getPermittedWorkspaceIds( - WorkspacePermissionMode.Read, - wrapperOptions.request - )), + ...(await this.permissionControl.getPermittedWorkspaceIds(wrapperOptions.request, [ + PermissionMode.Read, + ])), ]; } return await wrapperOptions.client.find(options); @@ -184,5 +212,5 @@ export class WorkspaceSavedObjectsClientWrapper { }; }; - constructor(private readonly permissionControl: WorkspacePermissionControl) {} + constructor(private readonly permissionControl: SavedObjectsPermissionControlContract) {} } diff --git a/src/core/server/workspaces/workspace_permission_control.ts b/src/core/server/workspaces/workspace_permission_control.ts deleted file mode 100644 index 203ce354561e..000000000000 --- a/src/core/server/workspaces/workspace_permission_control.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { OpenSearchDashboardsRequest } from '../http'; - -export enum WorkspacePermissionMode { - Read, - Admin, -} - -export class WorkspacePermissionControl { - public async validate( - workspaceId: string, - permissionModeOrModes: WorkspacePermissionMode | WorkspacePermissionMode[], - request: OpenSearchDashboardsRequest - ) { - return true; - } - - public async getPermittedWorkspaceIds( - permissionModeOrModes: WorkspacePermissionMode | WorkspacePermissionMode[], - request: OpenSearchDashboardsRequest - ) { - return []; - } - - public async setup() {} -} diff --git a/src/core/server/workspaces/workspaces_service.ts b/src/core/server/workspaces/workspaces_service.ts index 30be3b9d7526..cac4b551650b 100644 --- a/src/core/server/workspaces/workspaces_service.ts +++ b/src/core/server/workspaces/workspaces_service.ts @@ -14,18 +14,15 @@ import { } from '../saved_objects'; import { IWorkspaceDBImpl } from './types'; import { WorkspacesClientWithSavedObject } from './workspaces_client'; -import { WorkspacePermissionControl } from './workspace_permission_control'; import { UiSettingsServiceStart } from '../ui_settings/types'; import { WorkspaceSavedObjectsClientWrapper } from './saved_objects'; export interface WorkspacesServiceSetup { client: IWorkspaceDBImpl; - permissionControl: WorkspacePermissionControl; } export interface WorkspacesServiceStart { client: IWorkspaceDBImpl; - permissionControl: WorkspacePermissionControl; } export interface WorkspacesSetupDeps { @@ -46,7 +43,6 @@ export class WorkspacesService implements CoreService { private logger: Logger; private client?: IWorkspaceDBImpl; - private permissionControl?: WorkspacePermissionControl; private startDeps?: WorkspacesStartDeps; constructor(coreContext: CoreContext) { this.logger = coreContext.logger.get('workspaces-service'); @@ -87,12 +83,10 @@ export class WorkspacesService this.logger.debug('Setting up Workspaces service'); this.client = new WorkspacesClientWithSavedObject(setupDeps); - this.permissionControl = new WorkspacePermissionControl(); await this.client.setup(setupDeps); - await this.permissionControl.setup(); const workspaceSavedObjectsClientWrapper = new WorkspaceSavedObjectsClientWrapper( - this.permissionControl + setupDeps.savedObject.permissionControl ); setupDeps.savedObject.addClientWrapper( @@ -111,7 +105,6 @@ export class WorkspacesService return { client: this.client, - permissionControl: this.permissionControl, }; } @@ -121,7 +114,6 @@ export class WorkspacesService return { client: this.client as IWorkspaceDBImpl, - permissionControl: this.permissionControl as WorkspacePermissionControl, }; } diff --git a/src/core/utils/constants.ts b/src/core/utils/constants.ts index e35ff014b387..5bd25db2c848 100644 --- a/src/core/utils/constants.ts +++ b/src/core/utils/constants.ts @@ -4,3 +4,11 @@ */ export const WORKSPACE_PATH_PREFIX = '/w'; + +export enum PermissionMode { + Read = 'read', + Write = 'write', + Management = 'management', + LibraryRead = 'library_read', + LibraryWrite = 'library_write', +} diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 5c3130c500de..174152ffd750 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -38,4 +38,4 @@ export { } from './context'; export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; export { DEFAULT_WORKSPACE_TEMPLATES } from './default_workspace_templates'; -export { WORKSPACE_PATH_PREFIX } from './constants'; +export { WORKSPACE_PATH_PREFIX, PermissionMode } from './constants'; From 326b8648a2226cc72d7915b15f5d25d2d52f5e05 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Wed, 2 Aug 2023 11:07:18 +0800 Subject: [PATCH 068/174] Remove server side workspace feature flag check (#69) stop checking workspace feature flag in server side This is due to error when retrieving uiSettings in registerOnPreRouting which request is still unauthenticated --------- Signed-off-by: Yulong Ruan --- src/core/server/server.ts | 5 +-- .../server/workspaces/workspaces_service.ts | 39 ++++--------------- 2 files changed, 8 insertions(+), 36 deletions(-) diff --git a/src/core/server/server.ts b/src/core/server/server.ts index dbaee6c12400..47a72cfe6cbb 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -261,10 +261,7 @@ export class Server { opensearch: opensearchStart, savedObjects: savedObjectsStart, }); - await this.workspaces.start({ - savedObjects: savedObjectsStart, - uiSettings: uiSettingsStart, - }); + await this.workspaces.start(); this.coreStart = { capabilities: capabilitiesStart, diff --git a/src/core/server/workspaces/workspaces_service.ts b/src/core/server/workspaces/workspaces_service.ts index cac4b551650b..f86c0e7ca4d3 100644 --- a/src/core/server/workspaces/workspaces_service.ts +++ b/src/core/server/workspaces/workspaces_service.ts @@ -8,13 +8,9 @@ import { CoreContext } from '../core_context'; import { InternalHttpServiceSetup } from '../http'; import { Logger } from '../logging'; import { registerRoutes } from './routes'; -import { - InternalSavedObjectsServiceSetup, - InternalSavedObjectsServiceStart, -} from '../saved_objects'; +import { InternalSavedObjectsServiceSetup } from '../saved_objects'; import { IWorkspaceDBImpl } from './types'; import { WorkspacesClientWithSavedObject } from './workspaces_client'; -import { UiSettingsServiceStart } from '../ui_settings/types'; import { WorkspaceSavedObjectsClientWrapper } from './saved_objects'; export interface WorkspacesServiceSetup { @@ -33,47 +29,27 @@ export interface WorkspacesSetupDeps { export type InternalWorkspacesServiceSetup = WorkspacesServiceSetup; export type InternalWorkspacesServiceStart = WorkspacesServiceStart; -/** @internal */ -export interface WorkspacesStartDeps { - savedObjects: InternalSavedObjectsServiceStart; - uiSettings: UiSettingsServiceStart; -} - export class WorkspacesService implements CoreService { private logger: Logger; private client?: IWorkspaceDBImpl; - private startDeps?: WorkspacesStartDeps; + constructor(coreContext: CoreContext) { this.logger = coreContext.logger.get('workspaces-service'); } private proxyWorkspaceTrafficToRealHandler(setupDeps: WorkspacesSetupDeps) { /** - * Proxy all {basePath}/w/{workspaceId}{osdPath*} paths to - * {basePath}{osdPath*} when workspace is enabled - * - * Return HTTP 404 if accessing {basePath}/w/{workspaceId} when workspace is disabled + * Proxy all {basePath}/w/{workspaceId}{osdPath*} paths to {basePath}{osdPath*} */ setupDeps.http.registerOnPreRouting(async (request, response, toolkit) => { const regexp = /\/w\/([^\/]*)/; const matchedResult = request.url.pathname.match(regexp); if (matchedResult) { - if (this.startDeps) { - const savedObjectsClient = this.startDeps.savedObjects.getScopedClient(request); - const uiSettingsClient = this.startDeps.uiSettings.asScopedToClient(savedObjectsClient); - const workspacesEnabled = await uiSettingsClient.get('workspace:enabled'); - - if (workspacesEnabled) { - const requestUrl = new URL(request.url.toString()); - requestUrl.pathname = requestUrl.pathname.replace(regexp, ''); - return toolkit.rewriteUrl(requestUrl.toString()); - } else { - // If workspace was disable, return HTTP 404 - return response.notFound(); - } - } + const requestUrl = new URL(request.url.toString()); + requestUrl.pathname = requestUrl.pathname.replace(regexp, ''); + return toolkit.rewriteUrl(requestUrl.toString()); } return toolkit.next(); }); @@ -108,8 +84,7 @@ export class WorkspacesService }; } - public async start(deps: WorkspacesStartDeps): Promise { - this.startDeps = deps; + public async start(): Promise { this.logger.debug('Starting SavedObjects service'); return { From b3cf57c50acf4707d07f59643bebe4f2d37618c8 Mon Sep 17 00:00:00 2001 From: raintygao Date: Wed, 2 Aug 2023 17:18:50 +0800 Subject: [PATCH 069/174] apply workspace permission check when bulk creating object (#66) * feat: Apply workspace permission check when bulk creating object Signed-off-by: tygao * chore: update bulk create function Signed-off-by: tygao --------- Signed-off-by: tygao --- .../saved_objects/workspace_saved_objects_client_wrapper.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts index 0e4452dc23e3..941ea8824020 100644 --- a/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts +++ b/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -122,6 +122,12 @@ export class WorkspaceSavedObjectsClientWrapper { objects: Array>, options: SavedObjectsCreateOptions = {} ): Promise> => { + if (options.workspaces) { + await this.validateMultiWorkspacesPermissions(options.workspaces, wrapperOptions.request, [ + PermissionMode.Write, + PermissionMode.Management, + ]); + } return await wrapperOptions.client.bulkCreate(objects, options); }; From 55dca3e2eb0618f39c046962a469ab7ef61b877b Mon Sep 17 00:00:00 2001 From: Yuye Zhu Date: Thu, 3 Aug 2023 10:33:24 +0800 Subject: [PATCH 070/174] Register application "See more", "Home", "Create" and "Workspace Settings" (#68) * fix typo Signed-off-by: yuye-aws * change url when enter workspace Signed-off-by: yuye-aws * change default path to overview for workspace app Signed-off-by: yuye-aws * register create update list overview workspace as applications Signed-off-by: yuye-aws * implemenet four apps for workspaces Signed-off-by: yuye-aws * integrate four apps in workspace plugin.ts Signed-off-by: yuye-aws * remove hard code alerts and favorites Signed-off-by: yuye-aws * hide see more when inside workspace Signed-off-by: yuye-aws * when switch workspace, change destination from update to overview Signed-off-by: yuye-aws * Use new APP ID Co-authored-by: SuZhou-Joe * fix format error Signed-off-by: yuye-aws * add four workspace app names to constants Signed-off-by: yuye-aws * refactor mount parameter for application register Signed-off-by: yuye-aws * remove createbreadcrumb for workspace apps Signed-off-by: yuye-aws * remove router and set chrome breadcrumb in four workspace apps Signed-off-by: yuye-aws * remove appbasepath in four workspace apps Signed-off-by: yuye-aws * rename variable Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws Co-authored-by: SuZhou-Joe --- .../chrome/nav_links/nav_links_service.ts | 4 +- .../chrome/ui/header/collapsible_nav.tsx | 36 ---- src/plugins/workspace/common/constants.ts | 7 +- src/plugins/workspace/public/application.tsx | 66 ++++++-- .../components/utils/breadcrumbs.test.ts | 50 ------ .../public/components/utils/breadcrumbs.ts | 39 ----- .../public/components/utils/workspace.ts | 5 +- .../public/components/workspace_app.tsx | 38 ----- .../workspace_creator/workspace_creator.tsx | 5 +- .../components/workspace_creator_app.tsx | 35 ++++ .../public/components/workspace_list_app.tsx | 35 ++++ .../components/workspace_overview_app.tsx | 35 ++++ .../workspace_updater/workspace_updater.tsx | 5 +- .../components/workspace_updater_app.tsx | 35 ++++ .../workspace_dropdown_list.tsx | 4 +- src/plugins/workspace/public/plugin.ts | 160 ++++++++---------- 16 files changed, 285 insertions(+), 274 deletions(-) delete mode 100644 src/plugins/workspace/public/components/utils/breadcrumbs.test.ts delete mode 100644 src/plugins/workspace/public/components/utils/breadcrumbs.ts delete mode 100644 src/plugins/workspace/public/components/workspace_app.tsx create mode 100644 src/plugins/workspace/public/components/workspace_creator_app.tsx create mode 100644 src/plugins/workspace/public/components/workspace_list_app.tsx create mode 100644 src/plugins/workspace/public/components/workspace_overview_app.tsx create mode 100644 src/plugins/workspace/public/components/workspace_updater_app.tsx 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 3094cdccd7c4..b6ef2e6e771e 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.ts @@ -151,8 +151,8 @@ export class NavLinksService { return linkUpdaters.reduce((links, updater) => updater(links), appLinks); }) ) - .subscribe((navlinks) => { - navLinks$.next(navlinks); + .subscribe((navLinks) => { + navLinks$.next(navLinks); }); const forceAppSwitcherNavigation$ = new BehaviorSubject(false); diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index d78c2e58ed24..d4c7c99f797f 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -239,42 +239,6 @@ export function CollapsibleNav({ )} - {/* Alerts and Favorites */} - {/* ( - <> - - - -

    - {i18n.translate('core.ui.EmptyFavoriteList', { - defaultMessage: 'No Favorites', - })} -

    -
    - -

    - {i18n.translate('core.ui.SeeMoreFavorite', { - defaultMessage: 'SEE MORE', - })} -

    -
    -
    - - ) */} - {/* merged NavLinks */} {mergedNavLinks.map((item, i) => { if (typeof item === 'string') { diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 878a0ca9441e..ed48d8b11ec4 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -6,9 +6,10 @@ import { i18n } from '@osd/i18n'; import { AppCategory } from '../../../core/types'; -export const WORKSPACE_APP_ID = 'workspace'; -export const WORKSPACE_APP_NAME = 'Workspace'; - +export const WORKSPACE_CREATE_APP_ID = 'workspace_create'; +export const WORKSPACE_LIST_APP_ID = 'workspace_list'; +export const WORKSPACE_UPDATE_APP_ID = 'workspace_update'; +export const WORKSPACE_OVERVIEW_APP_ID = 'workspace_overview'; export const PATHS = { create: '/create', overview: '/overview', diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx index 6715450ca693..9772390f7118 100644 --- a/src/plugins/workspace/public/application.tsx +++ b/src/plugins/workspace/public/application.tsx @@ -5,22 +5,68 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { Router } from 'react-router-dom'; - import { AppMountParameters, CoreStart } from '../../../core/public'; -import { OpenSearchDashboardsContextProvider } from '../../../plugins/opensearch_dashboards_react/public'; -import { WorkspaceApp } from './components/workspace_app'; +import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public'; +import { WorkspaceListApp } from './components/workspace_list_app'; +import { WorkspaceCreatorApp } from './components/workspace_creator_app'; +import { WorkspaceUpdaterApp } from './components/workspace_updater_app'; +import { WorkspaceOverviewApp } from './components/workspace_overview_app'; + +export const renderListApp = ( + { element, history, appBasePath }: AppMountParameters, + services: CoreStart +) => { + ReactDOM.render( + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; +export const renderCreatorApp = ( + { element, history, appBasePath }: AppMountParameters, + services: CoreStart +) => { + ReactDOM.render( + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; + +export const renderUpdateApp = ( + { element, history, appBasePath }: AppMountParameters, + services: CoreStart +) => { + ReactDOM.render( + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; -export const renderApp = ( +export const renderOverviewApp = ( { element, history, appBasePath }: AppMountParameters, services: CoreStart ) => { ReactDOM.render( - - - - - , + + + , element ); diff --git a/src/plugins/workspace/public/components/utils/breadcrumbs.test.ts b/src/plugins/workspace/public/components/utils/breadcrumbs.test.ts deleted file mode 100644 index 229fcde96055..000000000000 --- a/src/plugins/workspace/public/components/utils/breadcrumbs.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { createBreadcrumbsFromPath } from './breadcrumbs'; - -describe('breadcrumbs utils', () => { - const ROUTES = [ - { - path: '/create', - Component: jest.fn(), - label: 'Create', - }, - { - path: '/manage', - Component: jest.fn(), - label: 'Manage Workspaces', - }, - { - path: '/manage/access', - Component: jest.fn(), - label: 'Manage Access', - }, - ]; - - it('should create breadcrumbs with matched route', () => { - const breadcrumbs = createBreadcrumbsFromPath('/create', ROUTES, '/'); - expect(breadcrumbs).toEqual([{ href: '/', text: 'Workspace' }, { text: 'Create' }]); - }); - - it('should create breadcrumbs with only root route if path did not match any route', () => { - const breadcrumbs = createBreadcrumbsFromPath('/unknown', ROUTES, '/'); - expect(breadcrumbs).toEqual([{ href: '/', text: 'Workspace' }]); - }); - - it('should create breadcrumbs with all matched routes', () => { - const breadcrumbs = createBreadcrumbsFromPath('/manage/access', ROUTES, '/'); - expect(breadcrumbs).toEqual([ - { href: '/', text: 'Workspace' }, - { href: '/manage', text: 'Manage Workspaces' }, - { text: 'Manage Access' }, - ]); - }); - - it('should create breadcrumbs with only matched routes', () => { - const breadcrumbs = createBreadcrumbsFromPath('/manage/not-matched', ROUTES, '/'); - expect(breadcrumbs).toEqual([{ href: '/', text: 'Workspace' }, { text: 'Manage Workspaces' }]); - }); -}); diff --git a/src/plugins/workspace/public/components/utils/breadcrumbs.ts b/src/plugins/workspace/public/components/utils/breadcrumbs.ts deleted file mode 100644 index d6d302a9c6fc..000000000000 --- a/src/plugins/workspace/public/components/utils/breadcrumbs.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { matchPath } from 'react-router-dom'; - -import { RouteConfig } from '../routes'; -import { ChromeBreadcrumb } from '../../../../../core/public'; -import { WORKSPACE_APP_NAME } from '../../../common/constants'; -import { join } from './path'; - -export const createBreadcrumbsFromPath = ( - pathname: string, - routeConfig: RouteConfig[], - appBasePath: string -): ChromeBreadcrumb[] => { - const breadcrumbs: ChromeBreadcrumb[] = []; - while (pathname !== '/') { - const matchedRoute = routeConfig.find((route) => - matchPath(pathname, { path: route.path, exact: true }) - ); - if (matchedRoute) { - if (breadcrumbs.length === 0) { - breadcrumbs.unshift({ text: matchedRoute.label }); - } else { - breadcrumbs.unshift({ - text: matchedRoute.label, - href: join(appBasePath, matchedRoute.path), - }); - } - } - const pathArr = pathname.split('/'); - pathArr.pop(); - pathname = pathArr.join('/') ? pathArr.join('/') : '/'; - } - breadcrumbs.unshift({ text: WORKSPACE_APP_NAME, href: appBasePath }); - return breadcrumbs; -}; diff --git a/src/plugins/workspace/public/components/utils/workspace.ts b/src/plugins/workspace/public/components/utils/workspace.ts index 7ad9a43bf72c..fa38e29fdc61 100644 --- a/src/plugins/workspace/public/components/utils/workspace.ts +++ b/src/plugins/workspace/public/components/utils/workspace.ts @@ -3,15 +3,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { WORKSPACE_APP_ID, PATHS } from '../../../common/constants'; +import { WORKSPACE_OVERVIEW_APP_ID } from '../../../common/constants'; import { CoreStart } from '../../../../../core/public'; type Core = Pick; export const switchWorkspace = ({ workspaces, application }: Core, id: string) => { const newUrl = workspaces?.formatUrlWithWorkspaceId( - application.getUrlForApp(WORKSPACE_APP_ID, { - path: PATHS.update, + application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { absolute: true, }), id diff --git a/src/plugins/workspace/public/components/workspace_app.tsx b/src/plugins/workspace/public/components/workspace_app.tsx deleted file mode 100644 index ec31f511da96..000000000000 --- a/src/plugins/workspace/public/components/workspace_app.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useEffect } from 'react'; -import { I18nProvider } from '@osd/i18n/react'; -import { Route, Switch, Redirect, useLocation } from 'react-router-dom'; -import { ROUTES } from './routes'; -import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; -import { createBreadcrumbsFromPath } from './utils/breadcrumbs'; -import { PATHS } from '../../common/constants'; - -export const WorkspaceApp = ({ appBasePath }: { appBasePath: string }) => { - const { - services: { chrome }, - } = useOpenSearchDashboards(); - const location = useLocation(); - - /** - * map the current pathname to breadcrumbs - */ - useEffect(() => { - const breadcrumbs = createBreadcrumbsFromPath(location.pathname, ROUTES, appBasePath); - chrome?.setBreadcrumbs(breadcrumbs); - }, [appBasePath, location.pathname, chrome]); - - return ( - - - {ROUTES.map(({ path, Component, exact }) => ( - } exact={exact ?? false} /> - ))} - - - - ); -}; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx index bbea2d2aa0a2..f2bc86c9f4cf 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -10,7 +10,7 @@ import { i18n } from '@osd/i18n'; import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public'; import { WorkspaceForm, WorkspaceFormData } from './workspace_form'; -import { PATHS, WORKSPACE_APP_ID, WORKSPACE_OP_TYPE_CREATE } from '../../../common/constants'; +import { WORKSPACE_OVERVIEW_APP_ID, WORKSPACE_OP_TYPE_CREATE } from '../../../common/constants'; export const WorkspaceCreator = () => { const { @@ -39,8 +39,7 @@ export const WorkspaceCreator = () => { }); if (application && workspaces) { window.location.href = workspaces.formatUrlWithWorkspaceId( - application.getUrlForApp(WORKSPACE_APP_ID, { - path: PATHS.overview, + application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { absolute: true, }), result.result.id diff --git a/src/plugins/workspace/public/components/workspace_creator_app.tsx b/src/plugins/workspace/public/components/workspace_creator_app.tsx new file mode 100644 index 000000000000..a292d21b18da --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator_app.tsx @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect } from 'react'; +import { I18nProvider } from '@osd/i18n/react'; +import { i18n } from '@osd/i18n'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { WorkspaceCreator } from './workspace_creator'; + +export const WorkspaceCreatorApp = () => { + const { + services: { chrome }, + } = useOpenSearchDashboards(); + + /** + * set breadcrumbs to chrome + */ + useEffect(() => { + chrome?.setBreadcrumbs([ + { + text: i18n.translate('workspace.workspaceCreateTitle', { + defaultMessage: 'Workspace Create', + }), + }, + ]); + }, [chrome]); + + return ( + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_list_app.tsx b/src/plugins/workspace/public/components/workspace_list_app.tsx new file mode 100644 index 000000000000..ec2fb6875bba --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_list_app.tsx @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect } from 'react'; +import { I18nProvider } from '@osd/i18n/react'; +import { i18n } from '@osd/i18n'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { WorkspaceList } from './workspace_list'; + +export const WorkspaceListApp = () => { + const { + services: { chrome }, + } = useOpenSearchDashboards(); + + /** + * set breadcrumbs to chrome + */ + useEffect(() => { + chrome?.setBreadcrumbs([ + { + text: i18n.translate('workspace.workspaceCreateTitle', { + defaultMessage: 'Workspace Create', + }), + }, + ]); + }, [chrome]); + + return ( + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_overview_app.tsx b/src/plugins/workspace/public/components/workspace_overview_app.tsx new file mode 100644 index 000000000000..d452600648b4 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_overview_app.tsx @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect } from 'react'; +import { I18nProvider } from '@osd/i18n/react'; +import { i18n } from '@osd/i18n'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { WorkspaceOverview } from './workspace_overview'; + +export const WorkspaceOverviewApp = () => { + const { + services: { chrome }, + } = useOpenSearchDashboards(); + + /** + * set breadcrumbs to chrome + */ + useEffect(() => { + chrome?.setBreadcrumbs([ + { + text: i18n.translate('workspace.workspaceOverviewTitle', { + defaultMessage: 'Workspace Overview', + }), + }, + ]); + }, [chrome]); + + return ( + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx index 278ed962966f..a3f0ae1914f8 100644 --- a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx @@ -19,7 +19,7 @@ import { WorkspaceAttribute } from 'opensearch-dashboards/public'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { PATHS } from '../../../common/constants'; import { WorkspaceForm, WorkspaceFormData } from '../workspace_creator/workspace_form'; -import { WORKSPACE_APP_ID, WORKSPACE_OP_TYPE_UPDATE } from '../../../common/constants'; +import { WORKSPACE_OVERVIEW_APP_ID, WORKSPACE_OP_TYPE_UPDATE } from '../../../common/constants'; import { ApplicationStart } from '../../../../../core/public'; import { DeleteWorkspaceModal } from '../delete_workspace_modal'; @@ -76,8 +76,7 @@ export const WorkspaceUpdater = () => { }); window.location.href = workspaces?.formatUrlWithWorkspaceId( - application.getUrlForApp(WORKSPACE_APP_ID, { - path: PATHS.overview, + application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { absolute: true, }), currentWorkspace.id diff --git a/src/plugins/workspace/public/components/workspace_updater_app.tsx b/src/plugins/workspace/public/components/workspace_updater_app.tsx new file mode 100644 index 000000000000..89ad15028f82 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_updater_app.tsx @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect } from 'react'; +import { I18nProvider } from '@osd/i18n/react'; +import { i18n } from '@osd/i18n'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { WorkspaceUpdater } from './workspace_updater'; + +export const WorkspaceUpdaterApp = () => { + const { + services: { chrome }, + } = useOpenSearchDashboards(); + + /** + * set breadcrumbs to chrome + */ + useEffect(() => { + chrome?.setBreadcrumbs([ + { + text: i18n.translate('workspace.workspaceUpdateTitle', { + defaultMessage: 'Workspace Update', + }), + }, + ]); + }, [chrome]); + + return ( + + + + ); +}; diff --git a/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx index a53e39cf1647..60bbed924d42 100644 --- a/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx +++ b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx @@ -8,7 +8,7 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { EuiButton, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import useObservable from 'react-use/lib/useObservable'; import { ApplicationStart, WorkspaceAttribute, WorkspacesStart } from '../../../../../core/public'; -import { WORKSPACE_APP_ID, PATHS } from '../../../common/constants'; +import { WORKSPACE_CREATE_APP_ID } from '../../../common/constants'; import { switchWorkspace } from '../../components/utils/workspace'; type WorkspaceOption = EuiComboBoxOptionOption; @@ -64,7 +64,7 @@ export function WorkspaceDropdownList(props: WorkspaceDropdownListProps) { ); const onCreateWorkspaceClick = () => { - props.application.navigateToApp(WORKSPACE_APP_ID, { path: PATHS.create }); + props.application.navigateToApp(WORKSPACE_CREATE_APP_ID); }; useEffect(() => { diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 0bd94c897fbc..0e21fca9d112 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -19,7 +19,13 @@ import { WorkspacesStart, DEFAULT_APP_CATEGORIES, } from '../../../core/public'; -import { PATHS, WORKSPACE_APP_ID, WORKSPACE_NAV_CATEGORY } from '../common/constants'; +import { + WORKSPACE_LIST_APP_ID, + WORKSPACE_UPDATE_APP_ID, + WORKSPACE_CREATE_APP_ID, + WORKSPACE_OVERVIEW_APP_ID, + WORKSPACE_NAV_CATEGORY, +} from '../common/constants'; import { mountDropdownList } from './mount'; import { SavedObjectsManagementPluginSetup } from '../../saved_objects_management/public'; import { getWorkspaceColumn } from './components/utils/workspace_column'; @@ -84,22 +90,70 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep */ savedObjectsManagement?.columns.register(getWorkspaceColumn(core)); + type WorkspaceAppType = (params: AppMountParameters, services: CoreStart) => () => void; + const mountWorkspaceApp = async (params: AppMountParameters, renderApp: WorkspaceAppType) => { + const [coreStart] = await core.getStartServices(); + const services = { + ...coreStart, + }; + + return renderApp(params, services); + }; + + // create core.application.register({ - id: WORKSPACE_APP_ID, - title: i18n.translate('workspace.settings.title', { - defaultMessage: 'Workspace', + id: WORKSPACE_CREATE_APP_ID, + title: i18n.translate('workspace.settings.workspaceCreate', { + defaultMessage: 'Create Workspace', }), - // order: 6010, navLinkStatus: AppNavLinkStatus.hidden, - // updater$: this.appUpdater, async mount(params: AppMountParameters) { - const { renderApp } = await import('./application'); - const [coreStart] = await core.getStartServices(); - const services = { - ...coreStart, - }; + const { renderCreatorApp } = await import('./application'); + return mountWorkspaceApp(params, renderCreatorApp); + }, + }); + + // overview + core.application.register({ + id: WORKSPACE_OVERVIEW_APP_ID, + title: i18n.translate('workspace.settings.workspaceOverview', { + defaultMessage: 'Home', + }), + order: 0, + euiIconType: 'home', + navLinkStatus: AppNavLinkStatus.default, + async mount(params: AppMountParameters) { + const { renderOverviewApp } = await import('./application'); + return mountWorkspaceApp(params, renderOverviewApp); + }, + }); + + // update + core.application.register({ + id: WORKSPACE_UPDATE_APP_ID, + title: i18n.translate('workspace.settings.workspaceUpdate', { + defaultMessage: 'Workspace Settings', + }), + euiIconType: 'managementApp', + navLinkStatus: AppNavLinkStatus.default, + async mount(params: AppMountParameters) { + const { renderUpdateApp } = await import('./application'); + return mountWorkspaceApp(params, renderUpdateApp); + }, + }); - return renderApp(params, services); + // list + core.application.register({ + id: WORKSPACE_LIST_APP_ID, + title: i18n.translate('workspace.settings.workspaceList', { + defaultMessage: 'See More', + }), + euiIconType: 'folderClosed', + category: WORKSPACE_NAV_CATEGORY, + navLinkStatus: workspaceId ? AppNavLinkStatus.hidden : AppNavLinkStatus.default, + async mount(params: AppMountParameters) { + const { renderListApp } = await import('./application'); + return mountWorkspaceApp(params, renderListApp); }, }); @@ -109,12 +163,12 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep private workspaceToChromeNavLink( workspace: WorkspaceAttribute, workspacesStart: WorkspacesStart, - application: ApplicationStart + application: ApplicationStart, + index: number ): ChromeNavLink { - const id = WORKSPACE_APP_ID + '/' + workspace.id; + const id = WORKSPACE_OVERVIEW_APP_ID + '/' + workspace.id; const url = workspacesStart?.formatUrlWithWorkspaceId( - application.getUrlForApp(WORKSPACE_APP_ID, { - path: '/', + application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { absolute: true, }), workspace.id @@ -122,6 +176,7 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep return { id, url, + order: index, hidden: false, disabled: false, baseUrl: url, @@ -166,82 +221,17 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep const filteredNavLinks = new Map(); chromeNavLinks = this.filterByWorkspace(currentWorkspace, chromeNavLinks); chromeNavLinks.forEach((chromeNavLink) => { - if (chromeNavLink.id === 'home') { - // set hidden, icon and order for home nav link - const homeNavLink: ChromeNavLink = { - ...chromeNavLink, - hidden: currentWorkspace !== null, - euiIconType: 'logoOpenSearch', - order: 0, - }; - filteredNavLinks.set(chromeNavLink.id, homeNavLink); - } else { - filteredNavLinks.set(chromeNavLink.id, chromeNavLink); - } + filteredNavLinks.set(chromeNavLink.id, chromeNavLink); }); - if (currentWorkspace) { - // Overview only inside workspace - const overviewId = WORKSPACE_APP_ID + PATHS.update; - const overviewUrl = core.workspaces.formatUrlWithWorkspaceId( - core.application.getUrlForApp(WORKSPACE_APP_ID, { - path: PATHS.update, - absolute: true, - }), - currentWorkspace.id - ); - const overviewNavLink: ChromeNavLink = { - id: overviewId, - title: i18n.translate('core.ui.workspaceNavList.overview', { - defaultMessage: 'Overview', - }), - hidden: false, - disabled: false, - baseUrl: overviewUrl, - href: overviewUrl, - euiIconType: 'grid', - order: 0, - }; - filteredNavLinks.set(overviewId, overviewNavLink); - } else { + if (!currentWorkspace) { workspaceList .filter((workspace, index) => index < 5) - .map((workspace) => - this.workspaceToChromeNavLink(workspace, core.workspaces, core.application) + .map((workspace, index) => + this.workspaceToChromeNavLink(workspace, core.workspaces, core.application, index) ) .forEach((workspaceNavLink) => filteredNavLinks.set(workspaceNavLink.id, workspaceNavLink) ); - // See more - const seeMoreId = WORKSPACE_APP_ID + PATHS.list; - const seeMoreUrl = WORKSPACE_APP_ID + PATHS.list; - const seeMoreNavLink: ChromeNavLink = { - id: seeMoreId, - title: i18n.translate('core.ui.workspaceNavList.seeMore', { - defaultMessage: 'SEE MORE', - }), - hidden: false, - disabled: false, - baseUrl: seeMoreUrl, - href: seeMoreUrl, - category: WORKSPACE_NAV_CATEGORY, - }; - filteredNavLinks.set(seeMoreId, seeMoreNavLink); - // Admin - const adminId = 'admin'; - const adminUrl = '/app/admin'; - const adminNavLink: ChromeNavLink = { - id: adminId, - title: i18n.translate('core.ui.workspaceNavList.admin', { - defaultMessage: 'Admin', - }), - hidden: false, - disabled: true, - baseUrl: adminUrl, - href: adminUrl, - euiIconType: 'managementApp', - order: 9000, - }; - filteredNavLinks.set(adminId, adminNavLink); } navLinksService.setFilteredNavLinks(filteredNavLinks); }); From 5649e4fb1ffc7cf6dae19a032545793459aee8b2 Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Thu, 3 Aug 2023 15:22:32 +0800 Subject: [PATCH 071/174] Add acl permission related functions (#65) * Add acl permission check functions Signed-off-by: gaobinlong * Refactor some code Signed-off-by: gaobinlong * Optimize some code Signed-off-by: gaobinlong * Refactor acl Signed-off-by: gaobinlong * Modify index mapping definition code Signed-off-by: gaobinlong * Optimize code Signed-off-by: gaobinlong * Optimize code Signed-off-by: gaobinlong --------- Signed-off-by: gaobinlong --- .../migrations/core/build_active_mappings.ts | 20 ++ .../permission_control/acl.test.ts | 164 +++++++++++ .../saved_objects/permission_control/acl.ts | 264 ++++++++++++++++++ src/core/utils/constants.ts | 5 + 4 files changed, 453 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/build_active_mappings.ts b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts index 812cc1fd5eb1..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 @@ -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: { @@ -178,6 +189,15 @@ function defaultMapping(): IndexMapping { workspaces: { type: 'keyword', }, + permissions: { + properties: { + read: principals, + write: principals, + management: principals, + library_read: principals, + library_write: principals, + }, + }, }, }; } 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..b292fb747b3c --- /dev/null +++ b/src/core/server/saved_objects/permission_control/acl.test.ts @@ -0,0 +1,164 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PermissionMode } from '../../../../core/utils/constants'; +import { Principals, Permissions, ACL } from './acl'; + +describe('SavedObjectTypeRegistry', () => { + let acl: ACL; + + it('test has permission', () => { + const principals: Principals = { + users: ['user1'], + groups: [], + }; + const permissions: Permissions = { + read: principals, + }; + acl = new ACL(permissions); + expect( + acl.hasPermission([PermissionMode.Read], { + users: ['user1'], + groups: [], + }) + ).toEqual(true); + expect( + acl.hasPermission([PermissionMode.Read], { + users: ['user2'], + groups: [], + }) + ).toEqual(false); + }); + + it('test add permission', () => { + acl = new ACL(); + const result1 = acl + .addPermission([PermissionMode.Read], { + users: ['user1'], + groups: [], + }) + .getPermissions(); + expect(result1?.read?.users).toEqual(['user1']); + + acl.resetPermissions(); + const result2 = acl + .addPermission([PermissionMode.Write, PermissionMode.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, + }; + acl = new ACL(permissions1); + const result1 = acl + .removePermission([PermissionMode.Read], { + users: ['user1'], + groups: [], + }) + .removePermission([PermissionMode.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, + }; + + acl = new ACL(permissions2); + const result2 = acl + .removePermission([PermissionMode.Read, PermissionMode.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, + }; + acl = new ACL(permissions); + const result = acl.transformPermissions(); + expect(result?.length).toEqual(3); + }); + + it('test genereate query DSL', () => { + const principals = { + users: ['user1'], + groups: ['group1'], + }; + const result = ACL.genereateGetPermittedSavedObjectsQueryDSL( + PermissionMode.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..4b7d506e11ba --- /dev/null +++ b/src/core/server/saved_objects/permission_control/acl.ts @@ -0,0 +1,264 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PrincipalType } from '../../../../core/utils/constants'; + +export interface Principals { + users?: string[]; + groups?: string[]; +} + +export type Permissions = Partial>; + +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 funciton, 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) { + this.permissions[permissionType] = deleteFromPrincipals( + this.permissions![permissionType], + principals.users, + principals.groups + ); + } + + return this; + } + + /* + transfrom 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 transformPermissions(): TransformedPermission[] { + const result: TransformedPermission[] = []; + if (!this.permissions) { + return result; + } + + const permissionMapResult: Record> = {}; + const principalTypes = [PrincipalType.Users, PrincipalType.Groups]; + for (const permissionType in this.permissions) { + if (!!permissionType) { + const value = this.permissions[permissionType]; + principalTypes.forEach((principalType) => { + if (value?.[principalType]) { + for (const principal of value[principalType]!) { + if (!permissionMapResult[principalType]) { + permissionMapResult[principalType] = {}; + } + if (!permissionMapResult[principalType][principal]) { + permissionMapResult[principalType][principal] = []; + } + permissionMapResult[principalType][principal] = [ + ...permissionMapResult[principalType][principal]!, + permissionType, + ]; + } + } + }); + } + } + + Object.entries(permissionMapResult).forEach(([type, permissionMap]) => { + Object.entries(permissionMap).forEach(([principal, permissions]) => { + result.push({ + type, + name: principal, + permissions, + }); + }); + }); + + 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 genereateGetPermittedSavedObjectsQueryDSL( + permissionType: string, + principals: Principals, + savedObjectType?: string | string[] + ) { + if (!principals || !permissionType) { + return { + query: { + match_none: {}, + }, + }; + } + + const bool: any = { + filter: [], + }; + const subBool: any = { + should: [], + }; + if (!!principals.users) { + subBool.should.push({ + terms: { + ['permissions.' + permissionType + '.users']: principals.users, + }, + }); + subBool.should.push({ + term: { + ['permissions.' + permissionType + '.users']: '*', + }, + }); + } + if (!!principals.groups) { + subBool.should.push({ + terms: { + ['permissions.' + permissionType + '.groups']: principals.groups, + }, + }); + subBool.should.push({ + term: { + ['permissions.' + permissionType + '.groups']: '*', + }, + }); + } + + bool.filter.push({ + bool: subBool, + }); + + if (!!savedObjectType) { + bool.filter.push({ + terms: { + type: Array.isArray(savedObjectType) ? savedObjectType : [savedObjectType], + }, + }); + } + + return { query: { bool } }; + } +} diff --git a/src/core/utils/constants.ts b/src/core/utils/constants.ts index 5bd25db2c848..a58af8947131 100644 --- a/src/core/utils/constants.ts +++ b/src/core/utils/constants.ts @@ -12,3 +12,8 @@ export enum PermissionMode { LibraryRead = 'library_read', LibraryWrite = 'library_write', } + +export enum PrincipalType { + Users = 'users', + Groups = 'groups', +} From 5f543b3d1e2be2f0725f6455913cf01688f5a7b5 Mon Sep 17 00:00:00 2001 From: Hailong Cui Date: Thu, 3 Aug 2023 15:23:19 +0800 Subject: [PATCH 072/174] share saved objects to workspace api (#67) * share saved objects to workspace api Signed-off-by: Hailong Cui * using script for bulk update Signed-off-by: Hailong Cui * filter out pulbic saved objects when sharing Signed-off-by: Hailong Cui * refactor saved object permission error Signed-off-by: Hailong Cui * fix merge issue Signed-off-by: Hailong Cui * permission check for target workspace Signed-off-by: Hailong Cui * move source workspace existence validation to repository Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui --- src/core/server/index.ts | 3 + .../permission_control/client.mock.ts | 1 + .../permission_control/client.ts | 10 +- src/core/server/saved_objects/routes/index.ts | 3 + src/core/server/saved_objects/routes/share.ts | 102 +++++++++++++ .../service/lib/repository.mock.ts | 1 + .../saved_objects/service/lib/repository.ts | 138 ++++++++++++++++-- .../service/saved_objects_client.ts | 27 ++++ src/core/server/workspaces/constants.ts | 1 + .../workspace_saved_objects_client_wrapper.ts | 39 ++++- 10 files changed, 309 insertions(+), 16 deletions(-) create mode 100644 src/core/server/saved_objects/routes/share.ts diff --git a/src/core/server/index.ts b/src/core/server/index.ts index e369b98d8100..0518a3a52250 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -319,6 +319,9 @@ export { exportSavedObjectsToStream, importSavedObjectsFromStream, resolveSavedObjectsImportErrors, + SavedObjectsShareObjects, + SavedObjectsAddToWorkspacesOptions, + SavedObjectsAddToWorkspacesResponse, } from './saved_objects'; export { diff --git a/src/core/server/saved_objects/permission_control/client.mock.ts b/src/core/server/saved_objects/permission_control/client.mock.ts index eeafc83995b6..218e62dcd4d4 100644 --- a/src/core/server/saved_objects/permission_control/client.mock.ts +++ b/src/core/server/saved_objects/permission_control/client.mock.ts @@ -8,6 +8,7 @@ import { SavedObjectsPermissionControlContract } from './client'; export const savedObjectsPermissionControlMock: SavedObjectsPermissionControlContract = { setup: jest.fn(), validate: jest.fn(), + batchValidate: jest.fn(), addPrinciplesToObjects: jest.fn(), removePrinciplesFromObjects: jest.fn(), getPrinciplesOfObjects: jest.fn(), diff --git a/src/core/server/saved_objects/permission_control/client.ts b/src/core/server/saved_objects/permission_control/client.ts index 9bd41a8c7a30..ee2fad51c651 100644 --- a/src/core/server/saved_objects/permission_control/client.ts +++ b/src/core/server/saved_objects/permission_control/client.ts @@ -35,7 +35,15 @@ export class SavedObjectsPermissionControl { savedObject: SavedObjectsBulkGetObject, permissionModeOrModes: SavedObjectsPermissionModes ) { - const savedObjectsGet = await this.bulkGetSavedObjects(request, [savedObject]); + return await this.batchValidate(request, [savedObject], permissionModeOrModes); + } + + public async batchValidate( + request: OpenSearchDashboardsRequest, + savedObjects: SavedObjectsBulkGetObject[], + permissionModeOrModes: SavedObjectsPermissionModes + ) { + const savedObjectsGet = await this.bulkGetSavedObjects(request, savedObjects); if (savedObjectsGet) { return { success: true, diff --git a/src/core/server/saved_objects/routes/index.ts b/src/core/server/saved_objects/routes/index.ts index 57dbe8f3ca7c..4794c00a9b78 100644 --- a/src/core/server/saved_objects/routes/index.ts +++ b/src/core/server/saved_objects/routes/index.ts @@ -46,6 +46,7 @@ import { registerImportRoute } from './import'; import { registerResolveImportErrorsRoute } from './resolve_import_errors'; import { registerMigrateRoute } from './migrate'; import { registerCopyRoute } from './copy'; +import { registerShareRoute } from './share'; export function registerRoutes({ http, @@ -73,6 +74,8 @@ export function registerRoutes({ registerImportRoute(router, config); registerCopyRoute(router, config); registerResolveImportErrorsRoute(router, config); + // TODO disable when workspace is not enabled + registerShareRoute(router); const internalRouter = http.createRouter('/internal/saved_objects/'); diff --git a/src/core/server/saved_objects/routes/share.ts b/src/core/server/saved_objects/routes/share.ts new file mode 100644 index 000000000000..340709017e7b --- /dev/null +++ b/src/core/server/saved_objects/routes/share.ts @@ -0,0 +1,102 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; +import { IRouter } from '../../http'; +import { exportSavedObjectsToStream } from '../export'; +import { validateObjects } from './utils'; +import { collectSavedObjects } from '../import/collect_saved_objects'; +import { WORKSPACE_TYPE } from '../../workspaces'; +import { GLOBAL_WORKSPACE_ID } from '../../workspaces/constants'; + +const SHARE_LIMIT = 10000; + +export const registerShareRoute = (router: IRouter) => { + router.post( + { + path: '/_share', + validate: { + body: schema.object({ + sourceWorkspaceId: schema.maybe(schema.string()), + objects: schema.arrayOf( + schema.object({ + id: schema.string(), + type: schema.string(), + }) + ), + targetWorkspaceIds: schema.arrayOf(schema.string()), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const savedObjectsClient = context.core.savedObjects.client; + const { sourceWorkspaceId, objects, targetWorkspaceIds } = req.body; + + // need to access the registry for type validation, can't use the schema for this + const supportedTypes = context.core.savedObjects.typeRegistry + .getAllTypes() + .filter((type) => type.name !== WORKSPACE_TYPE) + .map((t) => t.name); + + if (objects) { + const validationError = validateObjects(objects, supportedTypes); + if (validationError) { + return res.badRequest({ + body: { + message: validationError, + }, + }); + } + } + + const objectsListStream = await exportSavedObjectsToStream({ + savedObjectsClient, + objects, + exportSizeLimit: SHARE_LIMIT, + includeReferencesDeep: true, + excludeExportDetails: true, + }); + + const collectSavedObjectsResult = await collectSavedObjects({ + readStream: objectsListStream, + objectLimit: SHARE_LIMIT, + supportedTypes, + }); + + const savedObjects = collectSavedObjectsResult.collectedObjects; + + const nonPublicSharedObjects = savedObjects + // non-public + .filter( + (obj) => + obj.workspaces && + obj.workspaces.length > 0 && + !obj.workspaces.includes(GLOBAL_WORKSPACE_ID) + ) + .map((obj) => ({ id: obj.id, type: obj.type, workspaces: obj.workspaces })); + + if (nonPublicSharedObjects.length === 0) { + return res.ok({ + body: savedObjects.map((savedObject) => ({ + type: savedObject.type, + id: savedObject.id, + workspaces: savedObject.workspaces, + })), + }); + } + + const response = await savedObjectsClient.addToWorkspaces( + nonPublicSharedObjects, + targetWorkspaceIds, + { + workspaces: sourceWorkspaceId ? [sourceWorkspaceId] : undefined, + } + ); + return res.ok({ + body: response, + }); + }) + ); +}; 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..e6596a9b841f 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(), + addToWorkspaces: jest.fn(), }); export const savedObjectsRepositoryMock = { create }; diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 2d4cf2aa6b0b..9545eec608b1 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -28,11 +28,12 @@ * under the License. */ -import { omit } from 'lodash'; +import { omit, intersection } from 'lodash'; import type { opensearchtypes } from '@opensearch-project/opensearch'; import uuid from 'uuid'; import type { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; -import { OpenSearchClient, DeleteDocumentResponse } from '../../../opensearch/'; +import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import { DeleteDocumentResponse, OpenSearchClient } from '../../../opensearch/'; import { getRootPropertiesObjects, IndexMapping } from '../../mappings'; import { createRepositoryOpenSearchClient, @@ -40,43 +41,45 @@ import { } from './repository_opensearch_client'; import { getSearchDsl } from './search_dsl'; import { includedFields } from './included_fields'; -import { SavedObjectsErrorHelpers, DecoratedError } from './errors'; -import { decodeRequestVersion, encodeVersion, encodeHitVersion } from '../../version'; +import { DecoratedError, SavedObjectsErrorHelpers } from './errors'; +import { decodeRequestVersion, encodeHitVersion, encodeVersion } from '../../version'; import { IOpenSearchDashboardsMigrator } from '../../migrations'; import { - SavedObjectsSerializer, SavedObjectSanitizedDoc, SavedObjectsRawDoc, SavedObjectsRawDocSource, + SavedObjectsSerializer, } from '../../serialization'; import { + SavedObjectsAddToNamespacesOptions, + SavedObjectsAddToNamespacesResponse, + SavedObjectsAddToWorkspacesOptions, + SavedObjectsAddToWorkspacesResponse, SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, SavedObjectsBulkResponse, + SavedObjectsBulkUpdateObject, + SavedObjectsBulkUpdateOptions, SavedObjectsBulkUpdateResponse, SavedObjectsCheckConflictsObject, SavedObjectsCheckConflictsResponse, SavedObjectsCreateOptions, + SavedObjectsDeleteFromNamespacesOptions, + SavedObjectsDeleteFromNamespacesResponse, + SavedObjectsDeleteOptions, SavedObjectsFindResponse, SavedObjectsFindResult, + SavedObjectsShareObjects, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, - SavedObjectsBulkUpdateObject, - SavedObjectsBulkUpdateOptions, - SavedObjectsDeleteOptions, - SavedObjectsAddToNamespacesOptions, - SavedObjectsAddToNamespacesResponse, - SavedObjectsDeleteFromNamespacesOptions, - SavedObjectsDeleteFromNamespacesResponse, } from '../saved_objects_client'; import { + MutatingOperationRefreshSetting, SavedObject, SavedObjectsBaseOptions, SavedObjectsFindOptions, SavedObjectsMigrationVersion, - MutatingOperationRefreshSetting, } from '../../types'; -import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { validateConvertFilterToKueryNode } from './filter_utils'; import { ALL_NAMESPACES_STRING, @@ -84,6 +87,7 @@ import { FIND_DEFAULT_PER_PAGE, SavedObjectsUtils, } from './utils'; +import { GLOBAL_WORKSPACE_ID } from '../../../workspaces/constants'; // BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. @@ -1272,6 +1276,112 @@ export class SavedObjectsRepository { } } + async addToWorkspaces( + savedObjects: SavedObjectsShareObjects[], + workspaces: string[], + options: SavedObjectsAddToWorkspacesOptions = {} + ): Promise { + if (!savedObjects.length) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'shared savedObjects must not be an empty array' + ); + } + + // saved objects must exist in specified workspace + if (options.workspaces) { + const invalidObjects = savedObjects.filter((obj) => { + if ( + obj.workspaces && + obj.workspaces.length > 0 && + !obj.workspaces.includes(GLOBAL_WORKSPACE_ID) + ) { + return intersection(obj.workspaces, options.workspaces).length === 0; + } + return false; + }); + if (invalidObjects && invalidObjects.length > 0) { + const [savedObj] = invalidObjects; + throw SavedObjectsErrorHelpers.createConflictError(savedObj.type, savedObj.id); + } + } + + savedObjects.forEach(({ type, id }) => { + if (!this._allowedTypes.includes(type)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + }); + + if (!workspaces.length) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'workspaces must be a non-empty array of strings' + ); + } + + const { refresh = DEFAULT_REFRESH_SETTING } = options; + const savedObjectsBulkResponse = await this.bulkGet(savedObjects); + + const docs = savedObjectsBulkResponse.saved_objects.map((obj) => { + const { type, id } = obj; + const rawId = this._serializer.generateRawId(undefined, type, id); + const time = this._getCurrentTime(); + + return [ + { + update: { + _id: rawId, + _index: this.getIndexForType(type), + }, + }, + { + script: { + source: ` + if (params.workspaces != null && ctx._source.workspaces != null && !ctx._source.workspaces?.contains(params.globalWorkspaceId)) { + ctx._source.workspaces.addAll(params.workspaces); + HashSet workspacesSet = new HashSet(ctx._source.workspaces); + ctx._source.workspaces = new ArrayList(workspacesSet); + } + ctx._source.updated_at = params.time; + `, + lang: 'painless', + params: { + time, + workspaces, + globalWorkspaceId: GLOBAL_WORKSPACE_ID, + }, + }, + }, + ]; + }); + + const bulkUpdateResponse = await this.client.bulk({ + refresh, + body: docs.flat(), + _source_includes: ['workspaces'], + }); + + if (bulkUpdateResponse.body.errors) { + const failures = bulkUpdateResponse.body.items + .map((item) => item.update?.error?.reason) + .join(','); + throw SavedObjectsErrorHelpers.createBadRequestError( + 'Add to workspace failed with: ' + failures + ); + } + + const savedObjectIdWorkspaceMap = bulkUpdateResponse.body.items.reduce((map, item) => { + return map.set(item.update?._id!, item.update?.get?._source.workspaces); + }, new Map()); + + return savedObjects.map((obj) => { + const rawId = this._serializer.generateRawId(undefined, obj.type, obj.id); + return { + type: obj.type, + id: obj.id, + workspaces: savedObjectIdWorkspaceMap.get(rawId), + } as SavedObjectsAddToWorkspacesResponse; + }); + } + /** * Updates multiple objects in bulk * 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 4e1f1a7b4564..119aea8b2743 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -169,6 +169,8 @@ export interface SavedObjectsCheckConflictsResponse { }>; } +export type SavedObjectsShareObjects = Pick; + /** * * @public @@ -193,6 +195,11 @@ export interface SavedObjectsAddToNamespacesOptions extends SavedObjectsBaseOpti refresh?: MutatingOperationRefreshSetting; } +export type SavedObjectsAddToWorkspacesOptions = Pick< + SavedObjectsUpdateOptions, + 'refresh' | 'workspaces' +>; + /** * * @public @@ -202,6 +209,11 @@ export interface SavedObjectsAddToNamespacesResponse { namespaces: string[]; } +export interface SavedObjectsAddToWorkspacesResponse extends Pick { + /** The workspaces the object exists in after this operation is complete. */ + workspaces: string[]; +} + /** * * @public @@ -433,6 +445,21 @@ export class SavedObjectsClient { return await this._repository.deleteFromNamespaces(type, id, namespaces, options); } + /** + * Adds workspace to SavedObjects + * + * @param objects + * @param workspaces + * @param options + */ + addToWorkspaces = async ( + objects: SavedObjectsShareObjects[], + workspaces: string[], + options: SavedObjectsAddToWorkspacesOptions = {} + ): Promise => { + return await this._repository.addToWorkspaces(objects, workspaces, options); + }; + /** * Bulk Updates multiple SavedObject at once * diff --git a/src/core/server/workspaces/constants.ts b/src/core/server/workspaces/constants.ts index 73c2d6010846..c9bf406e50c9 100644 --- a/src/core/server/workspaces/constants.ts +++ b/src/core/server/workspaces/constants.ts @@ -4,3 +4,4 @@ */ export const WORKSPACE_TYPE = 'workspace'; +export const GLOBAL_WORKSPACE_ID = 'public'; diff --git a/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts index 941ea8824020..b812568d1be4 100644 --- a/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts +++ b/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -9,6 +9,7 @@ import Boom from '@hapi/boom'; import { OpenSearchDashboardsRequest, SavedObject, + SavedObjectsAddToWorkspacesOptions, SavedObjectsBaseOptions, SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, @@ -17,6 +18,7 @@ import { SavedObjectsCreateOptions, SavedObjectsDeleteOptions, SavedObjectsFindOptions, + SavedObjectsShareObjects, } from 'opensearch-dashboards/server'; import { SavedObjectsPermissionControlContract } from '../../saved_objects/permission_control/client'; import { WORKSPACE_TYPE } from '../constants'; @@ -26,7 +28,14 @@ import { PermissionMode } from '../../../utils'; const generateWorkspacePermissionError = () => Boom.illegal( i18n.translate('workspace.permission.invalidate', { - defaultMessage: 'Invalidate workspace permission', + defaultMessage: 'Invalid workspace permission', + }) + ); + +const generateSavedObjectsPermissionError = () => + Boom.illegal( + i18n.translate('saved_objects.permission.invalidate', { + defaultMessage: 'Invalid saved objects permission', }) ); @@ -201,6 +210,33 @@ export class WorkspaceSavedObjectsClientWrapper { return await wrapperOptions.client.find(options); }; + const addToWorkspacesWithPermissionControl = async ( + objects: SavedObjectsShareObjects[], + targetWorkspaces: string[], + options: SavedObjectsAddToWorkspacesOptions = {} + ) => { + // target workspaces + await this.validateMultiWorkspacesPermissions(targetWorkspaces, wrapperOptions.request, [ + PermissionMode.LibraryWrite, + PermissionMode.Management, + ]); + + // saved_objects + const permitted = await this.permissionControl.batchValidate( + wrapperOptions.request, + objects.map((savedObj) => ({ + ...savedObj, + })), + [PermissionMode.Write] + ); + + if (!permitted) { + throw generateSavedObjectsPermissionError(); + } + + return await wrapperOptions.client.addToWorkspaces(objects, targetWorkspaces, options); + }; + return { ...wrapperOptions.client, get: getWithWorkspacePermissionControl, @@ -215,6 +251,7 @@ export class WorkspaceSavedObjectsClientWrapper { delete: deleteWithWorkspacePermissionControl, update: wrapperOptions.client.update, bulkUpdate: wrapperOptions.client.bulkUpdate, + addToWorkspaces: addToWorkspacesWithPermissionControl, }; }; From b01cd4b7f09aa9c34eea83d65e838d38bdc46738 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Thu, 3 Aug 2023 17:16:30 +0800 Subject: [PATCH 073/174] Feat remove feature group (#72) * feat: remove featureGroup and update management category Signed-off-by: Lin Wang * feat: filter out empty feature category Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang --- src/core/public/application/types.ts | 5 ----- .../workspace_creator/workspace_form.tsx | 14 +++++++++++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index e284e80297f0..66ee93ffefb6 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -252,11 +252,6 @@ export interface App { */ exactRoute?: boolean; - /** - * The feature group of workspace, won't be displayed as feature if feature set is ADMIN. - */ - featureGroup?: Array<'WORKSPACE' | 'ADMIN'>; - /** * The dependencies of one application, required feature will be automatic select and can't * be unselect in the workspace configuration. diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx index 29e4d66edd2a..a43b318ce801 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx @@ -33,7 +33,12 @@ import { } from '@elastic/eui'; import { WorkspaceTemplate } from '../../../../../core/types'; -import { App, AppNavLinkStatus, ApplicationStart } from '../../../../../core/public'; +import { + App, + AppNavLinkStatus, + ApplicationStart, + DEFAULT_APP_CATEGORIES, +} from '../../../../../core/public'; import { useApplications, useWorkspaceTemplate } from '../../hooks'; import { WORKSPACE_OP_TYPE_CREATE, WORKSPACE_OP_TYPE_UPDATE } from '../../../common/constants'; import { @@ -122,10 +127,10 @@ export const WorkspaceForm = ({ const apps = category2Applications[currentKey]; const features = apps .filter( - ({ navLinkStatus, chromeless, featureGroup }) => + ({ navLinkStatus, chromeless, category }) => navLinkStatus !== AppNavLinkStatus.hidden && !chromeless && - featureGroup?.includes('WORKSPACE') + category?.id !== DEFAULT_APP_CATEGORIES.management.id ) .map(({ id, title, workspaceTemplate, dependencies }) => ({ id, @@ -133,6 +138,9 @@ export const WorkspaceForm = ({ templates: workspaceTemplate || [], dependencies, })); + if (features.length === 0) { + return previousValue; + } if (features.length === 1 || currentKey === 'undefined') { return [...previousValue, ...features]; } From 62cbed7be723efa594bf13d363e83b29e102e671 Mon Sep 17 00:00:00 2001 From: raintygao Date: Fri, 4 Aug 2023 11:32:01 +0800 Subject: [PATCH 074/174] feat: remove template section (#73) Signed-off-by: tygao --- src/core/public/application/types.ts | 8 +- src/core/public/index.ts | 1 - src/core/types/index.ts | 1 - src/core/types/workspace_template.ts | 31 ------- src/core/utils/default_workspace_templates.ts | 38 --------- src/core/utils/index.ts | 1 - src/plugins/dashboard/public/plugin.tsx | 7 -- src/plugins/discover/public/plugin.ts | 6 +- src/plugins/visualize/public/plugin.ts | 6 +- .../workspace_creator/workspace_form.tsx | 82 +------------------ src/plugins/workspace/public/hooks.ts | 33 -------- 11 files changed, 5 insertions(+), 209 deletions(-) delete mode 100644 src/core/types/workspace_template.ts delete mode 100644 src/core/utils/default_workspace_templates.ts diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 66ee93ffefb6..792d5195c4c6 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -45,7 +45,7 @@ import { OverlayStart } from '../overlays'; import { PluginOpaqueId } from '../plugins'; import { IUiSettingsClient } from '../ui_settings'; import { SavedObjectsStart } from '../saved_objects'; -import { AppCategory, WorkspaceTemplate } from '../../types'; +import { AppCategory } from '../../types'; import { ScopedHistory } from './scoped_history'; import { WorkspacesStart } from '../workspace'; @@ -124,12 +124,6 @@ export interface App { */ category?: AppCategory; - /** - * The template definition of features belongs to - * See {@link WorkspaceTemplate} - */ - workspaceTemplate?: WorkspaceTemplate[]; - /** * The initial status of the application. * Defaulting to `accessible` diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 7e66871b2946..5b1978a4c129 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -94,7 +94,6 @@ export { PackageInfo, EnvironmentMode } from '../server/types'; /** @interal */ export { CoreContext, CoreSystem } from './core_system'; export { DEFAULT_APP_CATEGORIES } from '../utils'; -export { DEFAULT_WORKSPACE_TEMPLATES } from '../utils'; export { AppCategory, UiSettingsParams, diff --git a/src/core/types/index.ts b/src/core/types/index.ts index e016d7ca7527..9f620273e3b2 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -35,7 +35,6 @@ export * from './core_service'; export * from './capabilities'; export * from './app_category'; -export * from './workspace_template'; export * from './ui_settings'; export * from './saved_objects'; export * from './serializable'; diff --git a/src/core/types/workspace_template.ts b/src/core/types/workspace_template.ts deleted file mode 100644 index 4be8c4882bf0..000000000000 --- a/src/core/types/workspace_template.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -export interface WorkspaceTemplate { - /** - * Unique identifier for the workspace template - */ - id: string; - - /** - * Label used for workspace template name. - */ - label: string; - - /** - * The order that workspace template will be sorted in - */ - order?: number; - - /** - * Introduction of the template - */ - description: string; - - /** - * template coverage image location - */ - coverImage?: string; -} diff --git a/src/core/utils/default_workspace_templates.ts b/src/core/utils/default_workspace_templates.ts deleted file mode 100644 index 153fc23f790a..000000000000 --- a/src/core/utils/default_workspace_templates.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { WorkspaceTemplate } from '../types'; - -/** @internal */ -export const DEFAULT_WORKSPACE_TEMPLATES: Record = Object.freeze({ - search: { - id: 'search', - label: 'Search', - order: 1000, - description: - "Intro paragraph blur about search, key features, and why you'd want to create ana search workspace", - }, - observability: { - id: 'observability', - label: 'Observability', - order: 2000, - description: - "Intro paragraph blur about observability, key features, and why you'd want to create ana observability workspace", - }, - security_analytics: { - id: 'security_analytics', - label: 'Security Analytics', - order: 3000, - description: - "Intro paragraph blur about security analytics, key features, and why you'd want to create ana security analytics workspace", - }, - general_analysis: { - id: 'general_analysis', - label: 'General Analytics', - order: 4000, - description: - "Intro paragraph blur about analytics, key features, and why you'd want to create ana analytics workspace", - }, -}); diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 174152ffd750..9f19132ea8bc 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -37,5 +37,4 @@ export { IContextProvider, } from './context'; export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; -export { DEFAULT_WORKSPACE_TEMPLATES } from './default_workspace_templates'; export { WORKSPACE_PATH_PREFIX, PermissionMode } from './constants'; diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 789de5867c62..956bb5a7a836 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -86,7 +86,6 @@ import { } from '../../opensearch_dashboards_legacy/public'; import { FeatureCatalogueCategory, HomePublicPluginSetup } from '../../../plugins/home/public'; import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; -import { DEFAULT_WORKSPACE_TEMPLATES } from '../../../core/public'; import { ACTION_CLONE_PANEL, @@ -373,12 +372,6 @@ export class DashboardPlugin defaultPath: `#${DashboardConstants.LANDING_PAGE_PATH}`, updater$: this.appStateUpdater, category: DEFAULT_APP_CATEGORIES.opensearchDashboards, - workspaceTemplate: [ - DEFAULT_WORKSPACE_TEMPLATES.search, - DEFAULT_WORKSPACE_TEMPLATES.general_analysis, - DEFAULT_WORKSPACE_TEMPLATES.observability, - DEFAULT_WORKSPACE_TEMPLATES.security_analytics, - ], mount: async (params: AppMountParameters) => { const [coreStart, pluginsStart, dashboardStart] = await core.getStartServices(); this.currentHistory = params.history; diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index 2d7a237a0db3..f1532b6f776b 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -33,7 +33,7 @@ import { lazy } from 'react'; import { DataPublicPluginStart, DataPublicPluginSetup, opensearchFilters } from '../../data/public'; import { SavedObjectLoader } from '../../saved_objects/public'; import { url } from '../../opensearch_dashboards_utils/public'; -import { DEFAULT_APP_CATEGORIES, DEFAULT_WORKSPACE_TEMPLATES } from '../../../core/public'; +import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; import { UrlGeneratorState } from '../../share/public'; import { DocViewInput, DocViewInputFn } from './application/doc_views/doc_views_types'; import { generateDocViewsUrl } from './application/components/doc_views/generate_doc_views_url'; @@ -251,10 +251,6 @@ export class DiscoverPlugin euiIconType: 'inputOutput', defaultPath: '#/', category: DEFAULT_APP_CATEGORIES.opensearchDashboards, - workspaceTemplate: [ - DEFAULT_WORKSPACE_TEMPLATES.search, - DEFAULT_WORKSPACE_TEMPLATES.general_analysis, - ], mount: async (params: AppMountParameters) => { if (!this.initializeServices) { throw Error('Discover plugin method initializeServices is undefined'); diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index f66f5751023a..47a38d63986d 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -55,7 +55,7 @@ import { VisualizationsStart } from '../../visualizations/public'; import { VisualizeConstants } from './application/visualize_constants'; import { FeatureCatalogueCategory, HomePublicPluginSetup } from '../../home/public'; import { VisualizeServices } from './application/types'; -import { DEFAULT_APP_CATEGORIES, DEFAULT_WORKSPACE_TEMPLATES } from '../../../core/public'; +import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; import { SavedObjectsStart } from '../../saved_objects/public'; import { EmbeddableStart } from '../../embeddable/public'; import { DashboardStart } from '../../dashboard/public'; @@ -156,10 +156,6 @@ export class VisualizePlugin euiIconType: 'inputOutput', defaultPath: '#/', category: DEFAULT_APP_CATEGORIES.opensearchDashboards, - workspaceTemplate: [ - DEFAULT_WORKSPACE_TEMPLATES.search, - DEFAULT_WORKSPACE_TEMPLATES.general_analysis, - ], updater$: this.appStateUpdater.asObservable(), // remove all references to visualize mount: async (params: AppMountParameters) => { diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx index a43b318ce801..ec1df52bdc5a 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx @@ -15,14 +15,10 @@ import { EuiText, EuiButton, EuiFlexItem, - EuiCheckableCard, htmlIdGenerator, EuiFlexGrid, - EuiFlexGroup, - EuiImage, EuiCheckbox, EuiCheckboxGroup, - EuiCheckableCardProps, EuiCheckboxGroupProps, EuiCheckboxProps, EuiFieldTextProps, @@ -32,14 +28,13 @@ import { EuiComboBoxProps, } from '@elastic/eui'; -import { WorkspaceTemplate } from '../../../../../core/types'; import { App, AppNavLinkStatus, ApplicationStart, DEFAULT_APP_CATEGORIES, } from '../../../../../core/public'; -import { useApplications, useWorkspaceTemplate } from '../../hooks'; +import { useApplications } from '../../hooks'; import { WORKSPACE_OP_TYPE_CREATE, WORKSPACE_OP_TYPE_UPDATE } from '../../../common/constants'; import { isFeatureDependBySelectedFeatures, @@ -52,7 +47,6 @@ import { WorkspaceIconSelector } from './workspace_icon_selector'; interface WorkspaceFeature extends Pick { id: string; name: string; - templates: WorkspaceTemplate[]; } interface WorkspaceFeatureGroup { @@ -92,7 +86,6 @@ export const WorkspaceForm = ({ defaultValues, opType, }: WorkspaceFormProps) => { - const { workspaceTemplates, templateFeatureMap } = useWorkspaceTemplate(application); const applications = useApplications(application); const [name, setName] = useState(defaultValues?.name); @@ -101,11 +94,7 @@ export const WorkspaceForm = ({ const [icon, setIcon] = useState(defaultValues?.icon); const [defaultVISTheme, setDefaultVISTheme] = useState(defaultValues?.defaultVISTheme); - const [selectedTemplateId, setSelectedTemplateId] = useState(); const [selectedFeatureIds, setSelectedFeatureIds] = useState(defaultValues?.features || []); - const selectedTemplate = workspaceTemplates.find( - (template) => template.id === selectedTemplateId - ); const [formErrors, setFormErrors] = useState({}); const formIdRef = useRef(); const getFormData = () => ({ @@ -132,10 +121,9 @@ export const WorkspaceForm = ({ !chromeless && category?.id !== DEFAULT_APP_CATEGORIES.management.id ) - .map(({ id, title, workspaceTemplate, dependencies }) => ({ + .map(({ id, title, dependencies }) => ({ id, name: title, - templates: workspaceTemplate || [], dependencies, })); if (features.length === 0) { @@ -179,22 +167,6 @@ export const WorkspaceForm = ({ formIdRef.current = workspaceHtmlIdGenerator(); } - const handleTemplateCardChange = useCallback( - (e) => { - const templateId = e.target.value; - setSelectedTemplateId(templateId); - setSelectedFeatureIds( - getFinalFeatureIdsByDependency( - allFeatures - .filter(({ templates }) => !!templates.find((template) => template.id === templateId)) - .map((feature) => feature.id), - featureDependencies - ) - ); - }, - [allFeatures, featureDependencies] - ); - const handleFeatureChange = useCallback( (featureId) => { setSelectedFeatureIds((previousData) => { @@ -336,56 +308,6 @@ export const WorkspaceForm = ({ - - -

    Workspace Template

    -
    - - - {workspaceTemplates.map((template) => ( - - - - ))} - - - {selectedTemplate && ( - <> - -

    Features

    -
    - - - {selectedTemplate.coverImage && ( - - - - )} - - {selectedTemplate.description} - -

    Key Features:

    -
    - - - {templateFeatureMap.get(selectedTemplate.id)?.map((feature) => ( - • {feature.title} - ))} - -
    -
    - - - )} -
    -

    Workspace features

    diff --git a/src/plugins/workspace/public/hooks.ts b/src/plugins/workspace/public/hooks.ts index 1132aac04e73..e84ee46507ef 100644 --- a/src/plugins/workspace/public/hooks.ts +++ b/src/plugins/workspace/public/hooks.ts @@ -6,7 +6,6 @@ import { ApplicationStart, PublicAppInfo } from 'opensearch-dashboards/public'; import { useObservable } from 'react-use'; import { useMemo } from 'react'; -import { WorkspaceTemplate } from '../../../core/types'; export function useApplications(application: ApplicationStart) { const applications = useObservable(application.applications$); @@ -18,35 +17,3 @@ export function useApplications(application: ApplicationStart) { return apps; }, [applications]); } - -export function useWorkspaceTemplate(application: ApplicationStart) { - const applications = useObservable(application.applications$); - - return useMemo(() => { - const tempWsTemplates = [] as WorkspaceTemplate[]; - let workspaceTemplates = [] as WorkspaceTemplate[]; - const templateFeatureMap = new Map(); - - if (applications) { - applications.forEach((app) => { - const { workspaceTemplate: templates = [] } = app; - tempWsTemplates.push(...templates); - for (const template of templates) { - const features = templateFeatureMap.get(template.id) || []; - features.push(app); - templateFeatureMap.set(template.id, features); - } - }); - - workspaceTemplates = tempWsTemplates.reduce((list, curr) => { - if (!list.find((ws) => ws.id === curr.id)) { - list.push(curr); - } - return list; - }, [] as WorkspaceTemplate[]); - workspaceTemplates.sort((a, b) => (a.order || 0) - (b.order || 0)); - } - - return { workspaceTemplates, templateFeatureMap }; - }, [applications]); -} From a6538c5b24f3d24d596791e598bffc5466155d3e Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Mon, 7 Aug 2023 18:51:07 +0800 Subject: [PATCH 075/174] Integration ACL check with saved objects. (#74) * feat: enable find with acl permission check Signed-off-by: SuZhou-Joe * fix: bootstrap error Signed-off-by: SuZhou-Joe * feat: add public Signed-off-by: SuZhou-Joe * feat: enable name change Signed-off-by: SuZhou-Joe * feat: make test run Signed-off-by: SuZhou-Joe * feat: some optimization Signed-off-by: SuZhou-Joe * feat: some optimization on authentication part Signed-off-by: SuZhou-Joe * feat: optimize authentication Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe --- .../saved_objects/saved_objects_client.ts | 1 + .../permission_control/acl.test.ts | 4 +- .../saved_objects/permission_control/acl.ts | 48 +++---- .../permission_control/client.mock.ts | 6 +- .../permission_control/client.ts | 129 ++++++++++++------ .../permission_control/routes/principles.ts | 4 +- .../saved_objects/saved_objects_service.ts | 2 +- .../saved_objects/serialization/types.ts | 2 + .../saved_objects/service/lib/repository.ts | 5 +- .../service/lib/search_dsl/query_params.ts | 15 +- .../service/lib/search_dsl/search_dsl.ts | 3 + src/core/server/saved_objects/types.ts | 1 + .../workspace_saved_objects_client_wrapper.ts | 64 ++++++--- src/core/types/saved_objects.ts | 2 + 14 files changed, 192 insertions(+), 94 deletions(-) diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 64f74255e2ad..356e555cfc53 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -363,6 +363,7 @@ export class SavedObjectsClient { namespaces: 'namespaces', preference: 'preference', workspaces: 'workspaces', + queryDSL: 'queryDSL', }; const workspaces = [ diff --git a/src/core/server/saved_objects/permission_control/acl.test.ts b/src/core/server/saved_objects/permission_control/acl.test.ts index b292fb747b3c..95da5ca4d33b 100644 --- a/src/core/server/saved_objects/permission_control/acl.test.ts +++ b/src/core/server/saved_objects/permission_control/acl.test.ts @@ -111,13 +111,13 @@ describe('SavedObjectTypeRegistry', () => { expect(result?.length).toEqual(3); }); - it('test genereate query DSL', () => { + it('test generate query DSL', () => { const principals = { users: ['user1'], groups: ['group1'], }; const result = ACL.genereateGetPermittedSavedObjectsQueryDSL( - PermissionMode.Read, + [PermissionMode.Read], principals, 'workspace' ); diff --git a/src/core/server/saved_objects/permission_control/acl.ts b/src/core/server/saved_objects/permission_control/acl.ts index 4b7d506e11ba..2bac6823634a 100644 --- a/src/core/server/saved_objects/permission_control/acl.ts +++ b/src/core/server/saved_objects/permission_control/acl.ts @@ -10,7 +10,7 @@ export interface Principals { groups?: string[]; } -export type Permissions = Partial>; +export type Permissions = Record; export interface TransformedPermission { type: string; @@ -121,11 +121,14 @@ export class ACL { } for (const permissionType of permissionTypes) { - this.permissions[permissionType] = deleteFromPrincipals( + const result = deleteFromPrincipals( this.permissions![permissionType], principals.users, principals.groups ); + if (result) { + this.permissions[permissionType] = result; + } } return this; @@ -204,11 +207,11 @@ export class ACL { generate query DSL by the specific conditions, used for fetching saved objects from the saved objects index */ public static genereateGetPermittedSavedObjectsQueryDSL( - permissionType: string, + permissionTypes: string[], principals: Principals, savedObjectType?: string | string[] ) { - if (!principals || !permissionType) { + if (!principals || !permissionTypes) { return { query: { match_none: {}, @@ -222,30 +225,21 @@ export class ACL { const subBool: any = { should: [], }; - if (!!principals.users) { - subBool.should.push({ - terms: { - ['permissions.' + permissionType + '.users']: principals.users, - }, - }); - subBool.should.push({ - term: { - ['permissions.' + permissionType + '.users']: '*', - }, - }); - } - if (!!principals.groups) { - subBool.should.push({ - terms: { - ['permissions.' + permissionType + '.groups']: principals.groups, - }, - }); - subBool.should.push({ - term: { - ['permissions.' + permissionType + '.groups']: '*', - }, + + 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, diff --git a/src/core/server/saved_objects/permission_control/client.mock.ts b/src/core/server/saved_objects/permission_control/client.mock.ts index 218e62dcd4d4..4cae55f62890 100644 --- a/src/core/server/saved_objects/permission_control/client.mock.ts +++ b/src/core/server/saved_objects/permission_control/client.mock.ts @@ -2,15 +2,13 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ - import { SavedObjectsPermissionControlContract } from './client'; export const savedObjectsPermissionControlMock: SavedObjectsPermissionControlContract = { setup: jest.fn(), validate: jest.fn(), batchValidate: jest.fn(), - addPrinciplesToObjects: jest.fn(), - removePrinciplesFromObjects: jest.fn(), - getPrinciplesOfObjects: jest.fn(), + getPrincipalsOfObjects: jest.fn(), getPermittedWorkspaceIds: jest.fn(), + getPrincipalsFromRequest: jest.fn(), }; diff --git a/src/core/server/saved_objects/permission_control/client.ts b/src/core/server/saved_objects/permission_control/client.ts index ee2fad51c651..0e34a4bc8cb6 100644 --- a/src/core/server/saved_objects/permission_control/client.ts +++ b/src/core/server/saved_objects/permission_control/client.ts @@ -2,10 +2,14 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ - +import { i18n } from '@osd/i18n'; import { OpenSearchDashboardsRequest } from '../../http'; +import { ensureRawRequest } from '../../http/router'; import { SavedObjectsServiceStart } from '../saved_objects_service'; import { SavedObjectsBulkGetObject } from '../service'; +import { ACL, Principals, TransformedPermission } from './acl'; +import { PrincipalType } from '../../../utils/constants'; +import { WORKSPACE_TYPE } from '../../workspaces'; export type SavedObjectsPermissionControlContract = Pick< SavedObjectsPermissionControl, @@ -14,78 +18,127 @@ export type SavedObjectsPermissionControlContract = Pick< export type SavedObjectsPermissionModes = string[]; +export interface AuthInfo { + backend_roles?: string[]; + user_name?: string; +} + export class SavedObjectsPermissionControl { - private getScopedClient?: SavedObjectsServiceStart['getScopedClient']; - private getScopedSavedObjectsClient(request: OpenSearchDashboardsRequest) { - return this.getScopedClient?.(request); + private createInternalRepository?: SavedObjectsServiceStart['createInternalRepository']; + private getInternalRepository() { + return this.createInternalRepository?.(); + } + public getPrincipalsFromRequest(request: OpenSearchDashboardsRequest): Principals { + const rawRequest = ensureRawRequest(request); + const authInfo = rawRequest?.auth?.credentials?.authInfo as AuthInfo | null; + const payload: Principals = {}; + if (!authInfo) { + /** + * Login user have access to all the workspaces when no authentication is presented. + * The logic will be used when users create workspaces with authentication enabled but turn off authentication for any reason. + */ + return payload; + } + if (!authInfo?.backend_roles?.length && !authInfo.user_name) { + /** + * It means OSD can not recognize who the user is even if authentication is enabled, + * use a fake user that won't be granted permission explicitly. + */ + payload[PrincipalType.Users] = [`_user_fake_${Date.now()}_`]; + return payload; + } + if (authInfo?.backend_roles) { + payload[PrincipalType.Groups] = authInfo.backend_roles; + } + if (authInfo?.user_name) { + payload[PrincipalType.Users] = [authInfo.user_name]; + } + return payload; } private async bulkGetSavedObjects( request: OpenSearchDashboardsRequest, savedObjects: SavedObjectsBulkGetObject[] ) { - return ( - (await this.getScopedSavedObjectsClient(request)?.bulkGet(savedObjects))?.saved_objects || [] - ); + return (await this.getInternalRepository()?.bulkGet(savedObjects))?.saved_objects || []; } - public async setup(getScopedClient: SavedObjectsServiceStart['getScopedClient']) { - this.getScopedClient = getScopedClient; + public async setup( + createInternalRepository: SavedObjectsServiceStart['createInternalRepository'] + ) { + this.createInternalRepository = createInternalRepository; } public async validate( request: OpenSearchDashboardsRequest, savedObject: SavedObjectsBulkGetObject, - permissionModeOrModes: SavedObjectsPermissionModes + permissionModes: SavedObjectsPermissionModes ) { - return await this.batchValidate(request, [savedObject], permissionModeOrModes); + return await this.batchValidate(request, [savedObject], permissionModes); } + /** + * In batch validate case, the logic is a.withPermission && b.withPermission + * @param request + * @param savedObjects + * @param permissionModes + * @returns + */ public async batchValidate( request: OpenSearchDashboardsRequest, savedObjects: SavedObjectsBulkGetObject[], - permissionModeOrModes: SavedObjectsPermissionModes + permissionModes: SavedObjectsPermissionModes ) { const savedObjectsGet = await this.bulkGetSavedObjects(request, savedObjects); if (savedObjectsGet) { + const principals = this.getPrincipalsFromRequest(request); + const hasAllPermission = savedObjectsGet.every((item) => { + // item.permissions + const aclInstance = new ACL(item.permissions); + return aclInstance.hasPermission(permissionModes, principals); + }); return { success: true, - result: true, + result: hasAllPermission, }; } return { - success: true, - result: false, + success: false, + error: i18n.translate('savedObjects.permission.notFound', { + defaultMessage: 'Can not find target saved objects.', + }), }; } - public async addPrinciplesToObjects( - request: OpenSearchDashboardsRequest, - savedObjects: SavedObjectsBulkGetObject[], - personas: string[], - permissionModeOrModes: SavedObjectsPermissionModes - ): Promise { - return true; - } - - public async removePrinciplesFromObjects( - request: OpenSearchDashboardsRequest, - savedObjects: SavedObjectsBulkGetObject[], - personas: string[], - permissionModeOrModes: SavedObjectsPermissionModes - ): Promise { - return true; - } - - public async getPrinciplesOfObjects( + public async getPrincipalsOfObjects( request: OpenSearchDashboardsRequest, savedObjects: SavedObjectsBulkGetObject[] - ): Promise> { - return {}; + ): Promise> { + const detailedSavedObjects = await this.bulkGetSavedObjects(request, savedObjects); + return detailedSavedObjects.reduce((total, current) => { + return { + ...total, + [current.id]: new ACL(current.permissions).transformPermissions(), + }; + }, {}); } public async getPermittedWorkspaceIds( request: OpenSearchDashboardsRequest, - permissionModeOrModes: SavedObjectsPermissionModes + permissionModes: SavedObjectsPermissionModes ) { - return []; + const principals = this.getPrincipalsFromRequest(request); + const queryDSL = ACL.genereateGetPermittedSavedObjectsQueryDSL(permissionModes, principals, [ + WORKSPACE_TYPE, + ]); + const repository = this.getInternalRepository(); + try { + const result = await repository?.find({ + type: [WORKSPACE_TYPE], + queryDSL, + perPage: 999, + }); + return result?.saved_objects.map((item) => item.id); + } catch (e) { + return []; + } } } diff --git a/src/core/server/saved_objects/permission_control/routes/principles.ts b/src/core/server/saved_objects/permission_control/routes/principles.ts index 986bf46ed967..e21e4151146b 100644 --- a/src/core/server/saved_objects/permission_control/routes/principles.ts +++ b/src/core/server/saved_objects/permission_control/routes/principles.ts @@ -13,7 +13,7 @@ export const registerListRoute = ( ) => { router.post( { - path: '/principles', + path: '/principals', validate: { body: schema.object({ objects: schema.arrayOf( @@ -26,7 +26,7 @@ export const registerListRoute = ( }, }, router.handleLegacyErrors(async (context, req, res) => { - const result = await permissionControl.getPrinciplesOfObjects(req, req.body.objects); + const result = await permissionControl.getPrincipalsOfObjects(req, req.body.objects); return res.ok({ body: result }); }) ); diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index 01738ac5a149..6108bc20a06d 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -544,7 +544,7 @@ export class SavedObjectsService this.started = true; const getScopedClient = clientProvider.getClient.bind(clientProvider); - this.permissionControl?.setup(getScopedClient); + this.permissionControl?.setup(repositoryFactory.createInternalRepository); return { getScopedClient, diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index 473a63cf65f4..360cdc6b3a62 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'; /** @@ -53,6 +54,7 @@ export interface SavedObjectsRawDocSource { references?: SavedObjectReference[]; originId?: string; workspaces?: string[]; + permissions?: Permissions; [typeMapping: string]: any; } diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 9545eec608b1..7b033b8e0211 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -769,6 +769,7 @@ export class SavedObjectsRepository { filter, preference, workspaces, + queryDSL, } = options; if (!type && !typeToNamespacesMap) { @@ -843,6 +844,7 @@ export class SavedObjectsRepository { hasReference, kueryNode, workspaces, + queryDSL, }), }, }; @@ -1897,7 +1899,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)) { @@ -1917,6 +1919,7 @@ function getSavedObjectFromSource( attributes: doc._source[type], references: doc._source.references || [], migrationVersion: doc._source.migrationVersion, + permissions, }; } 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 5a2aae5943a6..4ddbe992bcfc 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 @@ -27,7 +27,7 @@ * specific language governing permissions and limitations * under the License. */ - +import { mergeWith, isArray } from 'lodash'; // @ts-expect-error no ts import { opensearchKuery } from '../../../opensearch_query'; type KueryNode = any; @@ -174,6 +174,7 @@ interface QueryParams { hasReference?: HasReferenceQueryParams; kueryNode?: KueryNode; workspaces?: string[]; + queryDSL?: Record; } export function getClauseForReference(reference: HasReferenceQueryParams) { @@ -231,6 +232,7 @@ export function getQueryParams({ hasReference, kueryNode, workspaces, + queryDSL, }: QueryParams) { const types = getTypes( registry, @@ -287,7 +289,16 @@ export function getQueryParams({ } } - return { query: { bool } }; + const result = { query: { bool } }; + + if (queryDSL) { + return mergeWith({}, result, queryDSL, (objValue, srcValue) => { + if (isArray(objValue)) { + return objValue.concat(srcValue); + } + }); + } + return result; } // we only want to add match_phrase_prefix clauses 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 df6109eb9d0a..d6b8b83ac87e 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 @@ -53,6 +53,7 @@ interface GetSearchDslOptions { }; kueryNode?: KueryNode; workspaces?: string[]; + queryDSL?: Record; } export function getSearchDsl( @@ -73,6 +74,7 @@ export function getSearchDsl( hasReference, kueryNode, workspaces, + queryDSL, } = options; if (!type) { @@ -96,6 +98,7 @@ export function getSearchDsl( hasReference, kueryNode, workspaces, + queryDSL, }), ...getSortingParams(mappings, type, sortField, sortOrder), }; diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 33862cb149fb..25ccbff66dd7 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -111,6 +111,7 @@ export interface SavedObjectsFindOptions { /** An optional OpenSearch preference value to be used for the query **/ preference?: string; workspaces?: string[]; + queryDSL?: Record; } /** diff --git a/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts index b812568d1be4..0a028b7e356e 100644 --- a/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts +++ b/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -23,6 +23,7 @@ import { import { SavedObjectsPermissionControlContract } from '../../saved_objects/permission_control/client'; import { WORKSPACE_TYPE } from '../constants'; import { PermissionMode } from '../../../utils'; +import { ACL } from '../../saved_objects/permission_control/acl'; // Can't throw unauthorized for now, the page will be refreshed if unauthorized const generateWorkspacePermissionError = () => @@ -112,6 +113,16 @@ export class WorkspaceSavedObjectsClientWrapper { } } + /** + * check if the type include workspace + * Workspace permission check is totally different from object permission check. + * @param type + * @returns + */ + private isRelatedToWorkspace(type: string | string[]): boolean { + return type === WORKSPACE_TYPE || (Array.isArray(type) && type.includes(WORKSPACE_TYPE)); + } + public wrapperFactory: SavedObjectsClientWrapperFactory = (wrapperOptions) => { const deleteWithWorkspacePermissionControl = async ( type: string, @@ -187,26 +198,45 @@ export class WorkspaceSavedObjectsClientWrapper { const findWithWorkspacePermissionControl = async ( options: SavedObjectsFindOptions ) => { - if (options.workspaces) { - options.workspaces = options.workspaces.filter( - async (workspaceId) => - await this.permissionControl.validate( - wrapperOptions.request, - { - type: WORKSPACE_TYPE, - id: workspaceId, - }, - [PermissionMode.Read] - ) + const principals = this.permissionControl.getPrincipalsFromRequest(wrapperOptions.request); + + if (this.isRelatedToWorkspace(options.type)) { + const queryDSLForQueryingWorkspaces = ACL.genereateGetPermittedSavedObjectsQueryDSL( + [PermissionMode.LibraryRead, PermissionMode.LibraryWrite, PermissionMode.Management], + principals, + WORKSPACE_TYPE ); + options.queryDSL = queryDSLForQueryingWorkspaces; } else { - options.workspaces = [ - 'public', - ...(await this.permissionControl.getPermittedWorkspaceIds(wrapperOptions.request, [ - PermissionMode.Read, - ])), - ]; + const permittedWorkspaceIds = await this.permissionControl.getPermittedWorkspaceIds( + wrapperOptions.request, + [PermissionMode.LibraryRead, PermissionMode.LibraryWrite, PermissionMode.Management] + ); + if (options.workspaces) { + const isEveryWorkspaceIsPermitted = options.workspaces.every((item) => + // TODO modify this line to use permittedWorkspaceIds if public workspace is also a workspace + ['public', ...(permittedWorkspaceIds || [])]?.includes(item) + ); + if (!isEveryWorkspaceIsPermitted) { + throw generateWorkspacePermissionError(); + } + } else { + const queryDSL = ACL.genereateGetPermittedSavedObjectsQueryDSL( + [ + PermissionMode.LibraryRead, + PermissionMode.LibraryWrite, + PermissionMode.Management, + PermissionMode.Read, + PermissionMode.Write, + ], + principals, + options.type + ); + options.workspaces = permittedWorkspaceIds; + options.queryDSL = queryDSL; + } } + return await wrapperOptions.client.find(options); }; diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index 47faffb0b922..fccce5f72947 100644 --- a/src/core/types/saved_objects.ts +++ b/src/core/types/saved_objects.ts @@ -9,6 +9,7 @@ * GitHub history for details. */ +import { Permissions } from '../server/saved_objects/permission_control/acl'; /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with @@ -114,6 +115,7 @@ export interface SavedObject { */ originId?: string; workspaces?: string[]; + permissions?: Permissions; } export interface SavedObjectError { From 0a6e3687f66cc774c6be2959d1ac6d114eceb8c4 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Tue, 8 Aug 2023 10:32:56 +0800 Subject: [PATCH 076/174] feat: add permission control section to workspace form (#75) * feat: add permission control section to workspace form Signed-off-by: Lin Wang * refactor: update naming for convert acl Signed-off-by: Lin Wang * feat: update read / write to library_read / library_write Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang --- src/core/public/index.ts | 1 + src/core/public/workspace/index.ts | 7 +- .../public/workspace/workspaces_client.ts | 12 +- src/core/server/workspaces/routes/index.ts | 98 +++++- src/core/server/workspaces/types.ts | 11 + .../workspace_creator/workspace_form.tsx | 81 ++++- .../workspace_permission_setting_panel.tsx | 303 ++++++++++++++++++ .../workspace_updater/workspace_updater.tsx | 1 - 8 files changed, 501 insertions(+), 13 deletions(-) create mode 100644 src/plugins/workspace/public/components/workspace_creator/workspace_permission_setting_panel.tsx diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 5b1978a4c129..ddf0479e4049 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -354,6 +354,7 @@ export { WorkspacesService, WorkspaceAttribute, WorkspaceFindOptions, + WorkspacePermissionMode, } from './workspace'; export { getWorkspaceIdFromUrl, WORKSPACE_TYPE } from './utils'; diff --git a/src/core/public/workspace/index.ts b/src/core/public/workspace/index.ts index 359eee93f664..d523ce3ae6ab 100644 --- a/src/core/public/workspace/index.ts +++ b/src/core/public/workspace/index.ts @@ -4,4 +4,9 @@ */ export { WorkspacesClientContract, WorkspacesClient } from './workspaces_client'; export { WorkspacesStart, WorkspacesService, WorkspacesSetup } from './workspaces_service'; -export type { WorkspaceAttribute, WorkspaceFindOptions } from '../../server/types'; +export type { + WorkspaceAttribute, + WorkspaceFindOptions, + WorkspaceRoutePermissionItem, +} from '../../server/types'; +export { PermissionMode as WorkspacePermissionMode } from '../../utils/constants'; diff --git a/src/core/public/workspace/workspaces_client.ts b/src/core/public/workspace/workspaces_client.ts index 7b98c544daa2..92773712ec09 100644 --- a/src/core/public/workspace/workspaces_client.ts +++ b/src/core/public/workspace/workspaces_client.ts @@ -6,7 +6,7 @@ import type { PublicContract } from '@osd/utility-types'; import { BehaviorSubject, combineLatest } from 'rxjs'; import { isEqual } from 'lodash'; import { HttpFetchError, HttpFetchOptions, HttpSetup } from '../http'; -import { WorkspaceAttribute, WorkspaceFindOptions } from '.'; +import { WorkspaceAttribute, WorkspaceFindOptions, WorkspaceRoutePermissionItem } from '.'; import { WORKSPACES_API_BASE_URL, WORKSPACE_ERROR_REASON_MAP } from './consts'; /** @@ -192,7 +192,9 @@ export class WorkspacesClient { * @returns */ public async create( - attributes: Omit + attributes: Omit & { + permissions: WorkspaceRoutePermissionItem[]; + } ): Promise> { const path = this.getPath([]); @@ -277,7 +279,11 @@ export class WorkspacesClient { */ public async update( id: string, - attributes: Partial + attributes: Partial< + Omit & { + permissions: WorkspaceRoutePermissionItem[]; + } + > ): Promise> { const path = this.getPath([id]); const body = { diff --git a/src/core/server/workspaces/routes/index.ts b/src/core/server/workspaces/routes/index.ts index b00da3c3f38c..6a22b6fc310e 100644 --- a/src/core/server/workspaces/routes/index.ts +++ b/src/core/server/workspaces/routes/index.ts @@ -3,12 +3,34 @@ * SPDX-License-Identifier: Apache-2.0 */ import { schema } from '@osd/config-schema'; + +import { PermissionMode } from '../../../utils/constants'; +import { ACL, Permissions } from '../../saved_objects/permission_control/acl'; import { InternalHttpServiceSetup } from '../../http'; import { Logger } from '../../logging'; -import { IWorkspaceDBImpl } from '../types'; +import { IWorkspaceDBImpl, WorkspaceRoutePermissionItem } from '../types'; const WORKSPACES_API_BASE_URL = '/api/workspaces'; +const workspacePermissionMode = schema.oneOf([ + schema.literal(PermissionMode.LibraryRead), + schema.literal(PermissionMode.LibraryWrite), + schema.literal(PermissionMode.Management), +]); + +const workspacePermission = schema.oneOf([ + schema.object({ + type: schema.literal('user'), + userId: schema.string(), + modes: schema.arrayOf(workspacePermissionMode), + }), + schema.object({ + type: schema.literal('group'), + group: schema.string(), + modes: schema.arrayOf(workspacePermissionMode), + }), +]); + const workspaceAttributesSchema = schema.object({ description: schema.maybe(schema.string()), name: schema.string(), @@ -16,8 +38,42 @@ const workspaceAttributesSchema = schema.object({ color: schema.maybe(schema.string()), icon: schema.maybe(schema.string()), defaultVISTheme: schema.maybe(schema.string()), + permissions: schema.oneOf([workspacePermission, schema.arrayOf(workspacePermission)]), }); +const convertToACL = ( + workspacePermissions: WorkspaceRoutePermissionItem | WorkspaceRoutePermissionItem[] +) => { + workspacePermissions = Array.isArray(workspacePermissions) + ? workspacePermissions + : [workspacePermissions]; + + const acl = new ACL(); + + workspacePermissions.forEach((permission) => { + switch (permission.type) { + case 'user': + acl.addPermission(permission.modes, { users: [permission.userId] }); + return; + case 'group': + acl.addPermission(permission.modes, { groups: [permission.group] }); + return; + } + }); + + return acl.getPermissions() || {}; +}; + +const convertFromACL = (permissions: Permissions) => { + const acl = new ACL(permissions); + + return acl.transformPermissions().map(({ name, permissions: modes, type }) => ({ + type: type === 'users' ? 'user' : 'group', + modes, + ...{ [type === 'users' ? 'userId' : 'group']: name }, + })); +}; + export function registerRoutes({ client, logger, @@ -51,7 +107,21 @@ export function registerRoutes({ }, req.body ); - return res.ok({ body: result }); + if (!result.success) { + return res.ok({ body: result }); + } + return res.ok({ + body: { + ...result, + result: { + ...result.result, + workspaces: result.result.workspaces.map((workspace) => ({ + ...workspace, + permissions: convertFromACL(workspace.permissions), + })), + }, + }, + }); }) ); router.get( @@ -73,7 +143,19 @@ export function registerRoutes({ }, id ); - return res.ok({ body: result }); + if (!result.success) { + return res.ok({ body: result }); + } + + return res.ok({ + body: { + ...result, + result: { + ...result.result, + permissions: convertFromACL(result.result.permissions), + }, + }, + }); }) ); router.post( @@ -94,7 +176,10 @@ export function registerRoutes({ request: req, logger, }, - attributes + { + ...attributes, + permissions: convertToACL(attributes.permissions), + } ); return res.ok({ body: result }); }) @@ -122,7 +207,10 @@ export function registerRoutes({ logger, }, id, - attributes + { + ...attributes, + permissions: convertToACL(attributes.permissions), + } ); return res.ok({ body: result }); }) diff --git a/src/core/server/workspaces/types.ts b/src/core/server/workspaces/types.ts index 532a69ab9ce9..17b0776d07e6 100644 --- a/src/core/server/workspaces/types.ts +++ b/src/core/server/workspaces/types.ts @@ -8,6 +8,10 @@ import { RequestHandlerContext, SavedObjectsFindResponse, } from '..'; + +import { Permissions } from '../saved_objects/permission_control/acl'; +import { PermissionMode } from '../../utils/constants'; + import { WorkspacesSetupDeps } from './workspaces_service'; export interface WorkspaceAttribute { @@ -18,6 +22,7 @@ export interface WorkspaceAttribute { color?: string; icon?: string; defaultVISTheme?: string; + permissions: Permissions; } export interface WorkspaceFindOptions { @@ -70,3 +75,9 @@ export type IResponse = success: false; error?: string; }; + +export type WorkspaceRoutePermissionItem = { + modes: Array< + PermissionMode.LibraryRead | PermissionMode.LibraryWrite | PermissionMode.Management + >; +} & ({ type: 'user'; userId: string } | { type: 'group'; group: string }); diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx index ec1df52bdc5a..e31252faed44 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx @@ -27,6 +27,7 @@ import { EuiComboBox, EuiComboBoxProps, } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; import { App, @@ -43,6 +44,10 @@ import { } from '../utils/feature'; import { WorkspaceIconSelector } from './workspace_icon_selector'; +import { + WorkspacePermissionSetting, + WorkspacePermissionSettingPanel, +} from './workspace_permission_setting_panel'; interface WorkspaceFeature extends Pick { id: string; @@ -61,14 +66,24 @@ export interface WorkspaceFormData { color?: string; icon?: string; defaultVISTheme?: string; + permissions: WorkspacePermissionSetting[]; } -type WorkspaceFormErrors = { [key in keyof WorkspaceFormData]?: string }; +type WorkspaceFormErrors = Omit<{ [key in keyof WorkspaceFormData]?: string }, 'permissions'> & { + permissions?: string[]; +}; const isWorkspaceFeatureGroup = ( featureOrGroup: WorkspaceFeature | WorkspaceFeatureGroup ): featureOrGroup is WorkspaceFeatureGroup => 'features' in featureOrGroup; +const isValidWorkspacePermissionSetting = ( + setting: Partial +): setting is WorkspacePermissionSetting => + !!setting.modes && + setting.modes.length > 0 && + ((setting.type === 'user' && !!setting.userId) || (setting.type === 'group' && !!setting.group)); + const workspaceHtmlIdGenerator = htmlIdGenerator(); const defaultVISThemeOptions = [{ label: 'Categorical', value: 'categorical' }]; @@ -95,6 +110,14 @@ export const WorkspaceForm = ({ const [defaultVISTheme, setDefaultVISTheme] = useState(defaultValues?.defaultVISTheme); const [selectedFeatureIds, setSelectedFeatureIds] = useState(defaultValues?.features || []); + const [permissionSettings, setPermissionSettings] = useState< + Array> + >( + defaultValues?.permissions && defaultValues.permissions.length > 0 + ? defaultValues.permissions + : [{}] + ); + const [formErrors, setFormErrors] = useState({}); const formIdRef = useRef(); const getFormData = () => ({ @@ -104,6 +127,7 @@ export const WorkspaceForm = ({ color, icon, defaultVISTheme, + permissions: permissionSettings, }); const getFormDataRef = useRef(getFormData); getFormDataRef.current = getFormData; @@ -237,11 +261,51 @@ export const WorkspaceForm = ({ e.preventDefault(); const formData = getFormDataRef.current(); if (!formData.name) { - setFormErrors({ name: "Name can't be empty." }); + setFormErrors({ + name: i18n.translate('workspace.form.name.empty', { + defaultMessage: "Name can't be empty.", + }), + }); + return; + } + const permissionErrors: string[] = new Array(formData.permissions.length); + for (let i = 0; i < formData.permissions.length; i++) { + const permission = formData.permissions[i]; + if (isValidWorkspacePermissionSetting(permission)) { + continue; + } + if (!permission.type) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.type', { + defaultMessage: 'Invalid type', + }); + continue; + } + if (!permission.modes || permission.modes.length === 0) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.modes', { + defaultMessage: 'Invalid permission modes', + }); + continue; + } + if (permission.type === 'user' && !permission.userId) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.userId', { + defaultMessage: 'Invalid userId', + }); + continue; + } + if (permission.type === 'group' && !permission.group) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.group', { + defaultMessage: 'Invalid user group', + }); + continue; + } + } + if (permissionErrors.some((error) => !!error)) { + setFormErrors({ permissions: permissionErrors }); return; } + const permissions = formData.permissions.filter(isValidWorkspacePermissionSetting); setFormErrors({}); - onSubmit?.({ ...formData, name: formData.name }); + onSubmit?.({ ...formData, name: formData.name, permissions }); }, [onSubmit] ); @@ -367,6 +431,17 @@ export const WorkspaceForm = ({
    + + +

    Members & permissions

    +
    + +
    + {opType === WORKSPACE_OP_TYPE_CREATE && ( diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_permission_setting_panel.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_permission_setting_panel.tsx new file mode 100644 index 000000000000..99d3faeecca8 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_permission_setting_panel.tsx @@ -0,0 +1,303 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useMemo, useRef } from 'react'; +import { + EuiDescribedFormGroup, + EuiFlexGroup, + EuiSuperSelect, + EuiComboBox, + EuiFlexItem, + EuiButton, + EuiButtonIcon, + EuiButtonGroup, + EuiFormRow, +} from '@elastic/eui'; + +import { WorkspacePermissionMode } from '../../../../../core/public'; + +export type WorkspacePermissionSetting = ( + | { type: 'user'; userId: string } + | { type: 'group'; group: string } +) & { + type: 'user' | 'group'; + userId?: string; + group?: string; + modes: Array< + | WorkspacePermissionMode.LibraryRead + | WorkspacePermissionMode.LibraryWrite + | WorkspacePermissionMode.Management + >; +}; + +const permissionModeOptions = [ + { + id: WorkspacePermissionMode.LibraryRead, + label: 'View', + iconType: 'eye', + }, + { + id: WorkspacePermissionMode.LibraryWrite, + label: 'Edit', + iconType: 'pencil', + }, + { + id: WorkspacePermissionMode.Management, + label: 'Management', + iconType: 'visTimelion', + }, +]; + +const permissionTypeOptions = [ + { value: 'user' as const, inputDisplay: 'User' }, + { value: 'group' as const, inputDisplay: 'Group' }, +]; + +const isWorkspacePermissionMode = ( + test: string +): test is + | WorkspacePermissionMode.LibraryRead + | WorkspacePermissionMode.LibraryWrite + | WorkspacePermissionMode.Management => + test === WorkspacePermissionMode.LibraryRead || + test === WorkspacePermissionMode.LibraryWrite || + test === WorkspacePermissionMode.Management; + +interface WorkspacePermissionSettingInputProps { + index: number; + deletable: boolean; + type?: 'user' | 'group'; + userId?: string; + group?: string; + modes?: Array< + | WorkspacePermissionMode.LibraryRead + | WorkspacePermissionMode.LibraryWrite + | WorkspacePermissionMode.Management + >; + onTypeChange: (type: 'user' | 'group', index: number) => void; + onGroupOrUserIdChange: ( + groupOrUserId: + | { type: 'user'; userId: string } + | { type: 'group'; group: string } + | { type: 'user' | 'group' }, + index: number + ) => void; + onPermissionModesChange: ( + WorkspacePermissionMode: Array< + | WorkspacePermissionMode.LibraryRead + | WorkspacePermissionMode.LibraryWrite + | WorkspacePermissionMode.Management + >, + index: number + ) => void; + onDelete: (index: number) => void; +} + +const WorkspacePermissionSettingInput = ({ + index, + deletable, + type, + userId, + group, + modes, + onDelete, + onTypeChange, + onGroupOrUserIdChange, + onPermissionModesChange, +}: WorkspacePermissionSettingInputProps) => { + const groupOrUserIdSelectedOptions = useMemo( + () => (group || userId ? [{ label: (group || userId) as string }] : []), + [group, userId] + ); + const permissionModesIdToSelectMap = useMemo( + () => ({ + [WorkspacePermissionMode.LibraryRead]: !!modes?.includes(WorkspacePermissionMode.LibraryRead), + [WorkspacePermissionMode.LibraryWrite]: !!modes?.includes( + WorkspacePermissionMode.LibraryWrite + ), + [WorkspacePermissionMode.Management]: !!modes?.includes(WorkspacePermissionMode.Management), + }), + [modes] + ); + + const handleTypeChange = useCallback( + (newType: 'user' | 'group') => { + onTypeChange(newType, index); + }, + [onTypeChange, index] + ); + + const handleGroupOrUserIdCreate = useCallback( + (groupOrUserId) => { + if (!type) { + return; + } + onGroupOrUserIdChange( + type === 'group' ? { type, group: groupOrUserId } : { type, userId: groupOrUserId }, + index + ); + }, + [index, type, onGroupOrUserIdChange] + ); + + const handleGroupOrUserIdChange = useCallback( + (options) => { + if (!type) { + return; + } + if (options.length === 0) { + onGroupOrUserIdChange({ type }, index); + } + }, + [index, type, onGroupOrUserIdChange] + ); + + const handlePermissionStateChange = useCallback( + (id: string) => { + if (isWorkspacePermissionMode(id)) { + onPermissionModesChange( + modes?.includes(id) ? modes.filter((value) => value !== id) : [...(modes ?? []), id], + index + ); + } + }, + [index, modes, onPermissionModesChange] + ); + + const handleDelete = useCallback(() => { + onDelete(index); + }, [index, onDelete]); + + return ( + + + + + + + + + + + + + + + ); +}; + +interface WorkspacePermissionSettingPanelProps { + errors?: string[]; + value?: Array>; + onChange?: (value: Array>) => void; +} + +export const WorkspacePermissionSettingPanel = ({ + errors, + value, + onChange, +}: WorkspacePermissionSettingPanelProps) => { + const valueRef = useRef(value); + valueRef.current = value; + + const handleAddNewOne = useCallback(() => { + onChange?.([...(valueRef.current ?? []), {}]); + }, [onChange]); + + const handleDelete = useCallback( + (index: number) => { + onChange?.((valueRef.current ?? []).filter((_item, itemIndex) => itemIndex !== index)); + }, + [onChange] + ); + + const handlePermissionModesChange = useCallback< + WorkspacePermissionSettingInputProps['onPermissionModesChange'] + >( + (modes, index) => { + onChange?.( + (valueRef.current ?? []).map((item, itemIndex) => + index === itemIndex ? { ...item, modes } : item + ) + ); + }, + [onChange] + ); + + const handleTypeChange = useCallback( + (type, index) => { + onChange?.( + (valueRef.current ?? []).map((item, itemIndex) => + index === itemIndex ? { ...item, type } : item + ) + ); + }, + [onChange] + ); + + const handleGroupOrUserIdChange = useCallback< + WorkspacePermissionSettingInputProps['onGroupOrUserIdChange'] + >( + (userOrGroupIdWithType, index) => { + onChange?.( + (valueRef.current ?? []).map((item, itemIndex) => + index === itemIndex + ? { ...userOrGroupIdWithType, ...(item.modes ? { modes: item.modes } : {}) } + : item + ) + ); + }, + [onChange] + ); + + return ( + Users, User Groups & Groups}> + {value?.map((item, index) => ( + + + + + + ))} + + Add new + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx index a3f0ae1914f8..a4e519b045e0 100644 --- a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx @@ -17,7 +17,6 @@ import { i18n } from '@osd/i18n'; import { of } from 'rxjs'; import { WorkspaceAttribute } from 'opensearch-dashboards/public'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; -import { PATHS } from '../../../common/constants'; import { WorkspaceForm, WorkspaceFormData } from '../workspace_creator/workspace_form'; import { WORKSPACE_OVERVIEW_APP_ID, WORKSPACE_OP_TYPE_UPDATE } from '../../../common/constants'; import { ApplicationStart } from '../../../../../core/public'; From 6910346106087ceed39177f98577472eface7e1f Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Wed, 9 Aug 2023 11:31:44 +0800 Subject: [PATCH 077/174] write permisions outside instead of workspace attributes (#79) Signed-off-by: Lin Wang --- .../public/workspace/workspaces_client.ts | 4 +-- .../saved_objects/serialization/serializer.ts | 5 +++- .../saved_objects/serialization/types.ts | 1 + .../saved_objects/service/lib/repository.ts | 8 ++++-- .../service/saved_objects_client.ts | 6 +++++ src/core/server/workspaces/index.ts | 6 ++++- src/core/server/workspaces/types.ts | 14 +++++++--- .../server/workspaces/workspaces_client.ts | 26 ++++++++++++------- 8 files changed, 51 insertions(+), 19 deletions(-) diff --git a/src/core/public/workspace/workspaces_client.ts b/src/core/public/workspace/workspaces_client.ts index 92773712ec09..46ca139d9551 100644 --- a/src/core/public/workspace/workspaces_client.ts +++ b/src/core/public/workspace/workspaces_client.ts @@ -192,7 +192,7 @@ export class WorkspacesClient { * @returns */ public async create( - attributes: Omit & { + attributes: Omit & { permissions: WorkspaceRoutePermissionItem[]; } ): Promise> { @@ -280,7 +280,7 @@ export class WorkspacesClient { public async update( id: string, attributes: Partial< - Omit & { + WorkspaceAttribute & { permissions: WorkspaceRoutePermissionItem[]; } > diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index 5c3e22ac646a..492379068cdb 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 @@ -92,6 +92,7 @@ export class SavedObjectsSerializer { ...(_source.updated_at && { updated_at: _source.updated_at }), ...(version && { version }), ...(workspaces && { workspaces }), + ...(permissions && { permissions }), }; } @@ -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 360cdc6b3a62..fee9f503dceb 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -73,6 +73,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.ts b/src/core/server/saved_objects/service/lib/repository.ts index 7b033b8e0211..0fd6efd2675f 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -249,6 +249,7 @@ export class SavedObjectsRepository { initialNamespaces, version, workspaces, + permissions, } = options; const namespace = normalizeNamespace(options.namespace); @@ -310,6 +311,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); @@ -1012,7 +1014,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)) { @@ -1028,6 +1030,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 || [], @@ -1056,7 +1059,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; @@ -1070,6 +1073,7 @@ export class SavedObjectsRepository { [type]: attributes, updated_at: time, ...(Array.isArray(references) && { references }), + ...(permissions && { permissions }), }; const { body, statusCode } = await this.client.update( 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 119aea8b2743..ff37d3e45537 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -28,6 +28,8 @@ * under the License. */ +import { Permissions } from '../permission_control/acl'; + import { ISavedObjectsRepository } from './lib'; import { SavedObject, @@ -68,6 +70,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; } /** @@ -182,6 +186,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/server/workspaces/index.ts b/src/core/server/workspaces/index.ts index 6a312f00484d..cbebde2237f7 100644 --- a/src/core/server/workspaces/index.ts +++ b/src/core/server/workspaces/index.ts @@ -10,7 +10,11 @@ export { InternalWorkspacesServiceStart, } from './workspaces_service'; -export { WorkspaceAttribute, WorkspaceFindOptions } from './types'; +export { + WorkspaceAttribute, + WorkspaceFindOptions, + WorkspaceAttributeWithPermission, +} from './types'; export { workspacesValidator, formatWorkspaces } from './utils'; export { WORKSPACE_TYPE } from './constants'; diff --git a/src/core/server/workspaces/types.ts b/src/core/server/workspaces/types.ts index 17b0776d07e6..1a1ae8583639 100644 --- a/src/core/server/workspaces/types.ts +++ b/src/core/server/workspaces/types.ts @@ -22,6 +22,9 @@ export interface WorkspaceAttribute { color?: string; icon?: string; defaultVISTheme?: string; +} + +export interface WorkspaceAttributeWithPermission extends WorkspaceAttribute { permissions: Permissions; } @@ -44,7 +47,7 @@ export interface IWorkspaceDBImpl { setup(dep: WorkspacesSetupDeps): Promise>; create( requestDetail: IRequestDetail, - payload: Omit + payload: Omit ): Promise>; list( requestDetail: IRequestDetail, @@ -52,15 +55,18 @@ export interface IWorkspaceDBImpl { ): Promise< IResponse< { - workspaces: WorkspaceAttribute[]; + workspaces: WorkspaceAttributeWithPermission[]; } & Pick > >; - get(requestDetail: IRequestDetail, id: string): Promise>; + get( + requestDetail: IRequestDetail, + id: string + ): Promise>; update( requestDetail: IRequestDetail, id: string, - payload: Omit + payload: Omit ): Promise>; delete(requestDetail: IRequestDetail, id: string): Promise>; destroy(): Promise>; diff --git a/src/core/server/workspaces/workspaces_client.ts b/src/core/server/workspaces/workspaces_client.ts index 7d4fdc858143..698c80c1488a 100644 --- a/src/core/server/workspaces/workspaces_client.ts +++ b/src/core/server/workspaces/workspaces_client.ts @@ -9,6 +9,7 @@ import { WorkspaceFindOptions, IResponse, IRequestDetail, + WorkspaceAttributeWithPermission, } from './types'; import { WorkspacesSetupDeps } from './workspaces_service'; import { workspace } from './saved_objects'; @@ -24,11 +25,12 @@ export class WorkspacesClientWithSavedObject implements IWorkspaceDBImpl { ): SavedObjectsClientContract { return requestDetail.context.core.savedObjects.client; } - private getFlatternedResultWithSavedObject( + private getFlattenedResultWithSavedObject( savedObject: SavedObject - ): WorkspaceAttribute { + ): WorkspaceAttributeWithPermission { return { ...savedObject.attributes, + permissions: savedObject.permissions || {}, id: savedObject.id, }; } @@ -44,12 +46,15 @@ export class WorkspacesClientWithSavedObject implements IWorkspaceDBImpl { } public async create( requestDetail: IRequestDetail, - payload: Omit + payload: Omit ): ReturnType { try { + const { permissions, ...attributes } = payload; const result = await this.getSavedObjectClientsFromRequestDetail(requestDetail).create< Omit - >(WORKSPACE_TYPE, payload); + >(WORKSPACE_TYPE, attributes, { + permissions, + }); return { success: true, result: { @@ -81,7 +86,7 @@ export class WorkspacesClientWithSavedObject implements IWorkspaceDBImpl { success: true, result: { ...others, - workspaces: savedObjects.map((item) => this.getFlatternedResultWithSavedObject(item)), + workspaces: savedObjects.map((item) => this.getFlattenedResultWithSavedObject(item)), }, }; } catch (e: unknown) { @@ -94,14 +99,14 @@ export class WorkspacesClientWithSavedObject implements IWorkspaceDBImpl { public async get( requestDetail: IRequestDetail, id: string - ): Promise> { + ): Promise> { try { const result = await this.getSavedObjectClientsFromRequestDetail(requestDetail).get< WorkspaceAttribute >(WORKSPACE_TYPE, id); return { success: true, - result: this.getFlatternedResultWithSavedObject(result), + result: this.getFlattenedResultWithSavedObject(result), }; } catch (e: unknown) { return { @@ -113,12 +118,15 @@ export class WorkspacesClientWithSavedObject implements IWorkspaceDBImpl { public async update( requestDetail: IRequestDetail, id: string, - payload: Omit + payload: Omit ): Promise> { + const { permissions, ...attributes } = payload; try { await this.getSavedObjectClientsFromRequestDetail(requestDetail).update< Omit - >(WORKSPACE_TYPE, id, payload); + >(WORKSPACE_TYPE, id, attributes, { + permissions, + }); return { success: true, result: true, From e8dcf4913b4dc04003a263a85109efd442f795ea Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Wed, 9 Aug 2023 14:42:39 +0800 Subject: [PATCH 078/174] Refactor UI setting register (#77) refactor: move workspace uiSettings registration to workspace service --------- Signed-off-by: Yulong Ruan --- .../public/workspace/workspaces_service.ts | 7 +----- src/core/server/server.ts | 1 + .../server/ui_settings/settings/index.test.ts | 2 -- src/core/server/ui_settings/settings/index.ts | 2 -- .../server/ui_settings/settings/workspace.ts | 25 ------------------- src/core/server/workspaces/ui_settings.ts | 24 ++++++++++++++++++ .../server/workspaces/workspaces_service.ts | 5 ++++ src/plugins/workspace/public/plugin.ts | 1 + 8 files changed, 32 insertions(+), 35 deletions(-) delete mode 100644 src/core/server/ui_settings/settings/workspace.ts create mode 100644 src/core/server/workspaces/ui_settings.ts diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts index cb82f3c44406..34520a46787b 100644 --- a/src/core/public/workspace/workspaces_service.ts +++ b/src/core/public/workspace/workspaces_service.ts @@ -27,14 +27,9 @@ export class WorkspacesService implements CoreService this.formatUrlWithWorkspaceId(url, id), diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 47a72cfe6cbb..a3a7678ae21e 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -178,6 +178,7 @@ export class Server { await this.workspaces.setup({ http: httpSetup, savedObject: savedObjectsSetup, + uiSettings: uiSettingsSetup, }); const statusSetup = await this.status.setup({ diff --git a/src/core/server/ui_settings/settings/index.test.ts b/src/core/server/ui_settings/settings/index.test.ts index 03564ce7e9b2..f71f852eb3ce 100644 --- a/src/core/server/ui_settings/settings/index.test.ts +++ b/src/core/server/ui_settings/settings/index.test.ts @@ -36,7 +36,6 @@ import { getNotificationsSettings } from './notifications'; import { getThemeSettings } from './theme'; import { getCoreSettings } from './index'; import { getStateSettings } from './state'; -import { getWorkspaceSettings } from './workspace'; describe('getCoreSettings', () => { it('should not have setting overlaps', () => { @@ -49,7 +48,6 @@ describe('getCoreSettings', () => { getNotificationsSettings(), getThemeSettings(), getStateSettings(), - getWorkspaceSettings(), ].reduce((sum, settings) => sum + Object.keys(settings).length, 0); expect(coreSettingsLength).toBe(summedLength); diff --git a/src/core/server/ui_settings/settings/index.ts b/src/core/server/ui_settings/settings/index.ts index cea335117af8..b284744fc818 100644 --- a/src/core/server/ui_settings/settings/index.ts +++ b/src/core/server/ui_settings/settings/index.ts @@ -36,7 +36,6 @@ import { getNavigationSettings } from './navigation'; import { getNotificationsSettings } from './notifications'; import { getThemeSettings } from './theme'; import { getStateSettings } from './state'; -import { getWorkspaceSettings } from './workspace'; export const getCoreSettings = (): Record => { return { @@ -47,6 +46,5 @@ export const getCoreSettings = (): Record => { ...getNotificationsSettings(), ...getThemeSettings(), ...getStateSettings(), - ...getWorkspaceSettings(), }; }; diff --git a/src/core/server/ui_settings/settings/workspace.ts b/src/core/server/ui_settings/settings/workspace.ts deleted file mode 100644 index 3eb9e33b681c..000000000000 --- a/src/core/server/ui_settings/settings/workspace.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { schema } from '@osd/config-schema'; -import { i18n } from '@osd/i18n'; -import { UiSettingsParams } from '../../../types'; - -export const getWorkspaceSettings = (): Record => { - return { - 'workspace:enabled': { - name: i18n.translate('core.ui_settings.params.workspace.enableWorkspaceTitle', { - defaultMessage: 'Enable Workspace', - }), - value: false, - requiresPageReload: true, - description: i18n.translate('core.ui_settings.params.workspace.enableWorkspaceTitle', { - defaultMessage: 'Enable or disable OpenSearch Dashboards Workspace', - }), - category: ['workspace'], - schema: schema.boolean(), - }, - }; -}; diff --git a/src/core/server/workspaces/ui_settings.ts b/src/core/server/workspaces/ui_settings.ts new file mode 100644 index 000000000000..da9c9feb64d8 --- /dev/null +++ b/src/core/server/workspaces/ui_settings.ts @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { schema } from '@osd/config-schema'; + +import { UiSettingsParams } from 'opensearch-dashboards/server'; + +export const uiSettings: Record = { + 'workspace:enabled': { + name: i18n.translate('core.ui_settings.params.workspace.enableWorkspaceTitle', { + defaultMessage: 'Enable Workspace', + }), + value: false, + requiresPageReload: true, + description: i18n.translate('core.ui_settings.params.workspace.enableWorkspaceTitle', { + defaultMessage: 'Enable or disable OpenSearch Dashboards Workspace', + }), + category: ['workspace'], + schema: schema.boolean(), + }, +}; diff --git a/src/core/server/workspaces/workspaces_service.ts b/src/core/server/workspaces/workspaces_service.ts index f86c0e7ca4d3..ba79ead6097d 100644 --- a/src/core/server/workspaces/workspaces_service.ts +++ b/src/core/server/workspaces/workspaces_service.ts @@ -12,6 +12,8 @@ import { InternalSavedObjectsServiceSetup } from '../saved_objects'; import { IWorkspaceDBImpl } from './types'; import { WorkspacesClientWithSavedObject } from './workspaces_client'; import { WorkspaceSavedObjectsClientWrapper } from './saved_objects'; +import { InternalUiSettingsServiceSetup } from '../ui_settings'; +import { uiSettings } from './ui_settings'; export interface WorkspacesServiceSetup { client: IWorkspaceDBImpl; @@ -24,6 +26,7 @@ export interface WorkspacesServiceStart { export interface WorkspacesSetupDeps { http: InternalHttpServiceSetup; savedObject: InternalSavedObjectsServiceSetup; + uiSettings: InternalUiSettingsServiceSetup; } export type InternalWorkspacesServiceSetup = WorkspacesServiceSetup; @@ -58,6 +61,8 @@ export class WorkspacesService public async setup(setupDeps: WorkspacesSetupDeps): Promise { this.logger.debug('Setting up Workspaces service'); + setupDeps.uiSettings.register(uiSettings); + this.client = new WorkspacesClientWithSavedObject(setupDeps); await this.client.setup(setupDeps); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 0e21fca9d112..2d6da177165e 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -68,6 +68,7 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep } this.coreSetup = core; + core.workspaces.client.init(); core.workspaces.setFormatUrlWithWorkspaceId((url, id) => this.getPatchedUrl(url, id)); /** * Retrieve workspace id from url From d04380b4b26ed7d991103d5d27f096727fcafea5 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Wed, 9 Aug 2023 14:46:16 +0800 Subject: [PATCH 079/174] refactor: simplified ACL transformPermission function (#78) refactor: simplified ACL transformPermission function --------- Signed-off-by: Yulong Ruan --- .../permission_control/acl.test.ts | 13 ++- .../saved_objects/permission_control/acl.ts | 88 ++++++++----------- .../permission_control/client.ts | 2 +- src/core/server/workspaces/routes/index.ts | 2 +- 4 files changed, 51 insertions(+), 54 deletions(-) diff --git a/src/core/server/saved_objects/permission_control/acl.test.ts b/src/core/server/saved_objects/permission_control/acl.test.ts index 95da5ca4d33b..cec90bbec891 100644 --- a/src/core/server/saved_objects/permission_control/acl.test.ts +++ b/src/core/server/saved_objects/permission_control/acl.test.ts @@ -107,8 +107,17 @@ describe('SavedObjectTypeRegistry', () => { write: principals, }; acl = new ACL(permissions); - const result = acl.transformPermissions(); - expect(result?.length).toEqual(3); + 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', () => { diff --git a/src/core/server/saved_objects/permission_control/acl.ts b/src/core/server/saved_objects/permission_control/acl.ts index 2bac6823634a..1d34b9f6c401 100644 --- a/src/core/server/saved_objects/permission_control/acl.ts +++ b/src/core/server/saved_objects/permission_control/acl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { PrincipalType } from '../../../../core/utils/constants'; +import { PrincipalType } from '../../../utils/constants'; export interface Principals { users?: string[]; @@ -111,7 +111,7 @@ export class ACL { return this; } - // permissions object build funciton, remove specific permission of specific principal from the object + // permissions object build function, remove specific permission of specific principal from the object public removePermission(permissionTypes: string[], principals: Principals) { if (!permissionTypes || !principals) { return this; @@ -134,62 +134,50 @@ export class ACL { return this; } - /* - transfrom 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 transformPermissions(): TransformedPermission[] { + /** + * 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; } - const permissionMapResult: Record> = {}; - const principalTypes = [PrincipalType.Users, PrincipalType.Groups]; for (const permissionType in this.permissions) { - if (!!permissionType) { - const value = this.permissions[permissionType]; - principalTypes.forEach((principalType) => { - if (value?.[principalType]) { - for (const principal of value[principalType]!) { - if (!permissionMapResult[principalType]) { - permissionMapResult[principalType] = {}; - } - if (!permissionMapResult[principalType][principal]) { - permissionMapResult[principalType][principal] = []; - } - permissionMapResult[principalType][principal] = [ - ...permissionMapResult[principalType][principal]!, - permissionType, - ]; - } + 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] }); } }); } } - Object.entries(permissionMapResult).forEach(([type, permissionMap]) => { - Object.entries(permissionMap).forEach(([principal, permissions]) => { - result.push({ - type, - name: principal, - permissions, - }); - }); - }); - return result; } @@ -203,9 +191,9 @@ export class ACL { return this.permissions; } - /* - generate query DSL by the specific conditions, used for fetching saved objects from the saved objects index - */ + /** + * generate query DSL by the specific conditions, used for fetching saved objects from the saved objects index + */ public static genereateGetPermittedSavedObjectsQueryDSL( permissionTypes: string[], principals: Principals, diff --git a/src/core/server/saved_objects/permission_control/client.ts b/src/core/server/saved_objects/permission_control/client.ts index 0e34a4bc8cb6..e00274ce1a0c 100644 --- a/src/core/server/saved_objects/permission_control/client.ts +++ b/src/core/server/saved_objects/permission_control/client.ts @@ -116,7 +116,7 @@ export class SavedObjectsPermissionControl { return detailedSavedObjects.reduce((total, current) => { return { ...total, - [current.id]: new ACL(current.permissions).transformPermissions(), + [current.id]: new ACL(current.permissions).toFlatList(), }; }, {}); } diff --git a/src/core/server/workspaces/routes/index.ts b/src/core/server/workspaces/routes/index.ts index 6a22b6fc310e..67c3fcfd77a0 100644 --- a/src/core/server/workspaces/routes/index.ts +++ b/src/core/server/workspaces/routes/index.ts @@ -67,7 +67,7 @@ const convertToACL = ( const convertFromACL = (permissions: Permissions) => { const acl = new ACL(permissions); - return acl.transformPermissions().map(({ name, permissions: modes, type }) => ({ + return acl.toFlatList().map(({ name, permissions: modes, type }) => ({ type: type === 'users' ? 'user' : 'group', modes, ...{ [type === 'users' ? 'userId' : 'group']: name }, From bef80c2ec753ce5641ddba354a4c68e9f9a2886d Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Wed, 9 Aug 2023 18:16:17 +0800 Subject: [PATCH 080/174] Change public objects logic & Search with ACL control (#71) * feat: update public workspace Signed-off-by: SuZhou-Joe * refractor: change public objects logic Signed-off-by: SuZhou-Joe * feat: create public workspace when service start Signed-off-by: SuZhou-Joe * feat: some modify Signed-off-by: SuZhou-Joe * feature: some optimize and create workspace when ui settings is open Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * temp: submit Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update query dsl Signed-off-by: SuZhou-Joe * feat: use same constants Signed-off-by: SuZhou-Joe * feat: make it run Signed-off-by: SuZhou-Joe * feat: remove dashboard admin Signed-off-by: SuZhou-Joe * feat: modify query DSL Signed-off-by: SuZhou-Joe * feat: modify query DSL Signed-off-by: SuZhou-Joe * feat: modify query DSL Signed-off-by: SuZhou-Joe * feat: list principals route Signed-off-by: SuZhou-Joe * feat: optimize query DSL Signed-off-by: SuZhou-Joe * feat: change public logic Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: remove init Signed-off-by: SuZhou-Joe * feat: add judgement when workspaceList is empty Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe --- .../saved_objects/saved_objects_client.ts | 42 +++++++--- src/core/public/utils/index.ts | 6 +- .../public/workspace/workspaces_client.ts | 34 ++++---- .../permission_control/routes/index.ts | 2 + .../routes/{principles.ts => principals.ts} | 0 src/core/server/saved_objects/routes/share.ts | 4 +- .../saved_objects/service/lib/repository.ts | 6 +- .../service/lib/search_dsl/query_params.ts | 8 -- .../service/saved_objects_client.ts | 1 - src/core/server/server.ts | 4 +- src/core/server/workspaces/constants.ts | 1 - .../workspace_saved_objects_client_wrapper.ts | 45 +++++++--- .../server/workspaces/workspaces_service.ts | 84 ++++++++++++++++++- src/core/utils/constants.ts | 6 ++ src/core/utils/index.ts | 8 +- .../objects_table/saved_objects_table.tsx | 5 +- .../components/utils/workspace_column.tsx | 9 -- src/plugins/workspace/public/plugin.ts | 15 +++- 18 files changed, 206 insertions(+), 74 deletions(-) rename src/core/server/saved_objects/permission_control/routes/{principles.ts => principals.ts} (100%) diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 356e555cfc53..b0e3245f74e5 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -42,6 +42,7 @@ import { import { SimpleSavedObject } from './simple_saved_object'; import { HttpFetchOptions, HttpSetup } from '../http'; +import { PUBLIC_WORKSPACE } from '../../utils'; type SavedObjectsFindOptions = Omit< SavedObjectFindOptionsServer, @@ -184,7 +185,11 @@ const getObjectsToFetch = (queue: BatchQueueEntry[]): ObjectTypeAndId[] => { export class SavedObjectsClient { private http: HttpSetup; private batchQueue: BatchQueueEntry[]; - private currentWorkspaceId?: string; + /** + * if currentWorkspaceId is undefined, it means + * we should not carry out workspace info when doing any operation. + */ + private currentWorkspaceId: string | undefined; /** * Throttled processing of get requests into bulk requests at 100ms interval @@ -229,11 +234,11 @@ export class SavedObjectsClient { this.batchQueue = []; } - private async _getCurrentWorkspace(): Promise { - return this.currentWorkspaceId || null; + private _getCurrentWorkspace(): string | undefined { + return this.currentWorkspaceId; } - public async setCurrentWorkspace(workspaceId: string): Promise { + public setCurrentWorkspace(workspaceId: string): boolean { this.currentWorkspaceId = workspaceId; return true; } @@ -259,7 +264,13 @@ export class SavedObjectsClient { const query = { overwrite: options.overwrite, }; - const currentWorkspaceId = await this._getCurrentWorkspace(); + const currentWorkspaceId = this._getCurrentWorkspace(); + let finalWorkspaces; + if (options.hasOwnProperty('workspaces')) { + finalWorkspaces = options.workspaces; + } else if (typeof currentWorkspaceId === 'string') { + finalWorkspaces = [currentWorkspaceId]; + } const createRequest: Promise> = this.savedObjectsFetch(path, { method: 'POST', @@ -268,9 +279,9 @@ export class SavedObjectsClient { attributes, migrationVersion: options.migrationVersion, references: options.references, - ...(options.workspaces || currentWorkspaceId + ...(finalWorkspaces ? { - workspaces: options.workspaces || [currentWorkspaceId], + workspaces: finalWorkspaces, } : {}), }), @@ -366,14 +377,21 @@ export class SavedObjectsClient { queryDSL: 'queryDSL', }; - const workspaces = [ - ...(options.workspaces || [await this._getCurrentWorkspace()]), - 'public', - ].filter((item) => item); + const currentWorkspaceId = this._getCurrentWorkspace(); + let finalWorkspaces; + if (options.hasOwnProperty('workspaces')) { + finalWorkspaces = options.workspaces; + } else if (typeof currentWorkspaceId === 'string') { + finalWorkspaces = Array.from(new Set([PUBLIC_WORKSPACE, currentWorkspaceId])); + } const renamedQuery = renameKeys(renameMap, { ...options, - workspaces, + ...(finalWorkspaces + ? { + workspaces: finalWorkspaces, + } + : {}), }); const query = pick.apply(null, [renamedQuery, ...Object.values(renameMap)]) as Partial< Record diff --git a/src/core/public/utils/index.ts b/src/core/public/utils/index.ts index ad363c53bd42..4f958a60ae66 100644 --- a/src/core/public/utils/index.ts +++ b/src/core/public/utils/index.ts @@ -32,4 +32,8 @@ export { shareWeakReplay } from './share_weak_replay'; export { Sha256 } from './crypto'; export { MountWrapper, mountReactNode } from './mount'; export { getWorkspaceIdFromUrl, WORKSPACE_TYPE } from './workspace'; -export { WORKSPACE_PATH_PREFIX } from '../../utils'; +export { + WORKSPACE_PATH_PREFIX, + PUBLIC_WORKSPACE, + WORKSPACE_FEATURE_FLAG_KEY_IN_UI_SETTINGS, +} from '../../utils'; diff --git a/src/core/public/workspace/workspaces_client.ts b/src/core/public/workspace/workspaces_client.ts index 46ca139d9551..2f8c204bf418 100644 --- a/src/core/public/workspace/workspaces_client.ts +++ b/src/core/public/workspace/workspaces_client.ts @@ -48,25 +48,27 @@ export class WorkspacesClient { combineLatest([this.workspaceList$, this.currentWorkspaceId$]).subscribe( ([workspaceList, currentWorkspaceId]) => { - const currentWorkspace = this.findWorkspace([workspaceList, currentWorkspaceId]); + if (workspaceList.length) { + const currentWorkspace = this.findWorkspace([workspaceList, currentWorkspaceId]); - /** - * Do a simple idempotent verification here - */ - if (!isEqual(currentWorkspace, this.currentWorkspace$.getValue())) { - this.currentWorkspace$.next(currentWorkspace); - } - - if (currentWorkspaceId && !currentWorkspace?.id) { /** - * Current workspace is staled + * Do a simple idempotent verification here */ - this.currentWorkspaceId$.error({ - reason: WORKSPACE_ERROR_REASON_MAP.WORKSPACE_STALED, - }); - this.currentWorkspace$.error({ - reason: WORKSPACE_ERROR_REASON_MAP.WORKSPACE_STALED, - }); + if (!isEqual(currentWorkspace, this.currentWorkspace$.getValue())) { + this.currentWorkspace$.next(currentWorkspace); + } + + if (currentWorkspaceId && !currentWorkspace?.id) { + /** + * Current workspace is staled + */ + this.currentWorkspaceId$.error({ + reason: WORKSPACE_ERROR_REASON_MAP.WORKSPACE_STALED, + }); + this.currentWorkspace$.error({ + reason: WORKSPACE_ERROR_REASON_MAP.WORKSPACE_STALED, + }); + } } } ); diff --git a/src/core/server/saved_objects/permission_control/routes/index.ts b/src/core/server/saved_objects/permission_control/routes/index.ts index edd694b0ada0..405bd22bdbe9 100644 --- a/src/core/server/saved_objects/permission_control/routes/index.ts +++ b/src/core/server/saved_objects/permission_control/routes/index.ts @@ -5,6 +5,7 @@ import { InternalHttpServiceSetup } from '../../../http'; import { SavedObjectsPermissionControlContract } from '../client'; +import { registerListRoute } from './principals'; import { registerValidateRoute } from './validate'; export function registerPermissionCheckRoutes({ @@ -17,4 +18,5 @@ export function registerPermissionCheckRoutes({ const router = http.createRouter('/api/saved_objects_permission_control/'); registerValidateRoute(router, permissionControl); + registerListRoute(router, permissionControl); } diff --git a/src/core/server/saved_objects/permission_control/routes/principles.ts b/src/core/server/saved_objects/permission_control/routes/principals.ts similarity index 100% rename from src/core/server/saved_objects/permission_control/routes/principles.ts rename to src/core/server/saved_objects/permission_control/routes/principals.ts diff --git a/src/core/server/saved_objects/routes/share.ts b/src/core/server/saved_objects/routes/share.ts index 340709017e7b..526ad24893c6 100644 --- a/src/core/server/saved_objects/routes/share.ts +++ b/src/core/server/saved_objects/routes/share.ts @@ -9,7 +9,7 @@ import { exportSavedObjectsToStream } from '../export'; import { validateObjects } from './utils'; import { collectSavedObjects } from '../import/collect_saved_objects'; import { WORKSPACE_TYPE } from '../../workspaces'; -import { GLOBAL_WORKSPACE_ID } from '../../workspaces/constants'; +import { PUBLIC_WORKSPACE } from '../../../utils/constants'; const SHARE_LIMIT = 10000; @@ -73,7 +73,7 @@ export const registerShareRoute = (router: IRouter) => { (obj) => obj.workspaces && obj.workspaces.length > 0 && - !obj.workspaces.includes(GLOBAL_WORKSPACE_ID) + !obj.workspaces.includes(PUBLIC_WORKSPACE) ) .map((obj) => ({ id: obj.id, type: obj.type, workspaces: obj.workspaces })); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 0fd6efd2675f..6adefe16848d 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -87,7 +87,7 @@ import { FIND_DEFAULT_PER_PAGE, SavedObjectsUtils, } from './utils'; -import { GLOBAL_WORKSPACE_ID } from '../../../workspaces/constants'; +import { PUBLIC_WORKSPACE } from '../../../../utils/constants'; // BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. @@ -1299,7 +1299,7 @@ export class SavedObjectsRepository { if ( obj.workspaces && obj.workspaces.length > 0 && - !obj.workspaces.includes(GLOBAL_WORKSPACE_ID) + !obj.workspaces.includes(PUBLIC_WORKSPACE) ) { return intersection(obj.workspaces, options.workspaces).length === 0; } @@ -1352,7 +1352,7 @@ export class SavedObjectsRepository { params: { time, workspaces, - globalWorkspaceId: GLOBAL_WORKSPACE_ID, + globalWorkspaceId: PUBLIC_WORKSPACE, }, }, }, 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 4ddbe992bcfc..d9fbf7199c18 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 @@ -142,14 +142,6 @@ function getClauseForWorkspace(workspace: string) { }; } - if (workspace === 'public') { - return { - bool: { - must_not: [{ exists: { field: 'workspaces' } }], - }, - }; - } - return { bool: { must: [{ term: { workspaces: workspace } }], 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 ff37d3e45537..3a71c28c74ba 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -29,7 +29,6 @@ */ import { Permissions } from '../permission_control/acl'; - import { ISavedObjectsRepository } from './lib'; import { SavedObject, diff --git a/src/core/server/server.ts b/src/core/server/server.ts index a3a7678ae21e..afdbdc7b4846 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -262,7 +262,9 @@ export class Server { opensearch: opensearchStart, savedObjects: savedObjectsStart, }); - await this.workspaces.start(); + await this.workspaces.start({ + savedObjects: savedObjectsStart, + }); this.coreStart = { capabilities: capabilitiesStart, diff --git a/src/core/server/workspaces/constants.ts b/src/core/server/workspaces/constants.ts index c9bf406e50c9..73c2d6010846 100644 --- a/src/core/server/workspaces/constants.ts +++ b/src/core/server/workspaces/constants.ts @@ -4,4 +4,3 @@ */ export const WORKSPACE_TYPE = 'workspace'; -export const GLOBAL_WORKSPACE_ID = 'public'; diff --git a/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts index 0a028b7e356e..5f852d48a068 100644 --- a/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts +++ b/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -214,26 +214,49 @@ export class WorkspaceSavedObjectsClientWrapper { ); if (options.workspaces) { const isEveryWorkspaceIsPermitted = options.workspaces.every((item) => - // TODO modify this line to use permittedWorkspaceIds if public workspace is also a workspace - ['public', ...(permittedWorkspaceIds || [])]?.includes(item) + (permittedWorkspaceIds || []).includes(item) ); if (!isEveryWorkspaceIsPermitted) { throw generateWorkspacePermissionError(); } } else { const queryDSL = ACL.genereateGetPermittedSavedObjectsQueryDSL( - [ - PermissionMode.LibraryRead, - PermissionMode.LibraryWrite, - PermissionMode.Management, - PermissionMode.Read, - PermissionMode.Write, - ], + [PermissionMode.Read, PermissionMode.Write], principals, options.type ); - options.workspaces = permittedWorkspaceIds; - options.queryDSL = queryDSL; + options.workspaces = undefined; + /** + * Select all the docs that + * 1. ACL matches read or write permission OR + * 2. workspaces matches library_read or library_write or management OR + * 3. Advanced settings + */ + options.queryDSL = { + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + term: { + type: 'config', + }, + }, + queryDSL.query, + { + terms: { + workspaces: permittedWorkspaceIds, + }, + }, + ], + }, + }, + ], + }, + }, + }; } } diff --git a/src/core/server/workspaces/workspaces_service.ts b/src/core/server/workspaces/workspaces_service.ts index ba79ead6097d..2c322e38edb6 100644 --- a/src/core/server/workspaces/workspaces_service.ts +++ b/src/core/server/workspaces/workspaces_service.ts @@ -3,17 +3,25 @@ * SPDX-License-Identifier: Apache-2.0 */ import { URL } from 'node:url'; +import { i18n } from '@osd/i18n'; import { CoreService } from '../../types'; import { CoreContext } from '../core_context'; import { InternalHttpServiceSetup } from '../http'; import { Logger } from '../logging'; import { registerRoutes } from './routes'; -import { InternalSavedObjectsServiceSetup } from '../saved_objects'; -import { IWorkspaceDBImpl } from './types'; +import { + ISavedObjectsRepository, + InternalSavedObjectsServiceSetup, + SavedObjectsServiceStart, +} from '../saved_objects'; +import { IWorkspaceDBImpl, WorkspaceAttribute } from './types'; import { WorkspacesClientWithSavedObject } from './workspaces_client'; import { WorkspaceSavedObjectsClientWrapper } from './saved_objects'; import { InternalUiSettingsServiceSetup } from '../ui_settings'; import { uiSettings } from './ui_settings'; +import { WORKSPACE_TYPE } from './constants'; +import { MANAGEMENT_WORKSPACE, PUBLIC_WORKSPACE, PermissionMode } from '../../utils'; +import { ACL, Permissions } from '../saved_objects/permission_control/acl'; export interface WorkspacesServiceSetup { client: IWorkspaceDBImpl; @@ -29,6 +37,10 @@ export interface WorkspacesSetupDeps { uiSettings: InternalUiSettingsServiceSetup; } +export interface WorkpsaceStartDeps { + savedObjects: SavedObjectsServiceStart; +} + export type InternalWorkspacesServiceSetup = WorkspacesServiceSetup; export type InternalWorkspacesServiceStart = WorkspacesServiceStart; @@ -89,9 +101,75 @@ export class WorkspacesService }; } - public async start(): Promise { + private async checkAndCreateWorkspace( + internalRepository: ISavedObjectsRepository, + workspaceId: string, + workspaceAttribute: Omit, + permissions?: Permissions + ) { + /** + * Internal repository is attached to global tenant. + */ + try { + await internalRepository.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 internalRepository.create(WORKSPACE_TYPE, workspaceAttribute, { + id: workspaceId, + permissions, + }); + if (createResult.id) { + this.logger.info(`Created workspace ${createResult.id} in global tenant.`); + } + } catch (e) { + this.logger.error(`Create ${workspaceId} workspace error: ${e?.toString() || ''}`); + } + } + } + + private async setupWorkspaces(startDeps: WorkpsaceStartDeps) { + const internalRepository = startDeps.savedObjects.createInternalRepository(); + const publicWorkspaceACL = new ACL().addPermission( + [PermissionMode.LibraryRead, PermissionMode.LibraryWrite], + { + users: ['*'], + } + ); + const managementWorkspaceACL = new ACL().addPermission([PermissionMode.LibraryRead], { + users: ['*'], + }); + + await Promise.all([ + this.checkAndCreateWorkspace( + internalRepository, + PUBLIC_WORKSPACE, + { + name: i18n.translate('workspaces.public.workspace.default.name', { + defaultMessage: 'public', + }), + }, + publicWorkspaceACL.getPermissions() + ), + this.checkAndCreateWorkspace( + internalRepository, + MANAGEMENT_WORKSPACE, + { + name: i18n.translate('workspaces.management.workspace.default.name', { + defaultMessage: 'Management', + }), + }, + managementWorkspaceACL.getPermissions() + ), + ]); + } + + public async start(startDeps: WorkpsaceStartDeps): Promise { this.logger.debug('Starting SavedObjects service'); + this.setupWorkspaces(startDeps); + return { client: this.client as IWorkspaceDBImpl, }; diff --git a/src/core/utils/constants.ts b/src/core/utils/constants.ts index a58af8947131..5ebf5c0af141 100644 --- a/src/core/utils/constants.ts +++ b/src/core/utils/constants.ts @@ -17,3 +17,9 @@ export enum PrincipalType { Users = 'users', Groups = 'groups', } + +export const PUBLIC_WORKSPACE = 'public'; + +export const MANAGEMENT_WORKSPACE = 'management'; + +export const WORKSPACE_FEATURE_FLAG_KEY_IN_UI_SETTINGS = 'workspace:enabled'; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 9f19132ea8bc..4118a850c828 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -37,4 +37,10 @@ export { IContextProvider, } from './context'; export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; -export { WORKSPACE_PATH_PREFIX, PermissionMode } from './constants'; +export { + WORKSPACE_PATH_PREFIX, + PermissionMode, + PUBLIC_WORKSPACE, + WORKSPACE_FEATURE_FLAG_KEY_IN_UI_SETTINGS, + MANAGEMENT_WORKSPACE, +} from './constants'; 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 ca0aad55a0cb..b82fede2498e 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 @@ -94,6 +94,7 @@ import { import { Header, Table, Flyout, Relationships } from './components'; import { DataPublicPluginStart } from '../../../../../plugins/data/public'; import { SavedObjectsCopyModal } from './components/copy_modal'; +import { PUBLIC_WORKSPACE } from '../../../../../core/public/utils'; interface ExportAllOption { id: string; @@ -179,7 +180,9 @@ export class SavedObjectsTable extends Component()); - const publicWsName = i18n.translate('workspace.public.name', { - defaultMessage: 'public', - }); - wsLookUp?.set('public', publicWsName); - - if (!workspaces) { - return {publicWsName}; - } - const workspaceNames = workspaces?.map((wsId) => wsLookUp?.get(wsId)).join(' | '); return {workspaceNames}; diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 2d6da177165e..8776b7bef8c8 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -29,7 +29,12 @@ import { import { mountDropdownList } from './mount'; import { SavedObjectsManagementPluginSetup } from '../../saved_objects_management/public'; import { getWorkspaceColumn } from './components/utils/workspace_column'; -import { getWorkspaceIdFromUrl, WORKSPACE_PATH_PREFIX } from '../../../core/public/utils'; +import { + getWorkspaceIdFromUrl, + PUBLIC_WORKSPACE, + WORKSPACE_PATH_PREFIX, +} from '../../../core/public/utils'; +import { WORKSPACE_FEATURE_FLAG_KEY_IN_UI_SETTINGS } from '../../../core/public/utils'; interface WorkspacesPluginSetupDeps { savedObjectsManagement?: SavedObjectsManagementPluginSetup; @@ -63,7 +68,7 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep }; public async setup(core: CoreSetup, { savedObjectsManagement }: WorkspacesPluginSetupDeps) { // If workspace feature is disabled, it will not load the workspace plugin - if (core.uiSettings.get('workspace:enabled') === false) { + if (core.uiSettings.get(WORKSPACE_FEATURE_FLAG_KEY_IN_UI_SETTINGS) === false) { return {}; } @@ -194,7 +199,9 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep if (this.coreStart) { return this.coreStart.workspaces.client.currentWorkspaceId$.subscribe( (currentWorkspaceId) => { - this.coreStart?.savedObjects.client.setCurrentWorkspace(currentWorkspaceId); + if (currentWorkspaceId) { + this.coreStart?.savedObjects.client.setCurrentWorkspace(currentWorkspaceId); + } } ); } @@ -262,7 +269,7 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep public start(core: CoreStart) { // If workspace feature is disabled, it will not load the workspace plugin - if (core.uiSettings.get('workspace:enabled') === false) { + if (core.uiSettings.get(WORKSPACE_FEATURE_FLAG_KEY_IN_UI_SETTINGS) === false) { return {}; } From 041d9fcd49f0935418a1dd50199c0925bcaddf6c Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Fri, 11 Aug 2023 15:16:09 +0800 Subject: [PATCH 081/174] Refactor workspace server plugin (#82) - move workspace server to plugins - refactor ACL types - refactor core workspace service and move workspace client to workspace plugin public - rename workspaces -> workspace --------- Signed-off-by: Yulong Ruan --- src/core/public/application/types.ts | 6 +- src/core/public/core_system.ts | 10 +- src/core/public/index.ts | 22 ++--- src/core/public/utils/index.ts | 6 +- src/core/public/workspace/consts.ts | 10 -- src/core/public/workspace/index.ts | 10 +- .../workspace/workspaces_service.mock.ts | 21 +---- .../public/workspace/workspaces_service.ts | 64 +++++++------ src/core/server/index.ts | 13 ++- .../constants.ts | 0 src/core/server/saved_objects/index.ts | 4 + .../permission_control/acl.test.ts | 21 ++--- .../saved_objects/permission_control/acl.ts | 5 +- .../permission_control/client.ts | 5 +- .../saved_objects/routes/bulk_create.ts | 9 +- src/core/server/saved_objects/routes/find.ts | 7 +- .../server/saved_objects/routes/import.ts | 9 +- src/core/server/saved_objects/routes/share.ts | 2 +- src/core/server/server.ts | 13 --- src/core/server/types.ts | 1 - src/core/server/workspaces/index.ts | 20 ---- src/core/server/workspaces/ui_settings.ts | 24 ----- src/core/server/workspaces/utils.ts | 22 ----- src/core/utils/constants.ts | 9 +- src/core/utils/index.ts | 3 +- .../objects_table/components/copy_modal.tsx | 9 +- .../objects_table/saved_objects_table.tsx | 12 +-- .../saved_objects_management/public/plugin.ts | 6 +- .../server/routes/find.ts | 7 +- .../workspace/opensearch_dashboards.json | 2 +- src/plugins/workspace/public/application.tsx | 11 ++- .../public/components/utils/workspace.ts | 10 +- .../components/utils/workspace_column.tsx | 2 +- .../workspace_creator/workspace_creator.tsx | 17 ++-- .../components/workspace_list/index.tsx | 14 +-- .../public/components/workspace_overview.tsx | 10 +- .../workspace_updater/workspace_updater.tsx | 40 ++++---- .../workspace_dropdown_list.tsx | 18 ++-- src/plugins/workspace/public/index.ts | 4 +- src/plugins/workspace/public/mount.tsx | 8 +- src/plugins/workspace/public/plugin.ts | 84 ++++++----------- src/plugins/workspace/public/utils.ts | 31 ++++++ .../workspace/public/workspace_client.ts} | 75 +++++++++------ src/plugins/workspace/server/index.ts | 24 +++++ .../workspace/server/plugin.ts} | 94 +++++++------------ .../workspace/server}/routes/index.ts | 31 +++--- .../workspace/server}/saved_objects/index.ts | 0 .../server}/saved_objects/workspace.ts | 3 +- .../workspace_saved_objects_client_wrapper.ts | 54 ++++++----- .../workspace/server}/types.ts | 16 ++-- .../workspace/server/workspace_client.ts} | 19 ++-- 51 files changed, 427 insertions(+), 490 deletions(-) delete mode 100644 src/core/public/workspace/consts.ts rename src/core/server/{workspaces => saved_objects}/constants.ts (100%) delete mode 100644 src/core/server/workspaces/index.ts delete mode 100644 src/core/server/workspaces/ui_settings.ts delete mode 100644 src/core/server/workspaces/utils.ts create mode 100644 src/plugins/workspace/public/utils.ts rename src/{core/public/workspace/workspaces_client.ts => plugins/workspace/public/workspace_client.ts} (79%) create mode 100644 src/plugins/workspace/server/index.ts rename src/{core/server/workspaces/workspaces_service.ts => plugins/workspace/server/plugin.ts} (58%) rename src/{core/server/workspaces => plugins/workspace/server}/routes/index.ts (90%) rename src/{core/server/workspaces => plugins/workspace/server}/saved_objects/index.ts (100%) rename src/{core/server/workspaces => plugins/workspace/server}/saved_objects/workspace.ts (86%) rename src/{core/server/workspaces => plugins/workspace/server}/saved_objects/workspace_saved_objects_client_wrapper.ts (87%) rename src/{core/server/workspaces => plugins/workspace/server}/types.ts (84%) rename src/{core/server/workspaces/workspaces_client.ts => plugins/workspace/server/workspace_client.ts} (86%) diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 792d5195c4c6..69300c949fbe 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -47,7 +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'; +import { WorkspaceStart } from '../workspace'; /** * Accessibility status of an application. @@ -345,8 +345,8 @@ export interface AppMountContext { injectedMetadata: { getInjectedVar: (name: string, defaultValue?: any) => unknown; }; - /** {@link WorkspacesService} */ - workspaces: WorkspacesStart; + /** {@link WorkspaceService} */ + workspaces: WorkspaceStart; }; } diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 73536b76d76b..d4683087cdab 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -54,7 +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'; +import { WorkspaceService } from './workspace'; interface Params { rootDomElement: HTMLElement; @@ -111,7 +111,7 @@ export class CoreSystem { private readonly rootDomElement: HTMLElement; private readonly coreContext: CoreContext; - private readonly workspaces: WorkspacesService; + private readonly workspaces: WorkspaceService; private fatalErrorsSetup: FatalErrorsSetup | null = null; constructor(params: Params) { @@ -140,7 +140,7 @@ export class CoreSystem { this.rendering = new RenderingService(); this.application = new ApplicationService(); this.integrations = new IntegrationsService(); - this.workspaces = new WorkspacesService(); + this.workspaces = new WorkspaceService(); this.coreContext = { coreId: Symbol('core'), env: injectedMetadata.env }; @@ -163,7 +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 = await this.workspaces.setup({ http, uiSettings }); + const workspaces = this.workspaces.setup(); const pluginDependencies = this.plugins.getOpaqueIds(); const context = this.context.setup({ @@ -225,7 +225,7 @@ export class CoreSystem { targetDomElement: notificationsTargetDomElement, }); const application = await this.application.start({ http, overlays }); - const workspaces = await this.workspaces.start(); + const workspaces = this.workspaces.start(); const chrome = await this.chrome.start({ application, docLinks, diff --git a/src/core/public/index.ts b/src/core/public/index.ts index ddf0479e4049..07bf3ff0c016 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -87,7 +87,7 @@ import { HandlerParameters, } from './context'; import { Branding } from '../types'; -import { WorkspacesStart, WorkspacesSetup } from './workspace'; +import { WorkspaceStart, WorkspaceSetup } from './workspace'; export type { Logos } from '../common'; export { PackageInfo, EnvironmentMode } from '../server/types'; @@ -240,8 +240,8 @@ export interface CoreSetup; - /** {@link WorkspacesSetup} */ - workspaces: WorkspacesSetup; + /** {@link WorkspaceSetup} */ + workspaces: WorkspaceSetup; } /** @@ -296,8 +296,8 @@ export interface CoreStart { getInjectedVar: (name: string, defaultValue?: any) => unknown; getBranding: () => Branding; }; - /** {@link WorkspacesStart} */ - workspaces: WorkspacesStart; + /** {@link WorkspaceStart} */ + workspaces: WorkspaceStart; } export { @@ -347,14 +347,8 @@ export { export { __osdBootstrap__ } from './osd_bootstrap'; -export { - WorkspacesClientContract, - WorkspacesClient, - WorkspacesStart, - WorkspacesService, - WorkspaceAttribute, - WorkspaceFindOptions, - WorkspacePermissionMode, -} from './workspace'; +export { WorkspaceStart, WorkspaceService, WorkspaceAttribute } from './workspace'; + +export { WorkspacePermissionMode, PUBLIC_WORKSPACE, MANAGEMENT_WORKSPACE } from '../utils'; export { getWorkspaceIdFromUrl, WORKSPACE_TYPE } from './utils'; diff --git a/src/core/public/utils/index.ts b/src/core/public/utils/index.ts index 4f958a60ae66..a6d76a87e313 100644 --- a/src/core/public/utils/index.ts +++ b/src/core/public/utils/index.ts @@ -32,8 +32,4 @@ export { shareWeakReplay } from './share_weak_replay'; export { Sha256 } from './crypto'; export { MountWrapper, mountReactNode } from './mount'; export { getWorkspaceIdFromUrl, WORKSPACE_TYPE } from './workspace'; -export { - WORKSPACE_PATH_PREFIX, - PUBLIC_WORKSPACE, - WORKSPACE_FEATURE_FLAG_KEY_IN_UI_SETTINGS, -} from '../../utils'; +export { WORKSPACE_PATH_PREFIX, PUBLIC_WORKSPACE } from '../../utils'; diff --git a/src/core/public/workspace/consts.ts b/src/core/public/workspace/consts.ts deleted file mode 100644 index b02fa29f1013..000000000000 --- a/src/core/public/workspace/consts.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -export const WORKSPACES_API_BASE_URL = '/api/workspaces'; - -export enum WORKSPACE_ERROR_REASON_MAP { - WORKSPACE_STALED = 'WORKSPACE_STALED', -} diff --git a/src/core/public/workspace/index.ts b/src/core/public/workspace/index.ts index d523ce3ae6ab..c2c12bf20715 100644 --- a/src/core/public/workspace/index.ts +++ b/src/core/public/workspace/index.ts @@ -2,11 +2,5 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -export { WorkspacesClientContract, WorkspacesClient } from './workspaces_client'; -export { WorkspacesStart, WorkspacesService, WorkspacesSetup } from './workspaces_service'; -export type { - WorkspaceAttribute, - WorkspaceFindOptions, - WorkspaceRoutePermissionItem, -} from '../../server/types'; -export { PermissionMode as WorkspacePermissionMode } from '../../utils/constants'; +export { WorkspaceStart, WorkspaceService, WorkspaceSetup } from './workspaces_service'; +export type { WorkspaceAttribute } from './workspaces_service'; diff --git a/src/core/public/workspace/workspaces_service.mock.ts b/src/core/public/workspace/workspaces_service.mock.ts index 245303cb4c93..08e2ae597713 100644 --- a/src/core/public/workspace/workspaces_service.mock.ts +++ b/src/core/public/workspace/workspaces_service.mock.ts @@ -11,24 +11,9 @@ const workspaceList$ = new BehaviorSubject([]); const currentWorkspace$ = new BehaviorSubject(null); const createWorkspacesSetupContractMock = () => ({ - client: { - currentWorkspaceId$, - workspaceList$, - currentWorkspace$, - init: jest.fn(), - stop: jest.fn(), - enterWorkspace: jest.fn(), - exitWorkspace: jest.fn(), - create: jest.fn(), - delete: jest.fn(), - list: jest.fn(), - getCurrentWorkspace: jest.fn(), - getCurrentWorkspaceId: jest.fn(), - get: jest.fn(), - update: jest.fn(), - }, - formatUrlWithWorkspaceId: jest.fn(), - setFormatUrlWithWorkspaceId: jest.fn(), + currentWorkspaceId$, + workspaceList$, + currentWorkspace$, }); const createWorkspacesStartContractMock = createWorkspacesSetupContractMock; diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts index 34520a46787b..eb75bc2e81f5 100644 --- a/src/core/public/workspace/workspaces_service.ts +++ b/src/core/public/workspace/workspaces_service.ts @@ -2,50 +2,54 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -import { CoreService } from 'src/core/types'; -import { WorkspacesClient, WorkspacesClientContract } from './workspaces_client'; -import type { WorkspaceAttribute } from '../../server/types'; -import { HttpSetup } from '../http'; -import { IUiSettingsClient } from '../ui_settings'; +import { BehaviorSubject } from 'rxjs'; +import { CoreService } from '../../types'; /** * @public */ -export interface WorkspacesStart { - client: WorkspacesClientContract; - formatUrlWithWorkspaceId: (url: string, id: WorkspaceAttribute['id']) => string; - setFormatUrlWithWorkspaceId: (formatFn: WorkspacesStart['formatUrlWithWorkspaceId']) => void; +export interface WorkspaceStart { + currentWorkspaceId$: BehaviorSubject; + currentWorkspace$: BehaviorSubject; + workspaceList$: BehaviorSubject; } -export type WorkspacesSetup = WorkspacesStart; +export type WorkspaceSetup = WorkspaceStart; -export class WorkspacesService implements CoreService { - private client?: WorkspacesClientContract; - private formatUrlWithWorkspaceId(url: string, id: string) { - return url; - } - private setFormatUrlWithWorkspaceId(formatFn: WorkspacesStart['formatUrlWithWorkspaceId']) { - this.formatUrlWithWorkspaceId = formatFn; - } - public async setup({ http }: { http: HttpSetup; uiSettings: IUiSettingsClient }) { - this.client = new WorkspacesClient(http); +export interface WorkspaceAttribute { + id: string; + name: string; + description?: string; + features?: string[]; + color?: string; + icon?: string; + defaultVISTheme?: string; +} +export class WorkspaceService implements CoreService { + private currentWorkspaceId$ = new BehaviorSubject(''); + private workspaceList$ = new BehaviorSubject([]); + private currentWorkspace$ = new BehaviorSubject(null); + + public setup(): WorkspaceSetup { return { - client: this.client, - formatUrlWithWorkspaceId: (url: string, id: string) => this.formatUrlWithWorkspaceId(url, id), - setFormatUrlWithWorkspaceId: (fn: WorkspacesStart['formatUrlWithWorkspaceId']) => - this.setFormatUrlWithWorkspaceId(fn), + currentWorkspaceId$: this.currentWorkspaceId$, + currentWorkspace$: this.currentWorkspace$, + workspaceList$: this.workspaceList$, }; } - public async start(): Promise { + + public start(): WorkspaceStart { return { - client: this.client as WorkspacesClientContract, - formatUrlWithWorkspaceId: this.formatUrlWithWorkspaceId, - setFormatUrlWithWorkspaceId: (fn: WorkspacesStart['formatUrlWithWorkspaceId']) => - this.setFormatUrlWithWorkspaceId(fn), + currentWorkspaceId$: this.currentWorkspaceId$, + currentWorkspace$: this.currentWorkspace$, + workspaceList$: this.workspaceList$, }; } + public async stop() { - this.client?.stop(); + this.currentWorkspace$.unsubscribe(); + this.currentWorkspaceId$.unsubscribe(); + this.workspaceList$.unsubscribe(); } } diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 0518a3a52250..53c229caccbc 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -322,6 +322,10 @@ export { SavedObjectsShareObjects, SavedObjectsAddToWorkspacesOptions, SavedObjectsAddToWorkspacesResponse, + WORKSPACE_TYPE, + Permissions, + ACL, + SavedObjectsPermissionControlContract, } from './saved_objects'; export { @@ -349,7 +353,12 @@ export { } from './metrics'; export { AppCategory } from '../types'; -export { DEFAULT_APP_CATEGORIES } from '../utils'; +export { + DEFAULT_APP_CATEGORIES, + WorkspacePermissionMode, + PUBLIC_WORKSPACE, + MANAGEMENT_WORKSPACE, +} from '../utils'; export { SavedObject, @@ -511,5 +520,3 @@ export const config = { appenders: appendersSchema as Type, }, }; - -export { formatWorkspaces, workspacesValidator, WORKSPACE_TYPE } from './workspaces'; diff --git a/src/core/server/workspaces/constants.ts b/src/core/server/saved_objects/constants.ts similarity index 100% rename from src/core/server/workspaces/constants.ts rename to src/core/server/saved_objects/constants.ts diff --git a/src/core/server/saved_objects/index.ts b/src/core/server/saved_objects/index.ts index 06b2b65fd184..e75dcff897f8 100644 --- a/src/core/server/saved_objects/index.ts +++ b/src/core/server/saved_objects/index.ts @@ -84,3 +84,7 @@ export { export { savedObjectsConfig, savedObjectsMigrationConfig } from './saved_objects_config'; export { SavedObjectTypeRegistry, ISavedObjectTypeRegistry } from './saved_objects_type_registry'; + +export { WORKSPACE_TYPE } from './constants'; +export { Permissions, ACL } from './permission_control/acl'; +export { SavedObjectsPermissionControlContract } from './permission_control/client'; diff --git a/src/core/server/saved_objects/permission_control/acl.test.ts b/src/core/server/saved_objects/permission_control/acl.test.ts index cec90bbec891..4498c4768c2c 100644 --- a/src/core/server/saved_objects/permission_control/acl.test.ts +++ b/src/core/server/saved_objects/permission_control/acl.test.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { PermissionMode } from '../../../../core/utils/constants'; import { Principals, Permissions, ACL } from './acl'; describe('SavedObjectTypeRegistry', () => { @@ -19,13 +18,13 @@ describe('SavedObjectTypeRegistry', () => { }; acl = new ACL(permissions); expect( - acl.hasPermission([PermissionMode.Read], { + acl.hasPermission(['read'], { users: ['user1'], groups: [], }) ).toEqual(true); expect( - acl.hasPermission([PermissionMode.Read], { + acl.hasPermission(['read'], { users: ['user2'], groups: [], }) @@ -35,7 +34,7 @@ describe('SavedObjectTypeRegistry', () => { it('test add permission', () => { acl = new ACL(); const result1 = acl - .addPermission([PermissionMode.Read], { + .addPermission(['read'], { users: ['user1'], groups: [], }) @@ -44,7 +43,7 @@ describe('SavedObjectTypeRegistry', () => { acl.resetPermissions(); const result2 = acl - .addPermission([PermissionMode.Write, PermissionMode.Management], { + .addPermission(['write', 'management'], { users: ['user2'], groups: ['group1', 'group2'], }) @@ -64,11 +63,11 @@ describe('SavedObjectTypeRegistry', () => { }; acl = new ACL(permissions1); const result1 = acl - .removePermission([PermissionMode.Read], { + .removePermission(['read'], { users: ['user1'], groups: [], }) - .removePermission([PermissionMode.Write], { + .removePermission(['write'], { users: [], groups: ['group2'], }) @@ -88,7 +87,7 @@ describe('SavedObjectTypeRegistry', () => { acl = new ACL(permissions2); const result2 = acl - .removePermission([PermissionMode.Read, PermissionMode.Write], { + .removePermission(['read', 'write'], { users: ['user1'], groups: ['group1'], }) @@ -125,11 +124,7 @@ describe('SavedObjectTypeRegistry', () => { users: ['user1'], groups: ['group1'], }; - const result = ACL.genereateGetPermittedSavedObjectsQueryDSL( - [PermissionMode.Read], - principals, - 'workspace' - ); + const result = ACL.genereateGetPermittedSavedObjectsQueryDSL(['read'], principals, 'workspace'); expect(result).toEqual({ query: { bool: { diff --git a/src/core/server/saved_objects/permission_control/acl.ts b/src/core/server/saved_objects/permission_control/acl.ts index 1d34b9f6c401..cc17e8170ac8 100644 --- a/src/core/server/saved_objects/permission_control/acl.ts +++ b/src/core/server/saved_objects/permission_control/acl.ts @@ -3,7 +3,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { PrincipalType } from '../../../utils/constants'; +export enum PrincipalType { + Users = 'users', + Groups = 'groups', +} export interface Principals { users?: string[]; diff --git a/src/core/server/saved_objects/permission_control/client.ts b/src/core/server/saved_objects/permission_control/client.ts index e00274ce1a0c..b3dda958e0f0 100644 --- a/src/core/server/saved_objects/permission_control/client.ts +++ b/src/core/server/saved_objects/permission_control/client.ts @@ -7,9 +7,8 @@ import { OpenSearchDashboardsRequest } from '../../http'; import { ensureRawRequest } from '../../http/router'; import { SavedObjectsServiceStart } from '../saved_objects_service'; import { SavedObjectsBulkGetObject } from '../service'; -import { ACL, Principals, TransformedPermission } from './acl'; -import { PrincipalType } from '../../../utils/constants'; -import { WORKSPACE_TYPE } from '../../workspaces'; +import { ACL, Principals, TransformedPermission, PrincipalType } from './acl'; +import { WORKSPACE_TYPE } from '../constants'; export type SavedObjectsPermissionControlContract = Pick< SavedObjectsPermissionControl, diff --git a/src/core/server/saved_objects/routes/bulk_create.ts b/src/core/server/saved_objects/routes/bulk_create.ts index a43a69464308..056b1b795550 100644 --- a/src/core/server/saved_objects/routes/bulk_create.ts +++ b/src/core/server/saved_objects/routes/bulk_create.ts @@ -30,7 +30,6 @@ import { schema } from '@osd/config-schema'; import { IRouter } from '../../http'; -import { formatWorkspaces, workspacesValidator } from '../../workspaces'; export const registerBulkCreateRoute = (router: IRouter) => { router.post( @@ -39,7 +38,9 @@ export const registerBulkCreateRoute = (router: IRouter) => { validate: { query: schema.object({ overwrite: schema.boolean({ defaultValue: false }), - workspaces: workspacesValidator, + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }), body: schema.arrayOf( schema.object({ @@ -64,7 +65,9 @@ export const registerBulkCreateRoute = (router: IRouter) => { }, router.handleLegacyErrors(async (context, req, res) => { const { overwrite } = req.query; - const workspaces = formatWorkspaces(req.query.workspaces); + const workspaces = req.query.workspaces + ? Array().concat(req.query.workspaces) + : undefined; const result = await context.core.savedObjects.client.bulkCreate(req.body, { overwrite, workspaces, diff --git a/src/core/server/saved_objects/routes/find.ts b/src/core/server/saved_objects/routes/find.ts index 2ee7005f8e4f..36fa7c2cd9f5 100644 --- a/src/core/server/saved_objects/routes/find.ts +++ b/src/core/server/saved_objects/routes/find.ts @@ -30,7 +30,6 @@ import { schema } from '@osd/config-schema'; import { IRouter } from '../../http'; -import { formatWorkspaces, workspacesValidator } from '../../workspaces'; export const registerFindRoute = (router: IRouter) => { router.get( @@ -60,7 +59,9 @@ export const registerFindRoute = (router: IRouter) => { namespaces: schema.maybe( schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) ), - workspaces: workspacesValidator, + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }), }, }, @@ -69,7 +70,7 @@ export const registerFindRoute = (router: IRouter) => { const namespaces = typeof req.query.namespaces === 'string' ? [req.query.namespaces] : req.query.namespaces; - const workspaces = formatWorkspaces(query.workspaces); + const workspaces = query.workspaces ? Array().concat(query.workspaces) : undefined; const result = await context.core.savedObjects.client.find({ perPage: query.per_page, diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts index 9675d608541c..60e6cb254ee5 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -35,7 +35,6 @@ import { IRouter } from '../../http'; import { importSavedObjectsFromStream } from '../import'; import { SavedObjectConfig } from '../saved_objects_config'; import { createSavedObjectsStreamFromNdJson } from './utils'; -import { formatWorkspaces, workspacesValidator } from '../../workspaces'; interface FileStream extends Readable { hapi: { @@ -61,7 +60,9 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) { overwrite: schema.boolean({ defaultValue: false }), createNewCopies: schema.boolean({ defaultValue: false }), - workspaces: workspacesValidator, + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }, { validate: (object) => { @@ -93,7 +94,9 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) }); } - const workspaces = formatWorkspaces(req.query.workspaces); + const workspaces = req.query.workspaces + ? Array().concat(req.query.workspaces) + : undefined; const result = await importSavedObjectsFromStream({ savedObjectsClient: context.core.savedObjects.client, diff --git a/src/core/server/saved_objects/routes/share.ts b/src/core/server/saved_objects/routes/share.ts index 526ad24893c6..d14bd7e72c31 100644 --- a/src/core/server/saved_objects/routes/share.ts +++ b/src/core/server/saved_objects/routes/share.ts @@ -8,7 +8,7 @@ import { IRouter } from '../../http'; import { exportSavedObjectsToStream } from '../export'; import { validateObjects } from './utils'; import { collectSavedObjects } from '../import/collect_saved_objects'; -import { WORKSPACE_TYPE } from '../../workspaces'; +import { WORKSPACE_TYPE } from '../constants'; import { PUBLIC_WORKSPACE } from '../../../utils/constants'; const SHARE_LIMIT = 10000; diff --git a/src/core/server/server.ts b/src/core/server/server.ts index afdbdc7b4846..d4c041725ac7 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -62,7 +62,6 @@ import { RequestHandlerContext } from '.'; import { InternalCoreSetup, InternalCoreStart, ServiceConfigDescriptor } from './internal_types'; import { CoreUsageDataService } from './core_usage_data'; import { CoreRouteHandlerContext } from './core_route_handler_context'; -import { WorkspacesService } from './workspaces'; const coreId = Symbol('core'); const rootConfigPath = ''; @@ -87,7 +86,6 @@ export class Server { private readonly coreApp: CoreApp; private readonly auditTrail: AuditTrailService; private readonly coreUsageData: CoreUsageDataService; - private readonly workspaces: WorkspacesService; #pluginsInitialized?: boolean; private coreStart?: InternalCoreStart; @@ -120,7 +118,6 @@ export class Server { this.auditTrail = new AuditTrailService(core); this.logging = new LoggingService(core); this.coreUsageData = new CoreUsageDataService(core); - this.workspaces = new WorkspacesService(core); } public async setup() { @@ -175,12 +172,6 @@ export class Server { const metricsSetup = await this.metrics.setup({ http: httpSetup }); - await this.workspaces.setup({ - http: httpSetup, - savedObject: savedObjectsSetup, - uiSettings: uiSettingsSetup, - }); - const statusSetup = await this.status.setup({ opensearch: opensearchServiceSetup, pluginDependencies: pluginTree.asNames, @@ -262,9 +253,6 @@ export class Server { opensearch: opensearchStart, savedObjects: savedObjectsStart, }); - await this.workspaces.start({ - savedObjects: savedObjectsStart, - }); this.coreStart = { capabilities: capabilitiesStart, @@ -307,7 +295,6 @@ export class Server { await this.status.stop(); await this.logging.stop(); await this.auditTrail.stop(); - await this.workspaces.stop(); } private registerCoreContext(coreSetup: InternalCoreSetup) { diff --git a/src/core/server/types.ts b/src/core/server/types.ts index f6e54c201dae..90ccef575807 100644 --- a/src/core/server/types.ts +++ b/src/core/server/types.ts @@ -35,4 +35,3 @@ export * from './ui_settings/types'; export * from './legacy/types'; export type { EnvironmentMode, PackageInfo } from '@osd/config'; export { Branding } from '../../core/types'; -export * from './workspaces/types'; diff --git a/src/core/server/workspaces/index.ts b/src/core/server/workspaces/index.ts deleted file mode 100644 index cbebde2237f7..000000000000 --- a/src/core/server/workspaces/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ -export { - WorkspacesService, - InternalWorkspacesServiceSetup, - WorkspacesServiceStart, - WorkspacesServiceSetup, - InternalWorkspacesServiceStart, -} from './workspaces_service'; - -export { - WorkspaceAttribute, - WorkspaceFindOptions, - WorkspaceAttributeWithPermission, -} from './types'; - -export { workspacesValidator, formatWorkspaces } from './utils'; -export { WORKSPACE_TYPE } from './constants'; diff --git a/src/core/server/workspaces/ui_settings.ts b/src/core/server/workspaces/ui_settings.ts deleted file mode 100644 index da9c9feb64d8..000000000000 --- a/src/core/server/workspaces/ui_settings.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { i18n } from '@osd/i18n'; -import { schema } from '@osd/config-schema'; - -import { UiSettingsParams } from 'opensearch-dashboards/server'; - -export const uiSettings: Record = { - 'workspace:enabled': { - name: i18n.translate('core.ui_settings.params.workspace.enableWorkspaceTitle', { - defaultMessage: 'Enable Workspace', - }), - value: false, - requiresPageReload: true, - description: i18n.translate('core.ui_settings.params.workspace.enableWorkspaceTitle', { - defaultMessage: 'Enable or disable OpenSearch Dashboards Workspace', - }), - category: ['workspace'], - schema: schema.boolean(), - }, -}; diff --git a/src/core/server/workspaces/utils.ts b/src/core/server/workspaces/utils.ts deleted file mode 100644 index 955b49a1e5e3..000000000000 --- a/src/core/server/workspaces/utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { schema } from '@osd/config-schema'; - -export const workspacesValidator = schema.maybe( - schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) -); - -export function formatWorkspaces(workspaces?: string | string[]): string[] | undefined { - if (Array.isArray(workspaces)) { - return workspaces; - } - - if (!workspaces) { - return undefined; - } - - return [workspaces]; -} diff --git a/src/core/utils/constants.ts b/src/core/utils/constants.ts index 5ebf5c0af141..004e9b58a91b 100644 --- a/src/core/utils/constants.ts +++ b/src/core/utils/constants.ts @@ -5,7 +5,7 @@ export const WORKSPACE_PATH_PREFIX = '/w'; -export enum PermissionMode { +export enum WorkspacePermissionMode { Read = 'read', Write = 'write', Management = 'management', @@ -13,13 +13,6 @@ export enum PermissionMode { LibraryWrite = 'library_write', } -export enum PrincipalType { - Users = 'users', - Groups = 'groups', -} - export const PUBLIC_WORKSPACE = 'public'; export const MANAGEMENT_WORKSPACE = 'management'; - -export const WORKSPACE_FEATURE_FLAG_KEY_IN_UI_SETTINGS = 'workspace:enabled'; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 4118a850c828..2adab8bd8926 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -39,8 +39,7 @@ export { export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; export { WORKSPACE_PATH_PREFIX, - PermissionMode, + WorkspacePermissionMode, PUBLIC_WORKSPACE, - WORKSPACE_FEATURE_FLAG_KEY_IN_UI_SETTINGS, MANAGEMENT_WORKSPACE, } from './constants'; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/copy_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/copy_modal.tsx index d18c78dae0ad..b510dc450498 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/copy_modal.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/copy_modal.tsx @@ -30,9 +30,8 @@ import { EuiIcon, EuiCallOut, } from '@elastic/eui'; -import { WorkspaceAttribute, WorkspacesStart } from 'opensearch-dashboards/public'; +import { WorkspaceAttribute, WorkspaceStart } from 'opensearch-dashboards/public'; import { i18n } from '@osd/i18n'; -import { iteratorSymbol } from 'immer/dist/internal'; import { SavedObjectWithMetadata } from '../../../types'; import { getSavedObjectLabel } from '../../../lib'; import { SAVED_OBJECT_TYPE_WORKSAPCE } from '../../../constants'; @@ -40,7 +39,7 @@ import { SAVED_OBJECT_TYPE_WORKSAPCE } from '../../../constants'; type WorkspaceOption = EuiComboBoxOptionOption; interface Props { - workspaces: WorkspacesStart; + workspaces: WorkspaceStart; onCopy: ( savedObjects: SavedObjectWithMetadata[], includeReferencesDeep: boolean, @@ -81,8 +80,8 @@ export class SavedObjectsCopyModal extends React.Component { async componentDidMount() { const { workspaces } = this.props; - const workspaceList = await workspaces.client.workspaceList$; - const currentWorkspace = await workspaces.client.currentWorkspace$; + const workspaceList = workspaces.workspaceList$; + const currentWorkspace = workspaces.currentWorkspace$; if (!!currentWorkspace?.value?.name) { const currentWorkspaceName = currentWorkspace.value.name; 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 b82fede2498e..517b4a069266 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 @@ -61,12 +61,13 @@ import { FormattedMessage } from '@osd/i18n/react'; import { SavedObjectsClientContract, SavedObjectsFindOptions, - WorkspacesStart, + WorkspaceStart, HttpStart, OverlayStart, NotificationsStart, ApplicationStart, -} from 'src/core/public'; + PUBLIC_WORKSPACE, +} from '../../../../../core/public'; import { RedirectAppLinks } from '../../../../opensearch_dashboards_react/public'; import { IndexPatternsContract } from '../../../../data/public'; import { @@ -94,7 +95,6 @@ import { import { Header, Table, Flyout, Relationships } from './components'; import { DataPublicPluginStart } from '../../../../../plugins/data/public'; import { SavedObjectsCopyModal } from './components/copy_modal'; -import { PUBLIC_WORKSPACE } from '../../../../../core/public/utils'; interface ExportAllOption { id: string; @@ -110,7 +110,7 @@ export interface SavedObjectsTableProps { savedObjectsClient: SavedObjectsClientContract; indexPatterns: IndexPatternsContract; http: HttpStart; - workspaces: WorkspacesStart; + workspaces: WorkspaceStart; search: DataPublicPluginStart['search']; overlays: OverlayStart; notifications: NotificationsStart; @@ -175,7 +175,7 @@ export class SavedObjectsTable extends Component + this.props.workspaces.currentWorkspaceId$.subscribe((workspaceId) => this.setState({ workspaceId, }) diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index e3263f396512..3e72ee464aeb 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -68,6 +68,7 @@ export interface SavedObjectsManagementPluginSetup { columns: SavedObjectsManagementColumnServiceSetup; namespaces: SavedObjectsManagementNamespaceServiceSetup; serviceRegistry: ISavedObjectsManagementServiceRegistry; + registerLibrarySubApp: () => void; } export interface SavedObjectsManagementPluginStart { @@ -174,7 +175,6 @@ export class SavedObjectsManagementPlugin const actionSetup = this.actionService.setup(); const columnSetup = this.columnService.setup(); const namespaceSetup = this.namespaceService.setup(); - const isWorkspaceEnabled = core.uiSettings.get('workspace:enabled'); if (home) { home.featureCatalogue.register({ @@ -213,9 +213,6 @@ export class SavedObjectsManagementPlugin // sets up the context mappings and registers any triggers/actions for the plugin bootstrap(uiActions); - if (isWorkspaceEnabled) { - this.registerLibrarySubApp(core); - } // depends on `getStartServices`, should not be awaited registerServices(this.serviceRegistry, core.getStartServices); @@ -225,6 +222,7 @@ export class SavedObjectsManagementPlugin columns: columnSetup, namespaces: namespaceSetup, serviceRegistry: this.serviceRegistry, + registerLibrarySubApp: () => this.registerLibrarySubApp(core), }; } diff --git a/src/plugins/saved_objects_management/server/routes/find.ts b/src/plugins/saved_objects_management/server/routes/find.ts index af1f96aae58a..61211532e96c 100644 --- a/src/plugins/saved_objects_management/server/routes/find.ts +++ b/src/plugins/saved_objects_management/server/routes/find.ts @@ -34,7 +34,6 @@ import { DataSourceAttributes } from 'src/plugins/data_source/common/data_source import { getIndexPatternTitle } from '../../../data/common/index_patterns/utils'; import { injectMetaAttributes } from '../lib'; import { ISavedObjectsManagement } from '../services'; -import { formatWorkspaces, workspacesValidator } from '../../../../core/server'; export const registerFindRoute = ( router: IRouter, @@ -65,7 +64,9 @@ export const registerFindRoute = ( fields: schema.oneOf([schema.string(), schema.arrayOf(schema.string())], { defaultValue: [], }), - workspaces: workspacesValidator, + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }), }, }, @@ -96,7 +97,7 @@ export const registerFindRoute = ( ...req.query, fields: undefined, searchFields: [...searchFields], - workspaces: formatWorkspaces(req.query.workspaces), + workspaces: req.query.workspaces ? Array().concat(req.query.workspaces) : undefined, }); const savedObjects = await Promise.all( diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json index 0efc89373a94..5ab644da4e91 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -1,7 +1,7 @@ { "id": "workspace", "version": "opensearchDashboards", - "server": false, + "server": true, "ui": true, "requiredPlugins": ["savedObjects"], "optionalPlugins": ["savedObjectsManagement"], diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx index 9772390f7118..fc06e52d2e81 100644 --- a/src/plugins/workspace/public/application.tsx +++ b/src/plugins/workspace/public/application.tsx @@ -11,10 +11,13 @@ import { WorkspaceListApp } from './components/workspace_list_app'; import { WorkspaceCreatorApp } from './components/workspace_creator_app'; import { WorkspaceUpdaterApp } from './components/workspace_updater_app'; import { WorkspaceOverviewApp } from './components/workspace_overview_app'; +import { WorkspaceClient } from './workspace_client'; + +export type Services = CoreStart & { workspaceClient: WorkspaceClient }; export const renderListApp = ( { element, history, appBasePath }: AppMountParameters, - services: CoreStart + services: Services ) => { ReactDOM.render( @@ -29,7 +32,7 @@ export const renderListApp = ( }; export const renderCreatorApp = ( { element, history, appBasePath }: AppMountParameters, - services: CoreStart + services: Services ) => { ReactDOM.render( @@ -45,7 +48,7 @@ export const renderCreatorApp = ( export const renderUpdateApp = ( { element, history, appBasePath }: AppMountParameters, - services: CoreStart + services: Services ) => { ReactDOM.render( @@ -61,7 +64,7 @@ export const renderUpdateApp = ( export const renderOverviewApp = ( { element, history, appBasePath }: AppMountParameters, - services: CoreStart + services: Services ) => { ReactDOM.render( diff --git a/src/plugins/workspace/public/components/utils/workspace.ts b/src/plugins/workspace/public/components/utils/workspace.ts index fa38e29fdc61..63d88ae93e19 100644 --- a/src/plugins/workspace/public/components/utils/workspace.ts +++ b/src/plugins/workspace/public/components/utils/workspace.ts @@ -5,15 +5,17 @@ import { WORKSPACE_OVERVIEW_APP_ID } from '../../../common/constants'; import { CoreStart } from '../../../../../core/public'; +import { formatUrlWithWorkspaceId } from '../../utils'; -type Core = Pick; +type Core = Pick; -export const switchWorkspace = ({ workspaces, application }: Core, id: string) => { - const newUrl = workspaces?.formatUrlWithWorkspaceId( +export const switchWorkspace = ({ application, http }: Core, id: string) => { + const newUrl = formatUrlWithWorkspaceId( application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { absolute: true, }), - id + id, + http.basePath ); if (newUrl) { window.location.href = newUrl; diff --git a/src/plugins/workspace/public/components/utils/workspace_column.tsx b/src/plugins/workspace/public/components/utils/workspace_column.tsx index 9ad78a889bf6..0d1899959cfe 100644 --- a/src/plugins/workspace/public/components/utils/workspace_column.tsx +++ b/src/plugins/workspace/public/components/utils/workspace_column.tsx @@ -20,7 +20,7 @@ interface WorkspaceColumnProps { } function WorkspaceColumn({ coreSetup, workspaces, record }: WorkspaceColumnProps) { - const workspaceList = useObservable(coreSetup.workspaces.client.workspaceList$); + const workspaceList = useObservable(coreSetup.workspaces.workspaceList$); const wsLookUp = workspaceList?.reduce((map, ws) => { return map.set(ws.id, ws.name); diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx index f2bc86c9f4cf..eec5a03392aa 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -11,17 +11,19 @@ import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashb import { WorkspaceForm, WorkspaceFormData } from './workspace_form'; import { WORKSPACE_OVERVIEW_APP_ID, WORKSPACE_OP_TYPE_CREATE } from '../../../common/constants'; +import { formatUrlWithWorkspaceId } from '../../utils'; +import { WorkspaceClient } from '../../workspace_client'; export const WorkspaceCreator = () => { const { - services: { application, workspaces, notifications }, - } = useOpenSearchDashboards(); + services: { application, notifications, http, workspaceClient }, + } = useOpenSearchDashboards<{ workspaceClient: WorkspaceClient }>(); const handleWorkspaceFormSubmit = useCallback( async (data: WorkspaceFormData) => { let result; try { - result = await workspaces?.client.create(data); + result = await workspaceClient.create(data); } catch (error) { notifications?.toasts.addDanger({ title: i18n.translate('workspace.create.failed', { @@ -37,12 +39,13 @@ export const WorkspaceCreator = () => { defaultMessage: 'Create workspace successfully', }), }); - if (application && workspaces) { - window.location.href = workspaces.formatUrlWithWorkspaceId( + if (application && http) { + window.location.href = formatUrlWithWorkspaceId( application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { absolute: true, }), - result.result.id + result.result.id, + http.basePath ); } return; @@ -54,7 +57,7 @@ export const WorkspaceCreator = () => { text: result?.error, }); }, - [notifications?.toasts, workspaces, application] + [notifications?.toasts, http, application, workspaceClient] ); return ( diff --git a/src/plugins/workspace/public/components/workspace_list/index.tsx b/src/plugins/workspace/public/components/workspace_list/index.tsx index 4568836b87af..5599ae6bcf77 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.tsx @@ -15,8 +15,8 @@ import { CriteriaWithPagination, } from '@elastic/eui'; import useObservable from 'react-use/lib/useObservable'; -import { useMemo } from 'react'; -import { useCallback } from 'react'; +import { useMemo, useCallback } from 'react'; +import { of } from 'rxjs'; import { WorkspaceAttribute } from '../../../../../core/public'; import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public'; @@ -24,7 +24,7 @@ import { switchWorkspace } from '../utils/workspace'; export const WorkspaceList = () => { const { - services: { workspaces, application }, + services: { workspaces, application, http }, } = useOpenSearchDashboards(); const [pageIndex, setPageIndex] = useState(0); @@ -32,7 +32,7 @@ export const WorkspaceList = () => { const [sortField, setSortField] = useState<'name' | 'id'>('name'); const [sortDirection, setSortDirection] = useState('asc'); - const workspaceList = useObservable(workspaces!.client.workspaceList$, []); + const workspaceList = useObservable(workspaces?.workspaceList$ ?? of([]), []); const pageOfItems = useMemo(() => { return workspaceList @@ -45,11 +45,11 @@ export const WorkspaceList = () => { const handleSwitchWorkspace = useCallback( (id: string) => { - if (workspaces && application) { - switchWorkspace({ workspaces, application }, id); + if (application && http) { + switchWorkspace({ application, http }, id); } }, - [workspaces, application] + [application, http] ); const columns = [ diff --git a/src/plugins/workspace/public/components/workspace_overview.tsx b/src/plugins/workspace/public/components/workspace_overview.tsx index 55de87d20b66..5a7f9d4117c5 100644 --- a/src/plugins/workspace/public/components/workspace_overview.tsx +++ b/src/plugins/workspace/public/components/workspace_overview.tsx @@ -3,8 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState } from 'react'; -import { EuiPageHeader, EuiButton, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import React from 'react'; +import { EuiPageHeader, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; import { useObservable } from 'react-use'; import { of } from 'rxjs'; import { ApplicationStart } from '../../../../core/public'; @@ -12,12 +12,10 @@ import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/pu export const WorkspaceOverview = () => { const { - services: { workspaces, application, notifications }, + services: { workspaces }, } = useOpenSearchDashboards<{ application: ApplicationStart }>(); - const currentWorkspace = useObservable( - workspaces ? workspaces.client.currentWorkspace$ : of(null) - ); + const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null)); return ( <> diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx index a4e519b045e0..c474b4c3a2df 100644 --- a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx @@ -19,17 +19,16 @@ import { WorkspaceAttribute } from 'opensearch-dashboards/public'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { WorkspaceForm, WorkspaceFormData } from '../workspace_creator/workspace_form'; import { WORKSPACE_OVERVIEW_APP_ID, WORKSPACE_OP_TYPE_UPDATE } from '../../../common/constants'; -import { ApplicationStart } from '../../../../../core/public'; import { DeleteWorkspaceModal } from '../delete_workspace_modal'; +import { formatUrlWithWorkspaceId } from '../../utils'; +import { WorkspaceClient } from '../../workspace_client'; export const WorkspaceUpdater = () => { const { - services: { application, workspaces, notifications, http }, - } = useOpenSearchDashboards<{ application: ApplicationStart }>(); + services: { application, workspaces, notifications, http, workspaceClient }, + } = useOpenSearchDashboards<{ workspaceClient: WorkspaceClient }>(); - const currentWorkspace = useObservable( - workspaces ? workspaces.client.currentWorkspace$ : of(null) - ); + const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null)); const excludedAttribute = 'id'; const { [excludedAttribute]: removedProperty, ...otherAttributes } = @@ -57,7 +56,7 @@ export const WorkspaceUpdater = () => { return; } try { - result = await workspaces?.client.update(currentWorkspace?.id, data); + result = await workspaceClient.update(currentWorkspace?.id, data); } catch (error) { notifications?.toasts.addDanger({ title: i18n.translate('workspace.update.failed', { @@ -73,13 +72,16 @@ export const WorkspaceUpdater = () => { defaultMessage: 'Update workspace successfully', }), }); - window.location.href = - workspaces?.formatUrlWithWorkspaceId( - application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { - absolute: true, - }), - currentWorkspace.id - ) || ''; + if (application && http) { + window.location.href = + formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + absolute: true, + }), + currentWorkspace.id, + http.basePath + ) || ''; + } return; } notifications?.toasts.addDanger({ @@ -89,7 +91,7 @@ export const WorkspaceUpdater = () => { text: result?.error, }); }, - [notifications?.toasts, workspaces, currentWorkspace, application] + [notifications?.toasts, currentWorkspace, application, http, workspaceClient] ); if (!currentWorkspaceFormData.name) { @@ -99,7 +101,7 @@ export const WorkspaceUpdater = () => { if (currentWorkspace?.id) { let result; try { - result = await workspaces?.client.delete(currentWorkspace?.id); + result = await workspaceClient.delete(currentWorkspace?.id); } catch (error) { notifications?.toasts.addDanger({ title: i18n.translate('workspace.delete.failed', { @@ -125,7 +127,7 @@ export const WorkspaceUpdater = () => { } } setDeleteWorkspaceModalVisible(false); - if (http) { + if (http && application) { const homeUrl = application.getUrlForApp('home', { path: '/', absolute: false, @@ -140,7 +142,7 @@ export const WorkspaceUpdater = () => { const exitWorkspace = async () => { let result; try { - result = await workspaces?.client.exitWorkspace(); + result = await workspaceClient.exitWorkspace(); } catch (error) { notifications?.toasts.addDanger({ title: i18n.translate('workspace.exit.failed', { @@ -159,7 +161,7 @@ export const WorkspaceUpdater = () => { }); return; } - if (http) { + if (http && application) { const homeUrl = application.getUrlForApp('home', { path: '/', absolute: false, diff --git a/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx index 60bbed924d42..9bb4eec842c3 100644 --- a/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx +++ b/src/plugins/workspace/public/containers/workspace_dropdown_list/workspace_dropdown_list.tsx @@ -7,15 +7,21 @@ import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { EuiButton, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import useObservable from 'react-use/lib/useObservable'; -import { ApplicationStart, WorkspaceAttribute, WorkspacesStart } from '../../../../../core/public'; +import { + ApplicationStart, + HttpSetup, + WorkspaceAttribute, + WorkspaceStart, +} from '../../../../../core/public'; import { WORKSPACE_CREATE_APP_ID } from '../../../common/constants'; import { switchWorkspace } from '../../components/utils/workspace'; type WorkspaceOption = EuiComboBoxOptionOption; interface WorkspaceDropdownListProps { - workspaces: WorkspacesStart; + workspaces: WorkspaceStart; application: ApplicationStart; + http: HttpSetup; } function workspaceToOption(workspace: WorkspaceAttribute): WorkspaceOption { @@ -28,8 +34,8 @@ export function getErrorMessage(err: any) { } export function WorkspaceDropdownList(props: WorkspaceDropdownListProps) { - const workspaceList = useObservable(props.workspaces.client.workspaceList$, []); - const currentWorkspace = useObservable(props.workspaces.client.currentWorkspace$, null); + const workspaceList = useObservable(props.workspaces.workspaceList$, []); + const currentWorkspace = useObservable(props.workspaces.currentWorkspace$, null); const [loading, setLoading] = useState(false); const [workspaceOptions, setWorkspaceOptions] = useState([] as WorkspaceOption[]); @@ -57,10 +63,10 @@ export function WorkspaceDropdownList(props: WorkspaceDropdownListProps) { /** switch the workspace */ setLoading(true); const id = workspaceOption[0].key!; - switchWorkspace({ workspaces: props.workspaces, application: props.application }, id); + switchWorkspace({ http: props.http, application: props.application }, id); setLoading(false); }, - [props.application, props.workspaces] + [props.application, props.http] ); const onCreateWorkspaceClick = () => { diff --git a/src/plugins/workspace/public/index.ts b/src/plugins/workspace/public/index.ts index 9f5c720fc9d5..99161a7edbd7 100644 --- a/src/plugins/workspace/public/index.ts +++ b/src/plugins/workspace/public/index.ts @@ -3,8 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { WorkspacesPlugin } from './plugin'; +import { WorkspacePlugin } from './plugin'; export function plugin() { - return new WorkspacesPlugin(); + return new WorkspacePlugin(); } diff --git a/src/plugins/workspace/public/mount.tsx b/src/plugins/workspace/public/mount.tsx index dc2ff1de8c1e..7de85b89ea07 100644 --- a/src/plugins/workspace/public/mount.tsx +++ b/src/plugins/workspace/public/mount.tsx @@ -5,23 +5,25 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { ApplicationStart, ChromeStart, WorkspacesStart } from '../../../core/public'; +import { ApplicationStart, ChromeStart, HttpSetup, WorkspaceStart } from '../../../core/public'; import { WorkspaceDropdownList } from './containers/workspace_dropdown_list'; export const mountDropdownList = ({ application, workspaces, chrome, + http, }: { application: ApplicationStart; - workspaces: WorkspacesStart; + workspaces: WorkspaceStart; chrome: ChromeStart; + http: HttpSetup; }) => { chrome.navControls.registerLeft({ order: 0, mount: (element) => { ReactDOM.render( - , + , element ); return () => { diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 8776b7bef8c8..ebc204a351a3 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -16,8 +16,8 @@ import { CoreStart, Plugin, WorkspaceAttribute, - WorkspacesStart, DEFAULT_APP_CATEGORIES, + HttpSetup, } from '../../../core/public'; import { WORKSPACE_LIST_APP_ID, @@ -29,59 +29,32 @@ import { import { mountDropdownList } from './mount'; import { SavedObjectsManagementPluginSetup } from '../../saved_objects_management/public'; import { getWorkspaceColumn } from './components/utils/workspace_column'; -import { - getWorkspaceIdFromUrl, - PUBLIC_WORKSPACE, - WORKSPACE_PATH_PREFIX, -} from '../../../core/public/utils'; -import { WORKSPACE_FEATURE_FLAG_KEY_IN_UI_SETTINGS } from '../../../core/public/utils'; +import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; +import { formatUrlWithWorkspaceId } from './utils'; +import { WorkspaceClient } from './workspace_client'; +import { Services } from './application'; -interface WorkspacesPluginSetupDeps { +interface WorkspacePluginSetupDeps { savedObjectsManagement?: SavedObjectsManagementPluginSetup; } -export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDeps> { - private coreSetup?: CoreSetup; +export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> { private coreStart?: CoreStart; private currentWorkspaceSubscription?: Subscription; private getWorkspaceIdFromURL(): string | null { return getWorkspaceIdFromUrl(window.location.href); } - private getPatchedUrl = (url: string, workspaceId: string) => { - const newUrl = new URL(url, window.location.href); - /** - * Patch workspace id into path - */ - newUrl.pathname = this.coreSetup?.http.basePath.remove(newUrl.pathname) || ''; - if (workspaceId) { - newUrl.pathname = `${WORKSPACE_PATH_PREFIX}/${workspaceId}${newUrl.pathname}`; - } else { - newUrl.pathname = newUrl.pathname.replace(/^\/w\/([^\/]*)/, ''); - } - - newUrl.pathname = - this.coreSetup?.http.basePath.prepend(newUrl.pathname, { - withoutWorkspace: true, - }) || ''; + public async setup(core: CoreSetup, { savedObjectsManagement }: WorkspacePluginSetupDeps) { + const workspaceClient = new WorkspaceClient(core.http, core.workspaces); + workspaceClient.init(); - return newUrl.toString(); - }; - public async setup(core: CoreSetup, { savedObjectsManagement }: WorkspacesPluginSetupDeps) { - // If workspace feature is disabled, it will not load the workspace plugin - if (core.uiSettings.get(WORKSPACE_FEATURE_FLAG_KEY_IN_UI_SETTINGS) === false) { - return {}; - } - - this.coreSetup = core; - core.workspaces.client.init(); - core.workspaces.setFormatUrlWithWorkspaceId((url, id) => this.getPatchedUrl(url, id)); /** * Retrieve workspace id from url */ const workspaceId = this.getWorkspaceIdFromURL(); if (workspaceId) { - const result = await core.workspaces.client.enterWorkspace(workspaceId); + const result = await workspaceClient.enterWorkspace(workspaceId); if (!result.success) { core.fatalErrors.add( result.error || @@ -96,11 +69,15 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep */ savedObjectsManagement?.columns.register(getWorkspaceColumn(core)); - type WorkspaceAppType = (params: AppMountParameters, services: CoreStart) => () => void; + // register apps for library object management + savedObjectsManagement?.registerLibrarySubApp(); + + type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; const mountWorkspaceApp = async (params: AppMountParameters, renderApp: WorkspaceAppType) => { const [coreStart] = await core.getStartServices(); const services = { ...coreStart, + workspaceClient, }; return renderApp(params, services); @@ -168,16 +145,17 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep private workspaceToChromeNavLink( workspace: WorkspaceAttribute, - workspacesStart: WorkspacesStart, + http: HttpSetup, application: ApplicationStart, index: number ): ChromeNavLink { const id = WORKSPACE_OVERVIEW_APP_ID + '/' + workspace.id; - const url = workspacesStart?.formatUrlWithWorkspaceId( + const url = formatUrlWithWorkspaceId( application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { absolute: true, }), - workspace.id + workspace.id, + http.basePath ); return { id, @@ -197,13 +175,11 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep private async _changeSavedObjectCurrentWorkspace() { if (this.coreStart) { - return this.coreStart.workspaces.client.currentWorkspaceId$.subscribe( - (currentWorkspaceId) => { - if (currentWorkspaceId) { - this.coreStart?.savedObjects.client.setCurrentWorkspace(currentWorkspaceId); - } + return this.coreStart.workspaces.currentWorkspaceId$.subscribe((currentWorkspaceId) => { + if (currentWorkspaceId) { + this.coreStart?.savedObjects.client.setCurrentWorkspace(currentWorkspaceId); } - ); + }); } } @@ -219,8 +195,8 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep private filterNavLinks(core: CoreStart) { const navLinksService = core.chrome.navLinks; const chromeNavLinks$ = navLinksService.getNavLinks$(); - const workspaceList$ = core.workspaces.client.workspaceList$; - const currentWorkspace$ = core.workspaces.client.currentWorkspace$; + const workspaceList$ = core.workspaces.workspaceList$; + const currentWorkspace$ = core.workspaces.currentWorkspace$; combineLatest([ workspaceList$, chromeNavLinks$.pipe(map(this.changeCategoryNameByWorkspaceFeatureFlag)), @@ -235,7 +211,7 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep workspaceList .filter((workspace, index) => index < 5) .map((workspace, index) => - this.workspaceToChromeNavLink(workspace, core.workspaces, core.application, index) + this.workspaceToChromeNavLink(workspace, core.http, core.application, index) ) .forEach((workspaceNavLink) => filteredNavLinks.set(workspaceNavLink.id, workspaceNavLink) @@ -268,17 +244,13 @@ export class WorkspacesPlugin implements Plugin<{}, {}, WorkspacesPluginSetupDep } public start(core: CoreStart) { - // If workspace feature is disabled, it will not load the workspace plugin - if (core.uiSettings.get(WORKSPACE_FEATURE_FLAG_KEY_IN_UI_SETTINGS) === false) { - return {}; - } - this.coreStart = core; mountDropdownList({ application: core.application, workspaces: core.workspaces, chrome: core.chrome, + http: core.http, }); this.currentWorkspaceSubscription = this._changeSavedObjectCurrentWorkspace(); if (core) { diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts new file mode 100644 index 000000000000..71d3e3c1caf2 --- /dev/null +++ b/src/plugins/workspace/public/utils.ts @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IBasePath } from '../../../core/public'; +import { WORKSPACE_PATH_PREFIX } from '../../../core/public/utils'; + +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 = newUrl.pathname.replace(/^\/w\/([^\/]*)/, ''); + } + + newUrl.pathname = + basePath?.prepend(newUrl.pathname, { + withoutWorkspace: true, + }) || ''; + + return newUrl.toString(); +}; diff --git a/src/core/public/workspace/workspaces_client.ts b/src/plugins/workspace/public/workspace_client.ts similarity index 79% rename from src/core/public/workspace/workspaces_client.ts rename to src/plugins/workspace/public/workspace_client.ts index 2f8c204bf418..9c2fed440d1f 100644 --- a/src/core/public/workspace/workspaces_client.ts +++ b/src/plugins/workspace/public/workspace_client.ts @@ -2,19 +2,23 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -import type { PublicContract } from '@osd/utility-types'; -import { BehaviorSubject, combineLatest } from 'rxjs'; +import { combineLatest } from 'rxjs'; import { isEqual } from 'lodash'; -import { HttpFetchError, HttpFetchOptions, HttpSetup } from '../http'; -import { WorkspaceAttribute, WorkspaceFindOptions, WorkspaceRoutePermissionItem } from '.'; -import { WORKSPACES_API_BASE_URL, WORKSPACE_ERROR_REASON_MAP } from './consts'; -/** - * WorkspacesClientContract as implemented by the {@link WorkspacesClient} - * - * @public - */ -export type WorkspacesClientContract = PublicContract; +import { + HttpFetchError, + HttpFetchOptions, + HttpSetup, + WorkspaceAttribute, + WorkspaceStart, +} from '../../../core/public'; +import { WorkspacePermissionMode } from '../../../core/public'; + +const WORKSPACES_API_BASE_URL = '/api/workspaces'; + +enum WORKSPACE_ERROR_REASON_MAP { + WORKSPACE_STALED = 'WORKSPACE_STALED', +} const join = (...uriComponents: Array) => uriComponents @@ -32,21 +36,38 @@ type IResponse = error?: string; }; +type WorkspaceRoutePermissionItem = { + modes: Array< + | WorkspacePermissionMode.LibraryRead + | WorkspacePermissionMode.LibraryWrite + | WorkspacePermissionMode.Management + >; +} & ({ type: 'user'; userId: string } | { type: 'group'; group: 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 WorkspacesClient { +export class WorkspaceClient { private http: HttpSetup; - public currentWorkspaceId$ = new BehaviorSubject(''); - public workspaceList$ = new BehaviorSubject([]); - public currentWorkspace$ = new BehaviorSubject(null); - constructor(http: HttpSetup) { + private workspaces: WorkspaceStart; + + constructor(http: HttpSetup, workspaces: WorkspaceStart) { this.http = http; + this.workspaces = workspaces; - combineLatest([this.workspaceList$, this.currentWorkspaceId$]).subscribe( + combineLatest([workspaces.workspaceList$, workspaces.currentWorkspaceId$]).subscribe( ([workspaceList, currentWorkspaceId]) => { if (workspaceList.length) { const currentWorkspace = this.findWorkspace([workspaceList, currentWorkspaceId]); @@ -54,18 +75,18 @@ export class WorkspacesClient { /** * Do a simple idempotent verification here */ - if (!isEqual(currentWorkspace, this.currentWorkspace$.getValue())) { - this.currentWorkspace$.next(currentWorkspace); + if (!isEqual(currentWorkspace, workspaces.currentWorkspace$.getValue())) { + workspaces.currentWorkspace$.next(currentWorkspace); } if (currentWorkspaceId && !currentWorkspace?.id) { /** * Current workspace is staled */ - this.currentWorkspaceId$.error({ + workspaces.currentWorkspaceId$.error({ reason: WORKSPACE_ERROR_REASON_MAP.WORKSPACE_STALED, }); - this.currentWorkspace$.error({ + workspaces.currentWorkspace$.error({ reason: WORKSPACE_ERROR_REASON_MAP.WORKSPACE_STALED, }); } @@ -137,14 +158,14 @@ export class WorkspacesClient { }); if (result?.success) { - this.workspaceList$.next(result.result.workspaces); + this.workspaces.workspaceList$.next(result.result.workspaces); } } public async enterWorkspace(id: string): Promise> { const workspaceResp = await this.get(id); if (workspaceResp.success) { - this.currentWorkspaceId$.next(id); + this.workspaces.currentWorkspaceId$.next(id); return { success: true, result: null, @@ -155,7 +176,7 @@ export class WorkspacesClient { } public async exitWorkspace(): Promise> { - this.currentWorkspaceId$.next(''); + this.workspaces.currentWorkspaceId$.next(''); return { success: true, result: null, @@ -163,7 +184,7 @@ export class WorkspacesClient { } public async getCurrentWorkspaceId(): Promise> { - const currentWorkspaceId = this.currentWorkspaceId$.getValue(); + const currentWorkspaceId = this.workspaces.currentWorkspaceId$.getValue(); if (!currentWorkspaceId) { return { success: false, @@ -305,7 +326,7 @@ export class WorkspacesClient { } public stop() { - this.workspaceList$.unsubscribe(); - this.currentWorkspaceId$.unsubscribe(); + this.workspaces.workspaceList$.unsubscribe(); + this.workspaces.currentWorkspaceId$.unsubscribe(); } } diff --git a/src/plugins/workspace/server/index.ts b/src/plugins/workspace/server/index.ts new file mode 100644 index 000000000000..4e11dc50dab9 --- /dev/null +++ b/src/plugins/workspace/server/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; + +import { PluginConfigDescriptor, 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); +} + +export { MlCommonsPluginSetup, MlCommonsPluginStart } from './types'; + +export const config: PluginConfigDescriptor = { + schema: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), +}; diff --git a/src/core/server/workspaces/workspaces_service.ts b/src/plugins/workspace/server/plugin.ts similarity index 58% rename from src/core/server/workspaces/workspaces_service.ts rename to src/plugins/workspace/server/plugin.ts index 2c322e38edb6..1189af540d88 100644 --- a/src/core/server/workspaces/workspaces_service.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -2,58 +2,32 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -import { URL } from 'node:url'; import { i18n } from '@osd/i18n'; -import { CoreService } from '../../types'; -import { CoreContext } from '../core_context'; -import { InternalHttpServiceSetup } from '../http'; -import { Logger } from '../logging'; -import { registerRoutes } from './routes'; + import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, ISavedObjectsRepository, - InternalSavedObjectsServiceSetup, - SavedObjectsServiceStart, -} from '../saved_objects'; + WORKSPACE_TYPE, + ACL, + PUBLIC_WORKSPACE, + MANAGEMENT_WORKSPACE, + Permissions, + WorkspacePermissionMode, +} from '../../../core/server'; import { IWorkspaceDBImpl, WorkspaceAttribute } from './types'; -import { WorkspacesClientWithSavedObject } from './workspaces_client'; +import { WorkspaceClientWithSavedObject } from './workspace_client'; import { WorkspaceSavedObjectsClientWrapper } from './saved_objects'; -import { InternalUiSettingsServiceSetup } from '../ui_settings'; -import { uiSettings } from './ui_settings'; -import { WORKSPACE_TYPE } from './constants'; -import { MANAGEMENT_WORKSPACE, PUBLIC_WORKSPACE, PermissionMode } from '../../utils'; -import { ACL, Permissions } from '../saved_objects/permission_control/acl'; - -export interface WorkspacesServiceSetup { - client: IWorkspaceDBImpl; -} - -export interface WorkspacesServiceStart { - client: IWorkspaceDBImpl; -} - -export interface WorkspacesSetupDeps { - http: InternalHttpServiceSetup; - savedObject: InternalSavedObjectsServiceSetup; - uiSettings: InternalUiSettingsServiceSetup; -} - -export interface WorkpsaceStartDeps { - savedObjects: SavedObjectsServiceStart; -} - -export type InternalWorkspacesServiceSetup = WorkspacesServiceSetup; -export type InternalWorkspacesServiceStart = WorkspacesServiceStart; +import { registerRoutes } from './routes'; -export class WorkspacesService - implements CoreService { - private logger: Logger; +export class WorkspacePlugin implements Plugin<{}, {}> { + private readonly logger: Logger; private client?: IWorkspaceDBImpl; - constructor(coreContext: CoreContext) { - this.logger = coreContext.logger.get('workspaces-service'); - } - - private proxyWorkspaceTrafficToRealHandler(setupDeps: WorkspacesSetupDeps) { + private proxyWorkspaceTrafficToRealHandler(setupDeps: CoreSetup) { /** * Proxy all {basePath}/w/{workspaceId}{osdPath*} paths to {basePath}{osdPath*} */ @@ -70,28 +44,30 @@ export class WorkspacesService }); } - public async setup(setupDeps: WorkspacesSetupDeps): Promise { - this.logger.debug('Setting up Workspaces service'); + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get('plugins', 'workspace'); + } - setupDeps.uiSettings.register(uiSettings); + public async setup(core: CoreSetup) { + this.logger.debug('Setting up Workspaces service'); - this.client = new WorkspacesClientWithSavedObject(setupDeps); + this.client = new WorkspaceClientWithSavedObject(core); - await this.client.setup(setupDeps); + await this.client.setup(core); const workspaceSavedObjectsClientWrapper = new WorkspaceSavedObjectsClientWrapper( - setupDeps.savedObject.permissionControl + core.savedObjects.permissionControl ); - setupDeps.savedObject.addClientWrapper( + core.savedObjects.addClientWrapper( 0, 'workspace', workspaceSavedObjectsClientWrapper.wrapperFactory ); - this.proxyWorkspaceTrafficToRealHandler(setupDeps); + this.proxyWorkspaceTrafficToRealHandler(core); registerRoutes({ - http: setupDeps.http, + http: core.http, logger: this.logger, client: this.client as IWorkspaceDBImpl, }); @@ -129,15 +105,15 @@ export class WorkspacesService } } - private async setupWorkspaces(startDeps: WorkpsaceStartDeps) { + private async setupWorkspaces(startDeps: CoreStart) { const internalRepository = startDeps.savedObjects.createInternalRepository(); const publicWorkspaceACL = new ACL().addPermission( - [PermissionMode.LibraryRead, PermissionMode.LibraryWrite], + [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.LibraryWrite], { users: ['*'], } ); - const managementWorkspaceACL = new ACL().addPermission([PermissionMode.LibraryRead], { + const managementWorkspaceACL = new ACL().addPermission([WorkspacePermissionMode.LibraryRead], { users: ['*'], }); @@ -165,15 +141,15 @@ export class WorkspacesService ]); } - public async start(startDeps: WorkpsaceStartDeps): Promise { + public start(core: CoreStart) { this.logger.debug('Starting SavedObjects service'); - this.setupWorkspaces(startDeps); + this.setupWorkspaces(core); return { client: this.client as IWorkspaceDBImpl, }; } - public async stop() {} + public stop() {} } diff --git a/src/core/server/workspaces/routes/index.ts b/src/plugins/workspace/server/routes/index.ts similarity index 90% rename from src/core/server/workspaces/routes/index.ts rename to src/plugins/workspace/server/routes/index.ts index 67c3fcfd77a0..2ae62079322b 100644 --- a/src/core/server/workspaces/routes/index.ts +++ b/src/plugins/workspace/server/routes/index.ts @@ -4,18 +4,21 @@ */ import { schema } from '@osd/config-schema'; -import { PermissionMode } from '../../../utils/constants'; -import { ACL, Permissions } from '../../saved_objects/permission_control/acl'; -import { InternalHttpServiceSetup } from '../../http'; -import { Logger } from '../../logging'; +import { + ACL, + Permissions, + CoreSetup, + Logger, + WorkspacePermissionMode, +} from '../../../../core/server'; import { IWorkspaceDBImpl, WorkspaceRoutePermissionItem } from '../types'; const WORKSPACES_API_BASE_URL = '/api/workspaces'; const workspacePermissionMode = schema.oneOf([ - schema.literal(PermissionMode.LibraryRead), - schema.literal(PermissionMode.LibraryWrite), - schema.literal(PermissionMode.Management), + schema.literal(WorkspacePermissionMode.LibraryRead), + schema.literal(WorkspacePermissionMode.LibraryWrite), + schema.literal(WorkspacePermissionMode.Management), ]); const workspacePermission = schema.oneOf([ @@ -81,12 +84,12 @@ export function registerRoutes({ }: { client: IWorkspaceDBImpl; logger: Logger; - http: InternalHttpServiceSetup; + http: CoreSetup['http']; }) { - const router = http.createRouter(WORKSPACES_API_BASE_URL); + const router = http.createRouter(); router.post( { - path: '/_list', + path: `${WORKSPACES_API_BASE_URL}/_list`, validate: { body: schema.object({ search: schema.maybe(schema.string()), @@ -126,7 +129,7 @@ export function registerRoutes({ ); router.get( { - path: '/{id}', + path: `${WORKSPACES_API_BASE_URL}/{id}`, validate: { params: schema.object({ id: schema.string(), @@ -160,7 +163,7 @@ export function registerRoutes({ ); router.post( { - path: '', + path: `${WORKSPACES_API_BASE_URL}`, validate: { body: schema.object({ attributes: workspaceAttributesSchema, @@ -186,7 +189,7 @@ export function registerRoutes({ ); router.put( { - path: '/{id?}', + path: `${WORKSPACES_API_BASE_URL}/{id?}`, validate: { params: schema.object({ id: schema.string(), @@ -217,7 +220,7 @@ export function registerRoutes({ ); router.delete( { - path: '/{id?}', + path: `${WORKSPACES_API_BASE_URL}/{id?}`, validate: { params: schema.object({ id: schema.string(), diff --git a/src/core/server/workspaces/saved_objects/index.ts b/src/plugins/workspace/server/saved_objects/index.ts similarity index 100% rename from src/core/server/workspaces/saved_objects/index.ts rename to src/plugins/workspace/server/saved_objects/index.ts diff --git a/src/core/server/workspaces/saved_objects/workspace.ts b/src/plugins/workspace/server/saved_objects/workspace.ts similarity index 86% rename from src/core/server/workspaces/saved_objects/workspace.ts rename to src/plugins/workspace/server/saved_objects/workspace.ts index 284ed013b7ed..5142185b0c2d 100644 --- a/src/core/server/workspaces/saved_objects/workspace.ts +++ b/src/plugins/workspace/server/saved_objects/workspace.ts @@ -3,8 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { SavedObjectsType } from 'opensearch-dashboards/server'; -import { WORKSPACE_TYPE } from '../constants'; +import { SavedObjectsType, WORKSPACE_TYPE } from '../../../../core/server'; export const workspace: SavedObjectsType = { name: WORKSPACE_TYPE, diff --git a/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts similarity index 87% rename from src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts rename to src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts index 5f852d48a068..bd1d68bd0f7b 100644 --- a/src/core/server/workspaces/saved_objects/workspace_saved_objects_client_wrapper.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -19,11 +19,11 @@ import { SavedObjectsDeleteOptions, SavedObjectsFindOptions, SavedObjectsShareObjects, -} from 'opensearch-dashboards/server'; -import { SavedObjectsPermissionControlContract } from '../../saved_objects/permission_control/client'; -import { WORKSPACE_TYPE } from '../constants'; -import { PermissionMode } from '../../../utils'; -import { ACL } from '../../saved_objects/permission_control/acl'; + SavedObjectsPermissionControlContract, + WORKSPACE_TYPE, + ACL, + WorkspacePermissionMode, +} from '../../../../core/server'; // Can't throw unauthorized for now, the page will be refreshed if unauthorized const generateWorkspacePermissionError = () => @@ -51,8 +51,8 @@ const isWorkspacesLikeAttributes = (attributes: unknown): attributes is Attribut Array.isArray((attributes as { workspaces: unknown }).workspaces); export class WorkspaceSavedObjectsClientWrapper { - private formatPermissionModeToStringArray( - permission: PermissionMode | PermissionMode[] + private formatWorkspacePermissionModeToStringArray( + permission: WorkspacePermissionMode | WorkspacePermissionMode[] ): string[] { if (Array.isArray(permission)) { return permission; @@ -63,7 +63,7 @@ export class WorkspaceSavedObjectsClientWrapper { private async validateMultiWorkspacesPermissions( workspaces: string[] | undefined, request: OpenSearchDashboardsRequest, - permissionMode: PermissionMode | PermissionMode[] + permissionMode: WorkspacePermissionMode | WorkspacePermissionMode[] ) { if (!workspaces) { return; @@ -76,7 +76,7 @@ export class WorkspaceSavedObjectsClientWrapper { type: WORKSPACE_TYPE, id: workspaceId, }, - this.formatPermissionModeToStringArray(permissionMode) + this.formatWorkspacePermissionModeToStringArray(permissionMode) )) ) { throw generateWorkspacePermissionError(); @@ -87,7 +87,7 @@ export class WorkspaceSavedObjectsClientWrapper { private async validateAtLeastOnePermittedWorkspaces( workspaces: string[] | undefined, request: OpenSearchDashboardsRequest, - permissionMode: PermissionMode | PermissionMode[] + permissionMode: WorkspacePermissionMode | WorkspacePermissionMode[] ) { if (!workspaces) { return; @@ -101,7 +101,7 @@ export class WorkspaceSavedObjectsClientWrapper { type: WORKSPACE_TYPE, id: workspaceId, }, - this.formatPermissionModeToStringArray(permissionMode) + this.formatWorkspacePermissionModeToStringArray(permissionMode) ) ) { permitted = true; @@ -133,7 +133,7 @@ export class WorkspaceSavedObjectsClientWrapper { await this.validateMultiWorkspacesPermissions( objectToDeleted.workspaces, wrapperOptions.request, - PermissionMode.Management + WorkspacePermissionMode.Management ); return await wrapperOptions.client.delete(type, id, options); }; @@ -144,8 +144,8 @@ export class WorkspaceSavedObjectsClientWrapper { ): Promise> => { if (options.workspaces) { await this.validateMultiWorkspacesPermissions(options.workspaces, wrapperOptions.request, [ - PermissionMode.Write, - PermissionMode.Management, + WorkspacePermissionMode.Write, + WorkspacePermissionMode.Management, ]); } return await wrapperOptions.client.bulkCreate(objects, options); @@ -160,7 +160,7 @@ export class WorkspaceSavedObjectsClientWrapper { await this.validateMultiWorkspacesPermissions( attributes.workspaces, wrapperOptions.request, - PermissionMode.Management + WorkspacePermissionMode.Management ); } return await wrapperOptions.client.create(type, attributes, options); @@ -175,7 +175,7 @@ export class WorkspaceSavedObjectsClientWrapper { await this.validateAtLeastOnePermittedWorkspaces( objectToGet.workspaces, wrapperOptions.request, - PermissionMode.Read + WorkspacePermissionMode.Read ); return objectToGet; }; @@ -189,7 +189,7 @@ export class WorkspaceSavedObjectsClientWrapper { await this.validateAtLeastOnePermittedWorkspaces( object.workspaces, wrapperOptions.request, - PermissionMode.Read + WorkspacePermissionMode.Read ); } return objectToBulkGet; @@ -202,7 +202,11 @@ export class WorkspaceSavedObjectsClientWrapper { if (this.isRelatedToWorkspace(options.type)) { const queryDSLForQueryingWorkspaces = ACL.genereateGetPermittedSavedObjectsQueryDSL( - [PermissionMode.LibraryRead, PermissionMode.LibraryWrite, PermissionMode.Management], + [ + WorkspacePermissionMode.LibraryRead, + WorkspacePermissionMode.LibraryWrite, + WorkspacePermissionMode.Management, + ], principals, WORKSPACE_TYPE ); @@ -210,7 +214,11 @@ export class WorkspaceSavedObjectsClientWrapper { } else { const permittedWorkspaceIds = await this.permissionControl.getPermittedWorkspaceIds( wrapperOptions.request, - [PermissionMode.LibraryRead, PermissionMode.LibraryWrite, PermissionMode.Management] + [ + WorkspacePermissionMode.LibraryRead, + WorkspacePermissionMode.LibraryWrite, + WorkspacePermissionMode.Management, + ] ); if (options.workspaces) { const isEveryWorkspaceIsPermitted = options.workspaces.every((item) => @@ -221,7 +229,7 @@ export class WorkspaceSavedObjectsClientWrapper { } } else { const queryDSL = ACL.genereateGetPermittedSavedObjectsQueryDSL( - [PermissionMode.Read, PermissionMode.Write], + [WorkspacePermissionMode.Read, WorkspacePermissionMode.Write], principals, options.type ); @@ -270,8 +278,8 @@ export class WorkspaceSavedObjectsClientWrapper { ) => { // target workspaces await this.validateMultiWorkspacesPermissions(targetWorkspaces, wrapperOptions.request, [ - PermissionMode.LibraryWrite, - PermissionMode.Management, + WorkspacePermissionMode.LibraryWrite, + WorkspacePermissionMode.Management, ]); // saved_objects @@ -280,7 +288,7 @@ export class WorkspaceSavedObjectsClientWrapper { objects.map((savedObj) => ({ ...savedObj, })), - [PermissionMode.Write] + [WorkspacePermissionMode.Write] ); if (!permitted) { diff --git a/src/core/server/workspaces/types.ts b/src/plugins/workspace/server/types.ts similarity index 84% rename from src/core/server/workspaces/types.ts rename to src/plugins/workspace/server/types.ts index 1a1ae8583639..72afd87fff90 100644 --- a/src/core/server/workspaces/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -7,12 +7,10 @@ import { OpenSearchDashboardsRequest, RequestHandlerContext, SavedObjectsFindResponse, -} from '..'; - -import { Permissions } from '../saved_objects/permission_control/acl'; -import { PermissionMode } from '../../utils/constants'; - -import { WorkspacesSetupDeps } from './workspaces_service'; + CoreSetup, + WorkspacePermissionMode, + Permissions, +} from '../../../core/server'; export interface WorkspaceAttribute { id: string; @@ -44,7 +42,7 @@ export interface IRequestDetail { } export interface IWorkspaceDBImpl { - setup(dep: WorkspacesSetupDeps): Promise>; + setup(dep: CoreSetup): Promise>; create( requestDetail: IRequestDetail, payload: Omit @@ -84,6 +82,8 @@ export type IResponse = export type WorkspaceRoutePermissionItem = { modes: Array< - PermissionMode.LibraryRead | PermissionMode.LibraryWrite | PermissionMode.Management + | WorkspacePermissionMode.LibraryRead + | WorkspacePermissionMode.LibraryWrite + | WorkspacePermissionMode.Management >; } & ({ type: 'user'; userId: string } | { type: 'group'; group: string }); diff --git a/src/core/server/workspaces/workspaces_client.ts b/src/plugins/workspace/server/workspace_client.ts similarity index 86% rename from src/core/server/workspaces/workspaces_client.ts rename to src/plugins/workspace/server/workspace_client.ts index 698c80c1488a..a44060e7193b 100644 --- a/src/core/server/workspaces/workspaces_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -2,7 +2,8 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -import { SavedObject, SavedObjectError, SavedObjectsClientContract } from '../types'; +import type { SavedObject, SavedObjectsClientContract, CoreSetup } from '../../../core/server'; +import { WORKSPACE_TYPE } from '../../../core/server'; import { IWorkspaceDBImpl, WorkspaceAttribute, @@ -11,14 +12,12 @@ import { IRequestDetail, WorkspaceAttributeWithPermission, } from './types'; -import { WorkspacesSetupDeps } from './workspaces_service'; import { workspace } from './saved_objects'; -import { WORKSPACE_TYPE } from './constants'; -export class WorkspacesClientWithSavedObject implements IWorkspaceDBImpl { - private setupDep: WorkspacesSetupDeps; - constructor(dep: WorkspacesSetupDeps) { - this.setupDep = dep; +export class WorkspaceClientWithSavedObject implements IWorkspaceDBImpl { + private setupDep: CoreSetup; + constructor(core: CoreSetup) { + this.setupDep = core; } private getSavedObjectClientsFromRequestDetail( requestDetail: IRequestDetail @@ -34,11 +33,11 @@ export class WorkspacesClientWithSavedObject implements IWorkspaceDBImpl { id: savedObject.id, }; } - private formatError(error: SavedObjectError | Error | any): string { + private formatError(error: Error | any): string { return error.message || error.error || 'Error'; } - public async setup(dep: WorkspacesSetupDeps): Promise> { - this.setupDep.savedObject.registerType(workspace); + public async setup(core: CoreSetup): Promise> { + this.setupDep.savedObjects.registerType(workspace); return { success: true, result: true, From ac222dd72b2c718760d4bc94460cb6e779ade60d Mon Sep 17 00:00:00 2001 From: Hailong Cui Date: Fri, 11 Aug 2023 15:48:08 +0800 Subject: [PATCH 082/174] add workspace filter into saved objects page (#76) * add workspace filter into saved objects page Signed-off-by: Hailong Cui * workspace filter Signed-off-by: Hailong Cui * managment workspace filter Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui --- src/core/public/utils/index.ts | 2 +- .../public/lib/get_saved_object_counts.ts | 4 +- .../public/lib/parse_query.ts | 7 ++ .../objects_table/saved_objects_table.tsx | 114 +++++++++++++++--- .../server/routes/scroll_count.ts | 24 +++- 5 files changed, 129 insertions(+), 22 deletions(-) diff --git a/src/core/public/utils/index.ts b/src/core/public/utils/index.ts index a6d76a87e313..32ea0ba3c101 100644 --- a/src/core/public/utils/index.ts +++ b/src/core/public/utils/index.ts @@ -32,4 +32,4 @@ export { shareWeakReplay } from './share_weak_replay'; export { Sha256 } from './crypto'; export { MountWrapper, mountReactNode } from './mount'; export { getWorkspaceIdFromUrl, WORKSPACE_TYPE } from './workspace'; -export { WORKSPACE_PATH_PREFIX, PUBLIC_WORKSPACE } from '../../utils'; +export { WORKSPACE_PATH_PREFIX, PUBLIC_WORKSPACE, MANAGEMENT_WORKSPACE } from '../../utils'; diff --git a/src/plugins/saved_objects_management/public/lib/get_saved_object_counts.ts b/src/plugins/saved_objects_management/public/lib/get_saved_object_counts.ts index 9039dae2be53..374f2720b537 100644 --- a/src/plugins/saved_objects_management/public/lib/get_saved_object_counts.ts +++ b/src/plugins/saved_objects_management/public/lib/get_saved_object_counts.ts @@ -40,8 +40,8 @@ export interface SavedObjectCountOptions { export async function getSavedObjectCounts( http: HttpStart, options: SavedObjectCountOptions -): Promise> { - return await http.post>( +): Promise>> { + return await http.post>>( `/api/opensearch-dashboards/management/saved_objects/scroll/counts`, { body: JSON.stringify(options) } ); diff --git a/src/plugins/saved_objects_management/public/lib/parse_query.ts b/src/plugins/saved_objects_management/public/lib/parse_query.ts index 24c35d500aaa..3db3f7fcee1c 100644 --- a/src/plugins/saved_objects_management/public/lib/parse_query.ts +++ b/src/plugins/saved_objects_management/public/lib/parse_query.ts @@ -33,12 +33,15 @@ import { Query } from '@elastic/eui'; interface ParsedQuery { queryText?: string; visibleTypes?: string[]; + visibleNamespaces?: string[]; + visibleWorkspaces?: string[]; } export function parseQuery(query: Query): ParsedQuery { let queryText: string | undefined; let visibleTypes: string[] | undefined; let visibleNamespaces: string[] | undefined; + let visibleWorkspaces: string[] | undefined; if (query) { if (query.ast.getTermClauses().length) { @@ -53,11 +56,15 @@ export function parseQuery(query: Query): ParsedQuery { if (query.ast.getFieldClauses('namespaces')) { visibleNamespaces = query.ast.getFieldClauses('namespaces')[0].value as string[]; } + if (query.ast.getFieldClauses('workspaces')) { + visibleWorkspaces = query.ast.getFieldClauses('workspaces')[0].value as string[]; + } } return { queryText, visibleTypes, visibleNamespaces, + visibleWorkspaces, }; } 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 517b4a069266..fb621d13f8d0 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 @@ -66,8 +66,9 @@ import { OverlayStart, NotificationsStart, ApplicationStart, - PUBLIC_WORKSPACE, -} from '../../../../../core/public'; + WorkspaceAttribute, +} from 'src/core/public'; +import { Subscription } from 'rxjs'; import { RedirectAppLinks } from '../../../../opensearch_dashboards_react/public'; import { IndexPatternsContract } from '../../../../data/public'; import { @@ -95,6 +96,7 @@ import { import { Header, Table, Flyout, Relationships } from './components'; import { DataPublicPluginStart } from '../../../../../plugins/data/public'; import { SavedObjectsCopyModal } from './components/copy_modal'; +import { PUBLIC_WORKSPACE, MANAGEMENT_WORKSPACE } from '../../../../../core/public'; interface ExportAllOption { id: string; @@ -128,7 +130,7 @@ export interface SavedObjectsTableState { page: number; perPage: number; savedObjects: SavedObjectWithMetadata[]; - savedObjectCounts: Record; + savedObjectCounts: Record>; activeQuery: Query; selectedSavedObjects: SavedObjectWithMetadata[]; isShowingImportFlyout: boolean; @@ -144,23 +146,28 @@ export interface SavedObjectsTableState { exportAllSelectedOptions: Record; isIncludeReferencesDeepChecked: boolean; workspaceId: string | null; + availableWorkspace?: WorkspaceAttribute[]; } export class SavedObjectsTable extends Component { private _isMounted = false; + private currentWorkspaceIdSubscription?: Subscription; + private workspacesSubscription?: Subscription; constructor(props: SavedObjectsTableProps) { super(props); + const typeCounts = props.allowedTypes.reduce((typeToCountMap, type) => { + typeToCountMap[type] = 0; + return typeToCountMap; + }, {} as Record); + this.state = { totalCount: 0, page: 0, perPage: props.perPageConfig || 50, savedObjects: [], - savedObjectCounts: props.allowedTypes.reduce((typeToCountMap, type) => { - typeToCountMap[type] = 0; - return typeToCountMap; - }, {} as Record), + savedObjectCounts: { type: typeCounts } as Record>, activeQuery: Query.parse(''), selectedSavedObjects: [], isShowingImportFlyout: false, @@ -176,22 +183,37 @@ export class SavedObjectsTable extends Component ws.id); + } else if (workspaceId === PUBLIC_WORKSPACE) { + return [PUBLIC_WORKSPACE]; + } else { + return [workspaceId, PUBLIC_WORKSPACE]; + } + } + + private get wsNameIdLookup() { + const { availableWorkspace } = this.state; + // Assumption: workspace name is unique across the system + return availableWorkspace?.reduce((map, ws) => { + return map.set(ws.name, ws.id); + }, new Map()); } componentDidMount() { this._isMounted = true; - this.props.workspaces.currentWorkspaceId$.subscribe((workspaceId) => - this.setState({ - workspaceId, - }) - ); + + this.fetchWorkspace(); this.fetchSavedObjects(); this.fetchCounts(); } @@ -199,11 +221,15 @@ export class SavedObjectsTable extends Component { const { allowedTypes, namespaceRegistry } = this.props; - const { queryText, visibleTypes, visibleNamespaces } = parseQuery(this.state.activeQuery); + const { queryText, visibleTypes, visibleNamespaces, visibleWorkspaces } = parseQuery( + this.state.activeQuery + ); const filteredTypes = filterQuery(allowedTypes, visibleTypes); @@ -219,6 +245,11 @@ export class SavedObjectsTable extends Component this.wsNameIdLookup?.get(wsName) || PUBLIC_WORKSPACE + ); + } // These are the saved objects visible in the table. const filteredSavedObjectCounts = await getSavedObjectCounts( @@ -268,6 +299,19 @@ export class SavedObjectsTable extends Component { + const workspace = this.props.workspaces; + this.currentWorkspaceIdSubscription = workspace.currentWorkspaceId$.subscribe((workspaceId) => + this.setState({ + workspaceId, + }) + ); + + this.workspacesSubscription = workspace.workspaceList$.subscribe((workspaceList) => { + this.setState({ availableWorkspace: workspaceList }); + }); + }; + fetchSavedObject = (type: string, id: string) => { this.setState({ isSearching: true }, () => this.debouncedFetchObject(type, id)); }; @@ -275,7 +319,7 @@ export class SavedObjectsTable extends Component { const { activeQuery: query, page, perPage } = this.state; const { notifications, http, allowedTypes, namespaceRegistry } = this.props; - const { queryText, visibleTypes, visibleNamespaces } = parseQuery(query); + const { queryText, visibleTypes, visibleNamespaces, visibleWorkspaces } = parseQuery(query); const filteredTypes = filterQuery(allowedTypes, visibleTypes); // "searchFields" is missing from the "findOptions" but gets injected via the API. // The API extracts the fields from each uiExports.savedObjectsManagement "defaultSearchField" attribute @@ -294,6 +338,13 @@ export class SavedObjectsTable extends Component this.wsNameIdLookup?.get(wsName) || PUBLIC_WORKSPACE + ); + findOptions.workspaces = workspaceIds; + } + if (findOptions.type.length > 1) { findOptions.sortField = 'type'; } @@ -880,6 +931,7 @@ export class SavedObjectsTable extends Component { + return this.workspaceIdQuery?.includes(ws.id); + }) + .map((ws) => { + return { + name: ws.name, + value: ws.name, + view: `${ws.name} (${wsCounts[ws.id] || 0})`, + }; + }); + + filters.push({ + type: 'field_value_selection', + field: 'workspaces', + name: + namespaceRegistry.getAlias() || + i18n.translate('savedObjectsManagement.objectsTable.table.workspaceFilterName', { + defaultMessage: 'Workspaces', + }), + multiSelect: 'or', + options: wsFilterOptions, + }); + } + return ( { }, router.handleLegacyErrors(async (context, req, res) => { const { client } = context.core.savedObjects; - const counts = { + const counts: Record> = { type: {}, }; const findOptions: SavedObjectsFindOptions = { type: req.body.typesToInclude, perPage: 1000, - workspaces: req.body.workspaces, }; const requestHasNamespaces = Array.isArray(req.body.namespacesToInclude) && req.body.namespacesToInclude.length; + const requestHasWorkspaces = Array.isArray(req.body.workspaces) && req.body.workspaces.length; + if (requestHasNamespaces) { counts.namespaces = {}; findOptions.namespaces = req.body.namespacesToInclude; } + if (requestHasWorkspaces) { + counts.workspaces = {}; + findOptions.workspaces = req.body.workspaces; + } + if (req.body.searchString) { findOptions.search = `${req.body.searchString}*`; findOptions.searchFields = ['title']; @@ -84,6 +90,13 @@ export const registerScrollForCountRoute = (router: IRouter) => { counts.namespaces[ns]++; }); } + if (requestHasWorkspaces) { + const resultWorkspaces = result.workspaces || ['public']; + resultWorkspaces.forEach((ws) => { + counts.workspaces[ws] = counts.workspaces[ws] || 0; + counts.workspaces[ws]++; + }); + } counts.type[type] = counts.type[type] || 0; counts.type[type]++; }); @@ -101,6 +114,13 @@ export const registerScrollForCountRoute = (router: IRouter) => { } } + const workspacesToInclude = req.body.workspaces || []; + for (const ws of workspacesToInclude) { + if (!counts.workspaces[ws]) { + counts.workspaces[ws] = 0; + } + } + return res.ok({ body: counts, }); From 561a3311040c563c34899592b5d1e86418373656 Mon Sep 17 00:00:00 2001 From: raintygao Date: Fri, 11 Aug 2023 16:43:25 +0800 Subject: [PATCH 083/174] add permission check when updating workspace (#81) * feat: add permission check when updating workspace Signed-off-by: tygao * fix: only use management access and update bulkUpdate logic Signed-off-by: tygao * chore: update code Signed-off-by: tygao * chore: update code after rebase Signed-off-by: tygao --------- Signed-off-by: tygao --- .../workspace_saved_objects_client_wrapper.ts | 73 ++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts index bd1d68bd0f7b..170498fb40bb 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -19,6 +19,11 @@ import { SavedObjectsDeleteOptions, SavedObjectsFindOptions, SavedObjectsShareObjects, + SavedObjectsUpdateOptions, + SavedObjectsUpdateResponse, + SavedObjectsBulkUpdateObject, + SavedObjectsBulkUpdateResponse, + SavedObjectsBulkUpdateOptions, SavedObjectsPermissionControlContract, WORKSPACE_TYPE, ACL, @@ -60,6 +65,28 @@ export class WorkspaceSavedObjectsClientWrapper { return [permission]; } + private async validateSingleWorkspacePermissions( + workspaceId: string | undefined, + request: OpenSearchDashboardsRequest, + permissionMode: WorkspacePermissionMode | WorkspacePermissionMode[] + ) { + if (!workspaceId) { + return; + } + if ( + !(await this.permissionControl.validate( + request, + { + type: WORKSPACE_TYPE, + id: workspaceId, + }, + this.formatWorkspacePermissionModeToStringArray(permissionMode) + )) + ) { + throw generateWorkspacePermissionError(); + } + } + private async validateMultiWorkspacesPermissions( workspaces: string[] | undefined, request: OpenSearchDashboardsRequest, @@ -129,6 +156,12 @@ export class WorkspaceSavedObjectsClientWrapper { id: string, options: SavedObjectsDeleteOptions = {} ) => { + if (this.isRelatedToWorkspace(type)) { + await this.validateSingleWorkspacePermissions(id, wrapperOptions.request, [ + WorkspacePermissionMode.Management, + ]); + } + const objectToDeleted = await wrapperOptions.client.get(type, id, options); await this.validateMultiWorkspacesPermissions( objectToDeleted.workspaces, @@ -138,6 +171,42 @@ export class WorkspaceSavedObjectsClientWrapper { return await wrapperOptions.client.delete(type, id, options); }; + const updateWithWorkspacePermissionControl = async ( + type: string, + id: string, + attributes: Partial, + options: SavedObjectsUpdateOptions = {} + ): Promise> => { + if (this.isRelatedToWorkspace(type)) { + await this.validateSingleWorkspacePermissions(id, wrapperOptions.request, [ + WorkspacePermissionMode.Management, + ]); + } + return await wrapperOptions.client.update(type, id, attributes, options); + }; + + const bulkUpdateWithWorkspacePermissionControl = async ( + objects: Array>, + options?: SavedObjectsBulkUpdateOptions + ): Promise> => { + const workspaceIds = objects.reduce((acc, cur) => { + if (this.isRelatedToWorkspace(cur.type)) { + acc.push(cur.id); + } + return acc; + }, []); + const permittedWorkspaceIds = + (await this.permissionControl.getPermittedWorkspaceIds(wrapperOptions.request, [ + WorkspacePermissionMode.Management, + ])) ?? []; + const workspacePermitted = workspaceIds.every((id) => permittedWorkspaceIds.includes(id)); + if (!workspacePermitted) { + throw generateWorkspacePermissionError(); + } + + return await wrapperOptions.client.bulkUpdate(objects, options); + }; + const bulkCreateWithWorkspacePermissionControl = async ( objects: Array>, options: SavedObjectsCreateOptions = {} @@ -310,8 +379,8 @@ export class WorkspaceSavedObjectsClientWrapper { create: createWithWorkspacePermissionControl, bulkCreate: bulkCreateWithWorkspacePermissionControl, delete: deleteWithWorkspacePermissionControl, - update: wrapperOptions.client.update, - bulkUpdate: wrapperOptions.client.bulkUpdate, + update: updateWithWorkspacePermissionControl, + bulkUpdate: bulkUpdateWithWorkspacePermissionControl, addToWorkspaces: addToWorkspacesWithPermissionControl, }; }; From 46218abd030be756dec7dafb2c086100dc21ac87 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Fri, 11 Aug 2023 16:50:51 +0800 Subject: [PATCH 084/174] Show objects without workspace info when no workspaces are provided in find query. (#83) * temp: modify Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe --- .../workspace_saved_objects_client_wrapper.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts index 170498fb40bb..5b2b45b353d7 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -327,6 +327,16 @@ export class WorkspaceSavedObjectsClientWrapper { workspaces: permittedWorkspaceIds, }, }, + // TODO: remove this child clause when home workspace proposal is finalized. + { + bool: { + must_not: { + exists: { + field: 'workspaces', + }, + }, + }, + }, ], }, }, From 86d65705103a4a9ccca7c7e29b15665f736a550f Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Tue, 15 Aug 2023 10:34:34 +0800 Subject: [PATCH 085/174] feat: call saved objects with internal user (#80) Signed-off-by: Lin Wang --- src/plugins/workspace/server/plugin.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index 1189af540d88..710eaeea1819 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -17,6 +17,7 @@ import { MANAGEMENT_WORKSPACE, Permissions, WorkspacePermissionMode, + SavedObjectsClient, } from '../../../core/server'; import { IWorkspaceDBImpl, WorkspaceAttribute } from './types'; import { WorkspaceClientWithSavedObject } from './workspace_client'; @@ -72,6 +73,10 @@ export class WorkspacePlugin implements Plugin<{}, {}> { client: this.client as IWorkspaceDBImpl, }); + core.savedObjects.setClientFactoryProvider((repositoryFactory) => () => + new SavedObjectsClient(repositoryFactory.createInternalRepository()) + ); + return { client: this.client, }; From 60804aa86a5ac592187a481c8515055485a7e00a Mon Sep 17 00:00:00 2001 From: Yuye Zhu Date: Thu, 17 Aug 2023 07:55:58 +0800 Subject: [PATCH 086/174] feat: workspace context menu and picker menu (#86) * place current workspace at the top of worksapce list Signed-off-by: yuye-aws * prototype for workspace context menu and picker menu Signed-off-by: yuye-aws * resolve import issue and add props to test Signed-off-by: yuye-aws * move formatUrlWithWorkspaceId from plugin workspace to core Signed-off-by: yuye-aws * add workspaceEnabled props Signed-off-by: yuye-aws * implement logo and color for context and picker menu Signed-off-by: yuye-aws * bold texts Signed-off-by: yuye-aws * workspace disabled left menu header Signed-off-by: yuye-aws * move workspace applications to picker menu and context menu Signed-off-by: yuye-aws * refactor workspace disabled logic Signed-off-by: yuye-aws * add app id constants Signed-off-by: yuye-aws * only highlight current workspace Signed-off-by: yuye-aws * fix type error and key error Signed-off-by: yuye-aws * fix icon bug and import management workspace const Signed-off-by: yuye-aws * change const order Signed-off-by: yuye-aws * warp string with i18n Signed-off-by: yuye-aws * refactor getFilteredWorkspaceList function Signed-off-by: yuye-aws * remove unused props Signed-off-by: yuye-aws * avoid inline styles Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws --- src/core/public/chrome/chrome_service.tsx | 7 +- src/core/public/chrome/constants.ts | 4 + .../chrome/ui/header/collapsible_nav.test.tsx | 4 +- .../chrome/ui/header/collapsible_nav.tsx | 17 +- .../ui/header/collapsible_nav_header.tsx | 237 ++++++++++++++++++ .../public/chrome/ui/header/header.test.tsx | 3 +- src/core/public/chrome/ui/header/header.tsx | 7 +- src/core/public/chrome/ui/header/nav_link.tsx | 1 - src/core/public/core_system.ts | 1 + src/core/public/utils/index.ts | 2 +- src/core/public/utils/workspace.ts | 27 ++ .../workspace/workspaces_service.mock.ts | 2 + .../public/workspace/workspaces_service.ts | 4 + .../public/components/utils/workspace.ts | 2 +- .../workspace_creator/workspace_creator.tsx | 6 +- .../workspace_updater/workspace_updater.tsx | 2 +- src/plugins/workspace/public/plugin.ts | 55 +--- src/plugins/workspace/public/utils.ts | 31 --- 18 files changed, 311 insertions(+), 101 deletions(-) create mode 100644 src/core/public/chrome/ui/header/collapsible_nav_header.tsx delete mode 100644 src/plugins/workspace/public/utils.ts diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index d3dbdc3f5a04..bdd4dda1b090 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -34,7 +34,7 @@ import { FormattedMessage } from '@osd/i18n/react'; import { BehaviorSubject, combineLatest, merge, Observable, of, ReplaySubject } from 'rxjs'; import { flatMap, map, takeUntil } from 'rxjs/operators'; import { EuiLink } from '@elastic/eui'; -import { mountReactNode } from '../utils/mount'; +import { mountReactNode } from '../utils'; import { InternalApplicationStart } from '../application'; import { DocLinksStart } from '../doc_links'; import { HttpStart } from '../http'; @@ -48,7 +48,7 @@ import { ChromeNavLinks, NavLinksService, ChromeNavLink } from './nav_links'; import { ChromeRecentlyAccessed, RecentlyAccessedService } from './recently_accessed'; import { Header } from './ui'; import { ChromeHelpExtensionMenuLink } from './ui/header/header_help_menu'; -import { Branding } from '../'; +import { Branding, WorkspaceStart } from '../'; import { getLogos } from '../../common'; import type { Logos } from '../../common/types'; @@ -96,6 +96,7 @@ interface StartDeps { injectedMetadata: InjectedMetadataStart; notifications: NotificationsStart; uiSettings: IUiSettingsClient; + workspaces: WorkspaceStart; } /** @internal */ @@ -149,6 +150,7 @@ export class ChromeService { injectedMetadata, notifications, uiSettings, + workspaces, }: StartDeps): Promise { this.initVisibility(application); @@ -262,6 +264,7 @@ export class ChromeService { branding={injectedMetadata.getBranding()} logos={logos} survey={injectedMetadata.getSurvey()} + workspaces={workspaces} /> ), diff --git a/src/core/public/chrome/constants.ts b/src/core/public/chrome/constants.ts index 5008f8b4a69a..9fcf531f3ffe 100644 --- a/src/core/public/chrome/constants.ts +++ b/src/core/public/chrome/constants.ts @@ -31,3 +31,7 @@ export const OPENSEARCH_DASHBOARDS_ASK_OPENSEARCH_LINK = 'https://forum.opensearch.org/'; export const GITHUB_CREATE_ISSUE_LINK = 'https://github.com/opensearch-project/OpenSearch-Dashboards/issues/new/choose'; + +export const WORKSPACE_CREATE_APP_ID = 'workspace_create'; +export const WORKSPACE_LIST_APP_ID = 'workspace_list'; +export const WORKSPACE_OVERVIEW_APP_ID = 'workspace_overview'; diff --git a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx index 7e18e47280c6..df8568d5b4ed 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx @@ -93,11 +93,11 @@ function mockProps(branding = {}) { closeNav: () => {}, navigateToApp: () => Promise.resolve(), navigateToUrl: () => Promise.resolve(), + getUrlForApp: jest.fn(), + workspaces: workspacesServiceMock.createStartContract(), customNavLink$: new BehaviorSubject(undefined), branding, logos: getLogos(branding, mockBasePath.serverBasePath), - currentWorkspace$: workspacesServiceMock.createStartContract().client.currentWorkspace$, - workspaceList$: workspacesServiceMock.createStartContract().client.workspaceList$, }; } diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index d4c7c99f797f..8f38521ae6bd 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -43,14 +43,15 @@ import { groupBy, sortBy } from 'lodash'; import React, { useRef } from 'react'; import useObservable from 'react-use/lib/useObservable'; import * as Rx from 'rxjs'; +import { WorkspaceStart } from 'opensearch-dashboards/public'; import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem } from '../..'; import { AppCategory } from '../../../../types'; import { InternalApplicationStart } from '../../../application'; import { HttpStart } from '../../../http'; import { OnIsLockedUpdate } from './'; import type { Logos } from '../../../../common/types'; -import { WorkspaceAttribute } from '../../../workspace'; import { createEuiListItem, isModifiedOrPrevented, createRecentNavLink } from './nav_link'; +import { CollapsibleNavHeader } from './collapsible_nav_header'; function getAllCategories(allCategorizedLinks: Record) { const allCategories = {} as Record; @@ -122,14 +123,12 @@ interface Props { storage?: Storage; onIsLockedUpdate: OnIsLockedUpdate; closeNav: () => void; + getUrlForApp: InternalApplicationStart['getUrlForApp']; navigateToApp: InternalApplicationStart['navigateToApp']; navigateToUrl: InternalApplicationStart['navigateToUrl']; customNavLink$: Rx.Observable; logos: Logos; - exitWorkspace: () => void; - getWorkspaceUrl: (id: string) => string; - currentWorkspace$: Rx.BehaviorSubject; - workspaceList$: Rx.BehaviorSubject; + workspaces: WorkspaceStart; } export function CollapsibleNav({ @@ -141,9 +140,11 @@ export function CollapsibleNav({ storage = window.localStorage, onIsLockedUpdate, closeNav, + getUrlForApp, navigateToApp, navigateToUrl, logos, + workspaces, ...observables }: Props) { const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); @@ -184,6 +185,12 @@ export function CollapsibleNav({ outsideClickCloses={false} > + + {/* Recently viewed */} workspace.id !== MANAGEMENT_WORKSPACE && workspace.id !== currentWorkspace?.id + ), + ].slice(0, 5); +} + +export function CollapsibleNavHeader({ workspaces, getUrlForApp, basePath }: Props) { + const workspaceEnabled = useObservable(workspaces.workspaceEnabled$, false); + const workspaceList = useObservable(workspaces.workspaceList$, []); + const currentWorkspace = useObservable(workspaces.currentWorkspace$, null); + const filteredWorkspaceList = getFilteredWorkspaceList(workspaceList, currentWorkspace); + const defaultHeaderName = i18n.translate( + 'core.ui.primaryNav.workspacePickerMenu.defaultHeaderName', + { + defaultMessage: 'OpenSearch Analytics', + } + ); + const managementWorkspaceName = + workspaceList.find((workspace) => workspace.id === MANAGEMENT_WORKSPACE)?.name ?? + i18n.translate('core.ui.primaryNav.workspacePickerMenu.managementWorkspaceName', { + defaultMessage: 'Management', + }); + const currentWorkspaceName = currentWorkspace?.name ?? defaultHeaderName; + const [isPopoverOpen, setPopover] = useState(false); + + if (!workspaceEnabled) { + return ( + + + + + + + + {defaultHeaderName} + + + + + ); + } + const onButtonClick = () => { + setPopover(!isPopoverOpen); + }; + + const closePopover = () => { + setPopover(false); + }; + + const workspaceToItem = (workspace: WorkspaceAttribute, index: number) => { + const href = formatUrlWithWorkspaceId( + getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + absolute: false, + }), + workspace.id, + basePath + ); + const name = + currentWorkspace !== null && index === 0 ? ( + + {workspace.name} + + ) : ( + workspace.name + ); + return { + href, + name, + key: index.toString(), + icon: , + }; + }; + + const getWorkspaceListItems = () => { + const workspaceListItems = filteredWorkspaceList.map((workspace, index) => + workspaceToItem(workspace, index) + ); + const length = workspaceListItems.length; + workspaceListItems.push({ + icon: , + name: i18n.translate('core.ui.primaryNav.workspaceContextMenu.createWorkspace', { + defaultMessage: 'Create workspace', + }), + key: length.toString(), + href: formatUrlWithWorkspaceId( + getUrlForApp(WORKSPACE_CREATE_APP_ID, { + absolute: false, + }), + currentWorkspace?.id ?? '', + basePath + ), + }); + workspaceListItems.push({ + icon: , + name: i18n.translate('core.ui.primaryNav.workspaceContextMenu.allWorkspace', { + defaultMessage: 'All workspaces', + }), + key: (length + 1).toString(), + href: formatUrlWithWorkspaceId( + getUrlForApp(WORKSPACE_LIST_APP_ID, { + absolute: false, + }), + currentWorkspace?.id ?? '', + basePath + ), + }); + return workspaceListItems; + }; + + const currentWorkspaceButton = ( + + + + + + + + {currentWorkspaceName} + + + + + + + + ); + + const currentWorkspaceTitle = ( + + + + + + + {currentWorkspaceName} + + + + + + + ); + + const panels = [ + { + id: 0, + title: currentWorkspaceTitle, + items: [ + { + name: ( + + + {i18n.translate('core.ui.primaryNav.workspacePickerMenu.workspaceList', { + defaultMessage: 'Workspaces', + })} + + + ), + icon: 'folderClosed', + panel: 1, + }, + { + name: managementWorkspaceName, + icon: 'managementApp', + href: formatUrlWithWorkspaceId( + getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + absolute: false, + }), + MANAGEMENT_WORKSPACE, + basePath + ), + }, + ], + }, + { + id: 1, + title: 'Workspaces', + items: getWorkspaceListItems(), + }, + ]; + + return ( + + + + ); +} diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx index 97c13aff36a2..b039a37b471b 100644 --- a/src/core/public/chrome/ui/header/header.test.tsx +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -33,7 +33,7 @@ import { act } from 'react-dom/test-utils'; import { BehaviorSubject } from 'rxjs'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { httpServiceMock } from '../../../http/http_service.mock'; -import { applicationServiceMock, chromeServiceMock } from '../../../mocks'; +import { applicationServiceMock, chromeServiceMock, workspacesServiceMock } from '../../../mocks'; import { Header } from './header'; import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; @@ -74,6 +74,7 @@ function mockProps() { getWorkspaceUrl: (id: string) => '', survey: '/', logos: chromeServiceMock.createStartContract().logos, + workspaces: workspacesServiceMock.createStartContract(), }; } diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index acc7c6869145..36560bc75764 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -44,6 +44,7 @@ import classnames from 'classnames'; import React, { createRef, useState } from 'react'; import useObservable from 'react-use/lib/useObservable'; import { Observable } from 'rxjs'; +import { WorkspaceStart } from 'opensearch-dashboards/public'; import { LoadingIndicator } from '../'; import { ChromeBadge, @@ -52,7 +53,7 @@ import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem, } from '../..'; -import { InternalApplicationStart } from '../../../application/types'; +import { InternalApplicationStart } from '../../../application'; import { HttpStart } from '../../../http'; import { ChromeHelpExtension, ChromeBranding } from '../../chrome_service'; import { OnIsLockedUpdate } from './'; @@ -93,6 +94,7 @@ export interface HeaderProps { branding: ChromeBranding; logos: Logos; survey: string | undefined; + workspaces: WorkspaceStart; } export function Header({ @@ -105,6 +107,7 @@ export function Header({ branding, survey, logos, + workspaces, ...observables }: HeaderProps) { const isVisible = useObservable(observables.isVisible$, false); @@ -253,6 +256,7 @@ export function Header({ isNavOpen={isNavOpen} homeHref={homeHref} basePath={basePath} + getUrlForApp={application.getUrlForApp} navigateToApp={application.navigateToApp} navigateToUrl={application.navigateToUrl} onIsLockedUpdate={onIsLockedUpdate} @@ -264,6 +268,7 @@ export function Header({ }} customNavLink$={observables.customNavLink$} logos={logos} + workspaces={workspaces} />
    diff --git a/src/core/public/chrome/ui/header/nav_link.tsx b/src/core/public/chrome/ui/header/nav_link.tsx index 832708122d5e..ea35e192e7bd 100644 --- a/src/core/public/chrome/ui/header/nav_link.tsx +++ b/src/core/public/chrome/ui/header/nav_link.tsx @@ -73,7 +73,6 @@ export function createEuiListItem({ } if ( - !link.externalLink && // ignore external links event.button === 0 && // ignore everything but left clicks !isModifiedOrPrevented(event) ) { diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index d4683087cdab..356e3dd0690a 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -233,6 +233,7 @@ export class CoreSystem { injectedMetadata, notifications, uiSettings, + workspaces, }); this.coreApp.start({ application, http, notifications, uiSettings }); diff --git a/src/core/public/utils/index.ts b/src/core/public/utils/index.ts index 32ea0ba3c101..13d01ef1fe56 100644 --- a/src/core/public/utils/index.ts +++ b/src/core/public/utils/index.ts @@ -31,5 +31,5 @@ export { shareWeakReplay } from './share_weak_replay'; export { Sha256 } from './crypto'; export { MountWrapper, mountReactNode } from './mount'; -export { getWorkspaceIdFromUrl, WORKSPACE_TYPE } from './workspace'; +export { getWorkspaceIdFromUrl, WORKSPACE_TYPE, formatUrlWithWorkspaceId } from './workspace'; export { WORKSPACE_PATH_PREFIX, PUBLIC_WORKSPACE, MANAGEMENT_WORKSPACE } from '../../utils'; diff --git a/src/core/public/utils/workspace.ts b/src/core/public/utils/workspace.ts index 9a0f55b3fa8c..33012d4fbe4a 100644 --- a/src/core/public/utils/workspace.ts +++ b/src/core/public/utils/workspace.ts @@ -3,6 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { IBasePath } from '../http'; +import { WORKSPACE_PATH_PREFIX } from '../../utils'; + export const getWorkspaceIdFromUrl = (url: string): string => { const regexp = /\/w\/([^\/]*)/; const urlObject = new URL(url); @@ -14,4 +17,28 @@ export const getWorkspaceIdFromUrl = (url: string): string => { return ''; }; +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 = newUrl.pathname.replace(/^\/w\/([^\/]*)/, ''); + } + + newUrl.pathname = + basePath?.prepend(newUrl.pathname, { + withoutWorkspace: true, + }) || ''; + + return newUrl.toString(); +}; + export const WORKSPACE_TYPE = 'workspace'; diff --git a/src/core/public/workspace/workspaces_service.mock.ts b/src/core/public/workspace/workspaces_service.mock.ts index 08e2ae597713..57b9d976e4e5 100644 --- a/src/core/public/workspace/workspaces_service.mock.ts +++ b/src/core/public/workspace/workspaces_service.mock.ts @@ -9,11 +9,13 @@ import { WorkspaceAttribute } from '../workspace'; const currentWorkspaceId$ = new BehaviorSubject(''); const workspaceList$ = new BehaviorSubject([]); const currentWorkspace$ = new BehaviorSubject(null); +const workspaceEnabled$ = new BehaviorSubject(false); const createWorkspacesSetupContractMock = () => ({ currentWorkspaceId$, workspaceList$, currentWorkspace$, + workspaceEnabled$, }); const createWorkspacesStartContractMock = createWorkspacesSetupContractMock; diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts index eb75bc2e81f5..edf4ec998731 100644 --- a/src/core/public/workspace/workspaces_service.ts +++ b/src/core/public/workspace/workspaces_service.ts @@ -12,6 +12,7 @@ export interface WorkspaceStart { currentWorkspaceId$: BehaviorSubject; currentWorkspace$: BehaviorSubject; workspaceList$: BehaviorSubject; + workspaceEnabled$: BehaviorSubject; } export type WorkspaceSetup = WorkspaceStart; @@ -30,12 +31,14 @@ export class WorkspaceService implements CoreService(''); private workspaceList$ = new BehaviorSubject([]); private currentWorkspace$ = new BehaviorSubject(null); + private workspaceEnabled$ = new BehaviorSubject(false); public setup(): WorkspaceSetup { return { currentWorkspaceId$: this.currentWorkspaceId$, currentWorkspace$: this.currentWorkspace$, workspaceList$: this.workspaceList$, + workspaceEnabled$: this.workspaceEnabled$, }; } @@ -44,6 +47,7 @@ export class WorkspaceService implements CoreService; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx index eec5a03392aa..bfdbc1536fc3 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -6,12 +6,10 @@ import React, { useCallback } from 'react'; import { EuiPage, EuiPageBody, EuiPageHeader, EuiPageContent } from '@elastic/eui'; import { i18n } from '@osd/i18n'; - -import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public'; - +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { WorkspaceForm, WorkspaceFormData } from './workspace_form'; import { WORKSPACE_OVERVIEW_APP_ID, WORKSPACE_OP_TYPE_CREATE } from '../../../common/constants'; -import { formatUrlWithWorkspaceId } from '../../utils'; +import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; import { WorkspaceClient } from '../../workspace_client'; export const WorkspaceCreator = () => { diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx index c474b4c3a2df..972dc91d40c8 100644 --- a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx @@ -20,7 +20,7 @@ import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react import { WorkspaceForm, WorkspaceFormData } from '../workspace_creator/workspace_form'; import { WORKSPACE_OVERVIEW_APP_ID, WORKSPACE_OP_TYPE_UPDATE } from '../../../common/constants'; import { DeleteWorkspaceModal } from '../delete_workspace_modal'; -import { formatUrlWithWorkspaceId } from '../../utils'; +import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; import { WorkspaceClient } from '../../workspace_client'; export const WorkspaceUpdater = () => { diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index ebc204a351a3..aafb22ead142 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -8,7 +8,6 @@ import type { Subscription } from 'rxjs'; import { combineLatest } from 'rxjs'; import { map } from 'rxjs/operators'; import { - ApplicationStart, AppMountParameters, AppNavLinkStatus, ChromeNavLink, @@ -17,7 +16,6 @@ import { Plugin, WorkspaceAttribute, DEFAULT_APP_CATEGORIES, - HttpSetup, } from '../../../core/public'; import { WORKSPACE_LIST_APP_ID, @@ -30,7 +28,6 @@ import { mountDropdownList } from './mount'; import { SavedObjectsManagementPluginSetup } from '../../saved_objects_management/public'; import { getWorkspaceColumn } from './components/utils/workspace_column'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; -import { formatUrlWithWorkspaceId } from './utils'; import { WorkspaceClient } from './workspace_client'; import { Services } from './application'; @@ -47,6 +44,7 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> public async setup(core: CoreSetup, { savedObjectsManagement }: WorkspacePluginSetupDeps) { const workspaceClient = new WorkspaceClient(core.http, core.workspaces); workspaceClient.init(); + core.workspaces.workspaceEnabled$.next(true); /** * Retrieve workspace id from url @@ -133,7 +131,7 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> }), euiIconType: 'folderClosed', category: WORKSPACE_NAV_CATEGORY, - navLinkStatus: workspaceId ? AppNavLinkStatus.hidden : AppNavLinkStatus.default, + navLinkStatus: AppNavLinkStatus.hidden, async mount(params: AppMountParameters) { const { renderListApp } = await import('./application'); return mountWorkspaceApp(params, renderListApp); @@ -143,36 +141,6 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> return {}; } - private workspaceToChromeNavLink( - workspace: WorkspaceAttribute, - http: HttpSetup, - application: ApplicationStart, - index: number - ): ChromeNavLink { - const id = WORKSPACE_OVERVIEW_APP_ID + '/' + workspace.id; - const url = formatUrlWithWorkspaceId( - application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { - absolute: true, - }), - workspace.id, - http.basePath - ); - return { - id, - url, - order: index, - hidden: false, - disabled: false, - baseUrl: url, - href: url, - category: WORKSPACE_NAV_CATEGORY, - title: i18n.translate('core.ui.workspaceNavList.workspaceName', { - defaultMessage: workspace.name, - }), - externalLink: true, - }; - } - private async _changeSavedObjectCurrentWorkspace() { if (this.coreStart) { return this.coreStart.workspaces.currentWorkspaceId$.subscribe((currentWorkspaceId) => { @@ -183,10 +151,7 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> } } - private filterByWorkspace( - workspace: WorkspaceAttribute | null | undefined, - allNavLinks: ChromeNavLink[] - ) { + private filterByWorkspace(workspace: WorkspaceAttribute | null, allNavLinks: ChromeNavLink[]) { if (!workspace) return allNavLinks; const features = workspace.features ?? []; return allNavLinks.filter((item) => features.includes(item.id)); @@ -195,28 +160,16 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> private filterNavLinks(core: CoreStart) { const navLinksService = core.chrome.navLinks; const chromeNavLinks$ = navLinksService.getNavLinks$(); - const workspaceList$ = core.workspaces.workspaceList$; const currentWorkspace$ = core.workspaces.currentWorkspace$; combineLatest([ - workspaceList$, chromeNavLinks$.pipe(map(this.changeCategoryNameByWorkspaceFeatureFlag)), currentWorkspace$, - ]).subscribe(([workspaceList, chromeNavLinks, currentWorkspace]) => { + ]).subscribe(([chromeNavLinks, currentWorkspace]) => { const filteredNavLinks = new Map(); chromeNavLinks = this.filterByWorkspace(currentWorkspace, chromeNavLinks); chromeNavLinks.forEach((chromeNavLink) => { filteredNavLinks.set(chromeNavLink.id, chromeNavLink); }); - if (!currentWorkspace) { - workspaceList - .filter((workspace, index) => index < 5) - .map((workspace, index) => - this.workspaceToChromeNavLink(workspace, core.http, core.application, index) - ) - .forEach((workspaceNavLink) => - filteredNavLinks.set(workspaceNavLink.id, workspaceNavLink) - ); - } navLinksService.setFilteredNavLinks(filteredNavLinks); }); } diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts deleted file mode 100644 index 71d3e3c1caf2..000000000000 --- a/src/plugins/workspace/public/utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { IBasePath } from '../../../core/public'; -import { WORKSPACE_PATH_PREFIX } from '../../../core/public/utils'; - -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 = newUrl.pathname.replace(/^\/w\/([^\/]*)/, ''); - } - - newUrl.pathname = - basePath?.prepend(newUrl.pathname, { - withoutWorkspace: true, - }) || ''; - - return newUrl.toString(); -}; From b6008f4468712d45c3fdb8c87c68868251bc9727 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Thu, 17 Aug 2023 11:48:28 +0800 Subject: [PATCH 087/174] fix: permission check error (#88) Signed-off-by: SuZhou-Joe --- .../workspace_saved_objects_client_wrapper.ts | 57 +++++++++---------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts index 5b2b45b353d7..dbde03f91150 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -73,16 +73,15 @@ export class WorkspaceSavedObjectsClientWrapper { if (!workspaceId) { return; } - if ( - !(await this.permissionControl.validate( - request, - { - type: WORKSPACE_TYPE, - id: workspaceId, - }, - this.formatWorkspacePermissionModeToStringArray(permissionMode) - )) - ) { + const validateResult = await this.permissionControl.validate( + request, + { + type: WORKSPACE_TYPE, + id: workspaceId, + }, + this.formatWorkspacePermissionModeToStringArray(permissionMode) + ); + if (!validateResult?.result) { throw generateWorkspacePermissionError(); } } @@ -96,16 +95,15 @@ export class WorkspaceSavedObjectsClientWrapper { return; } for (const workspaceId of workspaces) { - if ( - !(await this.permissionControl.validate( - request, - { - type: WORKSPACE_TYPE, - id: workspaceId, - }, - this.formatWorkspacePermissionModeToStringArray(permissionMode) - )) - ) { + const validateResult = await this.permissionControl.validate( + request, + { + type: WORKSPACE_TYPE, + id: workspaceId, + }, + this.formatWorkspacePermissionModeToStringArray(permissionMode) + ); + if (!validateResult?.result) { throw generateWorkspacePermissionError(); } } @@ -121,16 +119,15 @@ export class WorkspaceSavedObjectsClientWrapper { } let permitted = false; for (const workspaceId of workspaces) { - if ( - await this.permissionControl.validate( - request, - { - type: WORKSPACE_TYPE, - id: workspaceId, - }, - this.formatWorkspacePermissionModeToStringArray(permissionMode) - ) - ) { + const validateResult = await this.permissionControl.validate( + request, + { + type: WORKSPACE_TYPE, + id: workspaceId, + }, + this.formatWorkspacePermissionModeToStringArray(permissionMode) + ); + if (validateResult?.result) { permitted = true; break; } From 89094a416d4207929f46e969b703c4848622dd97 Mon Sep 17 00:00:00 2001 From: Yuye Zhu Date: Thu, 17 Aug 2023 14:01:41 +0800 Subject: [PATCH 088/174] fix: redirect to home only when delete and exit workspace successfully (#89) * only navigate to home page when delete and exit workspace successfully Signed-off-by: yuye-aws * unsubscribe workspaceEnabled when workspace service stop Signed-off-by: yuye-aws * only hide delete modal when delete workspace successfully Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws --- .../public/workspace/workspaces_service.ts | 1 + .../workspace_updater/workspace_updater.tsx | 45 ++++++++++--------- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts index edf4ec998731..142ac67fed38 100644 --- a/src/core/public/workspace/workspaces_service.ts +++ b/src/core/public/workspace/workspaces_service.ts @@ -55,5 +55,6 @@ export class WorkspaceService implements CoreService { defaultMessage: 'Delete workspace successfully', }), }); + setDeleteWorkspaceModalVisible(false); + if (http && application) { + const homeUrl = application.getUrlForApp('home', { + path: '/', + absolute: false, + }); + const targetUrl = http.basePath.prepend(http.basePath.remove(homeUrl), { + withoutWorkspace: true, + }); + await application.navigateToUrl(targetUrl); + } } else { notifications?.toasts.addDanger({ title: i18n.translate('workspace.delete.failed', { @@ -126,17 +137,6 @@ export const WorkspaceUpdater = () => { }); } } - setDeleteWorkspaceModalVisible(false); - if (http && application) { - const homeUrl = application.getUrlForApp('home', { - path: '/', - absolute: false, - }); - const targetUrl = http.basePath.prepend(http.basePath.remove(homeUrl), { - withoutWorkspace: true, - }); - await application.navigateToUrl(targetUrl); - } }; const exitWorkspace = async () => { @@ -152,7 +152,18 @@ export const WorkspaceUpdater = () => { }); return; } - if (!result?.success) { + if (result.success) { + if (http && application) { + const homeUrl = application.getUrlForApp('home', { + path: '/', + absolute: false, + }); + const targetUrl = http.basePath.prepend(http.basePath.remove(homeUrl), { + withoutWorkspace: true, + }); + await application.navigateToUrl(targetUrl); + } + } else { notifications?.toasts.addDanger({ title: i18n.translate('workspace.exit.failed', { defaultMessage: 'Failed to exit workspace', @@ -161,16 +172,6 @@ export const WorkspaceUpdater = () => { }); return; } - if (http && application) { - const homeUrl = application.getUrlForApp('home', { - path: '/', - absolute: false, - }); - const targetUrl = http.basePath.prepend(http.basePath.remove(homeUrl), { - withoutWorkspace: true, - }); - await application.navigateToUrl(targetUrl); - } }; return ( From 99a3922b2b772e4571aed91bc1811b25ff1eda8c Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Fri, 18 Aug 2023 15:55:59 +0800 Subject: [PATCH 089/174] feat: add management permission for workspace create user (#92) Signed-off-by: Lin Wang --- src/core/server/http/index.ts | 1 + src/core/server/index.ts | 1 + src/plugins/workspace/server/routes/index.ts | 16 +++++++++++++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index 14397456afd6..bf89d14ddc5e 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -74,6 +74,7 @@ export { RouteValidationResultFactory, DestructiveRouteMethod, SafeRouteMethod, + ensureRawRequest, } from './router'; export { BasePathProxyServer } from './base_path_proxy_server'; export { OnPreRoutingHandler, OnPreRoutingToolkit } from './lifecycle/on_pre_routing'; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 53c229caccbc..b5c1f457337a 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -218,6 +218,7 @@ export { SessionStorageFactory, DestructiveRouteMethod, SafeRouteMethod, + ensureRawRequest, } from './http'; export { diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts index 2ae62079322b..0f37f14173d1 100644 --- a/src/plugins/workspace/server/routes/index.ts +++ b/src/plugins/workspace/server/routes/index.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import { schema } from '@osd/config-schema'; +import { ensureRawRequest } from '../../../../core/server'; import { ACL, @@ -172,6 +173,19 @@ export function registerRoutes({ }, router.handleLegacyErrors(async (context, req, res) => { const { attributes } = req.body; + const rawRequest = ensureRawRequest(req); + const authInfo = rawRequest?.auth?.credentials?.authInfo as { user_name?: string } | null; + const permissions = Array.isArray(attributes.permissions) + ? attributes.permissions + : [attributes.permissions]; + + if (!!authInfo?.user_name) { + permissions.push({ + type: 'user', + userId: authInfo.user_name, + modes: [WorkspacePermissionMode.Management], + }); + } const result = await client.create( { @@ -181,7 +195,7 @@ export function registerRoutes({ }, { ...attributes, - permissions: convertToACL(attributes.permissions), + permissions: convertToACL(permissions), } ); return res.ok({ body: result }); From 19ccc14cbd5b85617710d40027412c0bb680109f Mon Sep 17 00:00:00 2001 From: Yuye Zhu Date: Mon, 21 Aug 2023 12:30:29 +0800 Subject: [PATCH 090/174] refactor: in left menu, move recently viewed from top to middle (#87) * refactor recently visited links to category Signed-off-by: yuye-aws * bring back external link logic Signed-off-by: yuye-aws * add no recently visited items when empty Signed-off-by: yuye-aws * change annotation Signed-off-by: yuye-aws * refactor with type RecentNavLink Signed-off-by: yuye-aws * rename navlink type from ChromeOrRecentNavLink to CollapsibleNavLink Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws --- src/core/public/chrome/nav_links/nav_link.ts | 4 +- .../chrome/ui/header/collapsible_nav.tsx | 83 +++++-------------- src/core/public/chrome/ui/header/nav_link.tsx | 55 ++++++------ 3 files changed, 53 insertions(+), 89 deletions(-) diff --git a/src/core/public/chrome/nav_links/nav_link.ts b/src/core/public/chrome/nav_links/nav_link.ts index 8479c8468b74..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; diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index 8f38521ae6bd..587a74222295 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -36,7 +36,6 @@ import { EuiListGroup, EuiListGroupItem, EuiShowFor, - EuiText, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { groupBy, sortBy } from 'lodash'; @@ -50,10 +49,15 @@ import { InternalApplicationStart } from '../../../application'; import { HttpStart } from '../../../http'; import { OnIsLockedUpdate } from './'; import type { Logos } from '../../../../common/types'; -import { createEuiListItem, isModifiedOrPrevented, createRecentNavLink } from './nav_link'; +import { + createEuiListItem, + createRecentChromeNavLink, + emptyRecentlyVisited, + CollapsibleNavLink, +} from './nav_link'; import { CollapsibleNavHeader } from './collapsible_nav_header'; -function getAllCategories(allCategorizedLinks: Record) { +function getAllCategories(allCategorizedLinks: Record) { const allCategories = {} as Record; for (const [key, value] of Object.entries(allCategorizedLinks)) { @@ -64,7 +68,7 @@ function getAllCategories(allCategorizedLinks: Record) } function getOrderedCategories( - mainCategories: Record, + mainCategories: Record, categoryDictionary: ReturnType ) { return sortBy( @@ -75,9 +79,9 @@ function getOrderedCategories( function getMergedNavLinks( orderedCategories: string[], - uncategorizedLinks: ChromeNavLink[], + uncategorizedLinks: CollapsibleNavLink[], categoryDictionary: ReturnType -): Array { +): Array { const uncategorizedLinksWithOrder = sortBy( uncategorizedLinks.filter((link) => link.order !== null), 'order' @@ -149,9 +153,17 @@ export function CollapsibleNav({ }: Props) { const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); const recentlyAccessed = useObservable(observables.recentlyAccessed$, []); + 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 groupedNavLinks = groupBy(allNavLinks, (link) => link?.category?.id); const { undefined: uncategorizedLinks = [], ...allCategorizedLinks } = groupedNavLinks; const categoryDictionary = getAllCategories(allCategorizedLinks); const orderedCategories = getOrderedCategories(allCategorizedLinks, categoryDictionary); @@ -161,7 +173,7 @@ export function CollapsibleNav({ categoryDictionary ); - const readyForEUI = (link: ChromeNavLink, needsIcon: boolean = false) => { + const readyForEUI = (link: CollapsibleNavLink, needsIcon: boolean = false) => { return createEuiListItem({ link, appId, @@ -191,61 +203,6 @@ export function CollapsibleNav({ basePath={basePath} /> - {/* 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', - })} -

    -
    - )} -
    - {/* merged NavLinks */} {mergedNavLinks.map((item, i) => { if (typeof item === 'string') { diff --git a/src/core/public/chrome/ui/header/nav_link.tsx b/src/core/public/chrome/ui/header/nav_link.tsx index ea35e192e7bd..e8b335db1015 100644 --- a/src/core/public/chrome/ui/header/nav_link.tsx +++ b/src/core/public/chrome/ui/header/nav_link.tsx @@ -31,22 +31,21 @@ 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: ChromeNavLink | RecentNavLink; appId?: string; basePath?: HttpStart['basePath']; dataTestSubj: string; onClick?: Function; navigateToApp: CoreStart['application']['navigateToApp']; - externalLink?: boolean; } // TODO #64541 @@ -73,6 +72,7 @@ export function createEuiListItem({ } if ( + !link.externalLink && // ignore external links event.button === 0 && // ignore everything but left clicks !isModifiedOrPrevented(event) ) { @@ -91,14 +91,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 @@ -110,11 +112,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)); @@ -133,16 +134,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', + }), +}; From ac6acd9ed710bd2fc532d1b26390c5e17816311b Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Mon, 21 Aug 2023 12:59:45 +0800 Subject: [PATCH 091/174] refactor: register workspace dropdown menu to left menu (#90) - register workspace dropdown menu to left menu from workspace plugin - rename PUBLIC_WORKSPACE and MANAGEMENT_WORKSPACE --------- Signed-off-by: Yulong Ruan --- src/core/public/chrome/constants.ts | 4 - .../chrome/ui/header/collapsible_nav.tsx | 6 +- .../ui/header/collapsible_nav_header.tsx | 207 +--------------- src/core/public/core_system.ts | 2 +- src/core/public/index.ts | 10 +- .../saved_objects/saved_objects_client.ts | 4 +- src/core/public/utils/index.ts | 4 +- src/core/public/utils/workspace.ts | 27 --- src/core/public/workspace/index.ts | 7 +- .../workspace/workspaces_service.mock.ts | 11 +- .../public/workspace/workspaces_service.ts | 56 ++++- src/core/server/index.ts | 4 +- src/core/server/saved_objects/routes/share.ts | 4 +- .../saved_objects/service/lib/repository.ts | 6 +- src/core/utils/constants.ts | 4 +- src/core/utils/index.ts | 4 +- .../objects_table/saved_objects_table.tsx | 14 +- src/plugins/workspace/public/application.tsx | 6 +- .../public/components/utils/workspace.ts | 2 +- .../workspace_menu/workspace_menu.tsx | 224 ++++++++++++++++++ src/plugins/workspace/public/plugin.ts | 5 +- .../public/render_workspace_menu.tsx | 23 ++ src/plugins/workspace/public/types.ts | 9 + src/plugins/workspace/public/utils.ts | 31 +++ .../workspace/public/workspace_client.ts | 6 +- src/plugins/workspace/server/plugin.ts | 8 +- 26 files changed, 403 insertions(+), 285 deletions(-) create mode 100644 src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx create mode 100644 src/plugins/workspace/public/render_workspace_menu.tsx create mode 100644 src/plugins/workspace/public/types.ts create mode 100644 src/plugins/workspace/public/utils.ts diff --git a/src/core/public/chrome/constants.ts b/src/core/public/chrome/constants.ts index 9fcf531f3ffe..5008f8b4a69a 100644 --- a/src/core/public/chrome/constants.ts +++ b/src/core/public/chrome/constants.ts @@ -31,7 +31,3 @@ export const OPENSEARCH_DASHBOARDS_ASK_OPENSEARCH_LINK = 'https://forum.opensearch.org/'; export const GITHUB_CREATE_ISSUE_LINK = 'https://github.com/opensearch-project/OpenSearch-Dashboards/issues/new/choose'; - -export const WORKSPACE_CREATE_APP_ID = 'workspace_create'; -export const WORKSPACE_LIST_APP_ID = 'workspace_list'; -export const WORKSPACE_OVERVIEW_APP_ID = 'workspace_overview'; diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index 587a74222295..812772b92efd 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -197,11 +197,7 @@ export function CollapsibleNav({ outsideClickCloses={false} > - + {/* merged NavLinks */} {mergedNavLinks.map((item, i) => { diff --git a/src/core/public/chrome/ui/header/collapsible_nav_header.tsx b/src/core/public/chrome/ui/header/collapsible_nav_header.tsx index 8c902ac598a5..a3e983e9971d 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav_header.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav_header.tsx @@ -3,68 +3,23 @@ * SPDX-License-Identifier: Apache-2.0 */ import { i18n } from '@osd/i18n'; -import React, { useState } from 'react'; +import React from 'react'; import useObservable from 'react-use/lib/useObservable'; -import { - EuiContextMenu, - EuiPopover, - EuiIcon, - EuiFlexGroup, - EuiFlexItem, - EuiText, - EuiCollapsibleNavGroup, -} from '@elastic/eui'; -import { - HttpStart, - WorkspaceStart, - WorkspaceAttribute, - MANAGEMENT_WORKSPACE, -} from '../../../../public'; -import { InternalApplicationStart } from '../../../application'; -import { formatUrlWithWorkspaceId } from '../../../utils'; -import { - WORKSPACE_CREATE_APP_ID, - WORKSPACE_LIST_APP_ID, - WORKSPACE_OVERVIEW_APP_ID, -} from '../../constants'; +import { EuiIcon, EuiFlexGroup, EuiFlexItem, EuiText, EuiCollapsibleNavGroup } from '@elastic/eui'; +import { WorkspaceStart } from '../../../../public'; interface Props { workspaces: WorkspaceStart; - basePath: HttpStart['basePath']; - getUrlForApp: InternalApplicationStart['getUrlForApp']; } -function getFilteredWorkspaceList( - workspaceList: WorkspaceAttribute[], - currentWorkspace: WorkspaceAttribute | null -): WorkspaceAttribute[] { - // list top5 workspaces except management workspace, place current workspace at the top - return [ - ...(currentWorkspace ? [currentWorkspace] : []), - ...workspaceList.filter( - (workspace) => workspace.id !== MANAGEMENT_WORKSPACE && workspace.id !== currentWorkspace?.id - ), - ].slice(0, 5); -} - -export function CollapsibleNavHeader({ workspaces, getUrlForApp, basePath }: Props) { +export function CollapsibleNavHeader({ workspaces }: Props) { const workspaceEnabled = useObservable(workspaces.workspaceEnabled$, false); - const workspaceList = useObservable(workspaces.workspaceList$, []); - const currentWorkspace = useObservable(workspaces.currentWorkspace$, null); - const filteredWorkspaceList = getFilteredWorkspaceList(workspaceList, currentWorkspace); const defaultHeaderName = i18n.translate( 'core.ui.primaryNav.workspacePickerMenu.defaultHeaderName', { defaultMessage: 'OpenSearch Analytics', } ); - const managementWorkspaceName = - workspaceList.find((workspace) => workspace.id === MANAGEMENT_WORKSPACE)?.name ?? - i18n.translate('core.ui.primaryNav.workspacePickerMenu.managementWorkspaceName', { - defaultMessage: 'Management', - }); - const currentWorkspaceName = currentWorkspace?.name ?? defaultHeaderName; - const [isPopoverOpen, setPopover] = useState(false); if (!workspaceEnabled) { return ( @@ -81,157 +36,7 @@ export function CollapsibleNavHeader({ workspaces, getUrlForApp, basePath }: Pro ); + } else { + return workspaces.renderWorkspaceMenu(); } - const onButtonClick = () => { - setPopover(!isPopoverOpen); - }; - - const closePopover = () => { - setPopover(false); - }; - - const workspaceToItem = (workspace: WorkspaceAttribute, index: number) => { - const href = formatUrlWithWorkspaceId( - getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { - absolute: false, - }), - workspace.id, - basePath - ); - const name = - currentWorkspace !== null && index === 0 ? ( - - {workspace.name} - - ) : ( - workspace.name - ); - return { - href, - name, - key: index.toString(), - icon: , - }; - }; - - const getWorkspaceListItems = () => { - const workspaceListItems = filteredWorkspaceList.map((workspace, index) => - workspaceToItem(workspace, index) - ); - const length = workspaceListItems.length; - workspaceListItems.push({ - icon: , - name: i18n.translate('core.ui.primaryNav.workspaceContextMenu.createWorkspace', { - defaultMessage: 'Create workspace', - }), - key: length.toString(), - href: formatUrlWithWorkspaceId( - getUrlForApp(WORKSPACE_CREATE_APP_ID, { - absolute: false, - }), - currentWorkspace?.id ?? '', - basePath - ), - }); - workspaceListItems.push({ - icon: , - name: i18n.translate('core.ui.primaryNav.workspaceContextMenu.allWorkspace', { - defaultMessage: 'All workspaces', - }), - key: (length + 1).toString(), - href: formatUrlWithWorkspaceId( - getUrlForApp(WORKSPACE_LIST_APP_ID, { - absolute: false, - }), - currentWorkspace?.id ?? '', - basePath - ), - }); - return workspaceListItems; - }; - - const currentWorkspaceButton = ( - - - - - - - - {currentWorkspaceName} - - - - - - - - ); - - const currentWorkspaceTitle = ( - - - - - - - {currentWorkspaceName} - - - - - - - ); - - const panels = [ - { - id: 0, - title: currentWorkspaceTitle, - items: [ - { - name: ( - - - {i18n.translate('core.ui.primaryNav.workspacePickerMenu.workspaceList', { - defaultMessage: 'Workspaces', - })} - - - ), - icon: 'folderClosed', - panel: 1, - }, - { - name: managementWorkspaceName, - icon: 'managementApp', - href: formatUrlWithWorkspaceId( - getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { - absolute: false, - }), - MANAGEMENT_WORKSPACE, - basePath - ), - }, - ], - }, - { - id: 1, - title: 'Workspaces', - items: getWorkspaceListItems(), - }, - ]; - - return ( - - - - ); } diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 356e3dd0690a..b1fb35483dad 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(); + const workspaces = this.workspaces.start({ application, http }); const chrome = await this.chrome.start({ application, docLinks, diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 07bf3ff0c016..44aaedc19adb 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -347,8 +347,14 @@ export { export { __osdBootstrap__ } from './osd_bootstrap'; -export { WorkspaceStart, WorkspaceService, WorkspaceAttribute } from './workspace'; +export { + WorkspaceStart, + WorkspaceSetup, + WorkspaceService, + WorkspaceAttribute, + WorkspaceObservables, +} from './workspace'; -export { WorkspacePermissionMode, PUBLIC_WORKSPACE, MANAGEMENT_WORKSPACE } from '../utils'; +export { WorkspacePermissionMode, PUBLIC_WORKSPACE_ID, MANAGEMENT_WORKSPACE_ID } from '../utils'; export { getWorkspaceIdFromUrl, WORKSPACE_TYPE } from './utils'; diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index b0e3245f74e5..10b4bcd5eb97 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -42,7 +42,7 @@ import { import { SimpleSavedObject } from './simple_saved_object'; import { HttpFetchOptions, HttpSetup } from '../http'; -import { PUBLIC_WORKSPACE } from '../../utils'; +import { PUBLIC_WORKSPACE_ID } from '../../utils'; type SavedObjectsFindOptions = Omit< SavedObjectFindOptionsServer, @@ -382,7 +382,7 @@ export class SavedObjectsClient { if (options.hasOwnProperty('workspaces')) { finalWorkspaces = options.workspaces; } else if (typeof currentWorkspaceId === 'string') { - finalWorkspaces = Array.from(new Set([PUBLIC_WORKSPACE, currentWorkspaceId])); + finalWorkspaces = Array.from(new Set([PUBLIC_WORKSPACE_ID, currentWorkspaceId])); } const renamedQuery = renameKeys(renameMap, { diff --git a/src/core/public/utils/index.ts b/src/core/public/utils/index.ts index 13d01ef1fe56..9b831f6c46b3 100644 --- a/src/core/public/utils/index.ts +++ b/src/core/public/utils/index.ts @@ -31,5 +31,5 @@ export { shareWeakReplay } from './share_weak_replay'; export { Sha256 } from './crypto'; export { MountWrapper, mountReactNode } from './mount'; -export { getWorkspaceIdFromUrl, WORKSPACE_TYPE, formatUrlWithWorkspaceId } from './workspace'; -export { WORKSPACE_PATH_PREFIX, PUBLIC_WORKSPACE, MANAGEMENT_WORKSPACE } from '../../utils'; +export { getWorkspaceIdFromUrl, WORKSPACE_TYPE } from './workspace'; +export { WORKSPACE_PATH_PREFIX, PUBLIC_WORKSPACE_ID, MANAGEMENT_WORKSPACE_ID } from '../../utils'; diff --git a/src/core/public/utils/workspace.ts b/src/core/public/utils/workspace.ts index 33012d4fbe4a..9a0f55b3fa8c 100644 --- a/src/core/public/utils/workspace.ts +++ b/src/core/public/utils/workspace.ts @@ -3,9 +3,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { IBasePath } from '../http'; -import { WORKSPACE_PATH_PREFIX } from '../../utils'; - export const getWorkspaceIdFromUrl = (url: string): string => { const regexp = /\/w\/([^\/]*)/; const urlObject = new URL(url); @@ -17,28 +14,4 @@ export const getWorkspaceIdFromUrl = (url: string): string => { return ''; }; -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 = newUrl.pathname.replace(/^\/w\/([^\/]*)/, ''); - } - - newUrl.pathname = - basePath?.prepend(newUrl.pathname, { - withoutWorkspace: true, - }) || ''; - - return newUrl.toString(); -}; - export const WORKSPACE_TYPE = 'workspace'; diff --git a/src/core/public/workspace/index.ts b/src/core/public/workspace/index.ts index c2c12bf20715..c446ecda4499 100644 --- a/src/core/public/workspace/index.ts +++ b/src/core/public/workspace/index.ts @@ -2,5 +2,10 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -export { WorkspaceStart, WorkspaceService, WorkspaceSetup } from './workspaces_service'; +export { + WorkspaceStart, + WorkspaceService, + WorkspaceSetup, + WorkspaceObservables, +} from './workspaces_service'; export type { WorkspaceAttribute } from './workspaces_service'; diff --git a/src/core/public/workspace/workspaces_service.mock.ts b/src/core/public/workspace/workspaces_service.mock.ts index 57b9d976e4e5..b45d38542b55 100644 --- a/src/core/public/workspace/workspaces_service.mock.ts +++ b/src/core/public/workspace/workspaces_service.mock.ts @@ -16,11 +16,18 @@ const createWorkspacesSetupContractMock = () => ({ workspaceList$, currentWorkspace$, workspaceEnabled$, + registerWorkspaceMenuRender: jest.fn(), }); -const createWorkspacesStartContractMock = createWorkspacesSetupContractMock; +const createWorkspacesStartContractMock = () => ({ + currentWorkspaceId$, + workspaceList$, + currentWorkspace$, + workspaceEnabled$, + renderWorkspaceMenu: jest.fn(), +}); export const workspacesServiceMock = { - createSetupContractMock: createWorkspacesStartContractMock, + createSetupContractMock: createWorkspacesSetupContractMock, createStartContract: createWorkspacesStartContractMock, }; diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts index 142ac67fed38..d11a4f5da380 100644 --- a/src/core/public/workspace/workspaces_service.ts +++ b/src/core/public/workspace/workspaces_service.ts @@ -2,20 +2,39 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ + import { BehaviorSubject } from 'rxjs'; import { CoreService } from '../../types'; +import { InternalApplicationStart } from '../application'; +import { HttpSetup } from '../http'; -/** - * @public - */ -export interface WorkspaceStart { +type WorkspaceMenuRenderFn = ({ + basePath, + getUrlForApp, + observables, +}: { + getUrlForApp: InternalApplicationStart['getUrlForApp']; + basePath: HttpSetup['basePath']; + observables: WorkspaceObservables; +}) => JSX.Element | null; + +export interface WorkspaceObservables { currentWorkspaceId$: BehaviorSubject; currentWorkspace$: BehaviorSubject; workspaceList$: BehaviorSubject; workspaceEnabled$: BehaviorSubject; } -export type WorkspaceSetup = WorkspaceStart; +/** + * @public + */ +export interface WorkspaceSetup extends WorkspaceObservables { + registerWorkspaceMenuRender: (render: WorkspaceMenuRenderFn) => void; +} + +export interface WorkspaceStart extends WorkspaceObservables { + renderWorkspaceMenu: () => JSX.Element | null; +} export interface WorkspaceAttribute { id: string; @@ -32,6 +51,7 @@ export class WorkspaceService implements CoreService([]); private currentWorkspace$ = new BehaviorSubject(null); private workspaceEnabled$ = new BehaviorSubject(false); + private _renderWorkspaceMenu: WorkspaceMenuRenderFn | null = null; public setup(): WorkspaceSetup { return { @@ -39,16 +59,37 @@ export class WorkspaceService implements CoreService + (this._renderWorkspaceMenu = render), }; } - public start(): WorkspaceStart { - return { + public start({ + http, + application, + }: { + application: InternalApplicationStart; + http: HttpSetup; + }): WorkspaceStart { + const observables = { currentWorkspaceId$: this.currentWorkspaceId$, currentWorkspace$: this.currentWorkspace$, workspaceList$: this.workspaceList$, workspaceEnabled$: this.workspaceEnabled$, }; + return { + ...observables, + renderWorkspaceMenu: () => { + if (this._renderWorkspaceMenu) { + return this._renderWorkspaceMenu({ + basePath: http.basePath, + getUrlForApp: application.getUrlForApp, + observables, + }); + } + return null; + }, + }; } public async stop() { @@ -56,5 +97,6 @@ export class WorkspaceService implements CoreService { (obj) => obj.workspaces && obj.workspaces.length > 0 && - !obj.workspaces.includes(PUBLIC_WORKSPACE) + !obj.workspaces.includes(PUBLIC_WORKSPACE_ID) ) .map((obj) => ({ id: obj.id, type: obj.type, workspaces: obj.workspaces })); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 6adefe16848d..e479b1e95bc1 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -87,7 +87,7 @@ import { FIND_DEFAULT_PER_PAGE, SavedObjectsUtils, } from './utils'; -import { PUBLIC_WORKSPACE } from '../../../../utils/constants'; +import { PUBLIC_WORKSPACE_ID } from '../../../../utils/constants'; // BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. @@ -1299,7 +1299,7 @@ export class SavedObjectsRepository { if ( obj.workspaces && obj.workspaces.length > 0 && - !obj.workspaces.includes(PUBLIC_WORKSPACE) + !obj.workspaces.includes(PUBLIC_WORKSPACE_ID) ) { return intersection(obj.workspaces, options.workspaces).length === 0; } @@ -1352,7 +1352,7 @@ export class SavedObjectsRepository { params: { time, workspaces, - globalWorkspaceId: PUBLIC_WORKSPACE, + globalWorkspaceId: PUBLIC_WORKSPACE_ID, }, }, }, diff --git a/src/core/utils/constants.ts b/src/core/utils/constants.ts index 004e9b58a91b..72291f34ec10 100644 --- a/src/core/utils/constants.ts +++ b/src/core/utils/constants.ts @@ -13,6 +13,6 @@ export enum WorkspacePermissionMode { LibraryWrite = 'library_write', } -export const PUBLIC_WORKSPACE = 'public'; +export const PUBLIC_WORKSPACE_ID = 'public'; -export const MANAGEMENT_WORKSPACE = 'management'; +export const MANAGEMENT_WORKSPACE_ID = 'management'; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 2adab8bd8926..636d09ba0992 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -40,6 +40,6 @@ export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; export { WORKSPACE_PATH_PREFIX, WorkspacePermissionMode, - PUBLIC_WORKSPACE, - MANAGEMENT_WORKSPACE, + PUBLIC_WORKSPACE_ID, + MANAGEMENT_WORKSPACE_ID, } from './constants'; 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 fb621d13f8d0..482b33978edf 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 @@ -96,7 +96,7 @@ import { import { Header, Table, Flyout, Relationships } from './components'; import { DataPublicPluginStart } from '../../../../../plugins/data/public'; import { SavedObjectsCopyModal } from './components/copy_modal'; -import { PUBLIC_WORKSPACE, MANAGEMENT_WORKSPACE } from '../../../../../core/public'; +import { PUBLIC_WORKSPACE_ID, MANAGEMENT_WORKSPACE_ID } from '../../../../../core/public'; interface ExportAllOption { id: string; @@ -193,12 +193,12 @@ export class SavedObjectsTable extends Component ws.id); - } else if (workspaceId === PUBLIC_WORKSPACE) { - return [PUBLIC_WORKSPACE]; + } else if (workspaceId === PUBLIC_WORKSPACE_ID) { + return [PUBLIC_WORKSPACE_ID]; } else { - return [workspaceId, PUBLIC_WORKSPACE]; + return [workspaceId, PUBLIC_WORKSPACE_ID]; } } @@ -247,7 +247,7 @@ export class SavedObjectsTable extends Component this.wsNameIdLookup?.get(wsName) || PUBLIC_WORKSPACE + (wsName) => this.wsNameIdLookup?.get(wsName) || PUBLIC_WORKSPACE_ID ); } @@ -340,7 +340,7 @@ export class SavedObjectsTable extends Component this.wsNameIdLookup?.get(wsName) || PUBLIC_WORKSPACE + (wsName) => this.wsNameIdLookup?.get(wsName) || PUBLIC_WORKSPACE_ID ); findOptions.workspaces = workspaceIds; } diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx index fc06e52d2e81..000b89e5caeb 100644 --- a/src/plugins/workspace/public/application.tsx +++ b/src/plugins/workspace/public/application.tsx @@ -5,15 +5,13 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { AppMountParameters, CoreStart } from '../../../core/public'; +import { AppMountParameters } from '../../../core/public'; import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public'; import { WorkspaceListApp } from './components/workspace_list_app'; import { WorkspaceCreatorApp } from './components/workspace_creator_app'; import { WorkspaceUpdaterApp } from './components/workspace_updater_app'; import { WorkspaceOverviewApp } from './components/workspace_overview_app'; -import { WorkspaceClient } from './workspace_client'; - -export type Services = CoreStart & { workspaceClient: WorkspaceClient }; +import { Services } from './types'; export const renderListApp = ( { element, history, appBasePath }: AppMountParameters, diff --git a/src/plugins/workspace/public/components/utils/workspace.ts b/src/plugins/workspace/public/components/utils/workspace.ts index 6be21538838f..63d88ae93e19 100644 --- a/src/plugins/workspace/public/components/utils/workspace.ts +++ b/src/plugins/workspace/public/components/utils/workspace.ts @@ -5,7 +5,7 @@ import { WORKSPACE_OVERVIEW_APP_ID } from '../../../common/constants'; import { CoreStart } from '../../../../../core/public'; -import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +import { formatUrlWithWorkspaceId } from '../../utils'; type Core = Pick; diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx new file mode 100644 index 000000000000..ae48d50a2287 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx @@ -0,0 +1,224 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import React, { useState } from 'react'; +import { useObservable } from 'react-use'; +import { + EuiCollapsibleNavGroup, + EuiContextMenu, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPopover, + EuiText, +} from '@elastic/eui'; + +import { + ApplicationStart, + HttpSetup, + MANAGEMENT_WORKSPACE_ID, + WorkspaceAttribute, + WorkspaceObservables, +} from '../../../../../core/public'; +import { + WORKSPACE_CREATE_APP_ID, + WORKSPACE_LIST_APP_ID, + WORKSPACE_OVERVIEW_APP_ID, +} from '../../../common/constants'; +import { formatUrlWithWorkspaceId } from '../../utils'; + +interface Props { + getUrlForApp: ApplicationStart['getUrlForApp']; + basePath: HttpSetup['basePath']; + observables: WorkspaceObservables; +} + +function getFilteredWorkspaceList( + workspaceList: WorkspaceAttribute[], + currentWorkspace: WorkspaceAttribute | null +): WorkspaceAttribute[] { + // list top5 workspaces except management workspace, place current workspace at the top + return [ + ...(currentWorkspace ? [currentWorkspace] : []), + ...workspaceList.filter( + (workspace) => + workspace.id !== MANAGEMENT_WORKSPACE_ID && workspace.id !== currentWorkspace?.id + ), + ].slice(0, 5); +} + +export const WorkspaceMenu = ({ basePath, getUrlForApp, observables }: Props) => { + const [isPopoverOpen, setPopover] = useState(false); + const currentWorkspace = useObservable(observables.currentWorkspace$, null); + const workspaceList = useObservable(observables.workspaceList$, []); + + const defaultHeaderName = i18n.translate( + 'core.ui.primaryNav.workspacePickerMenu.defaultHeaderName', + { + defaultMessage: 'OpenSearch Analytics', + } + ); + const managementWorkspaceName = + workspaceList.find((workspace) => workspace.id === MANAGEMENT_WORKSPACE_ID)?.name ?? + i18n.translate('core.ui.primaryNav.workspacePickerMenu.managementWorkspaceName', { + defaultMessage: 'Management', + }); + const filteredWorkspaceList = getFilteredWorkspaceList(workspaceList, currentWorkspace); + const currentWorkspaceName = currentWorkspace?.name ?? defaultHeaderName; + + const onButtonClick = () => { + setPopover(!isPopoverOpen); + }; + + const closePopover = () => { + setPopover(false); + }; + + const workspaceToItem = (workspace: WorkspaceAttribute, index: number) => { + const href = formatUrlWithWorkspaceId( + getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + absolute: false, + }), + workspace.id, + basePath + ); + const name = + currentWorkspace !== null && index === 0 ? ( + + {workspace.name} + + ) : ( + workspace.name + ); + return { + href, + name, + key: index.toString(), + icon: , + }; + }; + + const getWorkspaceListItems = () => { + const workspaceListItems = filteredWorkspaceList.map((workspace, index) => + workspaceToItem(workspace, index) + ); + const length = workspaceListItems.length; + workspaceListItems.push({ + icon: , + name: i18n.translate('core.ui.primaryNav.workspaceContextMenu.createWorkspace', { + defaultMessage: 'Create workspace', + }), + key: length.toString(), + href: formatUrlWithWorkspaceId( + getUrlForApp(WORKSPACE_CREATE_APP_ID, { + absolute: false, + }), + currentWorkspace?.id ?? '', + basePath + ), + }); + workspaceListItems.push({ + icon: , + name: i18n.translate('core.ui.primaryNav.workspaceContextMenu.allWorkspace', { + defaultMessage: 'All workspaces', + }), + key: (length + 1).toString(), + href: formatUrlWithWorkspaceId( + getUrlForApp(WORKSPACE_LIST_APP_ID, { + absolute: false, + }), + currentWorkspace?.id ?? '', + basePath + ), + }); + return workspaceListItems; + }; + + const currentWorkspaceButton = ( + + + + + + + + {currentWorkspaceName} + + + + + + + + ); + + const currentWorkspaceTitle = ( + + + + + + + {currentWorkspaceName} + + + + + + + ); + + const panels = [ + { + id: 0, + title: currentWorkspaceTitle, + items: [ + { + name: ( + + + {i18n.translate('core.ui.primaryNav.workspacePickerMenu.workspaceList', { + defaultMessage: 'Workspaces', + })} + + + ), + icon: 'folderClosed', + panel: 1, + }, + { + name: managementWorkspaceName, + icon: 'managementApp', + href: formatUrlWithWorkspaceId( + getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + absolute: false, + }), + MANAGEMENT_WORKSPACE_ID, + basePath + ), + }, + ], + }, + { + id: 1, + title: 'Workspaces', + items: getWorkspaceListItems(), + }, + ]; + + return ( + + + + ); +}; diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index aafb22ead142..4dd0846107a9 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -29,7 +29,8 @@ import { SavedObjectsManagementPluginSetup } from '../../saved_objects_managemen import { getWorkspaceColumn } from './components/utils/workspace_column'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { WorkspaceClient } from './workspace_client'; -import { Services } from './application'; +import { renderWorkspaceMenu } from './render_workspace_menu'; +import { Services } from './types'; interface WorkspacePluginSetupDeps { savedObjectsManagement?: SavedObjectsManagementPluginSetup; @@ -46,6 +47,8 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> workspaceClient.init(); core.workspaces.workspaceEnabled$.next(true); + core.workspaces.registerWorkspaceMenuRender(renderWorkspaceMenu); + /** * Retrieve workspace id from url */ diff --git a/src/plugins/workspace/public/render_workspace_menu.tsx b/src/plugins/workspace/public/render_workspace_menu.tsx new file mode 100644 index 000000000000..12313594cbdc --- /dev/null +++ b/src/plugins/workspace/public/render_workspace_menu.tsx @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; + +import { WorkspaceMenu } from './components/workspace_menu/workspace_menu'; +import { ApplicationStart, HttpSetup, WorkspaceObservables } from '../../../core/public'; + +export function renderWorkspaceMenu({ + basePath, + getUrlForApp, + observables, +}: { + getUrlForApp: ApplicationStart['getUrlForApp']; + basePath: HttpSetup['basePath']; + observables: WorkspaceObservables; +}) { + 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/utils.ts b/src/plugins/workspace/public/utils.ts new file mode 100644 index 000000000000..ccb286860061 --- /dev/null +++ b/src/plugins/workspace/public/utils.ts @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WORKSPACE_PATH_PREFIX } from '../../../core/public/utils'; +import { IBasePath } from '../../../core/public'; + +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 = newUrl.pathname.replace(/^\/w\/([^\/]*)/, ''); + } + + newUrl.pathname = + basePath?.prepend(newUrl.pathname, { + withoutWorkspace: true, + }) || ''; + + return newUrl.toString(); +}; diff --git a/src/plugins/workspace/public/workspace_client.ts b/src/plugins/workspace/public/workspace_client.ts index 9c2fed440d1f..5e8080cba3f0 100644 --- a/src/plugins/workspace/public/workspace_client.ts +++ b/src/plugins/workspace/public/workspace_client.ts @@ -10,7 +10,7 @@ import { HttpFetchOptions, HttpSetup, WorkspaceAttribute, - WorkspaceStart, + WorkspaceSetup, } from '../../../core/public'; import { WorkspacePermissionMode } from '../../../core/public'; @@ -61,9 +61,9 @@ interface WorkspaceFindOptions { */ export class WorkspaceClient { private http: HttpSetup; - private workspaces: WorkspaceStart; + private workspaces: WorkspaceSetup; - constructor(http: HttpSetup, workspaces: WorkspaceStart) { + constructor(http: HttpSetup, workspaces: WorkspaceSetup) { this.http = http; this.workspaces = workspaces; diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index 710eaeea1819..dfddcd372c17 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -13,8 +13,8 @@ import { ISavedObjectsRepository, WORKSPACE_TYPE, ACL, - PUBLIC_WORKSPACE, - MANAGEMENT_WORKSPACE, + PUBLIC_WORKSPACE_ID, + MANAGEMENT_WORKSPACE_ID, Permissions, WorkspacePermissionMode, SavedObjectsClient, @@ -125,7 +125,7 @@ export class WorkspacePlugin implements Plugin<{}, {}> { await Promise.all([ this.checkAndCreateWorkspace( internalRepository, - PUBLIC_WORKSPACE, + PUBLIC_WORKSPACE_ID, { name: i18n.translate('workspaces.public.workspace.default.name', { defaultMessage: 'public', @@ -135,7 +135,7 @@ export class WorkspacePlugin implements Plugin<{}, {}> { ), this.checkAndCreateWorkspace( internalRepository, - MANAGEMENT_WORKSPACE, + MANAGEMENT_WORKSPACE_ID, { name: i18n.translate('workspaces.management.workspace.default.name', { defaultMessage: 'Management', From 306231a12927c6e4432294b807bd78349908cbfb Mon Sep 17 00:00:00 2001 From: Hailong Cui Date: Mon, 21 Aug 2023 23:23:00 +0800 Subject: [PATCH 092/174] 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 --- .../mount_management_section.tsx | 50 ++++++++------ .../index_pattern_management/public/plugin.ts | 68 +++++++++++++++++-- .../workspace/opensearch_dashboards.json | 2 +- src/plugins/workspace/public/plugin.ts | 10 ++- 4 files changed, 104 insertions(+), 26 deletions(-) diff --git a/src/plugins/index_pattern_management/public/management_app/mount_management_section.tsx b/src/plugins/index_pattern_management/public/management_app/mount_management_section.tsx index 162d1d0876c6..70bdfab469aa 100644 --- a/src/plugins/index_pattern_management/public/management_app/mount_management_section.tsx +++ b/src/plugins/index_pattern_management/public/management_app/mount_management_section.tsx @@ -36,6 +36,7 @@ import { i18n } from '@osd/i18n'; import { I18nProvider } from '@osd/i18n/react'; import { StartServicesAccessor } from 'src/core/public'; +import { EuiPage, EuiPageBody } from '@elastic/eui'; import { OpenSearchDashboardsContextProvider } from '../../../opensearch_dashboards_react/public'; import { ManagementAppMountParams } from '../../../management/public'; import { @@ -60,7 +61,8 @@ const readOnlyBadge = { export async function mountManagementSection( getStartServices: StartServicesAccessor, params: ManagementAppMountParams, - getMlCardState: () => MlCardState + getMlCardState: () => MlCardState, + withPage: boolean = false ) { const [ { chrome, application, savedObjects, uiSettings, notifications, overlays, http, docLinks }, @@ -90,26 +92,36 @@ export async function mountManagementSection( dataSourceEnabled, }; + const router = ( + + + + + + + + + + + + + + + + + ); + let content = router; + if (withPage) { + content = ( + + {router} + + ); + } + ReactDOM.render( - - - - - - - - - - - - - - - - - - + {content} , params.element ); diff --git a/src/plugins/index_pattern_management/public/plugin.ts b/src/plugins/index_pattern_management/public/plugin.ts index cf68e043b76c..15fd02681c3c 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,7 +47,9 @@ import { IndexPatternManagementServiceStart, } from './service'; -import { ManagementSetup } from '../../management/public'; +import { ManagementAppMountParams, ManagementSetup } from '../../management/public'; +import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; +import { reactRouterNavigate } from '../../opensearch_dashboards_react/public'; export interface IndexPatternManagementSetupDependencies { management: ManagementSetup; @@ -51,7 +61,9 @@ export interface IndexPatternManagementStartDependencies { dataSource?: DataSourcePluginStart; } -export type IndexPatternManagementSetup = IndexPatternManagementServiceSetup; +export interface IndexPatternManagementSetup extends IndexPatternManagementServiceSetup { + registerLibrarySubApp: () => void; +} export type IndexPatternManagementStart = IndexPatternManagementServiceStart; @@ -109,7 +121,55 @@ export class IndexPatternManagementPlugin }, }); - return this.indexPatternManagementService.setup({ httpClient: core.http }); + const registerLibrarySubApp = () => { + // disable it under Dashboards Management + opensearchDashboardsSection.getApp(IPM_APP_ID)?.disable(); + // register it under Library + core.application.register({ + id: IPM_APP_ID, + title: sectionsHeader, + order: 8100, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + mount: async (params: AppMountParameters) => { + const { mountManagementSection } = await import('./management_app'); + + const [coreStart] = await core.getStartServices(); + + const setBreadcrumbsScope = ( + crumbs: ChromeBreadcrumb[] = [], + appHistory?: ScopedHistory + ) => { + const wrapBreadcrumb = (item: ChromeBreadcrumb, scopedHistory: ScopedHistory) => ({ + ...item, + ...(item.href ? reactRouterNavigate(scopedHistory, item.href) : {}), + }); + + coreStart.chrome.setBreadcrumbs([ + ...crumbs.map((item) => wrapBreadcrumb(item, appHistory || params.history)), + ]); + }; + + const managementParams: ManagementAppMountParams = { + element: params.element, + history: params.history, + setBreadcrumbs: setBreadcrumbsScope, + basePath: params.appBasePath, + }; + + return mountManagementSection( + core.getStartServices, + managementParams, + () => this.indexPatternManagementService.environmentService.getEnvironment().ml(), + true + ); + }, + }); + }; + + return { + ...this.indexPatternManagementService.setup({ httpClient: core.http }), + registerLibrarySubApp, + }; } public start(core: CoreStart, plugins: IndexPatternManagementStartDependencies) { diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json index 5ab644da4e91..c6cc46bbe45f 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -4,7 +4,7 @@ "server": true, "ui": true, "requiredPlugins": ["savedObjects"], - "optionalPlugins": ["savedObjectsManagement"], + "optionalPlugins": ["savedObjectsManagement", "indexPatternManagement"], "requiredBundles": [ "opensearchDashboardsReact" ] diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 4dd0846107a9..1c6c59248016 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -29,11 +29,13 @@ import { SavedObjectsManagementPluginSetup } from '../../saved_objects_managemen import { getWorkspaceColumn } from './components/utils/workspace_column'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { WorkspaceClient } from './workspace_client'; +import { IndexPatternManagementSetup } from '../../index_pattern_management/public'; import { renderWorkspaceMenu } from './render_workspace_menu'; import { Services } from './types'; interface WorkspacePluginSetupDeps { savedObjectsManagement?: SavedObjectsManagementPluginSetup; + indexPatternManagement?: IndexPatternManagementSetup; } export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> { @@ -42,7 +44,10 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> private getWorkspaceIdFromURL(): string | null { return getWorkspaceIdFromUrl(window.location.href); } - public async setup(core: CoreSetup, { savedObjectsManagement }: WorkspacePluginSetupDeps) { + public async setup( + core: CoreSetup, + { savedObjectsManagement, indexPatternManagement }: WorkspacePluginSetupDeps + ) { const workspaceClient = new WorkspaceClient(core.http, core.workspaces); workspaceClient.init(); core.workspaces.workspaceEnabled$.next(true); @@ -72,6 +77,7 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> // register apps for library object management savedObjectsManagement?.registerLibrarySubApp(); + indexPatternManagement?.registerLibrarySubApp(); type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; const mountWorkspaceApp = async (params: AppMountParameters, renderApp: WorkspaceAppType) => { @@ -144,7 +150,7 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> return {}; } - private async _changeSavedObjectCurrentWorkspace() { + private _changeSavedObjectCurrentWorkspace() { if (this.coreStart) { return this.coreStart.workspaces.currentWorkspaceId$.subscribe((currentWorkspaceId) => { if (currentWorkspaceId) { From 6ce61a125f7f26d5ce66a4438c8c70a618065adf Mon Sep 17 00:00:00 2001 From: Yuye Zhu Date: Tue, 22 Aug 2023 10:45:33 +0800 Subject: [PATCH 093/174] create workspace bug fix: navigate to workspace overview after create (#97) Signed-off-by: yuye-aws --- .../public/components/workspace_creator/workspace_creator.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx index bfdbc1536fc3..7b992d23969e 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -9,7 +9,7 @@ import { i18n } from '@osd/i18n'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { WorkspaceForm, WorkspaceFormData } from './workspace_form'; import { WORKSPACE_OVERVIEW_APP_ID, WORKSPACE_OP_TYPE_CREATE } from '../../../common/constants'; -import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +import { formatUrlWithWorkspaceId } from '../../utils'; import { WorkspaceClient } from '../../workspace_client'; export const WorkspaceCreator = () => { From fe8bb1e879d74db533f431b81b408d1e4252acf3 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Tue, 22 Aug 2023 13:28:41 +0800 Subject: [PATCH 094/174] feat: accomplish dashboard_admin (#95) * feat: accomplish dashboard_admin Signed-off-by: SuZhou-Joe * feat: add yml default config and comment Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe --- config/opensearch_dashboards.yml | 4 +++ src/plugins/workspace/config.ts | 24 +++++++++++++ src/plugins/workspace/server/index.ts | 10 ++---- src/plugins/workspace/server/plugin.ts | 9 ++++- .../workspace_saved_objects_client_wrapper.ts | 35 ++++++++++++++++++- 5 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 src/plugins/workspace/config.ts diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 9c6c040433b4..7513dd51a484 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -273,3 +273,7 @@ # Set the value of this setting to true to enable plugin augmentation on Dashboard # vis_augmenter.pluginAugmentationEnabled: true + +# Set the backend roles, whoever has the backend roles defined in this config will be regard as dashboard admin. +# Dashboard admin will have the access to all the workspaces and objects inside OpenSearch Dashboards. +# workspace.dashboardAdmin.backendRoles: ["dashboard_admin"] diff --git a/src/plugins/workspace/config.ts b/src/plugins/workspace/config.ts new file mode 100644 index 000000000000..6fc163b67e45 --- /dev/null +++ b/src/plugins/workspace/config.ts @@ -0,0 +1,24 @@ +/* + * 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 }), + dashboardAdmin: schema.object( + { + backendRoles: schema.arrayOf(schema.string(), { + defaultValue: ['dashboard_admin'], + }), + }, + { + defaultValue: { + backendRoles: ['dashboard_admin'], + }, + } + ), +}); + +export type ConfigSchema = TypeOf; diff --git a/src/plugins/workspace/server/index.ts b/src/plugins/workspace/server/index.ts index 4e11dc50dab9..230b53b6f663 100644 --- a/src/plugins/workspace/server/index.ts +++ b/src/plugins/workspace/server/index.ts @@ -2,11 +2,9 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ - -import { schema } from '@osd/config-schema'; - 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. @@ -15,10 +13,6 @@ export function plugin(initializerContext: PluginInitializerContext) { return new WorkspacePlugin(initializerContext); } -export { MlCommonsPluginSetup, MlCommonsPluginStart } from './types'; - export const config: PluginConfigDescriptor = { - schema: schema.object({ - enabled: schema.boolean({ defaultValue: false }), - }), + schema: configSchema, }; diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index dfddcd372c17..1d3cdf3ad19d 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import { i18n } from '@osd/i18n'; +import { Observable } from 'rxjs'; import { PluginInitializerContext, @@ -23,10 +24,12 @@ import { IWorkspaceDBImpl, WorkspaceAttribute } from './types'; import { WorkspaceClientWithSavedObject } from './workspace_client'; import { WorkspaceSavedObjectsClientWrapper } from './saved_objects'; import { registerRoutes } from './routes'; +import { ConfigSchema } from '../config'; export class WorkspacePlugin implements Plugin<{}, {}> { private readonly logger: Logger; private client?: IWorkspaceDBImpl; + private config$: Observable; private proxyWorkspaceTrafficToRealHandler(setupDeps: CoreSetup) { /** @@ -47,6 +50,7 @@ export class WorkspacePlugin implements Plugin<{}, {}> { constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get('plugins', 'workspace'); + this.config$ = initializerContext.config.create(); } public async setup(core: CoreSetup) { @@ -56,7 +60,10 @@ export class WorkspacePlugin implements Plugin<{}, {}> { await this.client.setup(core); const workspaceSavedObjectsClientWrapper = new WorkspaceSavedObjectsClientWrapper( - core.savedObjects.permissionControl + core.savedObjects.permissionControl, + { + config$: this.config$, + } ); core.savedObjects.addClientWrapper( diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts index dbde03f91150..16543ca9be2b 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -5,6 +5,8 @@ import { i18n } from '@osd/i18n'; import Boom from '@hapi/boom'; +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; import { OpenSearchDashboardsRequest, @@ -29,6 +31,7 @@ import { ACL, WorkspacePermissionMode, } from '../../../../core/server'; +import { ConfigSchema } from '../../config'; // Can't throw unauthorized for now, the page will be refreshed if unauthorized const generateWorkspacePermissionError = () => @@ -56,6 +59,7 @@ const isWorkspacesLikeAttributes = (attributes: unknown): attributes is Attribut Array.isArray((attributes as { workspaces: unknown }).workspaces); export class WorkspaceSavedObjectsClientWrapper { + private config?: ConfigSchema; private formatWorkspacePermissionModeToStringArray( permission: WorkspacePermissionMode | WorkspacePermissionMode[] ): string[] { @@ -137,6 +141,14 @@ export class WorkspaceSavedObjectsClientWrapper { } } + private isDashboardAdmin(request: OpenSearchDashboardsRequest): boolean { + const config = this.config || ({} as ConfigSchema); + const principals = this.permissionControl.getPrincipalsFromRequest(request); + const adminBackendRoles = config?.dashboardAdmin?.backendRoles || []; + const matchAny = principals?.groups?.some((item) => adminBackendRoles.includes(item)) || false; + return matchAny; + } + /** * check if the type include workspace * Workspace permission check is totally different from object permission check. @@ -374,6 +386,12 @@ export class WorkspaceSavedObjectsClientWrapper { return await wrapperOptions.client.addToWorkspaces(objects, targetWorkspaces, options); }; + const isDashboardAdmin = this.isDashboardAdmin(wrapperOptions.request); + + if (isDashboardAdmin) { + return wrapperOptions.client; + } + return { ...wrapperOptions.client, get: getWithWorkspacePermissionControl, @@ -392,5 +410,20 @@ export class WorkspaceSavedObjectsClientWrapper { }; }; - constructor(private readonly permissionControl: SavedObjectsPermissionControlContract) {} + constructor( + private readonly permissionControl: SavedObjectsPermissionControlContract, + private readonly options: { + config$: Observable; + } + ) { + this.options.config$.subscribe((config) => { + this.config = config; + }); + this.options.config$ + .pipe(first()) + .toPromise() + .then((config) => { + this.config = config; + }); + } } From 3fde67abc5b502ea3a831e7ae7439b694cbe645e Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Tue, 22 Aug 2023 13:54:11 +0800 Subject: [PATCH 095/174] fix: incorrect permission modes when checking workspace permissions (#98) Signed-off-by: SuZhou-Joe --- .../workspace_saved_objects_client_wrapper.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts index 16543ca9be2b..1c353e597af4 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -175,7 +175,7 @@ export class WorkspaceSavedObjectsClientWrapper { await this.validateMultiWorkspacesPermissions( objectToDeleted.workspaces, wrapperOptions.request, - WorkspacePermissionMode.Management + [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Management] ); return await wrapperOptions.client.delete(type, id, options); }; @@ -238,7 +238,7 @@ export class WorkspaceSavedObjectsClientWrapper { await this.validateMultiWorkspacesPermissions( attributes.workspaces, wrapperOptions.request, - WorkspacePermissionMode.Management + [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Management] ); } return await wrapperOptions.client.create(type, attributes, options); @@ -253,7 +253,11 @@ export class WorkspaceSavedObjectsClientWrapper { await this.validateAtLeastOnePermittedWorkspaces( objectToGet.workspaces, wrapperOptions.request, - WorkspacePermissionMode.Read + [ + WorkspacePermissionMode.LibraryRead, + WorkspacePermissionMode.LibraryWrite, + WorkspacePermissionMode.Management, + ] ); return objectToGet; }; @@ -267,7 +271,11 @@ export class WorkspaceSavedObjectsClientWrapper { await this.validateAtLeastOnePermittedWorkspaces( object.workspaces, wrapperOptions.request, - WorkspacePermissionMode.Read + [ + WorkspacePermissionMode.LibraryRead, + WorkspacePermissionMode.LibraryWrite, + WorkspacePermissionMode.Management, + ] ); } return objectToBulkGet; From a7fbb39999632ac57de700bf52fa8b610d939fd7 Mon Sep 17 00:00:00 2001 From: Yuye Zhu Date: Tue, 22 Aug 2023 15:04:23 +0800 Subject: [PATCH 096/174] fix: two "Overview"s under library section (#94) * rename the second overview in library category to all library objects Signed-off-by: yuye-aws * change id from objects_overview to objects_all Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws --- .../saved_objects_management/public/constants.ts | 16 +++++++++++++--- .../saved_objects_management/public/plugin.ts | 9 +++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/plugins/saved_objects_management/public/constants.ts b/src/plugins/saved_objects_management/public/constants.ts index e66d808dcf4c..edb249187a2b 100644 --- a/src/plugins/saved_objects_management/public/constants.ts +++ b/src/plugins/saved_objects_management/public/constants.ts @@ -5,9 +5,19 @@ import { i18n } from '@osd/i18n'; -export const LIBRARY_OVERVIEW_WORDINGS = i18n.translate('savedObjectsManagement.libraryOverview', { - defaultMessage: 'Overview', -}); +export const ALL_LIBRARY_OBJECTS_WORDINGS = i18n.translate( + 'savedObjectsManagement.allLibraryObjects', + { + defaultMessage: 'All library objects', + } +); + +export const ALL_LIBRARY_OBJECTS_TITLE_WORDINGS = i18n.translate( + 'savedObjectsManagement.objectsTable.header.allLibraryObjectsTitle', + { + defaultMessage: 'Library objects in Analytics', + } +); export const SAVED_OBJECT_MANAGEMENT_TITLE_WORDINGS = i18n.translate( 'savedObjectsManagement.objectsTable.header.savedObjectsTitle', diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index 3e72ee464aeb..c24002b1e10d 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -57,7 +57,8 @@ import { registerServices } from './register_services'; import { bootstrap } from './ui_actions_bootstrap'; import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; import { - LIBRARY_OVERVIEW_WORDINGS, + ALL_LIBRARY_OBJECTS_TITLE_WORDINGS, + ALL_LIBRARY_OBJECTS_WORDINGS, SAVED_OBJECT_MANAGEMENT_TITLE_WORDINGS, SAVED_QUERIES_WORDINGS, SAVED_SEARCHES_WORDINGS, @@ -132,14 +133,14 @@ export class SavedObjectsManagementPlugin * Register saved objects overview & saved search & saved query here */ core.application.register({ - id: 'objects_overview', + id: 'objects_all', appRoute: '/app/objects', exactRoute: true, - title: LIBRARY_OVERVIEW_WORDINGS, + title: ALL_LIBRARY_OBJECTS_WORDINGS, order: 10000, category: DEFAULT_APP_CATEGORIES.opensearchDashboards, mount: mountWrapper({ - title: SAVED_OBJECT_MANAGEMENT_TITLE_WORDINGS, + title: ALL_LIBRARY_OBJECTS_TITLE_WORDINGS, }), }); From f70cfa15d87527b8dd4ecb5380a8f7bb75409545 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Tue, 22 Aug 2023 17:22:51 +0800 Subject: [PATCH 097/174] supports configure workspace features with wildcard (#96) supports configure workspace features with wildcard --------- Signed-off-by: Yulong Ruan --- src/core/public/index.ts | 2 +- src/core/public/workspace/index.ts | 1 - .../workspace/workspaces_service.mock.ts | 2 +- .../public/workspace/workspaces_service.ts | 12 +-- src/core/server/index.ts | 2 +- src/core/types/index.ts | 1 + src/core/types/workspace.ts | 14 ++++ src/plugins/workspace/public/plugin.ts | 3 +- src/plugins/workspace/public/utils.test.ts | 83 +++++++++++++++++++ src/plugins/workspace/public/utils.ts | 52 +++++++++++- src/plugins/workspace/server/plugin.ts | 5 +- src/plugins/workspace/server/types.ts | 11 +-- .../workspace/server/workspace_client.ts | 8 +- 13 files changed, 166 insertions(+), 30 deletions(-) create mode 100644 src/core/types/workspace.ts create mode 100644 src/plugins/workspace/public/utils.test.ts diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 44aaedc19adb..ff53b50a3e6c 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -103,6 +103,7 @@ export { StringValidation, StringValidationRegex, StringValidationRegexString, + WorkspaceAttribute, } from '../types'; export { @@ -351,7 +352,6 @@ export { WorkspaceStart, WorkspaceSetup, WorkspaceService, - WorkspaceAttribute, WorkspaceObservables, } from './workspace'; diff --git a/src/core/public/workspace/index.ts b/src/core/public/workspace/index.ts index c446ecda4499..d83bb2c90909 100644 --- a/src/core/public/workspace/index.ts +++ b/src/core/public/workspace/index.ts @@ -8,4 +8,3 @@ export { WorkspaceSetup, WorkspaceObservables, } from './workspaces_service'; -export type { WorkspaceAttribute } from './workspaces_service'; diff --git a/src/core/public/workspace/workspaces_service.mock.ts b/src/core/public/workspace/workspaces_service.mock.ts index b45d38542b55..d9190c28176f 100644 --- a/src/core/public/workspace/workspaces_service.mock.ts +++ b/src/core/public/workspace/workspaces_service.mock.ts @@ -4,7 +4,7 @@ */ import { BehaviorSubject } from 'rxjs'; -import { WorkspaceAttribute } from '../workspace'; +import { WorkspaceAttribute } from '..'; const currentWorkspaceId$ = new BehaviorSubject(''); const workspaceList$ = new BehaviorSubject([]); diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts index d11a4f5da380..9a1504808551 100644 --- a/src/core/public/workspace/workspaces_service.ts +++ b/src/core/public/workspace/workspaces_service.ts @@ -4,7 +4,7 @@ */ import { BehaviorSubject } from 'rxjs'; -import { CoreService } from '../../types'; +import { CoreService, WorkspaceAttribute } from '../../types'; import { InternalApplicationStart } from '../application'; import { HttpSetup } from '../http'; @@ -36,16 +36,6 @@ export interface WorkspaceStart extends WorkspaceObservables { renderWorkspaceMenu: () => JSX.Element | null; } -export interface WorkspaceAttribute { - id: string; - name: string; - description?: string; - features?: string[]; - color?: string; - icon?: string; - defaultVISTheme?: string; -} - export class WorkspaceService implements CoreService { private currentWorkspaceId$ = new BehaviorSubject(''); private workspaceList$ = new BehaviorSubject([]); diff --git a/src/core/server/index.ts b/src/core/server/index.ts index d150c4af2117..93e784e7a4ca 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -353,7 +353,7 @@ export { MetricsServiceStart, } from './metrics'; -export { AppCategory } from '../types'; +export { AppCategory, WorkspaceAttribute } from '../types'; export { DEFAULT_APP_CATEGORIES, WorkspacePermissionMode, 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/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 1c6c59248016..db380017a5c5 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -32,6 +32,7 @@ import { WorkspaceClient } from './workspace_client'; import { IndexPatternManagementSetup } from '../../index_pattern_management/public'; import { renderWorkspaceMenu } from './render_workspace_menu'; import { Services } from './types'; +import { featureMatchesConfig } from './utils'; interface WorkspacePluginSetupDeps { savedObjectsManagement?: SavedObjectsManagementPluginSetup; @@ -163,7 +164,7 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> private filterByWorkspace(workspace: WorkspaceAttribute | null, allNavLinks: ChromeNavLink[]) { if (!workspace) return allNavLinks; const features = workspace.features ?? []; - return allNavLinks.filter((item) => features.includes(item.id)); + return allNavLinks.filter(featureMatchesConfig(features)); } private filterNavLinks(core: CoreStart) { diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts new file mode 100644 index 000000000000..a24fcdce5ee4 --- /dev/null +++ b/src/plugins/workspace/public/utils.test.ts @@ -0,0 +1,83 @@ +/* + * 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 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 index ccb286860061..09528c2b080f 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -4,7 +4,7 @@ */ import { WORKSPACE_PATH_PREFIX } from '../../../core/public/utils'; -import { IBasePath } from '../../../core/public'; +import { AppCategory, IBasePath } from '../../../core/public'; export const formatUrlWithWorkspaceId = ( url: string, @@ -29,3 +29,53 @@ export const formatUrlWithWorkspaceId = ( return newUrl.toString(); }; + +/** + * 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; +}; diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index 1d3cdf3ad19d..ff5bf3a09933 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -19,8 +19,10 @@ import { Permissions, WorkspacePermissionMode, SavedObjectsClient, + WorkspaceAttribute, + DEFAULT_APP_CATEGORIES, } from '../../../core/server'; -import { IWorkspaceDBImpl, WorkspaceAttribute } from './types'; +import { IWorkspaceDBImpl } from './types'; import { WorkspaceClientWithSavedObject } from './workspace_client'; import { WorkspaceSavedObjectsClientWrapper } from './saved_objects'; import { registerRoutes } from './routes'; @@ -147,6 +149,7 @@ export class WorkspacePlugin implements Plugin<{}, {}> { name: i18n.translate('workspaces.management.workspace.default.name', { defaultMessage: 'Management', }), + features: [`@${DEFAULT_APP_CATEGORIES.management.id}`], }, managementWorkspaceACL.getPermissions() ), diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts index 72afd87fff90..3d0be3ac824d 100644 --- a/src/plugins/workspace/server/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -10,18 +10,9 @@ import { CoreSetup, WorkspacePermissionMode, Permissions, + WorkspaceAttribute, } from '../../../core/server'; -export interface WorkspaceAttribute { - id: string; - name: string; - description?: string; - features?: string[]; - color?: string; - icon?: string; - defaultVISTheme?: string; -} - export interface WorkspaceAttributeWithPermission extends WorkspaceAttribute { permissions: Permissions; } diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index a44060e7193b..01a75d416e76 100644 --- a/src/plugins/workspace/server/workspace_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -2,11 +2,15 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -import type { SavedObject, SavedObjectsClientContract, CoreSetup } from '../../../core/server'; +import type { + SavedObject, + SavedObjectsClientContract, + CoreSetup, + WorkspaceAttribute, +} from '../../../core/server'; import { WORKSPACE_TYPE } from '../../../core/server'; import { IWorkspaceDBImpl, - WorkspaceAttribute, WorkspaceFindOptions, IResponse, IRequestDetail, From c9f4b5fe85b4de23a2ebc134d6e79879d308c24b Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Thu, 24 Aug 2023 10:58:38 +0800 Subject: [PATCH 098/174] update feature config of public workspace (#99) Signed-off-by: Yulong Ruan --- src/plugins/workspace/public/plugin.ts | 2 +- src/plugins/workspace/public/utils.test.ts | 10 ++++++++++ src/plugins/workspace/server/plugin.ts | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index db380017a5c5..68342ee36c0b 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -163,7 +163,7 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> private filterByWorkspace(workspace: WorkspaceAttribute | null, allNavLinks: ChromeNavLink[]) { if (!workspace) return allNavLinks; - const features = workspace.features ?? []; + const features = workspace.features ?? ['*']; return allNavLinks.filter(featureMatchesConfig(features)); } diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts index a24fcdce5ee4..510a775cd745 100644 --- a/src/plugins/workspace/public/utils.test.ts +++ b/src/plugins/workspace/public/utils.test.ts @@ -60,6 +60,16 @@ describe('workspace utils: featureMatchesConfig', () => { ).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( diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index ff5bf3a09933..a2c18a3c7641 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -139,6 +139,7 @@ export class WorkspacePlugin implements Plugin<{}, {}> { name: i18n.translate('workspaces.public.workspace.default.name', { defaultMessage: 'public', }), + features: ['*', `!@${DEFAULT_APP_CATEGORIES.management.id}`], }, publicWorkspaceACL.getPermissions() ), From 83f77598e4a39dc86341c77863a7ced953bb2a7a Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Thu, 24 Aug 2023 11:49:47 +0800 Subject: [PATCH 099/174] fix: redirect error (#105) Signed-off-by: SuZhou-Joe --- .../index_patterns/redirect_no_index_pattern.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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..30d0bcb731e6 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 @@ -72,8 +72,8 @@ export const onRedirectNoIndexPattern = ( if (redirectTarget === '/home') { navigateToApp('home'); } else { - navigateToApp('management', { - path: `/opensearch-dashboards/indexPatterns?bannerMessage=${bannerMessage}`, + navigateToApp('indexPatterns', { + path: `?bannerMessage=${bannerMessage}`, }); } From 3d9fb6365e587cb9397e744fb25d67e4349ad8c4 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Thu, 24 Aug 2023 13:27:59 +0800 Subject: [PATCH 100/174] feat: register data source as application (#101) * feat: register data source as application Signed-off-by: SuZhou-Joe * feat: rename mountManagementSection Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe --- .../opensearch_dashboards.json | 2 +- .../public/components/page_wrapper/index.ts | 6 ++ .../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 ++ 7 files changed, 93 insertions(+), 47 deletions(-) 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.tsx 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/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.tsx b/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.tsx new file mode 100644 index 000000000000..e0b725edc42d --- /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: { fullWidth?: boolean; 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; +} From f5dd0f33a8c18525781c3b94d702d71e4503dd20 Mon Sep 17 00:00:00 2001 From: Yuye Zhu Date: Thu, 24 Aug 2023 13:39:11 +0800 Subject: [PATCH 101/174] fix import error on workspace updater (#107) Signed-off-by: yuye-aws --- .../public/components/workspace_updater/workspace_updater.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx index 41a3e1216770..99a66a7001f6 100644 --- a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx @@ -20,7 +20,7 @@ import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react import { WorkspaceForm, WorkspaceFormData } from '../workspace_creator/workspace_form'; import { WORKSPACE_OVERVIEW_APP_ID, WORKSPACE_OP_TYPE_UPDATE } from '../../../common/constants'; import { DeleteWorkspaceModal } from '../delete_workspace_modal'; -import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +import { formatUrlWithWorkspaceId } from '../../utils'; import { WorkspaceClient } from '../../workspace_client'; export const WorkspaceUpdater = () => { From 5ce834ebbc5661a7365afd9cb9e3e09165f5bb0c Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Thu, 24 Aug 2023 15:33:32 +0800 Subject: [PATCH 102/174] fix: make importing workspace-specific objects as copy (#62) * setup workspace plugin project skeleton Signed-off-by: Yulong Ruan * test: add unit tests add license header Signed-off-by: Yulong Ruan * workspace template init commit Signed-off-by: Hailong Cui * refacter workspace template into hooks Signed-off-by: Hailong Cui * refacter workspace template hooks Signed-off-by: Hailong Cui * update coverImage comments Signed-off-by: Hailong Cui * feature: add public/workspaces service Signed-off-by: SuZhoue-Joe * feat: add interfaces for workspaces client Signed-off-by: SuZhoue-Joe * feat: add interfaces for workspaces client Signed-off-by: SuZhoue-Joe * feat: add interfaces for workspaces client Signed-off-by: SuZhoue-Joe * feat: implement workspaces service Signed-off-by: SuZhoue-Joe * feat: changes to client type interface Signed-off-by: SuZhoue-Joe * feat: changes to client implement Signed-off-by: SuZhoue-Joe * feat: implement more for workspaces service Signed-off-by: SuZhoue-Joe * feat: implement more for workspaces service Signed-off-by: SuZhoue-Joe * feat: implement more for workspaces service Signed-off-by: SuZhoue-Joe * feat: add workspace creator page (#5) * feat: add workspace creator page Signed-off-by: Lin Wang * feat: integrate with application workspace template Signed-off-by: Lin Wang * feat: add max-width and remove image wrapper if not exists Signed-off-by: Lin Wang * feat: update filter condition to align with collapsible nav Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang * Add validation when load page (#8) * fix: validation & query Signed-off-by: SuZhoue-Joe * feat: modify file name to reduce confusion Signed-off-by: SuZhoue-Joe * feat: add landing logic to retrive workspace id Signed-off-by: SuZhoue-Joe * feat: add worklist observable Signed-off-by: SuZhoue-Joe * feat: add worklist observable Signed-off-by: SuZhoue-Joe * feat: add worklist observable Signed-off-by: SuZhoue-Joe * fix: type error Signed-off-by: SuZhoue-Joe * fix: type error Signed-off-by: SuZhoue-Joe * feat: make client more robust Signed-off-by: SuZhoue-Joe * feat: use Subject Signed-off-by: SuZhoue-Joe --------- Signed-off-by: SuZhoue-Joe * feat: use BehaviorObject and optimize code (#14) Signed-off-by: SuZhoue-Joe * feat: integrate with workspace create API (#13) * feat: integrate with workspace create API Signed-off-by: Lin Wang * feat: update to i18n text for toast Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang * Add currentWorkspace$ (#15) * feat: add currentWorkspace$ Signed-off-by: SuZhoue-Joe * fix: type error Signed-off-by: SuZhoue-Joe * feat: add emit on currentWorkspace$ Signed-off-by: SuZhoue-Joe --------- Signed-off-by: SuZhoue-Joe * register plugin with workspace template (#16) Signed-off-by: Hailong Cui * workspace dropdown list (#9) Add workspace dropdown list --------- Signed-off-by: zhichao-aws Signed-off-by: SuZhoue-Joe Signed-off-by: suzhou Co-authored-by: SuZhoue-Joe * init workspace menu stage 1 (#12) * feat: init workspace menu stage 1 Signed-off-by: tygao * fix: remove port diff Signed-off-by: tygao * feat: update menu logic Signed-off-by: tygao --------- Signed-off-by: tygao * Fix template registration import error (#21) * fix import error Signed-off-by: Hailong Cui * fix osd bootstrap failure Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui * Add workspace overview page (#19) * feat: add workspace overview page Signed-off-by: Lin Wang * refactor: move paths to common constants Signed-off-by: Lin Wang * feat: add workspace overview item by custom nav in start phase Signed-off-by: Lin Wang * refactor: change to currentWorkspace$ in workspace client Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang * feat: navigate to workspace create page after button clicked (#23) Signed-off-by: Lin Wang * fix failed test snapshots (#22) fix failed test snapshots temporary fix: fetch functional test from main branch fixed git error which cannot find ref due to feature branch `workspace` not exists on repo opensearch-dashboards-functional-test Signed-off-by: Yulong Ruan --------- Signed-off-by: Yulong Ruan * change to currentWorkspace, wrap title using i18n (#20) * change to currentWorkspace, wrap title using i18n Signed-off-by: zhichao-aws * change import Signed-off-by: zhichao-aws * directly return [] if currentWorkspace is null Signed-off-by: zhichao-aws --------- Signed-off-by: zhichao-aws * add workspace switch (#17) * feat: update workspace switch Signed-off-by: tygao * fix: fix switch error Signed-off-by: tygao * fix: fix prettier after merge Signed-off-by: tygao * chore: remove extra code after merge Signed-off-by: tygao --------- Signed-off-by: tygao * Add update workspace page (#25) Signed-off-by: gaobinlong * Delete Workspace (#24) * add delete workspace modal Signed-off-by: yuye-aws * implement delete on workspace overview page Signed-off-by: yuye-aws * fix export on delete workspace modal Signed-off-by: yuye-aws * add try catch to handle errors for workspace delete Signed-off-by: yuye-aws * move visibility control to workspace overview page exlusively Signed-off-by: yuye-aws * remove unused import Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws * feat: redirect to overview page after workspace switch (#26) Signed-off-by: Lin Wang * update menu filter logic (#28) * feat: update menu logic Signed-off-by: tygao * fix: use navLinks to filter Signed-off-by: tygao --------- Signed-off-by: tygao * feat: redirect to workspace overview page after created success (#29) Signed-off-by: Lin Wang * [Feature] Complied saved_objects create/find (#18) * temp: save Signed-off-by: SuZhoue-Joe * feat: make create/find support workspaces Signed-off-by: SuZhoue-Joe * feat: extract management code Signed-off-by: SuZhoue-Joe * fix: type check Signed-off-by: SuZhoue-Joe * fix: build error Signed-off-by: SuZhoue-Joe * feat: enable workspaces on saved client server side Signed-off-by: SuZhoue-Joe * feat: some optimization Signed-off-by: SuZhoue-Joe * feat: extract management code Signed-off-by: SuZhoue-Joe * feat: merge fix Signed-off-by: SuZhoue-Joe * feat: optimize code Signed-off-by: SuZhoue-Joe * feat: reuse common function Signed-off-by: SuZhoue-Joe * feat: optimize code when create Signed-off-by: SuZhoue-Joe * feat: remove useless test code Signed-off-by: SuZhoue-Joe --------- Signed-off-by: SuZhoue-Joe * feat: redirect to workspace update page after workspace switch (#30) * Move delete button to update page (#27) * add delete workspace modal Signed-off-by: yuye-aws * implement delete on workspace overview page Signed-off-by: yuye-aws * fix export on delete workspace modal Signed-off-by: yuye-aws * add try catch to handle errors for workspace delete Signed-off-by: yuye-aws * move visibility control to workspace overview page exlusively Signed-off-by: yuye-aws * remove unused import Signed-off-by: yuye-aws * change workspace overview route to workspace update Signed-off-by: yuye-aws * move delete button from workspace overview page to update page Signed-off-by: yuye-aws * remove update button from workspace overview page Signed-off-by: yuye-aws * recover router to workspace overview page Signed-off-by: yuye-aws * change navigation url for workspace overview button on left side panel Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws * fix: linting error Signed-off-by: Yulong Ruan * remove duplicate EuiPage (#34) * remove duplicate EuiPage Signed-off-by: Hailong Cui * fix: remove duplicate workspace template Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui * remove clear button, add the width of create button (#33) Signed-off-by: zhichao-aws * rename OpenSearch Plugins to OpenSearch Features this is a temporary fix just for demo, should be reverted later Signed-off-by: Yulong Ruan * Add some logic check when overwrite a saved object (#32) * feat: add some logic check when overwrite a saved object Signed-off-by: SuZhoue-Joe * fix: type check Signed-off-by: SuZhoue-Joe * feat: update Signed-off-by: SuZhoue-Joe --------- Signed-off-by: SuZhoue-Joe * Add color, icon and defaultVISTheme for workspace (#36) * feat: add color, icon and defaultVISTheme field for workspace saved object Signed-off-by: Lin Wang * add new fields to workspace form Signed-off-by: Lin Wang * feat: remove feature or group name hack Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang * feat: add workspace list (#39) Signed-off-by: tygao * Feature/menu change (#37) * feat: register library menus Signed-off-by: SuZhoue-Joe * feat: some update Signed-off-by: SuZhoue-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhoue-Joe Signed-off-by: SuZhou-Joe * feat: different left menu and exit workspace (#38) * Exit workspace from left menu Signed-off-by: yuye-aws * Show exit workspace button with small window size Signed-off-by: yuye-aws * Remove recently viewed and workspace overview on left menu Signed-off-by: yuye-aws * Add buttons for outside, inside workspace case Signed-off-by: yuye-aws * Implement home button and workspace over view button on left menu Signed-off-by: yuye-aws * Implement workspace dropdown list in left menu Signed-off-by: yuye-aws * Add props on recently accessed and custom nav link Signed-off-by: yuye-aws * Add three props to mock props for collapsible nav: exitWorkspace, getWorkspaceUrl, workspaceList$ Signed-off-by: yuye-aws * Add three props to mock props for header: exitWorkspace, getWorkspaceUrl, workspaceList$ Signed-off-by: yuye-aws * Fix bugs for function createWorkspaceNavLink Signed-off-by: yuye-aws * Remove unused constants Signed-off-by: yuye-aws * Reuse method getWorkspaceUrl Signed-off-by: yuye-aws * Remove recently accessed and custom nav props in test Signed-off-by: yuye-aws * Revert "Remove recently accessed and custom nav props in test" This reverts commit 7895e5c5dcde9e134f26b2d6a3df54a2d62e9274. * Wrap title with i18n Signed-off-by: yuye-aws * Add redirect for workspace app Signed-off-by: yuye-aws * Enable users to go to workspace lists page via see more under workspaces in left menu Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws * feat: make url stateful (#35) * feat: make url stateful Signed-off-by: SuZhoue-Joe * feat: optimize code Signed-off-by: SuZhoue-Joe * feat: remove useless change Signed-off-by: SuZhoue-Joe * feat: optimize url listener Signed-off-by: SuZhoue-Joe * feat: make formatUrlWithWorkspaceId extensible Signed-off-by: SuZhoue-Joe * feat: modify to related components Signed-off-by: SuZhoue-Joe * feat: modify the async format to be sync function Signed-off-by: SuZhoue-Joe * feat: modify the async format to be sync function Signed-off-by: SuZhoue-Joe * fix: type check Signed-off-by: SuZhoue-Joe * feat: use path to maintain workspace info Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhoue-Joe Signed-off-by: SuZhou-Joe * Fix build error and part of test error (#42) * fix: fix build error and some ut Signed-off-by: tygao * chore: remove saved object client test diff Signed-off-by: tygao --------- Signed-off-by: tygao * feat: optimize code (#40) Signed-off-by: SuZhou-Joe * fix: bootstrap error (#43) Signed-off-by: SuZhou-Joe * feat: add workspace permission control interface (#41) * feat: add workspace permission control interface Signed-off-by: Lin Wang * feat: add request parameter for workspace permission control Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang * allow user to turn on/off workspace from advance settings (#46) return 404 if accessing a workspace path when workspace is disabled --------- Signed-off-by: Yulong Ruan * fix: unit test failure (#50) Signed-off-by: SuZhou-Joe * Add workspace column into saved objects table (#44) * Add workspace column into saved management page Signed-off-by: Hailong Cui * savedObjectsManagement as optional dependency Signed-off-by: Hailong Cui * i18n for column title Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui * fix missing mocks of workspacesSetup Signed-off-by: Yulong Ruan * Integrate workspace service into saved object management (#31) * setup workspace plugin project skeleton Signed-off-by: Yulong Ruan * test: add unit tests add license header Signed-off-by: Yulong Ruan * workspace template init commit Signed-off-by: Hailong Cui * refacter workspace template into hooks Signed-off-by: Hailong Cui * refacter workspace template hooks Signed-off-by: Hailong Cui * update coverImage comments Signed-off-by: Hailong Cui * feature: add public/workspaces service Signed-off-by: SuZhoue-Joe * feat: add interfaces for workspaces client Signed-off-by: SuZhoue-Joe * feat: add interfaces for workspaces client Signed-off-by: SuZhoue-Joe * feat: add interfaces for workspaces client Signed-off-by: SuZhoue-Joe * feat: implement workspaces service Signed-off-by: SuZhoue-Joe * feat: changes to client type interface Signed-off-by: SuZhoue-Joe * feat: changes to client implement Signed-off-by: SuZhoue-Joe * feat: implement more for workspaces service Signed-off-by: SuZhoue-Joe * feat: implement more for workspaces service Signed-off-by: SuZhoue-Joe * feat: implement more for workspaces service Signed-off-by: SuZhoue-Joe * feat: add workspace creator page (#5) * feat: add workspace creator page Signed-off-by: Lin Wang * feat: integrate with application workspace template Signed-off-by: Lin Wang * feat: add max-width and remove image wrapper if not exists Signed-off-by: Lin Wang * feat: update filter condition to align with collapsible nav Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang * Add validation when load page (#8) * fix: validation & query Signed-off-by: SuZhoue-Joe * feat: modify file name to reduce confusion Signed-off-by: SuZhoue-Joe * feat: add landing logic to retrive workspace id Signed-off-by: SuZhoue-Joe * feat: add worklist observable Signed-off-by: SuZhoue-Joe * feat: add worklist observable Signed-off-by: SuZhoue-Joe * feat: add worklist observable Signed-off-by: SuZhoue-Joe * fix: type error Signed-off-by: SuZhoue-Joe * fix: type error Signed-off-by: SuZhoue-Joe * feat: make client more robust Signed-off-by: SuZhoue-Joe * feat: use Subject Signed-off-by: SuZhoue-Joe --------- Signed-off-by: SuZhoue-Joe * feat: use BehaviorObject and optimize code (#14) Signed-off-by: SuZhoue-Joe * feat: integrate with workspace create API (#13) * feat: integrate with workspace create API Signed-off-by: Lin Wang * feat: update to i18n text for toast Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang * Add currentWorkspace$ (#15) * feat: add currentWorkspace$ Signed-off-by: SuZhoue-Joe * fix: type error Signed-off-by: SuZhoue-Joe * feat: add emit on currentWorkspace$ Signed-off-by: SuZhoue-Joe --------- Signed-off-by: SuZhoue-Joe * register plugin with workspace template (#16) Signed-off-by: Hailong Cui * workspace dropdown list (#9) Add workspace dropdown list --------- Signed-off-by: zhichao-aws Signed-off-by: SuZhoue-Joe Signed-off-by: suzhou Co-authored-by: SuZhoue-Joe * init workspace menu stage 1 (#12) * feat: init workspace menu stage 1 Signed-off-by: tygao * fix: remove port diff Signed-off-by: tygao * feat: update menu logic Signed-off-by: tygao --------- Signed-off-by: tygao * Fix template registration import error (#21) * fix import error Signed-off-by: Hailong Cui * fix osd bootstrap failure Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui * Add workspace overview page (#19) * feat: add workspace overview page Signed-off-by: Lin Wang * refactor: move paths to common constants Signed-off-by: Lin Wang * feat: add workspace overview item by custom nav in start phase Signed-off-by: Lin Wang * refactor: change to currentWorkspace$ in workspace client Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang * feat: navigate to workspace create page after button clicked (#23) Signed-off-by: Lin Wang * fix failed test snapshots (#22) fix failed test snapshots temporary fix: fetch functional test from main branch fixed git error which cannot find ref due to feature branch `workspace` not exists on repo opensearch-dashboards-functional-test Signed-off-by: Yulong Ruan --------- Signed-off-by: Yulong Ruan * change to currentWorkspace, wrap title using i18n (#20) * change to currentWorkspace, wrap title using i18n Signed-off-by: zhichao-aws * change import Signed-off-by: zhichao-aws * directly return [] if currentWorkspace is null Signed-off-by: zhichao-aws --------- Signed-off-by: zhichao-aws * add workspace switch (#17) * feat: update workspace switch Signed-off-by: tygao * fix: fix switch error Signed-off-by: tygao * fix: fix prettier after merge Signed-off-by: tygao * chore: remove extra code after merge Signed-off-by: tygao --------- Signed-off-by: tygao * Add update workspace page (#25) Signed-off-by: gaobinlong * Delete Workspace (#24) * add delete workspace modal Signed-off-by: yuye-aws * implement delete on workspace overview page Signed-off-by: yuye-aws * fix export on delete workspace modal Signed-off-by: yuye-aws * add try catch to handle errors for workspace delete Signed-off-by: yuye-aws * move visibility control to workspace overview page exlusively Signed-off-by: yuye-aws * remove unused import Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws * feat: redirect to overview page after workspace switch (#26) Signed-off-by: Lin Wang * update menu filter logic (#28) * feat: update menu logic Signed-off-by: tygao * fix: use navLinks to filter Signed-off-by: tygao --------- Signed-off-by: tygao * feat: redirect to workspace overview page after created success (#29) Signed-off-by: Lin Wang * [Feature] Complied saved_objects create/find (#18) * temp: save Signed-off-by: SuZhoue-Joe * feat: make create/find support workspaces Signed-off-by: SuZhoue-Joe * feat: extract management code Signed-off-by: SuZhoue-Joe * fix: type check Signed-off-by: SuZhoue-Joe * fix: build error Signed-off-by: SuZhoue-Joe * feat: enable workspaces on saved client server side Signed-off-by: SuZhoue-Joe * feat: some optimization Signed-off-by: SuZhoue-Joe * feat: extract management code Signed-off-by: SuZhoue-Joe * feat: merge fix Signed-off-by: SuZhoue-Joe * feat: optimize code Signed-off-by: SuZhoue-Joe * feat: reuse common function Signed-off-by: SuZhoue-Joe * feat: optimize code when create Signed-off-by: SuZhoue-Joe * feat: remove useless test code Signed-off-by: SuZhoue-Joe --------- Signed-off-by: SuZhoue-Joe * feat: redirect to workspace update page after workspace switch (#30) * Move delete button to update page (#27) * add delete workspace modal Signed-off-by: yuye-aws * implement delete on workspace overview page Signed-off-by: yuye-aws * fix export on delete workspace modal Signed-off-by: yuye-aws * add try catch to handle errors for workspace delete Signed-off-by: yuye-aws * move visibility control to workspace overview page exlusively Signed-off-by: yuye-aws * remove unused import Signed-off-by: yuye-aws * change workspace overview route to workspace update Signed-off-by: yuye-aws * move delete button from workspace overview page to update page Signed-off-by: yuye-aws * remove update button from workspace overview page Signed-off-by: yuye-aws * recover router to workspace overview page Signed-off-by: yuye-aws * change navigation url for workspace overview button on left side panel Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws * fix: linting error Signed-off-by: Yulong Ruan * remove duplicate EuiPage (#34) * remove duplicate EuiPage Signed-off-by: Hailong Cui * fix: remove duplicate workspace template Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui * remove clear button, add the width of create button (#33) Signed-off-by: zhichao-aws * rename OpenSearch Plugins to OpenSearch Features this is a temporary fix just for demo, should be reverted later Signed-off-by: Yulong Ruan * Add some logic check when overwrite a saved object (#32) * feat: add some logic check when overwrite a saved object Signed-off-by: SuZhoue-Joe * fix: type check Signed-off-by: SuZhoue-Joe * feat: update Signed-off-by: SuZhoue-Joe --------- Signed-off-by: SuZhoue-Joe * Add color, icon and defaultVISTheme for workspace (#36) * feat: add color, icon and defaultVISTheme field for workspace saved object Signed-off-by: Lin Wang * add new fields to workspace form Signed-off-by: Lin Wang * feat: remove feature or group name hack Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang * feat: add workspace list (#39) Signed-off-by: tygao * Feature/menu change (#37) * feat: register library menus Signed-off-by: SuZhoue-Joe * feat: some update Signed-off-by: SuZhoue-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhoue-Joe Signed-off-by: SuZhou-Joe * feat: different left menu and exit workspace (#38) * Exit workspace from left menu Signed-off-by: yuye-aws * Show exit workspace button with small window size Signed-off-by: yuye-aws * Remove recently viewed and workspace overview on left menu Signed-off-by: yuye-aws * Add buttons for outside, inside workspace case Signed-off-by: yuye-aws * Implement home button and workspace over view button on left menu Signed-off-by: yuye-aws * Implement workspace dropdown list in left menu Signed-off-by: yuye-aws * Add props on recently accessed and custom nav link Signed-off-by: yuye-aws * Add three props to mock props for collapsible nav: exitWorkspace, getWorkspaceUrl, workspaceList$ Signed-off-by: yuye-aws * Add three props to mock props for header: exitWorkspace, getWorkspaceUrl, workspaceList$ Signed-off-by: yuye-aws * Fix bugs for function createWorkspaceNavLink Signed-off-by: yuye-aws * Remove unused constants Signed-off-by: yuye-aws * Reuse method getWorkspaceUrl Signed-off-by: yuye-aws * Remove recently accessed and custom nav props in test Signed-off-by: yuye-aws * Revert "Remove recently accessed and custom nav props in test" This reverts commit 7895e5c5dcde9e134f26b2d6a3df54a2d62e9274. * Wrap title with i18n Signed-off-by: yuye-aws * Add redirect for workspace app Signed-off-by: yuye-aws * Enable users to go to workspace lists page via see more under workspaces in left menu Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws * feat: make url stateful (#35) * feat: make url stateful Signed-off-by: SuZhoue-Joe * feat: optimize code Signed-off-by: SuZhoue-Joe * feat: remove useless change Signed-off-by: SuZhoue-Joe * feat: optimize url listener Signed-off-by: SuZhoue-Joe * feat: make formatUrlWithWorkspaceId extensible Signed-off-by: SuZhoue-Joe * feat: modify to related components Signed-off-by: SuZhoue-Joe * feat: modify the async format to be sync function Signed-off-by: SuZhoue-Joe * feat: modify the async format to be sync function Signed-off-by: SuZhoue-Joe * fix: type check Signed-off-by: SuZhoue-Joe * feat: use path to maintain workspace info Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhoue-Joe Signed-off-by: SuZhou-Joe * Fix build error and part of test error (#42) * fix: fix build error and some ut Signed-off-by: tygao * chore: remove saved object client test diff Signed-off-by: tygao --------- Signed-off-by: tygao * feat: optimize code (#40) Signed-off-by: SuZhou-Joe * fix: bootstrap error (#43) Signed-off-by: SuZhou-Joe * feat: add workspace permission control interface (#41) * feat: add workspace permission control interface Signed-off-by: Lin Wang * feat: add request parameter for workspace permission control Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang * temp: save Signed-off-by: SuZhoue-Joe * feat: make create/find support workspaces Signed-off-by: SuZhoue-Joe * feat: extract management code Signed-off-by: SuZhoue-Joe * fix: type check Signed-off-by: SuZhoue-Joe * fix: build error Signed-off-by: SuZhoue-Joe * feat: enable workspaces on saved client server side Signed-off-by: SuZhoue-Joe * feat: extract management code Signed-off-by: SuZhoue-Joe * feat: merge fix Signed-off-by: SuZhoue-Joe * feat: optimize code Signed-off-by: SuZhoue-Joe * feat: remove useless test code Signed-off-by: SuZhoue-Joe * feat: integrate with saved object management page Signed-off-by: SuZhoue-Joe * Revert "feat: extract management code" This reverts commit 9c765d23aeae8bb76ab35a897e4abef9cc9da860. * Revert "feat: extract management code" This reverts commit 526c28e01b2a6b80e4f8ee7170b740f5c452b97c. * fix: type check Signed-off-by: SuZhoue-Joe * feat: update Signed-off-by: SuZhoue-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: remove useless import Signed-off-by: SuZhou-Joe * feat: remove useless import Signed-off-by: SuZhou-Joe * feat: remove useless import Signed-off-by: SuZhou-Joe * feat: refractor workspacesServiceMock Signed-off-by: SuZhou-Joe * feat: make all test pass Signed-off-by: SuZhou-Joe --------- Signed-off-by: Yulong Ruan Signed-off-by: Hailong Cui Signed-off-by: SuZhoue-Joe Signed-off-by: Lin Wang Signed-off-by: zhichao-aws Signed-off-by: suzhou Signed-off-by: tygao Signed-off-by: gaobinlong Signed-off-by: yuye-aws Signed-off-by: SuZhou-Joe Co-authored-by: Yulong Ruan Co-authored-by: Hailong Cui Co-authored-by: Lin Wang Co-authored-by: zhichao-aws Co-authored-by: raintygao Co-authored-by: gaobinlong Co-authored-by: Yuye Zhu * Feature: hide workspace from saved objects management page. (#45) * setup workspace plugin project skeleton Signed-off-by: Yulong Ruan * test: add unit tests add license header Signed-off-by: Yulong Ruan * workspace template init commit Signed-off-by: Hailong Cui * refacter workspace template into hooks Signed-off-by: Hailong Cui * refacter workspace template hooks Signed-off-by: Hailong Cui * update coverImage comments Signed-off-by: Hailong Cui * feature: add public/workspaces service Signed-off-by: SuZhoue-Joe * feat: add interfaces for workspaces client Signed-off-by: SuZhoue-Joe * feat: add interfaces for workspaces client Signed-off-by: SuZhoue-Joe * feat: add interfaces for workspaces client Signed-off-by: SuZhoue-Joe * feat: implement workspaces service Signed-off-by: SuZhoue-Joe * feat: changes to client type interface Signed-off-by: SuZhoue-Joe * feat: changes to client implement Signed-off-by: SuZhoue-Joe * feat: implement more for workspaces service Signed-off-by: SuZhoue-Joe * feat: implement more for workspaces service Signed-off-by: SuZhoue-Joe * feat: implement more for workspaces service Signed-off-by: SuZhoue-Joe * feat: add workspace creator page (#5) * feat: add workspace creator page Signed-off-by: Lin Wang * feat: integrate with application workspace template Signed-off-by: Lin Wang * feat: add max-width and remove image wrapper if not exists Signed-off-by: Lin Wang * feat: update filter condition to align with collapsible nav Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang * Add validation when load page (#8) * fix: validation & query Signed-off-by: SuZhoue-Joe * feat: modify file name to reduce confusion Signed-off-by: SuZhoue-Joe * feat: add landing logic to retrive workspace id Signed-off-by: SuZhoue-Joe * feat: add worklist observable Signed-off-by: SuZhoue-Joe * feat: add worklist observable Signed-off-by: SuZhoue-Joe * feat: add worklist observable Signed-off-by: SuZhoue-Joe * fix: type error Signed-off-by: SuZhoue-Joe * fix: type error Signed-off-by: SuZhoue-Joe * feat: make client more robust Signed-off-by: SuZhoue-Joe * feat: use Subject Signed-off-by: SuZhoue-Joe --------- Signed-off-by: SuZhoue-Joe * feat: use BehaviorObject and optimize code (#14) Signed-off-by: SuZhoue-Joe * feat: integrate with workspace create API (#13) * feat: integrate with workspace create API Signed-off-by: Lin Wang * feat: update to i18n text for toast Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang * Add currentWorkspace$ (#15) * feat: add currentWorkspace$ Signed-off-by: SuZhoue-Joe * fix: type error Signed-off-by: SuZhoue-Joe * feat: add emit on currentWorkspace$ Signed-off-by: SuZhoue-Joe --------- Signed-off-by: SuZhoue-Joe * register plugin with workspace template (#16) Signed-off-by: Hailong Cui * workspace dropdown list (#9) Add workspace dropdown list --------- Signed-off-by: zhichao-aws Signed-off-by: SuZhoue-Joe Signed-off-by: suzhou Co-authored-by: SuZhoue-Joe * init workspace menu stage 1 (#12) * feat: init workspace menu stage 1 Signed-off-by: tygao * fix: remove port diff Signed-off-by: tygao * feat: update menu logic Signed-off-by: tygao --------- Signed-off-by: tygao * Fix template registration import error (#21) * fix import error Signed-off-by: Hailong Cui * fix osd bootstrap failure Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui * Add workspace overview page (#19) * feat: add workspace overview page Signed-off-by: Lin Wang * refactor: move paths to common constants Signed-off-by: Lin Wang * feat: add workspace overview item by custom nav in start phase Signed-off-by: Lin Wang * refactor: change to currentWorkspace$ in workspace client Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang * feat: navigate to workspace create page after button clicked (#23) Signed-off-by: Lin Wang * fix failed test snapshots (#22) fix failed test snapshots temporary fix: fetch functional test from main branch fixed git error which cannot find ref due to feature branch `workspace` not exists on repo opensearch-dashboards-functional-test Signed-off-by: Yulong Ruan --------- Signed-off-by: Yulong Ruan * change to currentWorkspace, wrap title using i18n (#20) * change to currentWorkspace, wrap title using i18n Signed-off-by: zhichao-aws * change import Signed-off-by: zhichao-aws * directly return [] if currentWorkspace is null Signed-off-by: zhichao-aws --------- Signed-off-by: zhichao-aws * add workspace switch (#17) * feat: update workspace switch Signed-off-by: tygao * fix: fix switch error Signed-off-by: tygao * fix: fix prettier after merge Signed-off-by: tygao * chore: remove extra code after merge Signed-off-by: tygao --------- Signed-off-by: tygao * Add update workspace page (#25) Signed-off-by: gaobinlong * Delete Workspace (#24) * add delete workspace modal Signed-off-by: yuye-aws * implement delete on workspace overview page Signed-off-by: yuye-aws * fix export on delete workspace modal Signed-off-by: yuye-aws * add try catch to handle errors for workspace delete Signed-off-by: yuye-aws * move visibility control to workspace overview page exlusively Signed-off-by: yuye-aws * remove unused import Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws * feat: redirect to overview page after workspace switch (#26) Signed-off-by: Lin Wang * update menu filter logic (#28) * feat: update menu logic Signed-off-by: tygao * fix: use navLinks to filter Signed-off-by: tygao --------- Signed-off-by: tygao * feat: redirect to workspace overview page after created success (#29) Signed-off-by: Lin Wang * [Feature] Complied saved_objects create/find (#18) * temp: save Signed-off-by: SuZhoue-Joe * feat: make create/find support workspaces Signed-off-by: SuZhoue-Joe * feat: extract management code Signed-off-by: SuZhoue-Joe * fix: type check Signed-off-by: SuZhoue-Joe * fix: build error Signed-off-by: SuZhoue-Joe * feat: enable workspaces on saved client server side Signed-off-by: SuZhoue-Joe * feat: some optimization Signed-off-by: SuZhoue-Joe * feat: extract management code Signed-off-by: SuZhoue-Joe * feat: merge fix Signed-off-by: SuZhoue-Joe * feat: optimize code Signed-off-by: SuZhoue-Joe * feat: reuse common function Signed-off-by: SuZhoue-Joe * feat: optimize code when create Signed-off-by: SuZhoue-Joe * feat: remove useless test code Signed-off-by: SuZhoue-Joe --------- Signed-off-by: SuZhoue-Joe * feat: redirect to workspace update page after workspace switch (#30) * Move delete button to update page (#27) * add delete workspace modal Signed-off-by: yuye-aws * implement delete on workspace overview page Signed-off-by: yuye-aws * fix export on delete workspace modal Signed-off-by: yuye-aws * add try catch to handle errors for workspace delete Signed-off-by: yuye-aws * move visibility control to workspace overview page exlusively Signed-off-by: yuye-aws * remove unused import Signed-off-by: yuye-aws * change workspace overview route to workspace update Signed-off-by: yuye-aws * move delete button from workspace overview page to update page Signed-off-by: yuye-aws * remove update button from workspace overview page Signed-off-by: yuye-aws * recover router to workspace overview page Signed-off-by: yuye-aws * change navigation url for workspace overview button on left side panel Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws * fix: linting error Signed-off-by: Yulong Ruan * remove duplicate EuiPage (#34) * remove duplicate EuiPage Signed-off-by: Hailong Cui * fix: remove duplicate workspace template Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui * remove clear button, add the width of create button (#33) Signed-off-by: zhichao-aws * rename OpenSearch Plugins to OpenSearch Features this is a temporary fix just for demo, should be reverted later Signed-off-by: Yulong Ruan * Add some logic check when overwrite a saved object (#32) * feat: add some logic check when overwrite a saved object Signed-off-by: SuZhoue-Joe * fix: type check Signed-off-by: SuZhoue-Joe * feat: update Signed-off-by: SuZhoue-Joe --------- Signed-off-by: SuZhoue-Joe * Add color, icon and defaultVISTheme for workspace (#36) * feat: add color, icon and defaultVISTheme field for workspace saved object Signed-off-by: Lin Wang * add new fields to workspace form Signed-off-by: Lin Wang * feat: remove feature or group name hack Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang * feat: add workspace list (#39) Signed-off-by: tygao * Feature/menu change (#37) * feat: register library menus Signed-off-by: SuZhoue-Joe * feat: some update Signed-off-by: SuZhoue-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhoue-Joe Signed-off-by: SuZhou-Joe * feat: different left menu and exit workspace (#38) * Exit workspace from left menu Signed-off-by: yuye-aws * Show exit workspace button with small window size Signed-off-by: yuye-aws * Remove recently viewed and workspace overview on left menu Signed-off-by: yuye-aws * Add buttons for outside, inside workspace case Signed-off-by: yuye-aws * Implement home button and workspace over view button on left menu Signed-off-by: yuye-aws * Implement workspace dropdown list in left menu Signed-off-by: yuye-aws * Add props on recently accessed and custom nav link Signed-off-by: yuye-aws * Add three props to mock props for collapsible nav: exitWorkspace, getWorkspaceUrl, workspaceList$ Signed-off-by: yuye-aws * Add three props to mock props for header: exitWorkspace, getWorkspaceUrl, workspaceList$ Signed-off-by: yuye-aws * Fix bugs for function createWorkspaceNavLink Signed-off-by: yuye-aws * Remove unused constants Signed-off-by: yuye-aws * Reuse method getWorkspaceUrl Signed-off-by: yuye-aws * Remove recently accessed and custom nav props in test Signed-off-by: yuye-aws * Revert "Remove recently accessed and custom nav props in test" This reverts commit 7895e5c5dcde9e134f26b2d6a3df54a2d62e9274. * Wrap title with i18n Signed-off-by: yuye-aws * Add redirect for workspace app Signed-off-by: yuye-aws * Enable users to go to workspace lists page via see more under workspaces in left menu Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws * feat: make url stateful (#35) * feat: make url stateful Signed-off-by: SuZhoue-Joe * feat: optimize code Signed-off-by: SuZhoue-Joe * feat: remove useless change Signed-off-by: SuZhoue-Joe * feat: optimize url listener Signed-off-by: SuZhoue-Joe * feat: make formatUrlWithWorkspaceId extensible Signed-off-by: SuZhoue-Joe * feat: modify to related components Signed-off-by: SuZhoue-Joe * feat: modify the async format to be sync function Signed-off-by: SuZhoue-Joe * feat: modify the async format to be sync function Signed-off-by: SuZhoue-Joe * fix: type check Signed-off-by: SuZhoue-Joe * feat: use path to maintain workspace info Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhoue-Joe Signed-off-by: SuZhou-Joe * Fix build error and part of test error (#42) * fix: fix build error and some ut Signed-off-by: tygao * chore: remove saved object client test diff Signed-off-by: tygao --------- Signed-off-by: tygao * feat: optimize code (#40) Signed-off-by: SuZhou-Joe * fix: bootstrap error (#43) Signed-off-by: SuZhou-Joe * feat: add workspace permission control interface (#41) * feat: add workspace permission control interface Signed-off-by: Lin Wang * feat: add request parameter for workspace permission control Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang * allow user to turn on/off workspace from advance settings (#46) return 404 if accessing a workspace path when workspace is disabled --------- Signed-off-by: Yulong Ruan * fix: unit test failure (#50) Signed-off-by: SuZhou-Joe * Add workspace column into saved objects table (#44) * Add workspace column into saved management page Signed-off-by: Hailong Cui * savedObjectsManagement as optional dependency Signed-off-by: Hailong Cui * i18n for column title Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui * feat: make edit url clickable Signed-off-by: SuZhou-Joe * feat: add ui capability path Signed-off-by: SuZhou-Joe * feat: add ui capability path Signed-off-by: SuZhou-Joe * fet: remove useless jump code Signed-off-by: SuZhou-Joe * feat: hide workspace from saved objects management page Signed-off-by: SuZhou-Joe * feat: hide workspace from saved objects management page Signed-off-by: SuZhou-Joe --------- Signed-off-by: Yulong Ruan Signed-off-by: Hailong Cui Signed-off-by: SuZhoue-Joe Signed-off-by: Lin Wang Signed-off-by: zhichao-aws Signed-off-by: suzhou Signed-off-by: tygao Signed-off-by: gaobinlong Signed-off-by: yuye-aws Signed-off-by: SuZhou-Joe Co-authored-by: Yulong Ruan Co-authored-by: Hailong Cui Co-authored-by: Lin Wang Co-authored-by: zhichao-aws Co-authored-by: raintygao Co-authored-by: gaobinlong Co-authored-by: Yuye Zhu * Add copy saved objects among workspaces functionality (#53) * Add copy saved objects among workspaces functionality Signed-off-by: gaobinlong Signed-off-by: gaobinlong * Fix bug Signed-off-by: gaobinlong * Fix bug Signed-off-by: gaobinlong --------- Signed-off-by: gaobinlong * add workspace saved objects client wrapper (#51) * add workspace savedd objects client wrapper Signed-off-by: Lin Wang * feat: add more methods to saved objects client wrapper Signed-off-by: Lin Wang * feat: add findWithWorkspacePermissionControl in workspace saved objects client wrapper Signed-off-by: Lin Wang * feat: throw 451 instead of interval error Signed-off-by: Lin Wang * chore: fix workspace client init method type error Signed-off-by: Lin Wang * feat: fix workspaces attribute type error in client wrapper Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang * Refactor navigation links from left menu hard code to workspace plugin register (#55) * feature: add public/workspaces service Signed-off-by: SuZhoue-Joe * Exit workspace from left menu Signed-off-by: yuye-aws * Show exit workspace button with small window size Signed-off-by: yuye-aws * Remove recently viewed and workspace overview on left menu Signed-off-by: yuye-aws * Add buttons for outside, inside workspace case Signed-off-by: yuye-aws * Implement home button and workspace over view button on left menu Signed-off-by: yuye-aws * Implement workspace dropdown list in left menu Signed-off-by: yuye-aws * Add props on recently accessed and custom nav link Signed-off-by: yuye-aws * Reuse method getWorkspaceUrl Signed-off-by: yuye-aws * Remove recently accessed and custom nav props in test Signed-off-by: yuye-aws * Revert "Remove recently accessed and custom nav props in test" This reverts commit 7895e5c5dcde9e134f26b2d6a3df54a2d62e9274. * Wrap title with i18n Signed-off-by: yuye-aws * Add redirect for workspace app Signed-off-by: yuye-aws * Enable users to go to workspace lists page via see more under workspaces in left menu Signed-off-by: yuye-aws * Fix build error and part of test error (#42) * fix: fix build error and some ut Signed-off-by: tygao * chore: remove saved object client test diff Signed-off-by: tygao --------- Signed-off-by: tygao * Comment Alerts and Favorites in left menu Signed-off-by: yuye-aws * Recover recently viewed items in left menu Signed-off-by: yuye-aws * Move exit workspace from left menu to update page Signed-off-by: yuye-aws * Remove unused import Signed-off-by: yuye-aws * Add workspace category info Signed-off-by: yuye-aws * Remove workspace nav link Signed-off-by: yuye-aws * Remove unused import Signed-off-by: yuye-aws * Add FilteredNavLinks props to chrome service mock Signed-off-by: yuye-aws * Remove workspace related constans from chrome Signed-off-by: yuye-aws * Remove workspace related props from chrome and core Signed-off-by: yuye-aws * Remove workspace related props from header Signed-off-by: yuye-aws * Shorten import path for workspace updater Signed-off-by: yuye-aws * Add euiIconType for workspace left menu category Signed-off-by: yuye-aws * Remove workspace related props for collapsible nav Signed-off-by: yuye-aws * Remove workspace related props for collapsible nav Signed-off-by: yuye-aws * Implement navigation for delete and exit workspace Signed-off-by: yuye-aws * Navigate external links through url change Signed-off-by: yuye-aws * Implement filteredNavLinks and sort ChromeNavLinks in nav link service Signed-off-by: yuye-aws * Add workspace list, see more, admin and overview into chromenavlinks Signed-off-by: yuye-aws * fix: unit test failure (#50) Signed-off-by: SuZhou-Joe * Fix osd bootstrap error Signed-off-by: yuye-aws * Check workspace enabled for left menu Signed-off-by: yuye-aws * Add home nav link to left menu when outside workspace Signed-off-by: yuye-aws * Fix unit test for collapsible nav Signed-off-by: yuye-aws * Fix unit test for header Signed-off-by: yuye-aws * Fix unit test for collapsible nav Signed-off-by: yuye-aws * Fix unit test for collapsible nav Signed-off-by: yuye-aws * Update snapshot for unit tests Signed-off-by: yuye-aws * fix osd bootstrap error Signed-off-by: yuye-aws * fix combinelatest import error Signed-off-by: yuye-aws * update snapshot for unit tests Signed-off-by: yuye-aws * variable rename Signed-off-by: yuye-aws * move custom nav link to mock props Signed-off-by: yuye-aws * move default filtered nav link to core Signed-off-by: yuye-aws * change navigation method in workspace updater Signed-off-by: yuye-aws * Update src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx Co-authored-by: SuZhou-Joe * revert some unncessary changes Signed-off-by: yuye-aws * fix navigation url bug Signed-off-by: yuye-aws * move default filtered nav link value setting from core to workspace plugin Signed-off-by: yuye-aws * move filter nav link to a new function Signed-off-by: yuye-aws * process filter nav links when workspace is disabled Signed-off-by: yuye-aws * change navigation method Signed-off-by: yuye-aws --------- Signed-off-by: SuZhoue-Joe Signed-off-by: yuye-aws Signed-off-by: tygao Signed-off-by: SuZhou-Joe Co-authored-by: SuZhoue-Joe Co-authored-by: raintygao * fix: osd bootstrap error (#57) * fix osd bootstrap error Signed-off-by: yuye-aws * fix build plugins error Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws * feat: filter out ADMIN application and add feature dependency logic (#49) * feat: filter out ADMIN application and add feature dependency logic Signed-off-by: Lin Wang * feat: separate feature utils function Signed-off-by: Lin Wang * feat: rename isFeatureDependBySelectedFeatures, separate generateFeatureDependencyMap and add annotation Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang * feat: do not register app when feature flag is off (#56) * feat: do not register app when feature flag is off Signed-off-by: SuZhou-Joe * feat: comply with the category name Signed-off-by: SuZhou-Joe * feat: opt according to PR Signed-off-by: SuZhou-Joe * feat: optimize the comment Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe * Sort category and non-category nav link according to order in left menu (#60) * change order for home Signed-off-by: yuye-aws * Sort category and non-category navlink types in left menu Signed-off-by: yuye-aws * change order for overview when inside workspace Signed-off-by: yuye-aws * assign sorted unknowns to another variable Signed-off-by: yuye-aws * change annotation Signed-off-by: yuye-aws * refactor function getMergedNavLinks in left menu Signed-off-by: yuye-aws * fix zero order bug Signed-off-by: yuye-aws * add annotation Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws * Refactor: Assign default value for filtered nav links (#64) * remove default filtered nav link value set Signed-off-by: yuye-aws * default value for nav link Signed-off-by: yuye-aws * refactor currentworkspace logic Signed-off-by: yuye-aws --------- Signed-off-by: yuye-aws * Permission control service for saved objects (#63) * feat: move permission control to saved objects directory Signed-off-by: SuZhou-Joe * feat: use bulkGetObjects and fix unit test Signed-off-by: SuZhou-Joe * feat: add http routes for validate & list Signed-off-by: SuZhou-Joe * feat: move permissionModes to common place Signed-off-by: SuZhou-Joe * feat: rename routes Signed-off-by: SuZhou-Joe * feat: some side effects Signed-off-by: SuZhou-Joe * feat: some side effects Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe * fix: make importing workspace-specific objects as copy Signed-off-by: SuZhou-Joe * feat: make unit test pass Signed-off-by: SuZhou-Joe * feat: revert the workspace change Signed-off-by: SuZhou-Joe * feat: make import support multiple workspaces Signed-off-by: SuZhou-Joe * feat: change logic Signed-off-by: SuZhou-Joe * feat: change logic Signed-off-by: SuZhou-Joe * fix: ci flow fail Signed-off-by: SuZhou-Joe * feat: copy to target workspace Signed-off-by: SuZhou-Joe * feat: revert changes Signed-off-by: SuZhou-Joe * feat: change public logic Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: revert some skipped test Signed-off-by: SuZhou-Joe * fix: type check Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize logic Signed-off-by: SuZhou-Joe * feat: update logic Signed-off-by: SuZhou-Joe * temp: merge code Signed-off-by: SuZhou-Joe * fix: formatUrlWithWorkspaceId reference Signed-off-by: SuZhou-Joe * fix: remove useless export call Signed-off-by: SuZhou-Joe * fix: omitIds Signed-off-by: SuZhou-Joe --------- Signed-off-by: Yulong Ruan Signed-off-by: Hailong Cui Signed-off-by: SuZhoue-Joe Signed-off-by: Lin Wang Signed-off-by: zhichao-aws Signed-off-by: suzhou Signed-off-by: tygao Signed-off-by: gaobinlong Signed-off-by: yuye-aws Signed-off-by: SuZhou-Joe Co-authored-by: Yulong Ruan Co-authored-by: Hailong Cui Co-authored-by: Lin Wang Co-authored-by: zhichao-aws Co-authored-by: raintygao Co-authored-by: gaobinlong Co-authored-by: Yuye Zhu --- .../collapsible_nav.test.tsx.snap | 104 +++++------------ .../chrome/ui/header/collapsible_nav.test.tsx | 10 +- .../workspace/workspaces_service.mock.ts | 3 + .../public/workspace/workspaces_service.ts | 5 + .../saved_objects/import/check_conflicts.ts | 3 + .../import/import_saved_objects.ts | 9 +- .../saved_objects/import/regenerate_ids.ts | 39 ++++++- .../saved_objects/service/lib/repository.ts | 110 +++++++++++++----- .../server/saved_objects/service/lib/utils.ts | 7 ++ .../saved_objects_table.test.tsx.snap | 2 + .../__snapshots__/header.test.tsx.snap | 16 +++ src/plugins/workspace/common/constants.ts | 1 + src/plugins/workspace/public/application.tsx | 18 ++- .../components/workspace_fatal_error/index.ts | 6 + .../workspace_fatal_error.tsx | 67 +++++++++++ src/plugins/workspace/public/plugin.ts | 46 +++++++- .../workspace/public/workspace_client.ts | 46 ++++---- .../workspace_saved_objects_client_wrapper.ts | 15 ++- 18 files changed, 368 insertions(+), 139 deletions(-) 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.tsx 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 7b88a0cac417..42aa2d4c596c 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 @@ -160,7 +160,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "baseUrl": "/", "category": Object { "id": "opensearchDashboards", - "label": "Library", + "label": "OpenSearch Dashboards", "order": 1000, }, "data-test-subj": "discover", @@ -215,7 +215,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "baseUrl": "/", "category": Object { "id": "opensearchDashboards", - "label": "Library", + "label": "OpenSearch Dashboards", "order": 1000, }, "data-test-subj": "visualize", @@ -228,7 +228,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "baseUrl": "/", "category": Object { "id": "opensearchDashboards", - "label": "Library", + "label": "OpenSearch Dashboards", "order": 1000, }, "data-test-subj": "dashboard", @@ -1770,7 +1770,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` isCollapsible={true} key="opensearchDashboards" onToggle={[Function]} - title="Library" + title="OpenSearch Dashboards" > - Library + OpenSearch Dashboards @@ -1884,7 +1884,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiTitle euiTitle--xxsmall euiCollapsibleNavGroup__title" id="mockId__title" > - Library + OpenSearch Dashboards @@ -1912,7 +1912,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiCollapsibleNavGroup__children" >