diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 891c858aa1a5..a0069ba271d0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @ananzh @kavilla @AMoo-Miki @ashwin-pc @joshuarrrr @abbyhu2000 @zengyan-amazon @zhongnansu @manasvinibs @ZilongX @Flyingliuhub @curq @bandinib-amzn @SuZhou-Joe @ruanyl @BionIT @xinruiba @zhyuanqi @mengweieric @LDrago27 @virajsanghvi @sejli @joshuali925 +* @ananzh @kavilla @AMoo-Miki @ashwin-pc @joshuarrrr @abbyhu2000 @zengyan-amazon @zhongnansu @manasvinibs @ZilongX @Flyingliuhub @curq @bandinib-amzn @SuZhou-Joe @ruanyl @BionIT @xinruiba @zhyuanqi @mengweieric @LDrago27 @virajsanghvi @sejli @joshuali925 @huyaboo diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 321d9ab02794..9f964d82a66c 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -29,6 +29,7 @@ This document contains a list of maintainers in this repo. See [opensearch-proje | Viraj Sanghvi | [virajsanghvi](https://github.com/virajsanghvi) | Amazon | | Sean Li | [sejli](https://github.com/sejli) | Amazon | | Joshua Li | [joshuali925](https://github.com/joshuali925) | Amazon | +| Huy Nguyen | [huyaboo](https://github.com/huyaboo) | Amazon | ## Emeritus diff --git a/changelogs/fragments/7881.yml b/changelogs/fragments/7881.yml new file mode 100644 index 000000000000..49da3e4fa2e5 --- /dev/null +++ b/changelogs/fragments/7881.yml @@ -0,0 +1,2 @@ +feat: +- Refactor content menu picker in side bar and enable searching ([#7881](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7881)) \ No newline at end of file diff --git a/changelogs/fragments/7930.yml b/changelogs/fragments/7930.yml new file mode 100644 index 000000000000..ce3a0c638262 --- /dev/null +++ b/changelogs/fragments/7930.yml @@ -0,0 +1,2 @@ +fix: +- Show alias fields in Discover tab ([#7930](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7930)) \ No newline at end of file diff --git a/changelogs/fragments/7951.yml b/changelogs/fragments/7951.yml new file mode 100644 index 000000000000..c4883e336671 --- /dev/null +++ b/changelogs/fragments/7951.yml @@ -0,0 +1,2 @@ +feat: +- Add query result and time to the query editor footer ([#7951](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7951)) \ No newline at end of file diff --git a/changelogs/fragments/7959.yml b/changelogs/fragments/7959.yml new file mode 100644 index 000000000000..9cf096121413 --- /dev/null +++ b/changelogs/fragments/7959.yml @@ -0,0 +1,2 @@ +feat: +- [Data source] Add data source permission wrapper and dataSourceAdmin role ([#7959](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7959)) \ No newline at end of file diff --git a/changelogs/fragments/7961.yml b/changelogs/fragments/7961.yml new file mode 100644 index 000000000000..bdc020962e51 --- /dev/null +++ b/changelogs/fragments/7961.yml @@ -0,0 +1,2 @@ +feat: +- Support DQCs in create page ([#7961](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7961)) \ No newline at end of file diff --git a/changelogs/fragments/7987.yml b/changelogs/fragments/7987.yml new file mode 100644 index 000000000000..0b67c17de12d --- /dev/null +++ b/changelogs/fragments/7987.yml @@ -0,0 +1,2 @@ +fix: +- Hide delete button for non OSD admin ([#7987](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7987)) \ No newline at end of file diff --git a/changelogs/fragments/7989.yml b/changelogs/fragments/7989.yml new file mode 100644 index 000000000000..401774e8b711 --- /dev/null +++ b/changelogs/fragments/7989.yml @@ -0,0 +1,2 @@ +refactor: +- Hide saved object import button when user is outside workspace ([#7989](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7989)) \ No newline at end of file diff --git a/changelogs/fragments/7992.yml b/changelogs/fragments/7992.yml new file mode 100644 index 000000000000..b73b8b4e07ef --- /dev/null +++ b/changelogs/fragments/7992.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace] Hide home breadcrumbs when in a workspace ([#7992](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7992)) \ No newline at end of file diff --git a/changelogs/fragments/8025.yml b/changelogs/fragments/8025.yml new file mode 100644 index 000000000000..af483ae30ec3 --- /dev/null +++ b/changelogs/fragments/8025.yml @@ -0,0 +1,2 @@ +doc: +- Add Huy as maintainer ([#8025](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8025)) \ No newline at end of file diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 47de509b4483..349b002c9a1c 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -327,6 +327,10 @@ # "all": The data source can be managed by all users. Default to "all". # data_source_management.manageableBy: "all" +# Set the backend roles in groups, whoever has the backend roles defined in this config will be regard as dataSourceAdmin. +# DataSource Admin will have the access to all the data source saved objects inside OpenSearch Dashboards by api. +# data_source_management.dataSourceAdmin.groups: ["data_source_management"] + # Set the value of this setting to false to hide the help menu link to the OpenSearch Dashboards user survey # opensearchDashboards.survey.url: "https://survey.opensearch.org" 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 d9b543a32627..96847daa9271 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 @@ -468,7 +468,45 @@ exports[`Header handles visibility and lock changes 1`] = ` "closed": false, "hasError": false, "isStopped": false, - "observers": Array [], + "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, } } @@ -7540,7 +7578,45 @@ exports[`Header renders application header without title and breadcrumbs 1`] = ` "closed": false, "hasError": false, "isStopped": false, - "observers": Array [], + "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, } } @@ -8843,6 +8919,7 @@ exports[`Header renders application header without title and breadcrumbs 1`] = ` "thrownError": null, } } + dropHomeFromBreadcrumb={false} renderFullLength={true} useUpdatedHeader={true} /> @@ -10100,7 +10177,45 @@ exports[`Header renders condensed header 1`] = ` "closed": false, "hasError": false, "isStopped": false, - "observers": Array [], + "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, } } @@ -15743,7 +15858,45 @@ exports[`Header renders page header with application title 1`] = ` "closed": false, "hasError": false, "isStopped": false, - "observers": Array [], + "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, } } @@ -17272,6 +17425,7 @@ exports[`Header renders page header with application title 1`] = ` "thrownError": null, } } + dropHomeFromBreadcrumb={false} renderFullLength={true} useUpdatedHeader={true} /> @@ -17617,6 +17771,8 @@ exports[`Header renders page header with application title 1`] = ` "thrownError": null, } } + dropHomeFromBreadcrumb={false} + renderFullLength={false} useUpdatedHeader={true} > { return getOsdSidecarPaddingStyle(sidecarConfig); @@ -203,13 +204,14 @@ export function Header({ /> ); - const renderBreadcrumbs = (renderFullLength?: boolean) => ( + const renderBreadcrumbs = (renderFullLength?: boolean, dropHomeFromBreadcrumb?: boolean) => ( ); @@ -351,7 +353,7 @@ export function Header({ recentlyAccessed$={observables.recentlyAccessed$} workspaceList$={observables.workspaceList$} navigateToUrl={application.navigateToUrl} - renderBreadcrumbs={renderBreadcrumbs(true)} + renderBreadcrumbs={renderBreadcrumbs(true, false)} buttonSize={useApplicationHeader ? 's' : 'xs'} /> @@ -399,7 +401,7 @@ export function Header({ {renderRecentItems()} - {renderBreadcrumbs()} + {renderBreadcrumbs(false, !!currentWorkspace)} {/* Secondary header */} diff --git a/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx b/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx index f01e751e39f0..2e3c7f753e4c 100644 --- a/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx +++ b/src/core/public/chrome/ui/header/header_breadcrumbs.test.tsx @@ -107,4 +107,44 @@ describe('HeaderBreadcrumbs', () => { wrapper.update(); expect(wrapper.find('.euiBreadcrumbWrapper')).toHaveLength(2); }); + + it('remove heading home when workspace is enabled', () => { + const breadcrumbs$ = new BehaviorSubject([{ text: 'First' }]); + const breadcrumbsEnricher$ = new BehaviorSubject((crumbs: ChromeBreadcrumb[]) => [ + { text: 'Home', home: true }, + { text: 'Analytics' }, + ...crumbs, + ]); + const wrapper = mount( + + ); + let breadcrumbs = wrapper.find('.euiBreadcrumbWrapper'); + expect(breadcrumbs).toHaveLength(2); + expect(breadcrumbs.at(0).text()).toBe('Analytics'); + expect(breadcrumbs.at(1).text()).toBe('First'); + + // if no home property, we don't drop by text + act(() => { + breadcrumbsEnricher$.next((items) => items); + breadcrumbs$.next([{ text: 'Home' }, { text: 'Second' }]); + }); + wrapper.update(); + breadcrumbs = wrapper.find('.euiBreadcrumbWrapper'); + expect(breadcrumbs).toHaveLength(2); + expect(breadcrumbs.at(0).text()).toBe('Home'); + expect(breadcrumbs.at(1).text()).toBe('Second'); + + act(() => { + breadcrumbsEnricher$.next((items) => []); + breadcrumbs$.next([{ text: 'Home' }, { text: 'Second' }]); + }); + wrapper.update(); + breadcrumbs = wrapper.find('.euiBreadcrumbWrapper'); + expect(breadcrumbs).toHaveLength(0); + }); }); diff --git a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx index 52c9aa2628e9..ca0f3610641d 100644 --- a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx +++ b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx @@ -41,6 +41,7 @@ interface Props { breadcrumbsEnricher$: Observable; useUpdatedHeader?: boolean; renderFullLength?: boolean; + dropHomeFromBreadcrumb?: boolean; } export function HeaderBreadcrumbs({ @@ -49,6 +50,7 @@ export function HeaderBreadcrumbs({ breadcrumbsEnricher$, useUpdatedHeader, renderFullLength, + dropHomeFromBreadcrumb, }: Props) { const appTitle = useObservable(appTitle$, 'OpenSearch Dashboards'); const breadcrumbs = useObservable(breadcrumbs$, []); @@ -73,6 +75,10 @@ export function HeaderBreadcrumbs({ crumbs = breadcrumbEnricher(crumbs); } + if (dropHomeFromBreadcrumb && crumbs.length && crumbs[0].hasOwnProperty('home')) { + crumbs = crumbs.slice(1); + } + crumbs = crumbs.map((breadcrumb, i) => ({ ...breadcrumb, 'data-test-subj': classNames( diff --git a/src/core/server/saved_objects/service/saved_objects_client.test.js b/src/core/server/saved_objects/service/saved_objects_client.test.js index aa597f98379a..8830220a3e3a 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.test.js +++ b/src/core/server/saved_objects/service/saved_objects_client.test.js @@ -246,30 +246,6 @@ test(`#deleteFromWorkspaces Should use update if there is existing workspaces`, }); }); -test(`#deleteFromWorkspaces Should use overwrite create if there is no existing workspaces`, async () => { - const returnValue = Symbol(); - const create = jest.fn(); - const mockRepository = { - get: jest.fn().mockResolvedValue({ - workspaces: [], - }), - update: jest.fn().mockResolvedValue(returnValue), - create, - }; - const client = new SavedObjectsClient(mockRepository); - - const type = Symbol(); - const id = Symbol(); - const workspaces = ['id1']; - await client.deleteFromWorkspaces(type, id, workspaces); - expect(mockRepository.get).toHaveBeenCalledWith(type, id, {}); - expect(mockRepository.create).toHaveBeenCalledWith( - type, - {}, - { id, overwrite: true, permissions: undefined, version: undefined } - ); -}); - test(`#deleteFromWorkspaces should throw error if no workspaces passed`, () => { const mockRepository = {}; const client = new SavedObjectsClient(mockRepository); 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 f17e6e35f0bd..741fd5f5f874 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -488,28 +488,11 @@ export class SavedObjectsClient { const newWorkspaces = existingWorkspaces.filter((item) => { return targetWorkspaces.indexOf(item) === -1; }); - if (newWorkspaces.length > 0) { - return await this.update(type, id, object.attributes, { - ...options, - workspaces: newWorkspaces, - version: object.version, - }); - } else { - // If there is no workspaces assigned, will create object with overwrite to delete workspace property. - return await this.create( - type, - { - ...object.attributes, - }, - { - ...options, - id, - permissions: object.permissions, - overwrite: true, - version: object.version, - } - ); - } + return await this.update(type, id, object.attributes, { + ...options, + workspaces: newWorkspaces, + version: object.version, + }); }; /** diff --git a/src/core/server/utils/auth_info.test.ts b/src/core/server/utils/auth_info.test.ts new file mode 100644 index 000000000000..d270f39be1b3 --- /dev/null +++ b/src/core/server/utils/auth_info.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthStatus } from '../http/auth_state_storage'; +import { httpServerMock, httpServiceMock } from '../mocks'; +import { getPrincipalsFromRequest } from './auth_info'; + +describe('utils', () => { + const mockAuth = httpServiceMock.createAuth(); + it('should return empty map when request do not have authentication', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + mockAuth.get.mockReturnValueOnce({ + status: AuthStatus.unknown, + state: { + authInfo: { + user_name: 'bar', + backend_roles: ['foo'], + }, + }, + }); + const result = getPrincipalsFromRequest(mockRequest, mockAuth); + expect(result).toEqual({}); + }); + + it('should return normally when request has authentication', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + mockAuth.get.mockReturnValueOnce({ + status: AuthStatus.authenticated, + state: { + authInfo: { + user_name: 'bar', + backend_roles: ['foo'], + }, + }, + }); + const result = getPrincipalsFromRequest(mockRequest, mockAuth); + expect(result.users).toEqual(['bar']); + expect(result.groups).toEqual(['foo']); + }); + + it('should throw error when request is not authenticated', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + mockAuth.get.mockReturnValueOnce({ + status: AuthStatus.unauthenticated, + state: {}, + }); + expect(() => getPrincipalsFromRequest(mockRequest, mockAuth)).toThrow('NOT_AUTHORIZED'); + }); + + it('should throw error when authentication status is not expected', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + mockAuth.get.mockReturnValueOnce({ + // @ts-expect-error + status: 'foo', + state: {}, + }); + expect(() => getPrincipalsFromRequest(mockRequest, mockAuth)).toThrow( + 'UNEXPECTED_AUTHORIZATION_STATUS' + ); + }); +}); diff --git a/src/core/server/utils/auth_info.ts b/src/core/server/utils/auth_info.ts new file mode 100644 index 000000000000..7d965ad05ca9 --- /dev/null +++ b/src/core/server/utils/auth_info.ts @@ -0,0 +1,45 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthStatus } from '../http/auth_state_storage'; +import { OpenSearchDashboardsRequest } from '../http/router'; +import { HttpAuth } from '../http/types'; +import { PrincipalType, Principals } from '../saved_objects/permission_control/acl'; + +export interface AuthInfo { + backend_roles?: string[]; + user_name?: string; +} + +export const getPrincipalsFromRequest = ( + request: OpenSearchDashboardsRequest, + auth?: HttpAuth +): Principals => { + const payload: Principals = {}; + const authInfoResp = auth?.get(request); + if (authInfoResp?.status === AuthStatus.unknown) { + /** + * Login user have access to all the workspaces when no authentication is presented. + */ + return payload; + } + + if (authInfoResp?.status === AuthStatus.authenticated) { + const authState = authInfoResp?.state as { authInfo: AuthInfo } | null; + if (authState?.authInfo?.backend_roles) { + payload[PrincipalType.Groups] = authState.authInfo.backend_roles; + } + if (authState?.authInfo?.user_name) { + payload[PrincipalType.Users] = [authState.authInfo.user_name]; + } + return payload; + } + + if (authInfoResp?.status === AuthStatus.unauthenticated) { + throw new Error('NOT_AUTHORIZED'); + } + + throw new Error('UNEXPECTED_AUTHORIZATION_STATUS'); +}; diff --git a/src/core/server/utils/index.ts b/src/core/server/utils/index.ts index a20b8c4c4e5b..64f8379a46e2 100644 --- a/src/core/server/utils/index.ts +++ b/src/core/server/utils/index.ts @@ -32,5 +32,6 @@ export * from './crypto'; export * from './from_root'; export * from './package_json'; export * from './streams'; +export { getPrincipalsFromRequest } from './auth_info'; export { getWorkspaceIdFromUrl, cleanWorkspaceId } from '../../utils'; export { updateWorkspaceState, getWorkspaceState } from './workspace'; diff --git a/src/core/server/utils/workspace.test.ts b/src/core/server/utils/workspace.test.ts index 19f8bad4f866..702542715ab2 100644 --- a/src/core/server/utils/workspace.test.ts +++ b/src/core/server/utils/workspace.test.ts @@ -12,10 +12,12 @@ describe('updateWorkspaceState', () => { updateWorkspaceState(requestMock, { requestWorkspaceId: 'foo', isDashboardAdmin: true, + isDataSourceAdmin: true, }); expect(getWorkspaceState(requestMock)).toEqual({ requestWorkspaceId: 'foo', isDashboardAdmin: true, + isDataSourceAdmin: true, }); }); }); diff --git a/src/core/server/utils/workspace.ts b/src/core/server/utils/workspace.ts index 89f2b7975964..efc75d96a1c2 100644 --- a/src/core/server/utils/workspace.ts +++ b/src/core/server/utils/workspace.ts @@ -8,6 +8,7 @@ import { OpenSearchDashboardsRequest, ensureRawRequest } from '../http/router'; export interface WorkspaceState { requestWorkspaceId?: string; isDashboardAdmin?: boolean; + isDataSourceAdmin?: boolean; } /** @@ -29,10 +30,13 @@ export const updateWorkspaceState = ( }; }; +// TODO: Move isDataSourceAdmin and isDashboardAdmin out of WorkspaceState and this change is planned for version 2.18 export const getWorkspaceState = (request: OpenSearchDashboardsRequest): WorkspaceState => { - const { requestWorkspaceId, isDashboardAdmin } = ensureRawRequest(request).app as WorkspaceState; + const { requestWorkspaceId, isDashboardAdmin, isDataSourceAdmin } = ensureRawRequest(request) + .app as WorkspaceState; return { requestWorkspaceId, isDashboardAdmin, + isDataSourceAdmin, }; }; diff --git a/src/core/utils/default_nav_groups.ts b/src/core/utils/default_nav_groups.ts index 7278734ae826..bbd38c81ffc5 100644 --- a/src/core/utils/default_nav_groups.ts +++ b/src/core/utils/default_nav_groups.ts @@ -52,8 +52,7 @@ const defaultNavGroups = { defaultMessage: 'Observability', }), description: i18n.translate('core.ui.group.observability.description', { - defaultMessage: - 'Gain visibility into system health, performance, and reliability through monitoring and analysis of logs, metrics, and traces.', + defaultMessage: 'Gain visibility into your application and infrastructure', }), order: 4000, icon: 'wsObservability', @@ -64,8 +63,7 @@ const defaultNavGroups = { defaultMessage: 'Security Analytics', }), description: i18n.translate('core.ui.group.security.analytics.description', { - defaultMessage: - 'Detect and investigate potential security threats and vulnerabilities across your systems and data.', + defaultMessage: 'Enhance your security posture with advanced analytics', }), order: 5000, icon: 'wsSecurityAnalytics', @@ -88,8 +86,7 @@ const defaultNavGroups = { defaultMessage: 'Search', }), description: i18n.translate('core.ui.group.search.description', { - defaultMessage: - "Quickly find and explore relevant information across your organization's data sources.", + defaultMessage: 'Discover and query your data with ease', }), order: 6000, icon: 'wsSearch', diff --git a/src/plugins/data/common/constants.ts b/src/plugins/data/common/constants.ts index 289d56d4d064..199391630b25 100644 --- a/src/plugins/data/common/constants.ts +++ b/src/plugins/data/common/constants.ts @@ -86,6 +86,7 @@ export const UI_SETTINGS = { COURIER_BATCH_SEARCHES: 'courier:batchSearches', SEARCH_INCLUDE_FROZEN: 'search:includeFrozen', SEARCH_TIMEOUT: 'search:timeout', + SEARCH_INCLUDE_ALL_FIELDS: 'search:includeAllFields', HISTOGRAM_BAR_TARGET: 'histogram:barTarget', HISTOGRAM_MAX_BARS: 'histogram:maxBars', HISTORY_LIMIT: 'history:limit', diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index c8eb99b038a7..6a2eee0162b5 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -131,6 +131,7 @@ export const searchSourceRequiredUiSettings = [ UI_SETTINGS.SEARCH_INCLUDE_FROZEN, UI_SETTINGS.SORT_OPTIONS, UI_SETTINGS.QUERY_DATAFRAME_HYDRATION_STRATEGY, + UI_SETTINGS.SEARCH_INCLUDE_ALL_FIELDS, ]; export interface SearchSourceDependencies extends FetchHandlers { @@ -586,6 +587,7 @@ export class SearchSource { flatten() { const searchRequest = this.mergeProps(); + const { getConfig } = this.dependencies; searchRequest.body = searchRequest.body || {}; const { body, index, fields, query, filters, highlightAll } = searchRequest; @@ -595,6 +597,9 @@ export class SearchSource { body.stored_fields = computedFields.storedFields; body.script_fields = body.script_fields || {}; + if (getConfig(UI_SETTINGS.SEARCH_INCLUDE_ALL_FIELDS)) { + body.fields = ['*']; + } extend(body.script_fields, computedFields.scriptFields); const defaultDocValueFields = computedFields.docvalueFields @@ -606,8 +611,6 @@ export class SearchSource { body._source = index.getSourceFiltering(); } - const { getConfig } = this.dependencies; - if (body._source) { // exclude source fields for this index pattern specified by the user const filter = fieldWildcardFilter(body._source.excludes, getConfig(UI_SETTINGS.META_FIELDS)); diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 0ca84c184bf4..cdf4d29697ca 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -473,6 +473,8 @@ export { LanguageServiceContract, RecentQueriesTable, QueryControls, + QueryResult, + QueryStatus, SavedQuery, SavedQueryService, SavedQueryTimeFilter, diff --git a/src/plugins/data/public/query/query_string/index.ts b/src/plugins/data/public/query/query_string/index.ts index c8f6df921ad3..a004103e971b 100644 --- a/src/plugins/data/public/query/query_string/index.ts +++ b/src/plugins/data/public/query/query_string/index.ts @@ -37,4 +37,6 @@ export { EditorEnhancements, RecentQueriesTable, QueryControls, + QueryResult, + QueryStatus, } from './language_service'; diff --git a/src/plugins/data/public/query/query_string/language_service/index.ts b/src/plugins/data/public/query/query_string/language_service/index.ts index cd04fcb50724..70df0971f50c 100644 --- a/src/plugins/data/public/query/query_string/language_service/index.ts +++ b/src/plugins/data/public/query/query_string/language_service/index.ts @@ -5,4 +5,4 @@ export * from './types'; export { LanguageServiceContract, LanguageService } from './language_service'; -export { RecentQueriesTable, QueryControls } from './lib'; +export { RecentQueriesTable, QueryControls, QueryResult, QueryStatus } from './lib'; diff --git a/src/plugins/data/public/query/query_string/language_service/lib/index.ts b/src/plugins/data/public/query/query_string/language_service/lib/index.ts index ca93870dfd4d..dbaaec3e1905 100644 --- a/src/plugins/data/public/query/query_string/language_service/lib/index.ts +++ b/src/plugins/data/public/query/query_string/language_service/lib/index.ts @@ -9,3 +9,4 @@ export * from './lucene_language'; export * from './default_language_reference'; export * from './get_query_control_links'; export * from './recent_query'; +export * from './query_result'; diff --git a/src/plugins/data/public/query/query_string/language_service/lib/query_result.tsx b/src/plugins/data/public/query/query_string/language_service/lib/query_result.tsx new file mode 100644 index 000000000000..9806b7cd55af --- /dev/null +++ b/src/plugins/data/public/query/query_string/language_service/lib/query_result.tsx @@ -0,0 +1,92 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; + +import './_recent_query.scss'; +import { EuiButtonEmpty, EuiPopover, EuiText, EuiPopoverTitle } from '@elastic/eui'; + +import React, { useState } from 'react'; + +export enum ResultStatus { + UNINITIALIZED = 'uninitialized', + LOADING = 'loading', // initial data load + READY = 'ready', // results came back + NO_RESULTS = 'none', // no results came back + ERROR = 'error', // error occurred +} + +export interface QueryStatus { + status: ResultStatus; + body?: { + error?: { + reason?: string; + details: string; + }; + statusCode?: number; + }; + elapsedMs?: number; +} + +export function QueryResult(props: { queryStatus: QueryStatus }) { + const [isPopoverOpen, setPopover] = useState(false); + const onButtonClick = () => { + setPopover(!isPopoverOpen); + }; + + if (props.queryStatus.status === ResultStatus.READY) { + return ( + {}}> + + {props.queryStatus.elapsedMs + ? `Completed in ${props.queryStatus.elapsedMs} ms` + : 'Completed'} + + + ); + } + + if (!props.queryStatus.body || !props.queryStatus.body.error) { + return null; + } + + return ( + + + {'Error'} + + + } + isOpen={isPopoverOpen} + closePopover={() => setPopover(false)} + panelPaddingSize="s" + anchorPosition={'downRight'} + > + ERRORS +
+ + + {i18n.translate('data.query.languageService.queryResults.reasons', { + defaultMessage: `Reasons:`, + })} + + {props.queryStatus.body.error.reason} + + +

+ + {i18n.translate('data.query.languageService.queryResults.details', { + defaultMessage: `Details:`, + })} + {' '} + {props.queryStatus.body.error.details} +

+
+
+
+ ); +} diff --git a/src/plugins/data/public/query/query_string/query_string_manager.ts b/src/plugins/data/public/query/query_string/query_string_manager.ts index e26f3a042c03..3fc9a53deff3 100644 --- a/src/plugins/data/public/query/query_string/query_string_manager.ts +++ b/src/plugins/data/public/query/query_string/query_string_manager.ts @@ -166,6 +166,7 @@ export class QueryStringManager { const language = this.languageService.getLanguage(languageId); const dataset = curQuery.dataset; const input = language?.getQueryString(curQuery) || ''; + this.languageService.setUserQueryString(input); return { query: input, diff --git a/src/plugins/data/public/ui/query_editor/query_editor.tsx b/src/plugins/data/public/ui/query_editor/query_editor.tsx index ce0b5ddb88fe..0abe9796bbf9 100644 --- a/src/plugins/data/public/ui/query_editor/query_editor.tsx +++ b/src/plugins/data/public/ui/query_editor/query_editor.tsx @@ -14,6 +14,7 @@ import { EuiText, PopoverAnchorPosition, } from '@elastic/eui'; +import { BehaviorSubject } from 'rxjs'; import classNames from 'classnames'; import { isEqual } from 'lodash'; import React, { Component, createRef, RefObject } from 'react'; @@ -27,6 +28,8 @@ import { TimeRange, QueryControls, RecentQueriesTable, + QueryResult, + QueryStatus, } from '../..'; import { OpenSearchDashboardsReactContextValue } from '../../../../opensearch_dashboards_react/public'; import { fromUser, getQueryLog, PersistedLog, toUser } from '../../query'; @@ -61,6 +64,7 @@ export interface QueryEditorProps { filterBar?: any; prepend?: React.ComponentProps['prepend']; savedQueryManagement?: any; + queryStatus?: QueryStatus; } interface Props extends QueryEditorProps { @@ -367,6 +371,7 @@ export default class QueryEditorUI extends Component { {this.props.query.dataset?.timeFieldName || ''} , + , ], end: [ ; savedQueryManagement?: any; + queryStatus?: QueryStatus; } // Needed for React.lazy @@ -186,6 +187,7 @@ export default function QueryEditorTopRow(props: QueryEditorTopRowProps) { dataTestSubj={props.dataTestSubj} filterBar={props.filterBar} savedQueryManagement={props.savedQueryManagement} + queryStatus={props.queryStatus} /> ); diff --git a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx index b3b240dfa2f1..03bb2abfa508 100644 --- a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx @@ -191,6 +191,7 @@ export function createSearchBar({ core, storage, data }: StatefulSearchBarDeps) showQueryBar={props.showQueryBar} showQueryInput={props.showQueryInput} showSaveQuery={props.showSaveQuery} + queryStatus={props.queryStatus} screenTitle={props.screenTitle} indexPatterns={props.indexPatterns} indicateNoData={props.indicateNoData} diff --git a/src/plugins/data/public/ui/search_bar/search_bar.tsx b/src/plugins/data/public/ui/search_bar/search_bar.tsx index bb9a2c7eb28c..975b0535c9d8 100644 --- a/src/plugins/data/public/ui/search_bar/search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx @@ -38,7 +38,7 @@ import { withOpenSearchDashboards, } from '../../../../opensearch_dashboards_react/public'; import { Filter, IIndexPattern, Query, TimeRange, UI_SETTINGS } from '../../../common'; -import { SavedQuery, SavedQueryAttributes, TimeHistoryContract } from '../../query'; +import { SavedQuery, SavedQueryAttributes, TimeHistoryContract, QueryStatus } from '../../query'; import { IDataPluginServices } from '../../types'; import { FilterBar } from '../filter_bar/filter_bar'; import { QueryEditorTopRow } from '../query_editor'; @@ -92,6 +92,7 @@ export interface SearchBarOwnProps { onRefresh?: (payload: { dateRange: TimeRange }) => void; indicateNoData?: boolean; + queryStatus?: QueryStatus; } export type SearchBarProps = SearchBarOwnProps & SearchBarInjectedDeps; @@ -550,6 +551,7 @@ class SearchBarUI extends Component { indicateNoData={this.props.indicateNoData} datePickerRef={this.props.datePickerRef} savedQueryManagement={searchBarMenu(false, true)} + queryStatus={this.props.queryStatus} /> ); } diff --git a/src/plugins/data/public/ui/types.ts b/src/plugins/data/public/ui/types.ts index ec57cb2e8c2c..d9f656fabb03 100644 --- a/src/plugins/data/public/ui/types.ts +++ b/src/plugins/data/public/ui/types.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { QueryStatus } from '../query'; import { IndexPatternSelectProps } from './index_pattern_select'; import { StatefulSearchBarProps } from './search_bar'; import { SuggestionsComponentProps } from './typeahead/suggestions_component'; diff --git a/src/plugins/data/public/ui/ui_service.ts b/src/plugins/data/public/ui/ui_service.ts index 87cfcf630965..4eb45b1a67f2 100644 --- a/src/plugins/data/public/ui/ui_service.ts +++ b/src/plugins/data/public/ui/ui_service.ts @@ -4,6 +4,7 @@ */ import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; +import { BehaviorSubject } from 'rxjs'; import { ConfigSchema } from '../../config'; import { DataPublicPluginStart } from '../types'; import { createIndexPatternSelect } from './index_pattern_select'; @@ -11,6 +12,8 @@ import { createSearchBar } from './search_bar/create_search_bar'; import { SuggestionsComponent } from './typeahead'; import { IUiSetup, IUiStart } from './types'; import { DataStorage } from '../../common'; +import { QueryStatus } from '../query'; +import { ResultStatus } from '../query/query_string/language_service/lib'; /** @internal */ // eslint-disable-next-line @typescript-eslint/no-empty-interface diff --git a/src/plugins/data/server/ui_settings.ts b/src/plugins/data/server/ui_settings.ts index 79cf50a900b9..4cee0a9894dd 100644 --- a/src/plugins/data/server/ui_settings.ts +++ b/src/plugins/data/server/ui_settings.ts @@ -762,5 +762,18 @@ export function getUiSettings(): Record> { }), schema: schema.arrayOf(schema.string()), }, + [UI_SETTINGS.SEARCH_INCLUDE_ALL_FIELDS]: { + name: i18n.translate('data.advancedSettings.searchIncludeAllFieldsTitle', { + defaultMessage: 'Include all fields in search request', + }), + value: false, + description: i18n.translate('data.advancedSettings.searchIncludeAllFieldsText', { + defaultMessage: ` + Experimental: + Adds the "fields": ["*"] property to search request body`, + }), + schema: schema.boolean(), + category: ['search'], + }, }; } diff --git a/src/plugins/data_source_management/common/index.ts b/src/plugins/data_source_management/common/index.ts index 2b9b3a4454bf..aba2f1208e82 100644 --- a/src/plugins/data_source_management/common/index.ts +++ b/src/plugins/data_source_management/common/index.ts @@ -7,3 +7,6 @@ export const PLUGIN_ID = 'dataSourceManagement'; export const PLUGIN_NAME = 'Data sources'; export const DEFAULT_DATA_SOURCE_UI_SETTINGS_ID = 'defaultDataSource'; export * from './types'; +export const DATA_SOURCE_PERMISSION_CLIENT_WRAPPER_ID = 'data-source-permission'; +// Run data source permission wrapper behind all other wrapper. +export const ORDER_FOR_DATA_SOURCE_PERMISSION_WRAPPER = 50; diff --git a/src/plugins/data_source_management/config.ts b/src/plugins/data_source_management/config.ts index 1a56a126a943..1cc93c85b44e 100644 --- a/src/plugins/data_source_management/config.ts +++ b/src/plugins/data_source_management/config.ts @@ -10,6 +10,11 @@ export const configSchema = schema.object({ [schema.literal('all'), schema.literal('dashboard_admin'), schema.literal('none')], { defaultValue: 'all' } ), + dataSourceAdmin: schema.object({ + groups: schema.arrayOf(schema.string(), { + defaultValue: [], + }), + }), }); export type ConfigSchema = TypeOf; diff --git a/src/plugins/data_source_management/server/plugin.ts b/src/plugins/data_source_management/server/plugin.ts index de8dcf74cf96..dad9242e04f0 100644 --- a/src/plugins/data_source_management/server/plugin.ts +++ b/src/plugins/data_source_management/server/plugin.ts @@ -21,8 +21,17 @@ import { DataSourceManagementPluginSetup, DataSourceManagementPluginStart } from import { OpenSearchDataSourceManagementPlugin } from './adaptors/opensearch_data_source_management_plugin'; import { PPLPlugin } from './adaptors/ppl_plugin'; import { ConfigSchema } from '../config'; -import { getWorkspaceState } from '../../../../src/core/server/utils'; -import { ManageableBy } from '../common'; +import { + getPrincipalsFromRequest, + getWorkspaceState, + updateWorkspaceState, +} from '../../../../src/core/server/utils'; +import { + DATA_SOURCE_PERMISSION_CLIENT_WRAPPER_ID, + ManageableBy, + ORDER_FOR_DATA_SOURCE_PERMISSION_WRAPPER, +} from '../common'; +import { DataSourcePermissionClientWrapper } from './saved_objects/data_source_premission_client_wrapper'; export interface DataSourceManagementPluginDependencies { dataSource: DataSourcePluginSetup; @@ -33,6 +42,35 @@ export class DataSourceManagementPlugin private readonly config$: Observable; private readonly logger: Logger; + private setupDataSourcePermission(core: CoreSetup, config: ConfigSchema) { + core.http.registerOnPostAuth(async (request, response, toolkit) => { + let groups: string[]; + const [coreStart] = await core.getStartServices(); + + try { + ({ groups = [] } = getPrincipalsFromRequest(request, coreStart.http.auth)); + } catch (e) { + return toolkit.next(); + } + + const configGroups = config.dataSourceAdmin.groups; + const isDataSourceAdmin = configGroups.some((configGroup) => groups.includes(configGroup)); + updateWorkspaceState(request, { + isDataSourceAdmin, + }); + return toolkit.next(); + }); + + const dataSourcePermissionWrapper = new DataSourcePermissionClientWrapper(config.manageableBy); + + // Add data source permission client wrapper factory + core.savedObjects.addClientWrapper( + ORDER_FOR_DATA_SOURCE_PERMISSION_WRAPPER, + DATA_SOURCE_PERMISSION_CLIENT_WRAPPER_ID, + dataSourcePermissionWrapper.wrapperFactory + ); + } + constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); this.config$ = initializerContext.config.create(); @@ -82,6 +120,8 @@ export class DataSourceManagementPlugin if (dataSourceEnabled) { dataSource.registerCustomApiSchema(PPLPlugin); dataSource.registerCustomApiSchema(OpenSearchDataSourceManagementPlugin); + + this.setupDataSourcePermission(core, config); } // @ts-ignore core.http.registerRouteHandlerContext( diff --git a/src/plugins/data_source_management/server/saved_objects/data_source_permission_client_wrapper.test.ts b/src/plugins/data_source_management/server/saved_objects/data_source_permission_client_wrapper.test.ts new file mode 100644 index 000000000000..280ee32c183e --- /dev/null +++ b/src/plugins/data_source_management/server/saved_objects/data_source_permission_client_wrapper.test.ts @@ -0,0 +1,353 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as utils from '../../../../core/server/utils/workspace'; +import { coreMock, httpServerMock, savedObjectsClientMock } from '../../../../core/server/mocks'; +import { DataSourcePermissionClientWrapper } from './data_source_premission_client_wrapper'; +import { DATA_SOURCE_SAVED_OBJECT_TYPE } from '../../../data_source/common'; +import { ManageableBy } from '../../common'; + +describe('DataSourcePermissionClientWrapper', () => { + const requestHandlerContext = coreMock.createRequestHandlerContext(); + const requestMock = httpServerMock.createOpenSearchDashboardsRequest(); + + const attributes = { + title: 'data-source', + description: 'jest testing', + endpoint: 'https://test.com', + auth: { type: 'no_auth' }, + workspaces: ['workspace-1'], + }; + + const dataSource = { + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + attributes, + }; + const dashboard = { + type: 'dashboard', + attributes: {}, + }; + + const errorMessage = 'You have no permission to perform this operation'; + + describe('Data source is managed by dashboard admin', () => { + describe('user is not dashboard admin', () => { + jest.spyOn(utils, 'getWorkspaceState').mockReturnValue({ isDashboardAdmin: false }); + const mockedClient = savedObjectsClientMock.create(); + const wrapperInstance = new DataSourcePermissionClientWrapper(ManageableBy.DashboardAdmin); + const wrapperClient = wrapperInstance.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: requestMock, + }); + + it('should not create data source when user is not dashboard admin', async () => { + let errorCatch; + try { + await wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, attributes, {}); + } catch (e) { + errorCatch = e; + } + expect(errorCatch.message).toEqual(errorMessage); + }); + + it('should not bulk create data source when user is not admin', async () => { + const mockCreateObjects = [dataSource, dashboard]; + const result = await wrapperClient.bulkCreate(mockCreateObjects); + expect(result.saved_objects[0].error?.message).toEqual(errorMessage); + }); + + it('should not update data source when user is not admin', async () => { + let errorCatch; + try { + await wrapperClient.update(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id', {}); + } catch (e) { + errorCatch = e; + } + expect(errorCatch.message).toEqual(errorMessage); + }); + + it('should not bulk update data source when user is not admin', async () => { + const mockCreateObjects = [ + { ...dataSource, id: 'data-source-id' }, + { ...dashboard, id: 'dashboard-id' }, + ]; + const result = await wrapperClient.bulkUpdate(mockCreateObjects); + expect(result.saved_objects[0].error?.message).toEqual(errorMessage); + }); + + it('should not delete data source when user is not admin', async () => { + let errorCatch; + try { + await wrapperClient.delete(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id'); + } catch (e) { + errorCatch = e; + } + expect(errorCatch.message).toEqual(errorMessage); + }); + }); + describe('user is osd admin', () => { + jest.spyOn(utils, 'getWorkspaceState').mockReturnValue({ isDashboardAdmin: true }); + const mockedClient = savedObjectsClientMock.create(); + const wrapperInstance = new DataSourcePermissionClientWrapper(ManageableBy.DashboardAdmin); + const wrapperClient = wrapperInstance.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: requestMock, + }); + + it('should create data source when user is admin', async () => { + await wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, attributes, {}); + expect(mockedClient.create).toBeCalledWith(DATA_SOURCE_SAVED_OBJECT_TYPE, attributes, {}); + }); + + it('should bulk create data source when user is admin', async () => { + const mockCreateObjects = [dataSource]; + await wrapperClient.bulkCreate(mockCreateObjects, { overwrite: true }); + expect(mockedClient.bulkCreate).toBeCalledWith(mockCreateObjects, { overwrite: true }); + }); + + it('should update data source when user is admin', async () => { + await wrapperClient.update(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id', {}); + expect(mockedClient.update).toBeCalledWith( + DATA_SOURCE_SAVED_OBJECT_TYPE, + 'data-source-id', + {} + ); + }); + + it('should bulk update data source when user is admin', async () => { + const mockUpdateObjects = [ + { + ...dataSource, + id: 'data-source-id', + }, + ]; + await wrapperClient.bulkUpdate(mockUpdateObjects, {}); + expect(mockedClient.bulkUpdate).toBeCalledWith(mockUpdateObjects, {}); + }); + + it('should delete data source', async () => { + await wrapperClient.delete(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id'); + expect(mockedClient.delete).toBeCalledWith(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id'); + }); + }); + describe('any user is osd admin when osd admin is not configured', () => { + jest.spyOn(utils, 'getWorkspaceState').mockReturnValue({}); + const mockedClient = savedObjectsClientMock.create(); + const wrapperInstance = new DataSourcePermissionClientWrapper(ManageableBy.DashboardAdmin); + const wrapperClient = wrapperInstance.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: requestMock, + }); + + it('should create data source when user is admin', async () => { + await wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, attributes, {}); + expect(mockedClient.create).toBeCalledWith(DATA_SOURCE_SAVED_OBJECT_TYPE, attributes, {}); + }); + + it('should bulk create data source when user is admin', async () => { + const mockCreateObjects = [dataSource]; + await wrapperClient.bulkCreate(mockCreateObjects, { overwrite: true }); + expect(mockedClient.bulkCreate).toBeCalledWith(mockCreateObjects, { overwrite: true }); + }); + + it('should update data source when user is admin', async () => { + await wrapperClient.update(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id', {}); + expect(mockedClient.update).toBeCalledWith( + DATA_SOURCE_SAVED_OBJECT_TYPE, + 'data-source-id', + {} + ); + }); + + it('should bulk update data source when user is admin', async () => { + const mockUpdateObjects = [ + { + ...dataSource, + id: 'data-source-id', + }, + ]; + await wrapperClient.bulkUpdate(mockUpdateObjects, {}); + expect(mockedClient.bulkUpdate).toBeCalledWith(mockUpdateObjects, {}); + }); + + it('should delete data source', async () => { + jest.spyOn(utils, 'getWorkspaceState').mockReturnValue({}); + await wrapperClient.delete(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id'); + expect(mockedClient.delete).toBeCalledWith(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id'); + }); + }); + }); + + describe('Data source is managed by none', () => { + jest.spyOn(utils, 'getWorkspaceState').mockReturnValue({ isDashboardAdmin: false }); + const mockedClient = { + ...savedObjectsClientMock.create(), + get: jest.fn().mockImplementation(async (id) => { + return { + id, + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + attributes, + }; + }), + }; + const wrapperInstance = new DataSourcePermissionClientWrapper(ManageableBy.None); + const wrapperClient = wrapperInstance.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: requestMock, + }); + + it('should not create data source', async () => { + let errorCatch; + try { + await wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, attributes, {}); + } catch (e) { + errorCatch = e; + } + expect(errorCatch.message).toEqual(errorMessage); + + await wrapperClient.create('dashboard', {}, {}); + expect(mockedClient.create).toBeCalledWith('dashboard', {}, {}); + }); + + it('should not bulk create data source', async () => { + const mockCreateObjects = [dataSource, dashboard]; + const result = await wrapperClient.bulkCreate(mockCreateObjects); + expect(result.saved_objects[0].error?.message).toEqual(errorMessage); + }); + + it('should not update data source', async () => { + let errorCatch; + try { + await wrapperClient.update(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id', {}); + } catch (e) { + errorCatch = e; + } + expect(errorCatch.message).toEqual(errorMessage); + + await wrapperClient.update('dashboard', 'dashboard-id', {}); + expect(mockedClient.update).toBeCalledWith('dashboard', 'dashboard-id', {}, {}); + }); + + it('should not bulk update data source', async () => { + const mockCreateObjects = [ + { ...dataSource, id: 'data-source-id' }, + { ...dashboard, id: 'dashboard-id' }, + ]; + const result = await wrapperClient.bulkUpdate(mockCreateObjects); + expect(result.saved_objects[0].error?.message).toEqual(errorMessage); + }); + + it('should not delete data source', async () => { + let errorCatch; + try { + await wrapperClient.delete(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id'); + } catch (e) { + errorCatch = e; + } + expect(errorCatch.message).toEqual(errorMessage); + + await wrapperClient.delete('dashboard', 'dashboard-id'); + expect(mockedClient.delete).toBeCalledWith('dashboard', 'dashboard-id', {}); + }); + }); + + describe('User is data source admin', () => { + jest.spyOn(utils, 'getWorkspaceState').mockReturnValue({ isDataSourceAdmin: true }); + const mockedClient = savedObjectsClientMock.create(); + const wrapperInstance = new DataSourcePermissionClientWrapper(ManageableBy.None); + const wrapperClient = wrapperInstance.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: requestMock, + }); + + it('should create data source', async () => { + await wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, attributes, {}); + expect(mockedClient.create).toBeCalledWith(DATA_SOURCE_SAVED_OBJECT_TYPE, attributes, {}); + }); + + it('should bulk create data source', async () => { + const mockCreateObjects = [dataSource]; + await wrapperClient.bulkCreate(mockCreateObjects, { overwrite: true }); + expect(mockedClient.bulkCreate).toBeCalledWith(mockCreateObjects, { overwrite: true }); + }); + + it('should update data source', async () => { + await wrapperClient.update(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id', {}); + expect(mockedClient.update).toBeCalledWith( + DATA_SOURCE_SAVED_OBJECT_TYPE, + 'data-source-id', + {} + ); + }); + + it('should bulk update data source', async () => { + const mockUpdateObjects = [ + { + ...dataSource, + id: 'data-source-id', + }, + ]; + await wrapperClient.bulkUpdate(mockUpdateObjects, {}); + expect(mockedClient.bulkUpdate).toBeCalledWith(mockUpdateObjects, {}); + }); + + it('should delete data source', async () => { + await wrapperClient.delete(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id'); + expect(mockedClient.delete).toBeCalledWith(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id'); + }); + }); + + describe('Data source is managed by all', () => { + jest.spyOn(utils, 'getWorkspaceState').mockReturnValue({ isDashboardAdmin: false }); + const mockedClient = savedObjectsClientMock.create(); + const wrapperInstance = new DataSourcePermissionClientWrapper(ManageableBy.All); + const wrapperClient = wrapperInstance.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: requestMock, + }); + + it('should create data source', async () => { + await wrapperClient.create(DATA_SOURCE_SAVED_OBJECT_TYPE, attributes, {}); + expect(mockedClient.create).toBeCalledWith(DATA_SOURCE_SAVED_OBJECT_TYPE, attributes, {}); + }); + + it('should bulk create data source', async () => { + const mockCreateObjects = [dataSource]; + await wrapperClient.bulkCreate(mockCreateObjects, { overwrite: true }); + expect(mockedClient.bulkCreate).toBeCalledWith(mockCreateObjects, { overwrite: true }); + }); + + it('should update data source', async () => { + await wrapperClient.update(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id', {}); + expect(mockedClient.update).toBeCalledWith( + DATA_SOURCE_SAVED_OBJECT_TYPE, + 'data-source-id', + {} + ); + }); + + it('should bulk update data source', async () => { + const mockUpdateObjects = [ + { + ...dataSource, + id: 'data-source-id', + }, + ]; + await wrapperClient.bulkUpdate(mockUpdateObjects, {}); + expect(mockedClient.bulkUpdate).toBeCalledWith(mockUpdateObjects, {}); + }); + + it('should delete data source', async () => { + await wrapperClient.delete(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id'); + expect(mockedClient.delete).toBeCalledWith(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id'); + }); + }); +}); diff --git a/src/plugins/data_source_management/server/saved_objects/data_source_premission_client_wrapper.ts b/src/plugins/data_source_management/server/saved_objects/data_source_premission_client_wrapper.ts new file mode 100644 index 000000000000..17c979303535 --- /dev/null +++ b/src/plugins/data_source_management/server/saved_objects/data_source_premission_client_wrapper.ts @@ -0,0 +1,162 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { + SavedObjectsBulkCreateObject, + SavedObjectsBulkResponse, + SavedObjectsBulkUpdateObject, + SavedObjectsBulkUpdateOptions, + SavedObjectsBulkUpdateResponse, + SavedObjectsClientWrapperFactory, + SavedObjectsCreateOptions, + SavedObjectsDeleteOptions, + SavedObjectsErrorHelpers, + SavedObjectsUpdateOptions, + SavedObjectsUpdateResponse, +} from '../../../../core/server'; +import { getWorkspaceState } from '../../../../core/server/utils'; +import { DATA_SOURCE_SAVED_OBJECT_TYPE } from '../../../data_source/common'; +import { ManageableBy } from '../../common'; + +/** + * Determine whether the user has the permissions to create, delete, and update data source based on manageableBy/dataSourceAdmin. + * DataSourceAdmin user has all permissions. + * If manageableBy is all, any user has permissions. + * If manageableBy is none, any user has no permissions. + * If manageableBy is dashboard_admin, only OSD admin has permissions. + */ +export class DataSourcePermissionClientWrapper { + constructor(private manageableBy: string) {} + + public wrapperFactory: SavedObjectsClientWrapperFactory = (wrapperOptions) => { + const { isDashboardAdmin, isDataSourceAdmin } = getWorkspaceState(wrapperOptions.request) || {}; + // If isDashboardAdmin is undefined / true, the user will be dashboard admin + const isDashboardAdminRequest = isDashboardAdmin !== false; + if ( + isDataSourceAdmin || + this.manageableBy === ManageableBy.All || + (this.manageableBy === ManageableBy.DashboardAdmin && isDashboardAdminRequest) + ) { + return wrapperOptions.client; + } + + const createWithManageableBy = async ( + type: string, + attributes: T, + options?: SavedObjectsCreateOptions + ) => { + if (DATA_SOURCE_SAVED_OBJECT_TYPE === type) { + throw this.generatePermissionError(); + } + return await wrapperOptions.client.create(type, attributes, options); + }; + + const bulkCreateWithManageableBy = async ( + objects: Array>, + options?: SavedObjectsCreateOptions + ): Promise> => { + const disallowedSavedObjects: Array> = []; + const allowedSavedObjects: Array> = []; + + objects.forEach((item) => { + if (DATA_SOURCE_SAVED_OBJECT_TYPE === item.type) { + disallowedSavedObjects.push(item); + } else { + allowedSavedObjects.push(item); + } + }); + + const bulkCreateResult = await wrapperOptions.client.bulkCreate(allowedSavedObjects, options); + + // Merge the data source saved objects and real client bulkCreate result. + return { + saved_objects: [ + ...(bulkCreateResult?.saved_objects || []), + ...disallowedSavedObjects.map((item) => ({ + ...item, + error: { + ...this.generatePermissionError().output.payload, + metadata: { isNotOverwritable: true }, + }, + })), + ], + } as SavedObjectsBulkResponse; + }; + + const updateWithManageableBy = async ( + type: string, + id: string, + attributes: Partial, + options: SavedObjectsUpdateOptions = {} + ): Promise> => { + if (DATA_SOURCE_SAVED_OBJECT_TYPE === type) { + throw this.generatePermissionError(); + } + return await wrapperOptions.client.update(type, id, attributes, options); + }; + + const bulkUpdateWithManageableBy = async ( + objects: Array>, + options?: SavedObjectsBulkUpdateOptions + ): Promise> => { + const disallowedSavedObjects: Array> = []; + const allowedSavedObjects: Array> = []; + + objects.forEach((item) => { + if (DATA_SOURCE_SAVED_OBJECT_TYPE === item.type) { + disallowedSavedObjects.push(item); + } else { + allowedSavedObjects.push(item); + } + }); + + const bulkUpdateResult = await wrapperOptions.client.bulkUpdate(allowedSavedObjects, options); + + // Merge the data source saved objects and real client bulkUpdate result. + return { + saved_objects: [ + ...(bulkUpdateResult?.saved_objects || []), + ...disallowedSavedObjects.map((item) => ({ + ...item, + error: { + ...this.generatePermissionError().output.payload, + metadata: { isNotOverwritable: true }, + }, + })), + ], + } as SavedObjectsBulkUpdateResponse; + }; + + const deleteWithManageableBy = async ( + type: string, + id: string, + options: SavedObjectsDeleteOptions = {} + ) => { + if (DATA_SOURCE_SAVED_OBJECT_TYPE === type) { + throw this.generatePermissionError(); + } + return await wrapperOptions.client.delete(type, id, options); + }; + + return { + ...wrapperOptions.client, + create: createWithManageableBy, + bulkCreate: bulkCreateWithManageableBy, + delete: deleteWithManageableBy, + update: updateWithManageableBy, + bulkUpdate: bulkUpdateWithManageableBy, + }; + }; + + private generatePermissionError = () => + SavedObjectsErrorHelpers.decorateForbiddenError( + new Error( + i18n.translate('dashboard.admin.permission.invalidate', { + defaultMessage: 'You have no permission to perform this operation', + }) + ) + ); +} diff --git a/src/plugins/discover/public/application/view_components/canvas/index.tsx b/src/plugins/discover/public/application/view_components/canvas/index.tsx index a44ac89c5d62..2e956d6908e9 100644 --- a/src/plugins/discover/public/application/view_components/canvas/index.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/index.tsx @@ -144,6 +144,9 @@ export default function DiscoverCanvas({ setHeaderActionMenu, history, optionalR {fetchState.status === ResultStatus.NO_RESULTS && ( )} + {fetchState.status === ResultStatus.ERROR && ( + + )} {fetchState.status === ResultStatus.UNINITIALIZED && ( refetch$.next()} /> )} diff --git a/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx index 6cccc06cc0ee..48b6da5a39a5 100644 --- a/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx @@ -9,7 +9,11 @@ import { createPortal } from 'react-dom'; import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { AppMountParameters } from '../../../../../../core/public'; -import { connectStorageToQueryState, opensearchFilters } from '../../../../../data/public'; +import { + connectStorageToQueryState, + opensearchFilters, + QueryStatus, +} from '../../../../../data/public'; import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; import { PLUGIN_ID } from '../../../../common'; import { DiscoverViewServices } from '../../../build_services'; @@ -21,6 +25,7 @@ import { useDispatch, setSavedQuery, useSelector } from '../../utils/state_manag import './discover_canvas.scss'; import { TopNavMenuItemRenderType } from '../../../../../navigation/public'; +import { ResultStatus } from '../utils'; export interface TopNavProps { opts: { @@ -34,9 +39,10 @@ export interface TopNavProps { export const TopNav = ({ opts, showSaveQuery, isEnhancementsEnabled }: TopNavProps) => { const { services } = useOpenSearchDashboards(); - const { inspectorAdapters, savedSearch, indexPattern } = useDiscoverContext(); + const { data$, inspectorAdapters, savedSearch, indexPattern } = useDiscoverContext(); const [indexPatterns, setIndexPatterns] = useState(undefined); const [screenTitle, setScreenTitle] = useState(''); + const [queryStatus, setQueryStatus] = useState({ status: ResultStatus.READY }); const state = useSelector((s) => s.discover); const dispatch = useDispatch(); @@ -69,6 +75,16 @@ export const TopNav = ({ opts, showSaveQuery, isEnhancementsEnabled }: TopNavPro uiSettings ); + useEffect(() => { + const subscription = data$.subscribe((queryData) => { + const result = { + status: queryData.status, + ...queryData.queryStatus, + }; + setQueryStatus(result); + }); + }, [data$]); + useEffect(() => { let isMounted = true; const initializeDataset = async () => { @@ -160,6 +176,7 @@ export const TopNav = ({ opts, showSaveQuery, isEnhancementsEnabled }: TopNavPro datePickerRef={opts?.optionalRef?.datePickerRef} groupActions={showActionsInGroup} screenTitle={screenTitle} + queryStatus={queryStatus} /> ); diff --git a/src/plugins/discover/public/application/view_components/index.ts b/src/plugins/discover/public/application/view_components/index.ts index 45fd68cf1285..7aff16330f66 100644 --- a/src/plugins/discover/public/application/view_components/index.ts +++ b/src/plugins/discover/public/application/view_components/index.ts @@ -5,3 +5,4 @@ export * from './canvas'; export * from './panel'; +export * from './utils'; diff --git a/src/plugins/discover/public/application/view_components/utils/index.tsx b/src/plugins/discover/public/application/view_components/utils/index.tsx new file mode 100644 index 000000000000..c9dbbb829a3f --- /dev/null +++ b/src/plugins/discover/public/application/view_components/utils/index.tsx @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { SearchData, ResultStatus } from './use_search'; diff --git a/src/plugins/discover/public/application/view_components/utils/use_search.ts b/src/plugins/discover/public/application/view_components/utils/use_search.ts index b6c13d4982f2..ccb22f86bb29 100644 --- a/src/plugins/discover/public/application/view_components/utils/use_search.ts +++ b/src/plugins/discover/public/application/view_components/utils/use_search.ts @@ -12,7 +12,7 @@ import { cloneDeep } from 'lodash'; import { useLocation } from 'react-router-dom'; import { RequestAdapter } from '../../../../../inspector/public'; import { DiscoverViewServices } from '../../../build_services'; -import { search } from '../../../../../data/public'; +import { QueryStatus, search } from '../../../../../data/public'; import { validateTimeRange } from '../../helpers/validate_time_range'; import { updateSearchSource } from './update_search_source'; import { useIndexPattern } from './use_index_pattern'; @@ -39,6 +39,7 @@ export enum ResultStatus { LOADING = 'loading', // initial data load READY = 'ready', // results came back NO_RESULTS = 'none', // no results came back + ERROR = 'error', // error occurred } export interface SearchData { @@ -50,6 +51,16 @@ export interface SearchData { bucketInterval?: TimechartHeaderBucketInterval | {}; chartData?: Chart; title?: string; + queryStatus?: { + body?: { + error?: { + reason?: string; + details: string; + }; + statusCode?: number; + }; + elapsedMs?: number; + }; } export type SearchRefetch = 'refetch' | undefined; @@ -149,6 +160,8 @@ export const useSearch = (services: DiscoverViewServices) => { dataset = searchSource.getField('index'); + let elapsedMs; + try { // Only show loading indicator if we are fetching when the rows are empty if (fetchStateRef.current.rows?.length === 0) { @@ -180,6 +193,7 @@ export const useSearch = (services: DiscoverViewServices) => { .ok({ json: fetchResp }); const hits = fetchResp.hits.total as number; const rows = fetchResp.hits.hits; + elapsedMs = inspectorRequest.getTime(); let bucketInterval = {}; let chartData; for (const row of rows) { @@ -216,17 +230,38 @@ export const useSearch = (services: DiscoverViewServices) => { indexPattern?.title !== searchSource.getDataFrame()?.name ? searchSource.getDataFrame()?.name : indexPattern?.title, + queryStatus: { + elapsedMs, + }, }); } catch (error) { // If the request was aborted then no need to surface this error in the UI if (error instanceof Error && error.name === 'AbortError') return; + const queryLanguage = data.query.queryString.getQuery().language; + if (queryLanguage === 'kuery' || queryLanguage === 'lucene') { + data$.next({ + status: ResultStatus.NO_RESULTS, + rows: [], + }); + + data.search.showError(error as Error); + return; + } + let errorBody; + try { + errorBody = JSON.parse(error.body.message); + } catch (e) { + errorBody = error.body.message; + } + data$.next({ - status: ResultStatus.NO_RESULTS, - rows: [], + status: ResultStatus.ERROR, + queryStatus: { + body: errorBody, + elapsedMs, + }, }); - - data.search.showError(error as Error); } finally { initalSearchComplete.current = true; } diff --git a/src/plugins/discover/public/index.ts b/src/plugins/discover/public/index.ts index 164aea1fb5bc..54575735a518 100644 --- a/src/plugins/discover/public/index.ts +++ b/src/plugins/discover/public/index.ts @@ -40,3 +40,4 @@ export { SavedSearch, SavedSearchLoader, createSavedSearchesLoader } from './sav export { ISearchEmbeddable, SEARCH_EMBEDDABLE_TYPE, SearchInput } from './embeddable'; export { DISCOVER_APP_URL_GENERATOR, DiscoverUrlGeneratorState } from './url_generator'; +export { SearchData, ResultStatus } from './application/view_components'; diff --git a/src/plugins/home/public/application/components/use_case_card.test.ts b/src/plugins/home/public/application/components/use_case_card.test.ts new file mode 100644 index 000000000000..e26e6b1fb0eb --- /dev/null +++ b/src/plugins/home/public/application/components/use_case_card.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiIcon } from '@elastic/eui'; +import { coreMock } from '../../../../../core/public/mocks'; +import { registerUseCaseCard } from './use_case_card'; +import { contentManagementPluginMocks } from '../../../../content_management/public'; + +describe('registerUseCaseCard', () => { + const registerContentProviderFn = jest.fn(); + const contentManagementStartMock = { + ...contentManagementPluginMocks.createStartContract(), + registerContentProvider: registerContentProviderFn, + }; + + const core = coreMock.createStart(); + + it('should register useCase card correctly', () => { + registerUseCaseCard(contentManagementStartMock, core, { + id: 'testId', + order: 1, + target: 'osd_homepage/get_started', + icon: 'wsObservability', + title: 'observability', + description: 'Gain visibility into your application and infrastructure', + navigateAppId: 'observability_overview', + }); + + expect(contentManagementStartMock.registerContentProvider).toHaveBeenCalledTimes(1); + + const registerCall = contentManagementStartMock.registerContentProvider.mock.calls[0][0]; + + expect(registerCall.getTargetArea()).toEqual('osd_homepage/get_started'); + + expect(registerCall.getContent()).toEqual({ + id: 'testId', + kind: 'card', + order: 1, + description: 'Gain visibility into your application and infrastructure', + title: 'observability', + cardProps: { + layout: 'horizontal', + }, + onClick: expect.any(Function), + getIcon: expect.any(Function), + }); + + const icon = registerCall.getContent().getIcon(); + expect(icon.type).toBe(EuiIcon); + expect(icon.props).toEqual({ + size: 'l', + color: 'subdued', + type: 'wsObservability', + }); + }); + + it('should be able to navigate to the expected overview page when click the card', () => { + const navigateToAppMock = jest.fn(); + core.application.navigateToApp = navigateToAppMock; + + registerUseCaseCard(contentManagementStartMock, core, { + id: 'testId', + order: 1, + target: 'osd_homepage/get_started', + icon: 'wsObservability', + title: 'observability', + description: 'Gain visibility into your application and infrastructure', + navigateAppId: 'observability_overview', + }); + + const registerCall = contentManagementStartMock.registerContentProvider.mock.calls[0][0]; + const card = registerCall.getContent(); + card.onClick(); + expect(navigateToAppMock).toHaveBeenCalledWith('observability_overview'); + }); +}); diff --git a/src/plugins/home/public/application/components/use_case_card.ts b/src/plugins/home/public/application/components/use_case_card.ts new file mode 100644 index 000000000000..b71f381358f4 --- /dev/null +++ b/src/plugins/home/public/application/components/use_case_card.ts @@ -0,0 +1,55 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { CoreStart } from 'opensearch-dashboards/public'; +import { EuiIcon } from '@elastic/eui'; +import { ContentManagementPluginStart } from '../../../../content_management/public'; + +export const registerUseCaseCard = ( + contentManagement: ContentManagementPluginStart, + core: CoreStart, + { + target, + order, + id, + title, + description, + icon, + navigateAppId, + }: { + target: string; + order: number; + id: string; + title: string; + description: string; + icon: string; + navigateAppId: string; + } +) => { + contentManagement.registerContentProvider({ + id: `home_get_started_${id}`, + getTargetArea: () => target, + getContent: () => ({ + id, + kind: 'card', + order, + description, + title, + cardProps: { + layout: 'horizontal', + }, + onClick: () => { + core.application.navigateToApp(navigateAppId); + }, + getIcon: () => + React.createElement(EuiIcon, { + size: 'l', + color: 'subdued', + type: icon, + }), + }), + }); +}; diff --git a/src/plugins/home/public/application/home_render.test.tsx b/src/plugins/home/public/application/home_render.test.tsx new file mode 100644 index 000000000000..34a5d690b44b --- /dev/null +++ b/src/plugins/home/public/application/home_render.test.tsx @@ -0,0 +1,142 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DEFAULT_NAV_GROUPS } from '../../../../core/public'; +import { + HOME_CONTENT_AREAS, + SEARCH_OVERVIEW_PAGE_ID, + OBSERVABILITY_OVERVIEW_PAGE_ID, + SECURITY_ANALYTICS_OVERVIEW_PAGE_ID, +} from '../../../../plugins/content_management/public'; +import { contentManagementPluginMocks } from '../../../../plugins/content_management/public/mocks'; +import { registerUseCaseCard } from './components/use_case_card'; +import { initHome } from './home_render'; + +import { + WHATS_NEW_CONFIG, + LEARN_OPENSEARCH_CONFIG, + registerHomeListCard, +} from './components/home_list_card'; + +jest.mock('./components/use_case_card', () => ({ + registerUseCaseCard: jest.fn(), +})); + +jest.mock('./components/home_list_card', () => ({ + registerHomeListCard: jest.fn(), +})); + +describe('initHome', () => { + const registerContentProviderFn = jest.fn(); + const contentManagementStartMock = { + ...contentManagementPluginMocks.createStartContract(), + registerContentProvider: registerContentProviderFn, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should register use case cards when workspace is enabled', () => { + const coreMock = { + createStart: jest.fn(() => ({ + application: { + capabilities: { + workspaces: { + enabled: false, + }, + }, + navigateToApp: jest.fn(), + }, + })), + }; + const core = coreMock.createStart(); + + initHome(contentManagementStartMock, core); + + expect(registerUseCaseCard).toHaveBeenCalledTimes(3); + + expect(registerUseCaseCard).toHaveBeenCalledWith(contentManagementStartMock, core, { + id: DEFAULT_NAV_GROUPS.observability.id, + order: 1, + description: DEFAULT_NAV_GROUPS.observability.description, + title: DEFAULT_NAV_GROUPS.observability.title, + target: HOME_CONTENT_AREAS.GET_STARTED, + icon: DEFAULT_NAV_GROUPS.observability.icon ?? '', + navigateAppId: OBSERVABILITY_OVERVIEW_PAGE_ID, + }); + + expect(registerUseCaseCard).toHaveBeenCalledWith(contentManagementStartMock, core, { + id: DEFAULT_NAV_GROUPS.search.id, + order: 2, + description: DEFAULT_NAV_GROUPS.search.description, + title: DEFAULT_NAV_GROUPS.search.title, + target: HOME_CONTENT_AREAS.GET_STARTED, + icon: DEFAULT_NAV_GROUPS.search.icon ?? '', + navigateAppId: SEARCH_OVERVIEW_PAGE_ID, + }); + + expect(registerUseCaseCard).toHaveBeenCalledWith(contentManagementStartMock, core, { + id: DEFAULT_NAV_GROUPS['security-analytics'].id, + order: 3, + description: DEFAULT_NAV_GROUPS['security-analytics'].description, + title: DEFAULT_NAV_GROUPS['security-analytics'].title, + target: HOME_CONTENT_AREAS.GET_STARTED, + icon: DEFAULT_NAV_GROUPS['security-analytics'].icon ?? '', + navigateAppId: SECURITY_ANALYTICS_OVERVIEW_PAGE_ID, + }); + }); + + it('should not register use case cards when workspace is disabled', () => { + const coreMock = { + createStart: jest.fn(() => ({ + application: { + capabilities: { + workspaces: { + enabled: true, + }, + }, + }, + })), + }; + const core = coreMock.createStart(); + initHome(contentManagementStartMock, core); + expect(registerUseCaseCard).not.toHaveBeenCalled(); + }); + + it('should register home list cards correctly', () => { + const coreMock = { + createStart: jest.fn(() => ({ + application: { + capabilities: { + workspaces: { + enabled: false, + }, + }, + }, + })), + }; + const core = coreMock.createStart(); + initHome(contentManagementStartMock, core); + + expect(registerHomeListCard).toHaveBeenCalledTimes(2); + + expect(registerHomeListCard).toHaveBeenCalledWith(contentManagementStartMock, { + id: 'whats_new', + order: 10, + config: WHATS_NEW_CONFIG, + target: HOME_CONTENT_AREAS.SERVICE_CARDS, + width: 16, + }); + + expect(registerHomeListCard).toHaveBeenCalledWith(contentManagementStartMock, { + id: 'learn_opensearch_new', + order: 11, + config: LEARN_OPENSEARCH_CONFIG, + target: HOME_CONTENT_AREAS.SERVICE_CARDS, + width: 16, + }); + }); +}); diff --git a/src/plugins/home/public/application/home_render.tsx b/src/plugins/home/public/application/home_render.tsx index ef73d7c44d99..f22f3560205c 100644 --- a/src/plugins/home/public/application/home_render.tsx +++ b/src/plugins/home/public/application/home_render.tsx @@ -5,12 +5,16 @@ import React from 'react'; import { CoreStart } from 'opensearch-dashboards/public'; +import { DEFAULT_NAV_GROUPS } from '../../../../core/public'; import { ContentManagementPluginSetup, ContentManagementPluginStart, HOME_PAGE_ID, SECTIONS, HOME_CONTENT_AREAS, + SEARCH_OVERVIEW_PAGE_ID, + OBSERVABILITY_OVERVIEW_PAGE_ID, + SECURITY_ANALYTICS_OVERVIEW_PAGE_ID, } from '../../../../plugins/content_management/public'; import { WHATS_NEW_CONFIG, @@ -18,16 +22,13 @@ import { registerHomeListCard, } from './components/home_list_card'; +import { registerUseCaseCard } from './components/use_case_card'; + export const setupHome = (contentManagement: ContentManagementPluginSetup) => { contentManagement.registerPage({ id: HOME_PAGE_ID, title: 'Home', sections: [ - { - id: SECTIONS.SERVICE_CARDS, - order: 3000, - kind: 'dashboard', - }, { id: SECTIONS.RECENTLY_VIEWED, order: 2000, @@ -47,10 +48,15 @@ export const setupHome = (contentManagement: ContentManagementPluginSetup) => { ); }, }, + { + id: SECTIONS.SERVICE_CARDS, + order: 3000, + kind: 'dashboard', + }, { id: SECTIONS.GET_STARTED, order: 1000, - title: 'Get started with OpenSearch’s powerful features', + title: "Get started with OpenSearch's powerful features", kind: 'card', }, ], @@ -58,9 +64,34 @@ export const setupHome = (contentManagement: ContentManagementPluginSetup) => { }; export const initHome = (contentManagement: ContentManagementPluginStart, core: CoreStart) => { + const workspaceEnabled = core.application.capabilities.workspaces.enabled; + + if (!workspaceEnabled) { + const useCases = [ + { ...DEFAULT_NAV_GROUPS.observability, navigateAppId: OBSERVABILITY_OVERVIEW_PAGE_ID }, + { ...DEFAULT_NAV_GROUPS.search, navigateAppId: SEARCH_OVERVIEW_PAGE_ID }, + { + ...DEFAULT_NAV_GROUPS['security-analytics'], + navigateAppId: SECURITY_ANALYTICS_OVERVIEW_PAGE_ID, + }, + ]; + + useCases.forEach((useCase, index) => { + registerUseCaseCard(contentManagement, core, { + id: useCase.id, + order: index + 1, + description: useCase.description, + title: useCase.title, + target: HOME_CONTENT_AREAS.GET_STARTED, + icon: useCase.icon ?? '', + navigateAppId: useCase.navigateAppId, + }); + }); + } + registerHomeListCard(contentManagement, { id: 'whats_new', - order: 3, + order: 10, config: WHATS_NEW_CONFIG, target: HOME_CONTENT_AREAS.SERVICE_CARDS, width: 16, @@ -68,7 +99,7 @@ export const initHome = (contentManagement: ContentManagementPluginStart, core: registerHomeListCard(contentManagement, { id: 'learn_opensearch_new', - order: 4, + order: 11, config: LEARN_OPENSEARCH_CONFIG, target: HOME_CONTENT_AREAS.SERVICE_CARDS, width: 16, diff --git a/src/plugins/inspector/common/adapters/request/request_responder.ts b/src/plugins/inspector/common/adapters/request/request_responder.ts index a65d1b9bc9cd..539257ffaaeb 100644 --- a/src/plugins/inspector/common/adapters/request/request_responder.ts +++ b/src/plugins/inspector/common/adapters/request/request_responder.ts @@ -86,4 +86,8 @@ export class RequestResponder { public error(response: Response): void { this.finish(RequestStatus.ERROR, response); } + + public getTime() { + return this.request.time; + } } diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index e1d93a560e53..7eb32c2a4a6b 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -35,6 +35,7 @@ import React, { ReactElement, useRef } from 'react'; import { MountPoint } from '../../../../core/public'; import { DataPublicPluginStart, + QueryStatus, SearchBarProps, StatefulSearchBarProps, } from '../../../data/public'; @@ -82,6 +83,7 @@ export type TopNavMenuProps = Omit & * ``` */ setMenuMountPoint?: (menuMount: MountPoint | undefined) => void; + queryStatus?: QueryStatus; }; /* @@ -157,6 +159,7 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { {...searchBarProps} showDatePicker={![TopNavMenuItemRenderType.OMITTED, false].includes(showDatePicker!)} {...overrides} + queryStatus={props.queryStatus} /> ); } diff --git a/src/plugins/query_enhancements/common/utils.ts b/src/plugins/query_enhancements/common/utils.ts index 597bf3124496..7388d6beea40 100644 --- a/src/plugins/query_enhancements/common/utils.ts +++ b/src/plugins/query_enhancements/common/utils.ts @@ -5,7 +5,7 @@ import { Query } from 'src/plugins/data/common'; import { from, throwError, timer } from 'rxjs'; -import { filter, mergeMap, take, takeWhile, tap } from 'rxjs/operators'; +import { filter, mergeMap, take, takeWhile } from 'rxjs/operators'; import { EnhancedFetchContext, QueryAggConfig, @@ -42,16 +42,9 @@ export const removeKeyword = (queryString: string | undefined) => { }; export const handleFacetError = (response: any) => { - const error = new Error(response.data); - error.name = response.status; - return throwError(error); -}; - -export const handleFetchError = (response: any) => { - if (response.body.error) { - const error = new Error(response.body.error.response); - return throwError(error); - } + const error = new Error(response.data.body ?? response.data); + error.name = response.data.status ?? response.status; + throw error; }; export const fetch = (context: EnhancedFetchContext, query: Query, aggConfig?: QueryAggConfig) => { @@ -64,7 +57,7 @@ export const fetch = (context: EnhancedFetchContext, query: Query, aggConfig?: Q body, signal, }) - ).pipe(tap(handleFetchError)); + ); }; export const handleQueryStatus = (options: QueryStatusOptions): Promise => { diff --git a/src/plugins/query_enhancements/public/search/ppl_search_interceptor.ts b/src/plugins/query_enhancements/public/search/ppl_search_interceptor.ts index 4618945a35f7..c8a5165cb565 100644 --- a/src/plugins/query_enhancements/public/search/ppl_search_interceptor.ts +++ b/src/plugins/query_enhancements/public/search/ppl_search_interceptor.ts @@ -4,8 +4,7 @@ */ import { trimEnd } from 'lodash'; -import { Observable, throwError } from 'rxjs'; -import { catchError } from 'rxjs/operators'; +import { Observable } from 'rxjs'; import { formatTimePickerDate, Query } from '../../../data/common'; import { DataPublicPluginStart, @@ -52,11 +51,7 @@ export class PPLSearchInterceptor extends SearchInterceptor { const query = this.buildQuery(); - return fetch(context, query, this.getAggConfig(searchRequest, query)).pipe( - catchError((error) => { - return throwError(error); - }) - ); + return fetch(context, query, this.getAggConfig(searchRequest, query)); } public search(request: IOpenSearchDashboardsSearchRequest, options: ISearchOptions) { diff --git a/src/plugins/query_enhancements/server/routes/index.ts b/src/plugins/query_enhancements/server/routes/index.ts index a3673946114d..50bfa30f70ee 100644 --- a/src/plugins/query_enhancements/server/routes/index.ts +++ b/src/plugins/query_enhancements/server/routes/index.ts @@ -85,10 +85,9 @@ function defineRoute( ); return res.ok({ body: { ...queryRes } }); } catch (err) { - logger.error(err); return res.custom({ - statusCode: 500, - body: err, + statusCode: err.name, + body: err.message, }); } } diff --git a/src/plugins/query_enhancements/server/search/sql_search_strategy.test.ts b/src/plugins/query_enhancements/server/search/sql_search_strategy.test.ts index 2df810f7d4b5..234e972e050d 100644 --- a/src/plugins/query_enhancements/server/search/sql_search_strategy.test.ts +++ b/src/plugins/query_enhancements/server/search/sql_search_strategy.test.ts @@ -113,19 +113,15 @@ describe('sqlSearchStrategyProvider', () => { jest.spyOn(facet, 'Facet').mockImplementation(() => mockFacet); const strategy = sqlSearchStrategyProvider(config$, logger, client, usage); - const result = await strategy.search( - emptyRequestHandlerContext, - ({ - body: { query: { query: 'SELECT * FROM table' } }, - } as unknown) as IOpenSearchDashboardsSearchRequest, - {} - ); - - expect(result).toEqual({ - type: DATA_FRAME_TYPES.ERROR, - body: { error: { cause: 'Query failed' } }, - took: 50, - } as IDataFrameError); + await expect( + strategy.search( + emptyRequestHandlerContext, + ({ + body: { query: { query: 'SELECT * FROM table' } }, + } as unknown) as IOpenSearchDashboardsSearchRequest, + {} + ) + ).rejects.toThrow(); }); it('should handle exceptions', async () => { diff --git a/src/plugins/query_enhancements/server/search/sql_search_strategy.ts b/src/plugins/query_enhancements/server/search/sql_search_strategy.ts index ffc7f0a6f0cd..31b03941af24 100644 --- a/src/plugins/query_enhancements/server/search/sql_search_strategy.ts +++ b/src/plugins/query_enhancements/server/search/sql_search_strategy.ts @@ -8,7 +8,6 @@ import { Observable } from 'rxjs'; import { ISearchStrategy, SearchUsage } from '../../../data/server'; import { DATA_FRAME_TYPES, - IDataFrameError, IDataFrameResponse, IOpenSearchDashboardsSearchRequest, Query, @@ -38,11 +37,9 @@ export const sqlSearchStrategyProvider = ( const rawResponse: any = await sqlFacet.describeQuery(context, request); if (!rawResponse.success) { - return { - type: DATA_FRAME_TYPES.ERROR, - body: { error: rawResponse.data }, - took: rawResponse.took, - } as IDataFrameError; + const error = new Error(rawResponse.data.body); + error.name = rawResponse.data.status; + throw error; } const dataFrame = createDataFrame({ diff --git a/src/plugins/query_enhancements/server/utils/facet.test.ts b/src/plugins/query_enhancements/server/utils/facet.test.ts new file mode 100644 index 000000000000..20ae78612c11 --- /dev/null +++ b/src/plugins/query_enhancements/server/utils/facet.test.ts @@ -0,0 +1,119 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Logger } from 'opensearch-dashboards/server'; +import { Facet, FacetProps } from './facet'; + +describe('Facet', () => { + let facet: Facet; + let mockClient: jest.Mock; + let mockLogger: jest.Mocked; + let mockContext: any; + let mockRequest: any; + + beforeEach(() => { + mockClient = jest.fn(); + mockLogger = ({ + error: jest.fn(), + } as unknown) as jest.Mocked; + + const props: FacetProps = { + client: { asScoped: jest.fn().mockReturnValue({ callAsCurrentUser: mockClient }) }, + logger: mockLogger, + endpoint: 'test-endpoint', + }; + + facet = new Facet(props); + + mockContext = { + dataSource: { + opensearch: { + legacy: { + getClient: jest.fn().mockReturnValue({ callAPI: mockClient }), + }, + }, + }, + }; + + mockRequest = { + body: { + query: { + query: 'test query', + dataset: { + dataSource: { + id: 'test-id', + meta: { + name: 'test-name', + sessionId: 'test-session', + }, + }, + }, + }, + format: 'jdbc', + lang: 'sql', + }, + }; + }); + + describe('describeQuery', () => { + it('should handle request with complete dataset information', async () => { + mockClient.mockResolvedValue({ result: 'success' }); + + const result = await facet.describeQuery(mockContext, mockRequest); + + expect(result).toEqual({ success: true, data: { result: 'success' } }); + expect(mockClient).toHaveBeenCalledWith('test-endpoint', { + body: { + query: 'test query', + datasource: 'test-name', + sessionId: 'test-session', + lang: 'sql', + }, + }); + }); + + it('should handle request with missing dataSource', async () => { + mockRequest.body.query.dataset.dataSource = undefined; + mockClient.mockResolvedValue({ result: 'success' }); + + const result = await facet.describeQuery(mockContext, mockRequest); + + expect(result).toEqual({ success: true, data: { result: 'success' } }); + expect(mockClient).toHaveBeenCalledWith('test-endpoint', { + body: { + query: 'test query', + lang: 'sql', + }, + }); + }); + + it('should handle request with missing dataset', async () => { + mockRequest.body.query.dataset = undefined; + mockClient.mockResolvedValue({ result: 'success' }); + + const result = await facet.describeQuery(mockContext, mockRequest); + + expect(result).toEqual({ success: true, data: { result: 'success' } }); + expect(mockClient).toHaveBeenCalledWith('test-endpoint', { + body: { + query: 'test query', + lang: 'sql', + }, + }); + }); + + it('should handle errors', async () => { + const error = new Error('Test error'); + mockClient.mockRejectedValue(error); + + const result = await facet.describeQuery(mockContext, mockRequest); + + expect(result).toEqual({ success: false, data: error }); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Facet fetch: test-endpoint: Error: Test error' + ); + }); + }); +}); diff --git a/src/plugins/query_enhancements/server/utils/facet.ts b/src/plugins/query_enhancements/server/utils/facet.ts index f86a07b5432c..0b6dd52407cd 100644 --- a/src/plugins/query_enhancements/server/utils/facet.ts +++ b/src/plugins/query_enhancements/server/utils/facet.ts @@ -38,8 +38,8 @@ export class Facet { ): Promise => { try { const query: Query = request.body.query; - const { dataSource } = query.dataset!; - const { meta } = dataSource!; + const dataSource = query.dataset?.dataSource; + const meta = dataSource?.meta; const { format, lang } = request.body; const params = { body: { diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap index 799535aec1c7..c3ba3dfea905 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap @@ -1042,6 +1042,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` onExportAll={[Function]} onImport={[Function]} onRefresh={[Function]} + showImportButton={true} /> + + + + + + +`; + exports[`Header should render normally 1`] = ` null, TopNavMenu: () => null }, applications: applicationServiceMock.createStartContract(), + showImportButton: true, }; describe('Header', () => { @@ -92,4 +93,48 @@ describe('Header - workspace enabled', () => { expect(component.find('EuiButtonEmpty[data-test-subj="duplicateObjects"]').exists()).toBe(true); }); + + it('should render `Import` button inside a workspace', () => { + const props = { + ...defaultProps, + showImportButton: true, + }; + + const component = shallow(
); + + expect(component.find('EuiButtonEmpty[data-test-subj="importObjects"]').exists()).toBe(true); + + const newUxProps = { + ...defaultProps, + showImportButton: true, + useUpdatedUX: true, + }; + + const newUxComponent = shallow(
); + + expect(newUxComponent).toMatchSnapshot(); + }); + + it('should not render `Import` button outside a workspace', () => { + const props = { + ...defaultProps, + showImportButton: false, + }; + + const component = shallow(
); + + expect(component.find('EuiButtonEmpty[data-test-subj="importObjects"]').exists()).toBe(false); + + const newUxProps = { + ...defaultProps, + showImportButton: true, + useUpdatedUX: false, + }; + + const newUxComponent = shallow(
); + + expect(newUxComponent.find('EuiButtonEmpty[data-test-subj="importObjects"]').exists()).toBe( + true + ); + }); }); 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 911db61be704..9ac49f007057 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 @@ -56,6 +56,7 @@ export const Header = ({ navigationUI: { HeaderControl }, applications, currentWorkspaceName, + showImportButton, }: { onExportAll: () => void; onImport: () => void; @@ -67,6 +68,7 @@ export const Header = ({ navigationUI: NavigationPublicPluginStart['ui']; applications: ApplicationStart; currentWorkspaceName: string; + showImportButton: boolean; }) => { const title = useUpdatedUX ? null : ( @@ -143,15 +145,22 @@ export const Header = ({ defaultMessage: 'Export all objects', }), } as TopNavControlButtonData, - { - testId: 'importObjects', - run: onImport, - controlType: 'button', - iconType: 'importAction', - label: i18n.translate('savedObjectsManagement.objectsTable.header.importButtonLabel', { - defaultMessage: 'Import', - }), - } as TopNavControlButtonData, + ...(showImportButton + ? [ + { + testId: 'importObjects', + run: onImport, + controlType: 'button', + iconType: 'importAction', + label: i18n.translate( + 'savedObjectsManagement.objectsTable.header.importButtonLabel', + { + defaultMessage: 'Import', + } + ), + } as TopNavControlButtonData, + ] + : []), ]} setMountPoint={applications.setAppRightControls} /> @@ -187,19 +196,21 @@ export const Header = ({ /> - - - - - + {showImportButton && ( + + + + + + )} {!useUpdatedUX && } diff --git a/src/plugins/saved_objects_management/public/management_section/recent_work.tsx b/src/plugins/saved_objects_management/public/management_section/recent_work.tsx index eb04652d6343..88cdfc142af4 100644 --- a/src/plugins/saved_objects_management/public/management_section/recent_work.tsx +++ b/src/plugins/saved_objects_management/public/management_section/recent_work.tsx @@ -257,38 +257,45 @@ export const RecentWork = (props: { core: CoreStart; workspaceEnabled?: boolean textAlign="left" href={recentNavLink.href} footer={ - <> - - - - {selectedSort === recentlyViewed - ? i18n.translate( - 'savedObjectsManagement.recentWorkSection.viewedAt', - { - defaultMessage: 'Viewed', - } - ) - : i18n.translate( - 'savedObjectsManagement.recentWorkSection.updatedAt', - { - defaultMessage: 'Updated', - } - )} - :{' '} - - - - - + + + + + {selectedSort === recentlyViewed - ? moment(recentAccessItem?.lastAccessedTime).fromNow() - : moment(recentAccessItem?.updatedAt).fromNow()} - - - - - {workspaceEnabled && ( - <> + ? i18n.translate( + 'savedObjectsManagement.recentWorkSection.viewedAt', + { + defaultMessage: 'Viewed', + } + ) + : i18n.translate( + 'savedObjectsManagement.recentWorkSection.updatedAt', + { + defaultMessage: 'Updated', + } + )} + :{' '} + + + + + + {selectedSort === recentlyViewed + ? moment(recentAccessItem?.lastAccessedTime).fromNow() + : moment(recentAccessItem?.updatedAt).fromNow()} + + + + + + {workspaceEnabled && ( + + {i18n.translate( @@ -300,15 +307,15 @@ export const RecentWork = (props: { core: CoreStart; workspaceEnabled?: boolean : - + {recentAccessItem.workspaceName || 'N/A'} - - )} - - + + + )} + } onClick={recentNavLink.onClick} /> diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index f66f7ea71752..22c746c0dbed 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -45,6 +45,7 @@ export const PRIORITY_FOR_PERMISSION_CONTROL_WRAPPER = 0; * store a static map in workspace. * */ + export const WORKSPACE_USE_CASES = Object.freeze({ observability: { id: 'observability', @@ -52,9 +53,9 @@ export const WORKSPACE_USE_CASES = Object.freeze({ defaultMessage: 'Observability', }), description: i18n.translate('workspace.usecase.observability.description', { - defaultMessage: - 'Gain visibility into system health, performance, and reliability through monitoring and analysis of logs, metrics, and traces.', + defaultMessage: 'Gain visibility into your application and infrastructure', }), + icon: 'wsObservability', features: [ 'discover', 'dashboards', @@ -78,9 +79,9 @@ export const WORKSPACE_USE_CASES = Object.freeze({ defaultMessage: 'Security Analytics', }), description: i18n.translate('workspace.usecase.analytics.description', { - defaultMessage: - 'Detect and investigate potential security threats and vulnerabilities across your systems and data.', + defaultMessage: 'Enhance your security posture with advanced analytics', }), + icon: 'wsSecurityAnalytics', features: [ 'discover', 'dashboards', @@ -102,9 +103,9 @@ export const WORKSPACE_USE_CASES = Object.freeze({ defaultMessage: 'Essentials', }), description: i18n.translate('workspace.usecase.essentials.description', { - defaultMessage: - 'Analyze data to derive insights, identify patterns and trends, and make data-driven decisions.', + defaultMessage: 'Get start with just the basics', }), + icon: 'wsEssentials', features: [ 'discover', 'dashboards', @@ -125,9 +126,9 @@ export const WORKSPACE_USE_CASES = Object.freeze({ defaultMessage: 'Search', }), description: i18n.translate('workspace.usecase.search.description', { - defaultMessage: - "Quickly find and explore relevant information across your organization's data sources.", + defaultMessage: 'Discover and query your data with ease', }), + icon: 'wsSearch', features: [ 'discover', 'dashboards', diff --git a/src/plugins/workspace/public/components/use_case_overview/setup_overview.test.tsx b/src/plugins/workspace/public/components/use_case_overview/setup_overview.test.tsx index cdcebca33e19..e1f47c9756d1 100644 --- a/src/plugins/workspace/public/components/use_case_overview/setup_overview.test.tsx +++ b/src/plugins/workspace/public/components/use_case_overview/setup_overview.test.tsx @@ -137,7 +137,7 @@ describe('Setup use case overview', () => { "cardProps": Object { "layout": "horizontal", }, - "description": "Gain visibility into system health, performance, and reliability through monitoring and analysis of logs, metrics, and traces.", + "description": "Gain visibility into your application and infrastructure", "getIcon": [Function], "id": "observability", "kind": "card", diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx index 5ca958ced23e..4efbc30680ce 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx @@ -15,6 +15,9 @@ import { WorkspaceCreator as WorkspaceCreatorComponent, WorkspaceCreatorProps, } from './workspace_creator'; +import { DataSourceEngineType } from '../../../../data_source/common/data_sources'; +import { DataSourceConnectionType } from '../../../common/types'; +import * as utils from '../../utils'; const workspaceClientCreate = jest .fn() @@ -32,21 +35,50 @@ const dataSourcesList = [ { id: 'id1', title: 'ds1', + description: 'Description of data source 1', + auth: '', + dataSourceEngineType: '' as DataSourceEngineType, + workspaces: [], // This is used for mocking saved object function get: () => { return 'ds1'; }, }, { - id: '2', + id: 'id2', title: 'ds2', + description: 'Description of data source 1', + auth: '', + dataSourceEngineType: '' as DataSourceEngineType, + workspaces: [], get: () => { return 'ds2'; }, }, ]; +const dataSourceConnectionsList = [ + { + id: 'id1', + name: 'ds1', + connectionType: DataSourceConnectionType.OpenSearchConnection, + type: 'OpenSearch', + relatedConnections: [], + }, + { + id: 'id2', + name: 'ds2', + connectionType: DataSourceConnectionType.OpenSearchConnection, + type: 'OpenSearch', + }, +]; + const mockCoreStart = coreMock.createStart(); +jest.spyOn(utils, 'fetchDataSourceConnections').mockImplementation(async (passedDataSources) => { + return dataSourceConnectionsList.filter(({ id }) => + passedDataSources.some((dataSource) => dataSource.id === id) + ); +}); const WorkspaceCreator = ({ isDashboardAdmin = false, @@ -304,7 +336,15 @@ describe('WorkspaceCreator', () => { }); it('create workspace with customized selected dataSources', async () => { - const { getByTestId, getByTitle, getByText } = render( + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 600, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + value: 600, + }); + const { getByTestId, getAllByText, getByText } = render( ); @@ -317,10 +357,17 @@ describe('WorkspaceCreator', () => { target: { value: 'test workspace name' }, }); fireEvent.click(getByTestId('workspaceUseCase-observability')); - fireEvent.click(getByTestId('workspaceForm-select-dataSource-addNew')); - fireEvent.click(getByTestId('workspaceForm-select-dataSource-comboBox')); - fireEvent.click(getByText('Select')); - fireEvent.click(getByTitle(dataSourcesList[0].title)); + fireEvent.click(getByTestId('workspace-creator-dataSources-assign-button')); + await waitFor(() => { + expect( + getByText( + 'Add data sources that will be available in the workspace. If a selected data source has related Direct Query connection, they will also be available in the workspace.' + ) + ).toBeInTheDocument(); + expect(getByText(dataSourcesList[0].title)).toBeInTheDocument(); + }); + fireEvent.click(getByText(dataSourcesList[0].title)); + fireEvent.click(getAllByText('Associate data sources')[1]); fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); expect(workspaceClientCreate).toHaveBeenCalledWith( 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 b8f32eb4a33e..ed4370a7b3f5 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -15,12 +15,12 @@ import { getUseCaseFeatureConfig } from '../../../common/utils'; import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; import { WorkspaceClient } from '../../workspace_client'; import { convertPermissionSettingsToPermissions } from '../workspace_form'; -import { DataSource } from '../../../common/types'; import { DataSourceManagementPluginSetup } from '../../../../../plugins/data_source_management/public'; import { WorkspaceUseCase } from '../../types'; import { getFirstUseCaseOfFeatureConfigs } from '../../utils'; import { useFormAvailableUseCases } from '../workspace_form/use_form_available_use_cases'; import { NavigationPublicPluginStart } from '../../../../../plugins/navigation/public'; +import { DataSourceConnectionType } from '../../../common/types'; import { WorkspaceCreatorForm } from './workspace_creator_form'; export interface WorkspaceCreatorProps { @@ -72,10 +72,14 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { } setIsFormSubmitting(true); try { - const { permissionSettings, selectedDataSources, ...attributes } = data; - const selectedDataSourceIds = (selectedDataSources ?? []).map((ds: DataSource) => { - return ds.id; - }); + const { permissionSettings, selectedDataSourceConnections, ...attributes } = data; + const selectedDataSourceIds = (selectedDataSourceConnections ?? []) + .filter( + ({ connectionType }) => connectionType === DataSourceConnectionType.OpenSearchConnection + ) + .map(({ id }) => { + return id; + }); result = await workspaceClient.create(attributes, { dataSources: selectedDataSourceIds, permissions: convertPermissionSettingsToPermissions(permissionSettings), diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator_form.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator_form.tsx index 9ab0a35e722b..4a99fc006524 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator_form.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator_form.tsx @@ -58,7 +58,7 @@ export const WorkspaceCreatorForm = (props: WorkspaceCreatorFormProps) => { handleColorChange, handleUseCaseChange: handleUseCaseChangeInHook, setPermissionSettings, - setSelectedDataSources, + setSelectedDataSourceConnections, } = useWorkspaceForm(props); const nameManualChangedRef = useRef(false); @@ -86,7 +86,7 @@ export const WorkspaceCreatorForm = (props: WorkspaceCreatorFormProps) => { return ( - + { })} diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_form_summary_panel.test.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_form_summary_panel.test.tsx index 9ce350af85e2..cfe9e5833631 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_form_summary_panel.test.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_form_summary_panel.test.tsx @@ -20,10 +20,10 @@ describe('WorkspaceFormSummaryPanel', () => { name: 'Test Workspace', description: 'This is a test workspace', color: '#000000', - selectedDataSources: [ - { id: 'data-source-1', title: 'Data Source 1' }, - { id: 'data-source-2', title: 'Data Source 2' }, - { id: 'data-source-3', title: 'Data Source 3' }, + selectedDataSourceConnections: [ + { id: 'data-source-1', name: 'Data Source 1' }, + { id: 'data-source-2', name: 'Data Source 2' }, + { id: 'data-source-3', name: 'Data Source 3' }, ], permissionSettings: [ { id: 1, type: WorkspacePermissionItemType.User, userId: 'user1' }, @@ -74,7 +74,7 @@ describe('WorkspaceFormSummaryPanel', () => { - {formData.selectedDataSources.length > 0 && ( + {formData.selectedDataSourceConnections.length > 0 && ( title)} + texts={formData.selectedDataSourceConnections.map(({ name }) => name)} collapseDisplayCount={2} /> )} diff --git a/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.test.tsx b/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.test.tsx index 1329bef04beb..1c15eac21193 100644 --- a/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.test.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.test.tsx @@ -8,10 +8,13 @@ import React from 'react'; import { coreMock } from '../../../../../core/public/mocks'; import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; import { WorkspaceFormProvider, WorkspaceOperationType } from '../workspace_form'; -import { SelectDataSourceDetailPanel } from './select_data_source_panel'; +import { + SelectDataSourceDetailPanel, + SelectDataSourceDetailPanelProps, +} from './select_data_source_panel'; import * as utils from '../../utils'; import { IntlProvider } from 'react-intl'; -import { DataSourceConnectionType } from '../../../common/types'; +import { DataSourceConnection, DataSourceConnectionType } from '../../../common/types'; const mockCoreStart = coreMock.createStart(); @@ -81,8 +84,7 @@ const defaultValues = { }; const defaultProps = { - savedObjects: {}, - assignedDataSources: [], + savedObjects: mockCoreStart.savedObjects, detailTitle: 'Data sources', isDashboardAdmin: true, currentWorkspace: workspaceObject, @@ -96,7 +98,14 @@ const success = jest.fn().mockResolvedValue({ }); const failed = jest.fn().mockResolvedValue({}); -const selectDataSourceDetailPanel = (props: any) => { +const selectDataSourceDetailPanel = ({ + action, + selectedDataSourceConnections, + ...props +}: { + action?: Function; + selectedDataSourceConnections?: DataSourceConnection[]; +} & Partial) => { const { Provider } = createOpenSearchDashboardsReactContext({ ...mockCoreStart, ...{ @@ -109,7 +118,7 @@ const selectDataSourceDetailPanel = (props: any) => { }, }, workspaceClient: { - update: props.action, + update: action, }, }, }); @@ -122,11 +131,16 @@ const selectDataSourceDetailPanel = (props: any) => { operationType={WorkspaceOperationType.Update} permissionEnabled={true} onSubmit={jest.fn()} - defaultValues={defaultValues} + defaultValues={{ ...defaultValues, selectedDataSourceConnections }} availableUseCases={[]} > - + @@ -134,12 +148,11 @@ const selectDataSourceDetailPanel = (props: any) => { }; describe('SelectDataSourceDetailPanel', () => { - afterEach(() => { + beforeEach(() => { jest.clearAllMocks(); }); it('should show message when no data sources are assigned', async () => { - jest.spyOn(utils, 'fetchDataSourceConnections').mockResolvedValue([]); const { getByText, getAllByText } = render(selectDataSourceDetailPanel(defaultProps)); await waitFor(() => { expect(getByText('No data sources to display')).toBeInTheDocument(); @@ -151,7 +164,6 @@ describe('SelectDataSourceDetailPanel', () => { }); it('should not show assocition button when user is not OSD admin', async () => { - jest.spyOn(utils, 'fetchDataSourceConnections').mockResolvedValue([]); const { getByText, queryByText } = render( selectDataSourceDetailPanel({ ...defaultProps, @@ -167,11 +179,9 @@ describe('SelectDataSourceDetailPanel', () => { }); it('should not show remove associations button when user is not OSD admin', async () => { - jest.spyOn(utils, 'fetchDataSourceConnections').mockResolvedValue(dataSourceConnectionsMock); const { queryByTestId } = render( selectDataSourceDetailPanel({ ...defaultProps, - assignedDataSources: dataSources, isDashboardAdmin: false, }) ); @@ -181,7 +191,6 @@ describe('SelectDataSourceDetailPanel', () => { }); it('should switch toggle button', async () => { - jest.spyOn(utils, 'fetchDataSourceConnections').mockResolvedValue(dataSourceConnectionsMock); const { getByText } = render(selectDataSourceDetailPanel(defaultProps)); await waitFor(() => { const dqcButton = getByText('Direct query connections'); @@ -200,7 +209,6 @@ describe('SelectDataSourceDetailPanel', () => { value: 600, }); jest.spyOn(utils, 'getDataSourcesList').mockResolvedValue([]); - jest.spyOn(utils, 'fetchDataSourceConnections').mockResolvedValueOnce([]); jest .spyOn(utils, 'fetchDataSourceConnections') .mockResolvedValueOnce(dataSourceConnectionsMock); @@ -245,7 +253,6 @@ describe('SelectDataSourceDetailPanel', () => { value: 600, }); jest.spyOn(utils, 'getDataSourcesList').mockResolvedValue([]); - jest.spyOn(utils, 'fetchDataSourceConnections').mockResolvedValueOnce([]); jest .spyOn(utils, 'fetchDataSourceConnections') .mockResolvedValueOnce(dataSourceConnectionsMock); @@ -317,13 +324,10 @@ describe('SelectDataSourceDetailPanel', () => { }); it('should success to remove data sources', async () => { - jest - .spyOn(utils, 'fetchDataSourceConnections') - .mockResolvedValueOnce([dataSourceConnectionsMock[0]]); const { getByText, getByTestId, getByRole } = render( selectDataSourceDetailPanel({ ...defaultProps, - assignedDataSources: [dataSources[0]], + selectedDataSourceConnections: [dataSourceConnectionsMock[0]], action: success, }) ); @@ -341,13 +345,10 @@ describe('SelectDataSourceDetailPanel', () => { }); it('should fail to remove data sources', async () => { - jest - .spyOn(utils, 'fetchDataSourceConnections') - .mockResolvedValueOnce([dataSourceConnectionsMock[0]]); const { getByText, getByTestId, getByRole } = render( selectDataSourceDetailPanel({ ...defaultProps, - assignedDataSources: [dataSources[0]], + selectedDataSourceConnections: [dataSourceConnectionsMock[0]], action: failed, }) ); @@ -365,13 +366,10 @@ describe('SelectDataSourceDetailPanel', () => { }); it('should remove selected data sources successfully', async () => { - jest - .spyOn(utils, 'fetchDataSourceConnections') - .mockResolvedValueOnce([dataSourceConnectionsMock[0]]); const { getByText, queryByTestId, getAllByRole, getByRole } = render( selectDataSourceDetailPanel({ ...defaultProps, - assignedDataSources: [dataSources[0]], + selectedDataSourceConnections: [dataSourceConnectionsMock[0]], action: success, }) ); @@ -392,22 +390,38 @@ describe('SelectDataSourceDetailPanel', () => { }); it('should handle input in the search box', async () => { - jest.spyOn(utils, 'fetchDataSourceConnections').mockResolvedValue(dataSourceConnectionsMock); const { getByText, queryByText } = render( selectDataSourceDetailPanel({ ...defaultProps, - assignedDataSources: dataSources, + selectedDataSourceConnections: dataSourceConnectionsMock, }) ); await waitFor(() => { expect(getByText('Data Source 1')).toBeInTheDocument(); expect(getByText('Data Source 2')).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText('Search...'); + // Simulate typing in the search input + fireEvent.change(searchInput, { target: { value: 'Data Source 1' } }); - const searchInput = screen.getByPlaceholderText('Search...'); - // Simulate typing in the search input - fireEvent.change(searchInput, { target: { value: 'Data Source 1' } }); + await waitFor(() => { expect(getByText('Data Source 1')).toBeInTheDocument(); expect(queryByText('Data Source 2')).toBeNull(); }); }); + + it('should show loading message when loading', async () => { + const { queryByText, getByText, rerender } = render( + selectDataSourceDetailPanel({ loading: false }) + ); + await waitFor(() => { + expect(queryByText('Loading data sources...')).not.toBeInTheDocument(); + }); + + rerender(selectDataSourceDetailPanel({ loading: true })); + await waitFor(() => { + expect(getByText('Loading data sources...')).toBeInTheDocument(); + }); + }); }); diff --git a/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.tsx b/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.tsx index 1f13da230f9e..7a503265983d 100644 --- a/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { EuiText, EuiTitle, @@ -21,9 +21,10 @@ import { } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from 'react-intl'; -import { DataSource, DataSourceConnection, DataSourceConnectionType } from '../../../common/types'; +import { useUpdateEffect } from 'react-use'; +import { DataSourceConnection, DataSourceConnectionType } from '../../../common/types'; import { WorkspaceClient } from '../../workspace_client'; -import { DataSourceConnectionTable } from './data_source_connection_table'; +import { WorkspaceDetailConnectionTable } from './workspace_detail_connection_table'; import { AssociationDataSourceModal } from './association_data_source_modal'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { @@ -32,8 +33,11 @@ import { WorkspaceObject, ChromeStart, } from '../../../../../core/public'; -import { convertPermissionSettingsToPermissions, useWorkspaceFormContext } from '../workspace_form'; -import { fetchDataSourceConnections } from '../../utils'; +import { + convertPermissionSettingsToPermissions, + isWorkspacePermissionSetting, + useWorkspaceFormContext, +} from '../workspace_form'; import { AssociationDataSourceModalMode } from '../../../common/constants'; const toggleButtons: EuiButtonGroupOptionProps[] = [ @@ -50,66 +54,59 @@ const toggleButtons: EuiButtonGroupOptionProps[] = [ }), }, ]; -export interface SelectDataSourcePanelProps { +export interface SelectDataSourceDetailPanelProps { savedObjects: SavedObjectsStart; - assignedDataSources: DataSource[]; detailTitle: string; isDashboardAdmin: boolean; currentWorkspace: WorkspaceObject; chrome: ChromeStart; + loading?: boolean; } export const SelectDataSourceDetailPanel = ({ - assignedDataSources, savedObjects, detailTitle, isDashboardAdmin, currentWorkspace, chrome, -}: SelectDataSourcePanelProps) => { + loading = false, +}: SelectDataSourceDetailPanelProps) => { const { services: { notifications, workspaceClient, http }, } = useOpenSearchDashboards<{ CoreStart: CoreStart; workspaceClient: WorkspaceClient }>(); - const { formData, setSelectedDataSources } = useWorkspaceFormContext(); - const [isLoading, setIsLoading] = useState(false); + const { formData, setSelectedDataSourceConnections } = useWorkspaceFormContext(); + const [isLoading, setIsLoading] = useState(loading); const [isVisible, setIsVisible] = useState(false); - const [assignedDataSourceConnections, setAssignedDataSourceConnections] = useState< - DataSourceConnection[] - >([]); const [toggleIdSelected, setToggleIdSelected] = useState(toggleButtons[0].id); - useEffect(() => { - setIsLoading(true); - fetchDataSourceConnections(assignedDataSources, http, notifications).then((connections) => { - setAssignedDataSourceConnections(connections); - setIsLoading(false); - }); - }, [assignedDataSources, http, notifications]); - const handleAssignDataSourceConnections = async ( - dataSourceConnections: DataSourceConnection[] + newAssignedDataSourceConnections: DataSourceConnection[] ) => { - const dataSources = dataSourceConnections - .filter( - ({ connectionType }) => connectionType === DataSourceConnectionType.OpenSearchConnection - ) - .map(({ id, type, name, description }) => ({ - id, - title: name, - description, - dataSourceEngineType: type, - })); try { setIsLoading(true); setIsVisible(false); - const { permissionSettings, selectedDataSources, useCase, ...attributes } = formData; - const savedDataSources: DataSource[] = [...selectedDataSources, ...dataSources]; + const { + permissionSettings, + selectedDataSourceConnections, + useCase, + ...attributes + } = formData; + + const savedDataSourceConnections = [ + ...formData.selectedDataSourceConnections, + ...newAssignedDataSourceConnections, + ]; const result = await workspaceClient.update(currentWorkspace.id, attributes, { - dataSources: savedDataSources.map((ds) => { - return ds.id; - }), - permissions: convertPermissionSettingsToPermissions(permissionSettings), + dataSources: savedDataSourceConnections + .filter( + ({ connectionType }) => connectionType === DataSourceConnectionType.OpenSearchConnection + ) + .map(({ id }) => id), + // Todo: Make permissions be an optional parameter when update workspace + permissions: convertPermissionSettingsToPermissions( + permissionSettings.filter(isWorkspacePermissionSetting) + ), }); if (result?.success) { notifications?.toasts.addSuccess({ @@ -117,7 +114,7 @@ export const SelectDataSourceDetailPanel = ({ defaultMessage: 'Associate OpenSearch connections successfully', }), }); - setSelectedDataSources(savedDataSources); + setSelectedDataSourceConnections(savedDataSourceConnections); } else { throw new Error(result?.error ? result?.error : 'Associate OpenSearch connections failed'); } @@ -134,17 +131,30 @@ export const SelectDataSourceDetailPanel = ({ }; const handleUnassignDataSources = useCallback( - async (dataSources: DataSourceConnection[]) => { + async (unAssignedDataSources: DataSourceConnection[]) => { try { setIsLoading(true); - const { permissionSettings, selectedDataSources, useCase, ...attributes } = formData; - const savedDataSources = (selectedDataSources ?? [])?.filter( - ({ id }: DataSource) => !dataSources.some((item) => item.id === id) + const { + permissionSettings, + selectedDataSourceConnections, + useCase, + ...attributes + } = formData; + const savedDataSourceConnections = selectedDataSourceConnections.filter( + ({ id }) => !unAssignedDataSources.some((item) => item.id === id) ); const result = await workspaceClient.update(currentWorkspace.id, attributes, { - dataSources: savedDataSources.map(({ id }: DataSource) => id), - permissions: convertPermissionSettingsToPermissions(permissionSettings), + dataSources: savedDataSourceConnections + .filter( + ({ connectionType }) => + connectionType === DataSourceConnectionType.OpenSearchConnection + ) + .map(({ id }) => id), + // Todo: Make permissions be an optional parameter when update workspace + permissions: convertPermissionSettingsToPermissions( + permissionSettings.filter(isWorkspacePermissionSetting) + ), }); if (result?.success) { notifications?.toasts.addSuccess({ @@ -152,7 +162,7 @@ export const SelectDataSourceDetailPanel = ({ defaultMessage: 'Remove associated OpenSearch connections successfully', }), }); - setSelectedDataSources(savedDataSources); + setSelectedDataSourceConnections(savedDataSourceConnections); } else { throw new Error( result?.error ? result?.error : 'Remove associated OpenSearch connections failed' @@ -169,7 +179,13 @@ export const SelectDataSourceDetailPanel = ({ setIsLoading(false); } }, - [currentWorkspace.id, formData, notifications?.toasts, setSelectedDataSources, workspaceClient] + [ + currentWorkspace.id, + formData, + notifications?.toasts, + setSelectedDataSourceConnections, + workspaceClient, + ] ); const associationButton = ( @@ -194,7 +210,7 @@ export const SelectDataSourceDetailPanel = ({ @@ -240,19 +256,23 @@ export const SelectDataSourceDetailPanel = ({ if (isLoading) { return loadingMessage; } - if (assignedDataSources.length === 0) { + if (formData.selectedDataSourceConnections.length === 0) { return noAssociationMessage; } return ( - ); }; + useUpdateEffect(() => { + setIsLoading(loading); + }, [loading]); + return ( @@ -284,7 +304,7 @@ export const SelectDataSourceDetailPanel = ({ notifications={notifications} savedObjects={savedObjects} closeModal={() => setIsVisible(false)} - assignedConnections={assignedDataSourceConnections} + assignedConnections={formData.selectedDataSourceConnections} handleAssignDataSourceConnections={handleAssignDataSourceConnections} mode={toggleIdSelected as AssociationDataSourceModalMode} logos={chrome.logos} diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx index 6004e4de206b..dffc12da3bbc 100644 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx @@ -3,16 +3,18 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { BehaviorSubject } from 'rxjs'; +import { MemoryRouter } from 'react-router-dom'; import { PublicAppInfo, WorkspaceObject } from 'opensearch-dashboards/public'; import { coreMock } from '../../../../../core/public/mocks'; import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; import { createMockedRegisteredUseCases$ } from '../../mocks'; import { WorkspaceDetail } from './workspace_detail'; import { WorkspaceFormProvider, WorkspaceOperationType } from '../workspace_form'; -import { MemoryRouter } from 'react-router-dom'; +import { DataSourceConnectionType } from '../../../common/types'; +import * as utilsExports from '../../utils'; // all applications const PublicAPPInfoMap = new Map([ @@ -52,12 +54,13 @@ const defaultValues = { modes: ['library_write', 'write'], }, ], - selectedDataSources: [ + selectedDataSourceConnections: [ { id: 'ds-1', - title: 'ds-1-title', + name: 'ds-1-title', description: 'ds-1-description', - dataSourceEngineType: 'OpenSearch', + type: 'OpenSearch', + connectionType: DataSourceConnectionType.OpenSearchConnection, }, ], }; @@ -200,6 +203,9 @@ describe('WorkspaceDetail', () => { const { getByText } = render(WorkspaceDetailPage({ workspacesService: workspaceService })); fireEvent.click(getByText('Data sources')); expect(document.querySelector('#dataSources')).toHaveClass('euiTab-isSelected'); + await waitFor(() => { + expect(getByText('Loading data sources...')).toBeInTheDocument(); + }); }); it('delete button will been shown at page header', async () => { @@ -297,4 +303,34 @@ describe('WorkspaceDetail', () => { expect(alertSpy).toBeCalledTimes(0); alertSpy.mockRestore(); }); + + it('should show loaded data sources', async () => { + jest.spyOn(utilsExports, 'fetchDataSourceConnectionsByDataSourceIds').mockResolvedValue([ + { + id: 'dqc-1', + name: 'dqc-1-title', + description: 'dqc-1-description', + type: 'Amazon S3', + parentId: 'ds-1', + connectionType: DataSourceConnectionType.DirectQueryConnection, + }, + { + id: 'dqc-2', + name: 'dqc-1-title', + description: 'dqc-1-description', + type: 'Amazon S3', + parentId: 'ds-1', + connectionType: DataSourceConnectionType.DirectQueryConnection, + }, + ]); + const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); + const { getByText, getByRole } = render( + WorkspaceDetailPage({ workspacesService: workspaceService }) + ); + fireEvent.click(getByText('Data sources')); + await waitFor(() => { + expect(getByText('ds-1-title')).toBeInTheDocument(); + expect(getByRole('button', { name: '2' })).toBeInTheDocument(); + }); + }); }); diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx index 3543795f7e44..052a54dcc785 100644 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx @@ -24,7 +24,12 @@ import { WORKSPACE_LIST_APP_ID } from '../../../common/constants'; import { cleanWorkspaceId } from '../../../../../core/public/utils'; import { DetailTab, DetailTabTitles, WorkspaceOperationType } from '../workspace_form/constants'; import { CoreStart, WorkspaceAttribute } from '../../../../../core/public'; -import { getFirstUseCaseOfFeatureConfigs, getUseCaseUrl } from '../../utils'; +import { + fetchDataSourceConnectionsByDataSourceIds, + fulfillRelatedConnections, + getFirstUseCaseOfFeatureConfigs, + getUseCaseUrl, +} from '../../utils'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { DataSourceManagementPluginSetup } from '../../../../../plugins/data_source_management/public'; import { SelectDataSourceDetailPanel } from './select_data_source_panel'; @@ -50,6 +55,7 @@ export const WorkspaceDetail = (props: WorkspaceDetailProps) => { uiSettings, navigationUI: { HeaderControl }, chrome, + notifications, }, } = useOpenSearchDashboards<{ CoreStart: CoreStart; @@ -58,18 +64,20 @@ export const WorkspaceDetail = (props: WorkspaceDetailProps) => { }>(); const { - formData, isEditing, formId, numberOfErrors, handleResetForm, numberOfChanges, setIsEditing, + formData, + setSelectedDataSourceConnections, } = useWorkspaceFormContext(); const [deletedWorkspace, setDeletedWorkspace] = useState(null); const [selectedTabId, setSelectedTabId] = useState(DetailTab.Details); const [modalVisible, setModalVisible] = useState(false); const [tabId, setTabId] = useState(DetailTab.Details); + const [isDQCFilled, setIsDQCFilled] = useState(false); const availableUseCases = useObservable(props.registeredUseCases$, []); const isDashboardAdmin = !!application?.capabilities?.dashboards?.isDashboardAdmin; @@ -89,6 +97,31 @@ export const WorkspaceDetail = (props: WorkspaceDetailProps) => { } }, [location.search]); + useEffect(() => { + if (selectedTabId !== DetailTab.DataSources || isDQCFilled || !http || !notifications) { + return; + } + fetchDataSourceConnectionsByDataSourceIds( + formData.selectedDataSourceConnections.map(({ id }) => id), + http + ) + .then((directQueryConnections) => { + setSelectedDataSourceConnections( + fulfillRelatedConnections(formData.selectedDataSourceConnections, directQueryConnections) + ); + }) + .finally(() => { + setIsDQCFilled(true); + }); + }, [ + http, + isDQCFilled, + selectedTabId, + notifications, + setSelectedDataSourceConnections, + formData.selectedDataSourceConnections, + ]); + if (!currentWorkspace || !application || !http || !savedObjects || !uiSettings || !chrome) { return null; } @@ -141,8 +174,8 @@ export const WorkspaceDetail = (props: WorkspaceDetailProps) => { name: DetailTabTitles.dataSources, content: ( { setMountPoint={application.setAppDescriptionControls} /> )} - setDeletedWorkspace(currentWorkspace), - color: 'danger', - iconType: 'trash', - ariaLabel: i18n.translate('workspace.detail.delete.button', { - defaultMessage: 'Delete', - }), - testId: 'workspace-detail-delete-button', - controlType: 'icon', - display: 'base', - } as TopNavControlIconData, - ]} - setMountPoint={application.setAppRightControls} - /> + {isDashboardAdmin && ( + setDeletedWorkspace(currentWorkspace), + color: 'danger', + iconType: 'trash', + ariaLabel: i18n.translate('workspace.detail.delete.button', { + defaultMessage: 'Delete', + }), + testId: 'workspace-detail-delete-button', + controlType: 'icon', + display: 'base', + } as TopNavControlIconData, + ]} + setMountPoint={application.setAppRightControls} + /> + )} + ({ + ...jest.requireActual('../../../../opensearch_dashboards_react/public'), + useOpenSearchDashboards: jest.fn(), +})); const handleUnassignDataSources = jest.fn(); const dataSourceConnectionsMock = [ { @@ -57,14 +62,27 @@ const dataSourceConnectionsMock = [ }, ]; -describe('DataSourceConnectionTable', () => { +describe('WorkspaceDetailConnectionTable', () => { + beforeEach(() => { + const mockPrepend = jest.fn().mockImplementation((path) => path); + const mockHttp = { + basePath: { + prepend: mockPrepend, + }, + }; + (useOpenSearchDashboards as jest.Mock).mockImplementation(() => ({ + services: { + http: mockHttp, + }, + })); + }); afterEach(() => { jest.clearAllMocks(); }); describe('OpenSearch connections', () => { it('renders the table with OpenSearch connections', () => { const { getByText, queryByText } = render( - { it('should show dqc popover when click the Related connections number ', () => { const { getByText } = render( - { it('should remove selected OpenSearch connections by dashboard admin', () => { const { getByText, queryByTestId, getAllByRole, getByRole } = render( - { it('should remove single OpenSearch connections by dashboard admin', () => { const { queryAllByTestId, getByText, getByRole } = render( - { it('should hide remove action iif user is not dashboard admin', () => { const { queryByText, queryByTestId, getAllByRole } = render( - { describe('Direct query connections', () => { it('renders the table with Direct query connections', () => { const { getByText, queryByText, getByTestId } = render( - void; +} + +export const WorkspaceDetailConnectionTable = ({ + isDashboardAdmin, + connectionType, + dataSourceConnections, + handleUnassignDataSources, +}: WorkspaceDetailConnectionTableProps) => { + const [selectedItems, setSelectedItems] = useState([]); + const [modalVisible, setModalVisible] = useState(false); + + useEffect(() => { + // Reset selected items when connectionType changes + setSelectedItems([]); + }, [connectionType]); + + const openSearchConnections = useMemo(() => { + return dataSourceConnections.filter((dsc) => + connectionType === AssociationDataSourceModalMode.OpenSearchConnections + ? dsc.connectionType === DataSourceConnectionType.OpenSearchConnection + : dsc?.relatedConnections && dsc.relatedConnections?.length > 0 + ); + }, [connectionType, dataSourceConnections]); + + const renderToolsLeft = useCallback(() => { + return selectedItems.length > 0 && !modalVisible + ? [ + setModalVisible(true)} + data-test-subj="workspace-detail-dataSources-table-bulkRemove" + > + {i18n.translate('workspace.detail.dataSources.table.remove.button', { + defaultMessage: 'Remove {numberOfSelect} association(s)', + values: { numberOfSelect: selectedItems.length }, + })} + , + ] + : []; + }, [selectedItems, modalVisible]); + + const search: EuiSearchBarProps = { + toolsLeft: renderToolsLeft(), + box: { + incremental: true, + }, + filters: [ + { + type: 'field_value_selection', + field: 'type', + name: 'Type', + multiSelect: 'or', + options: Array.from( + new Set(openSearchConnections.map(({ type }) => type).filter(Boolean)) + ).map((type) => ({ + value: type!, + name: type!, + })), + }, + ], + }; + + return ( + <> + { + { + setSelectedItems([item]); + setModalVisible(true); + }} + onSelectionChange={setSelectedItems} + tableProps={{ + search, + pagination: { + initialPageSize: 10, + pageSizeOptions: [10, 20, 30], + }, + }} + /* Unmount table after connection type */ + key={connectionType} + /> + } + {modalVisible && ( + { + setModalVisible(false); + setSelectedItems([]); + }} + onConfirm={() => { + setModalVisible(false); + handleUnassignDataSources(selectedItems); + }} + cancelButtonText={i18n.translate('workspace.detail.dataSources.modal.cancelButton', { + defaultMessage: 'Cancel', + })} + confirmButtonText={i18n.translate('workspace.detail.dataSources.Modal.confirmButton', { + defaultMessage: 'Remove data source(s)', + })} + buttonColor="danger" + defaultFocusedButton="confirm" + /> + )} + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_detail_app.tsx b/src/plugins/workspace/public/components/workspace_detail_app.tsx index 1347b130575b..9518b5707bb3 100644 --- a/src/plugins/workspace/public/components/workspace_detail_app.tsx +++ b/src/plugins/workspace/public/components/workspace_detail_app.tsx @@ -19,11 +19,11 @@ import { convertPermissionSettingsToPermissions, convertPermissionsToPermissionSettings, } from './workspace_form'; -import { DataSource } from '../../common/types'; +import { DataSourceConnectionType } from '../../common/types'; import { WorkspaceClient } from '../workspace_client'; import { formatUrlWithWorkspaceId } from '../../../../core/public/utils'; import { WORKSPACE_DETAIL_APP_ID } from '../../common/constants'; -import { getDataSourcesList } from '../utils'; +import { getDataSourcesList, mergeDataSourcesWithConnections } from '../utils'; import { WorkspaceAttributeWithPermission } from '../../../../core/types'; function getFormDataFromWorkspace( @@ -34,16 +34,13 @@ function getFormDataFromWorkspace( } return { ...currentWorkspace, + features: currentWorkspace.features ?? [], permissionSettings: currentWorkspace.permissions ? convertPermissionsToPermissionSettings(currentWorkspace.permissions) : currentWorkspace.permissions, }; } -type FormDataFromWorkspace = ReturnType & { - selectedDataSources: DataSource[]; -}; - export const WorkspaceDetailApp = (props: WorkspaceDetailProps) => { const { services: { @@ -56,7 +53,9 @@ export const WorkspaceDetailApp = (props: WorkspaceDetailProps) => { http, }, } = useOpenSearchDashboards<{ CoreStart: CoreStart; workspaceClient: WorkspaceClient }>(); - const [currentWorkspaceFormData, setCurrentWorkspaceFormData] = useState(); + const [currentWorkspaceFormData, setCurrentWorkspaceFormData] = useState< + WorkspaceFormSubmitData + >(); const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null)); const availableUseCases = useObservable(props.registeredUseCases$, []); const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; @@ -93,14 +92,15 @@ export const WorkspaceDetailApp = (props: WorkspaceDetailProps) => { const rawFormData = getFormDataFromWorkspace(currentWorkspace); if (rawFormData && savedObjects && currentWorkspace) { - getDataSourcesList(savedObjects.client, [currentWorkspace.id]).then((selectedDataSources) => { + getDataSourcesList(savedObjects.client, [currentWorkspace.id]).then((dataSources) => { setCurrentWorkspaceFormData({ ...rawFormData, - selectedDataSources, + // Direct query connections info is not required for all tabs, it can be fetched later + selectedDataSourceConnections: mergeDataSourcesWithConnections(dataSources, []), }); }); } - }, [currentWorkspace, savedObjects]); + }, [currentWorkspace, savedObjects, http, notifications]); const handleWorkspaceFormSubmit = useCallback( async (data: WorkspaceFormSubmitData) => { @@ -115,10 +115,14 @@ export const WorkspaceDetailApp = (props: WorkspaceDetailProps) => { } try { - const { permissionSettings, selectedDataSources, ...attributes } = data; - const selectedDataSourceIds = (selectedDataSources ?? []).map((ds: DataSource) => { - return ds.id; - }); + const { permissionSettings, selectedDataSourceConnections, ...attributes } = data; + const selectedDataSourceIds = (selectedDataSourceConnections ?? []) + .filter( + ({ connectionType }) => connectionType === DataSourceConnectionType.OpenSearchConnection + ) + .map((connection) => { + return connection.id; + }); result = await workspaceClient.update(currentWorkspace.id, attributes, { dataSources: selectedDataSourceIds, diff --git a/src/plugins/workspace/public/components/workspace_form/constants.ts b/src/plugins/workspace/public/components/workspace_form/constants.ts index ed2268bec8d7..c09ac11f5489 100644 --- a/src/plugins/workspace/public/components/workspace_form/constants.ts +++ b/src/plugins/workspace/public/components/workspace_form/constants.ts @@ -123,3 +123,7 @@ export const DetailTabTitles: { [key in DetailTab]: string } = { defaultMessage: 'Collaborators', }), }; + +export const PERMISSION_TYPE_LABEL_ID = 'workspace-form-permission-type-label'; +export const PERMISSION_COLLABORATOR_LABEL_ID = 'workspace-form-permission-collaborator-label'; +export const PERMISSION_ACCESS_LEVEL_LABEL_ID = 'workspace-form-permission-access-level-label'; diff --git a/src/plugins/workspace/public/components/workspace_detail/data_source_connection_table.scss b/src/plugins/workspace/public/components/workspace_form/data_source_connection_table.scss similarity index 100% rename from src/plugins/workspace/public/components/workspace_detail/data_source_connection_table.scss rename to src/plugins/workspace/public/components/workspace_form/data_source_connection_table.scss diff --git a/src/plugins/workspace/public/components/workspace_detail/data_source_connection_table.tsx b/src/plugins/workspace/public/components/workspace_form/data_source_connection_table.tsx similarity index 63% rename from src/plugins/workspace/public/components/workspace_detail/data_source_connection_table.tsx rename to src/plugins/workspace/public/components/workspace_form/data_source_connection_table.tsx index c11f9fbee254..d440d3e751d6 100644 --- a/src/plugins/workspace/public/components/workspace_detail/data_source_connection_table.tsx +++ b/src/plugins/workspace/public/components/workspace_form/data_source_connection_table.tsx @@ -3,106 +3,55 @@ * SPDX-License-Identifier: Apache-2.0 */ -import './data_source_connection_table.scss'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { - EuiSpacer, EuiInMemoryTable, EuiBasicTableColumn, - EuiTableSelectionType, EuiTableActionsColumnType, - EuiConfirmModal, - EuiSearchBarProps, EuiText, EuiListGroup, EuiListGroupItem, EuiPopover, EuiButtonEmpty, EuiPopoverTitle, - EuiSmallButton, EuiLink, EuiButtonIcon, + EuiInMemoryTableProps, + EuiTableSelectionType, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { DataSourceConnection, DataSourceConnectionType } from '../../../common/types'; import { AssociationDataSourceModalMode } from '../../../common/constants'; -import { DirectQueryConnectionIcon } from '../workspace_form'; +import { DirectQueryConnectionIcon } from './direct_query_connection_icon'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { CoreStart } from '../../../../../core/public'; + +import './data_source_connection_table.scss'; interface DataSourceConnectionTableProps { isDashboardAdmin: boolean; connectionType: string; + onUnlinkDataSource: (dataSources: DataSourceConnection) => void; + onSelectionChange: (selections: DataSourceConnection[]) => void; dataSourceConnections: DataSourceConnection[]; - handleUnassignDataSources: (dataSources: DataSourceConnection[]) => Promise; + tableProps?: Pick, 'pagination' | 'search'>; } export const DataSourceConnectionTable = ({ isDashboardAdmin, connectionType, + onUnlinkDataSource, + onSelectionChange, + tableProps, dataSourceConnections, - handleUnassignDataSources, }: DataSourceConnectionTableProps) => { - const [selectedItems, setSelectedItems] = useState([]); - const [modalVisible, setModalVisible] = useState(false); const [popoversState, setPopoversState] = useState>({}); const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState< Record >({}); - - useEffect(() => { - // Reset selected items when connectionType changes - setSelectedItems([]); - setItemIdToExpandedRowMap({}); - }, [connectionType]); - - const openSearchConnections = useMemo(() => { - return dataSourceConnections.filter((dsc) => - connectionType === AssociationDataSourceModalMode.OpenSearchConnections - ? dsc.connectionType === DataSourceConnectionType.OpenSearchConnection - : dsc?.relatedConnections && dsc.relatedConnections?.length > 0 - ); - }, [connectionType, dataSourceConnections]); - - const renderToolsLeft = useCallback(() => { - return selectedItems.length > 0 && !modalVisible - ? [ - setModalVisible(true)} - data-test-subj="workspace-detail-dataSources-table-bulkRemove" - > - {i18n.translate('workspace.detail.dataSources.table.remove.button', { - defaultMessage: 'Remove {numberOfSelect} association(s)', - values: { numberOfSelect: selectedItems.length }, - })} - , - ] - : []; - }, [selectedItems, modalVisible]); - - const onSelectionChange = (selectedDataSources: DataSourceConnection[]) => { - setSelectedItems(selectedDataSources); - }; - - const search: EuiSearchBarProps = { - toolsLeft: renderToolsLeft(), - box: { - incremental: true, - }, - filters: [ - { - type: 'field_value_selection', - field: 'type', - name: 'Type', - multiSelect: 'or', - options: Array.from( - new Set(openSearchConnections.map(({ type }) => type).filter(Boolean)) - ).map((type) => ({ - value: type!, - name: type!, - })), - }, - ], - }; + const { + services: { http }, + } = useOpenSearchDashboards(); const togglePopover = (itemId: string) => { setPopoversState((prevState) => ({ @@ -152,19 +101,20 @@ export const DataSourceConnectionTable = ({ ] : []), { - width: '25%', + width: '20%', field: 'name', name: i18n.translate('workspace.detail.dataSources.table.title', { defaultMessage: 'Title', }), truncateText: true, render: (name: string, record) => { - const origin = window.location.origin; let url: string; if (record.connectionType === DataSourceConnectionType.OpenSearchConnection) { - url = `${origin}/app/dataSources/${record.id}`; + url = http.basePath.prepend(`/app/dataSources/${record.id}`); } else { - url = `${origin}/app/dataSources/manage/${name}?dataSourceMDSId=${record.parentId}`; + url = http.basePath.prepend( + `/app/dataSources/manage/${name}?dataSourceMDSId=${record.parentId}` + ); } return ( @@ -174,7 +124,7 @@ export const DataSourceConnectionTable = ({ }, }, { - width: '10%', + width: '20%', field: 'type', name: i18n.translate('workspace.detail.dataSources.table.type', { defaultMessage: 'Type', @@ -182,7 +132,6 @@ export const DataSourceConnectionTable = ({ truncateText: true, }, { - width: '35%', field: 'description', name: i18n.translate('workspace.detail.dataSources.table.description', { defaultMessage: 'Description', @@ -190,11 +139,11 @@ export const DataSourceConnectionTable = ({ truncateText: true, }, { + width: '140px', field: 'relatedConnections', name: i18n.translate('workspace.detail.dataSources.table.relatedConnections', { defaultMessage: 'Related connections', }), - align: 'right', truncateText: true, render: (relatedConnections: DataSourceConnection[], record) => relatedConnections?.length > 0 ? ( @@ -267,12 +216,12 @@ export const DataSourceConnectionTable = ({ icon: 'unlink', type: 'icon', onClick: (item: DataSourceConnection) => { - setSelectedItems([item]); - setModalVisible(true); + onUnlinkDataSource(item); }, 'data-test-subj': 'workspace-detail-dataSources-table-actions-remove', }, ], + width: '10%', } as EuiTableActionsColumnType, ] : []), @@ -284,48 +233,15 @@ export const DataSourceConnectionTable = ({ }; return ( - <> - - - {modalVisible && ( - { - setModalVisible(false); - setSelectedItems([]); - }} - onConfirm={() => { - setModalVisible(false); - handleUnassignDataSources(selectedItems); - }} - cancelButtonText={i18n.translate('workspace.detail.dataSources.modal.cancelButton', { - defaultMessage: 'Cancel', - })} - confirmButtonText={i18n.translate('workspace.detail.dataSources.Modal.confirmButton', { - defaultMessage: 'Remove data source(s)', - })} - buttonColor="danger" - defaultFocusedButton="confirm" - /> - )} - + ); }; diff --git a/src/plugins/workspace/public/components/workspace_form/index.ts b/src/plugins/workspace/public/components/workspace_form/index.ts index 79d934fb80b9..2fae243a37a2 100644 --- a/src/plugins/workspace/public/components/workspace_form/index.ts +++ b/src/plugins/workspace/public/components/workspace_form/index.ts @@ -11,6 +11,7 @@ export { WorkspacePermissionSettingPanel } from './workspace_permission_setting_ export { WorkspaceCancelModal } from './workspace_cancel_modal'; export { WorkspaceNameField, WorkspaceDescriptionField } from './fields'; export { DirectQueryConnectionIcon } from './direct_query_connection_icon'; +export { DataSourceConnectionTable } from './data_source_connection_table'; export { WorkspaceFormSubmitData, WorkspaceFormProps, WorkspaceFormDataState } from './types'; export { @@ -26,6 +27,7 @@ export { export { convertPermissionsToPermissionSettings, convertPermissionSettingsToPermissions, + isWorkspacePermissionSetting, } from './utils'; export { WorkspaceFormProvider, useWorkspaceFormContext } from './workspace_form_context'; diff --git a/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.test.tsx b/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.test.tsx index 2890333f1268..460921530d4b 100644 --- a/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.test.tsx +++ b/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.test.tsx @@ -4,86 +4,229 @@ */ import React from 'react'; -import { fireEvent, render, act } from '@testing-library/react'; -import { SelectDataSourcePanel, SelectDataSourcePanelProps } from './select_data_source_panel'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { I18nProvider } from '@osd/i18n/react'; import { coreMock } from '../../../../../core/public/mocks'; +import * as utils from '../../utils'; +import { DataSourceEngineType } from 'src/plugins/data_source/common/data_sources'; +import { OpenSearchDashboardsContextProvider } from '../../../../../plugins/opensearch_dashboards_react/public'; +import { DataSourceConnectionType } from '../../../common/types'; + +import { SelectDataSourcePanel, SelectDataSourcePanelProps } from './select_data_source_panel'; + +const dataSourceConnectionsMock = [ + { + id: 'ds1', + name: 'Data Source 1', + connectionType: DataSourceConnectionType.OpenSearchConnection, + type: 'OpenSearch', + relatedConnections: [ + { + id: 'ds1-dqc1', + name: 'dqc1', + parentId: 'ds1', + connectionType: DataSourceConnectionType.DirectQueryConnection, + type: 'Amazon S3', + }, + ], + }, + { + id: 'ds1-dqc1', + name: 'dqc1', + parentId: 'ds1', + connectionType: DataSourceConnectionType.DirectQueryConnection, + type: 'Amazon S3', + }, + { + id: 'ds2', + name: 'Data Source 2', + connectionType: DataSourceConnectionType.OpenSearchConnection, + type: 'OpenSearch', + }, +]; + +const assignedDataSourcesConnections = [dataSourceConnectionsMock[0], dataSourceConnectionsMock[2]]; const dataSources = [ { - id: 'id1', - title: 'title1', + id: 'ds1', + title: 'Data Source 1', + description: 'Description of data source 1', + auth: '', + dataSourceEngineType: '' as DataSourceEngineType, + workspaces: [], + }, + { + id: 'ds2', + title: 'Data Source 2', + description: 'Description of data source 2', + auth: '', + dataSourceEngineType: '' as DataSourceEngineType, + workspaces: [], }, - { id: 'id2', title: 'title2' }, ]; -jest.mock('../../utils', () => ({ - getDataSourcesList: jest.fn().mockResolvedValue(dataSources), -})); +jest.spyOn(utils, 'getDataSourcesList').mockResolvedValue(dataSources); +jest.spyOn(utils, 'fetchDataSourceConnections').mockImplementation(async (passedDataSources) => { + return dataSourceConnectionsMock.filter(({ id }) => + passedDataSources.some((dataSource) => dataSource.id === id) + ); +}); const mockCoreStart = coreMock.createStart(); const setup = ({ savedObjects = mockCoreStart.savedObjects, - selectedDataSources = [], + assignedDataSourceConnections = [], onChange = jest.fn(), errors = undefined, + showDataSourceManagement = true, }: Partial) => { return render( - + + + + + ); }; describe('SelectDataSourcePanel', () => { - it('should render consistent data sources when selected data sources passed', () => { - const { getByText } = setup({ selectedDataSources: dataSources }); + const originalOffsetHeight = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'offsetHeight' + ); + const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth'); + beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 600, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + value: 600, + }); + }); + afterEach(() => { + Object.defineProperty( + HTMLElement.prototype, + 'offsetHeight', + originalOffsetHeight as PropertyDescriptor + ); + Object.defineProperty( + HTMLElement.prototype, + 'offsetWidth', + originalOffsetWidth as PropertyDescriptor + ); + }); + it('should render consistent data sources when selected data sources passed', async () => { + const { getByText, getByTestId, queryByText } = setup({ + assignedDataSourceConnections: [assignedDataSourcesConnections[0]], + }); + + await waitFor(() => { + expect(getByText(assignedDataSourcesConnections[0].name)).toBeInTheDocument(); + expect(queryByText(assignedDataSourcesConnections[1].name)).not.toBeInTheDocument(); + }); - expect(getByText(dataSources[0].title)).toBeInTheDocument(); - expect(getByText(dataSources[1].title)).toBeInTheDocument(); + fireEvent.click(getByTestId('workspace-creator-dataSources-assign-button')); + + await waitFor(() => { + expect(getByText(assignedDataSourcesConnections[1].name)).toBeInTheDocument(); + }); }); - it('should call onChange when clicking add new data source button', () => { + it('should call onChange when updating data sources', async () => { const onChangeMock = jest.fn(); - const { getByTestId } = setup({ onChange: onChangeMock }); + const { getByTestId, getByText } = setup({ + onChange: onChangeMock, + assignedDataSourceConnections: [], + }); expect(onChangeMock).not.toHaveBeenCalled(); - fireEvent.click(getByTestId('workspaceForm-select-dataSource-addNew')); + fireEvent.click(getByTestId('workspace-creator-dataSources-assign-button')); + + await waitFor(() => { + expect( + getByText( + 'Add data sources that will be available in the workspace. If a selected data source has related Direct Query connection, they will also be available in the workspace.' + ) + ).toBeInTheDocument(); + expect(getByText(assignedDataSourcesConnections[1].name)).toBeInTheDocument(); + }); + + fireEvent.click(getByText(assignedDataSourcesConnections[1].name)); + fireEvent.click(getByText('Associate data sources')); expect(onChangeMock).toHaveBeenCalledWith([ - { - id: '', - title: '', - }, + expect.objectContaining({ + id: assignedDataSourcesConnections[1].id, + }), + ]); + + fireEvent.click(getByTestId('workspace-creator-dqc-assign-button')); + await waitFor(() => { + expect(getByText(assignedDataSourcesConnections[0].name)).toBeInTheDocument(); + }); + fireEvent.click(getByText(assignedDataSourcesConnections[0].name)); + fireEvent.click(getByText('Associate data sources')); + expect(onChangeMock).toHaveBeenCalledWith([ + expect.objectContaining({ + id: assignedDataSourcesConnections[0].id, + }), ]); }); - it('should call onChange when updating selected data sources in combo box', async () => { + it('should call onChange when deleting selected data source', async () => { const onChangeMock = jest.fn(); - const { getByTitle, getByText } = setup({ + const { getByText, getByTestId } = setup({ onChange: onChangeMock, - selectedDataSources: [{ id: '', title: '' }], + assignedDataSourceConnections: assignedDataSourcesConnections, + }); + fireEvent.click(getByTestId('workspace-creator-dataSources-assign-button')); + + await waitFor(() => { + expect(getByText(assignedDataSourcesConnections[0].name)).toBeInTheDocument(); + expect(getByText(assignedDataSourcesConnections[1].name)).toBeInTheDocument(); }); + + fireEvent.click(getByText(assignedDataSourcesConnections[0].name)); + fireEvent.click(getByText(assignedDataSourcesConnections[1].name)); + expect(onChangeMock).not.toHaveBeenCalled(); - await act(() => { - fireEvent.click(getByText('Select')); + + fireEvent.click(getByText('Associate data sources')); + + await waitFor(() => { + fireEvent.click(getByTestId('checkboxSelectRow-' + dataSources[1].id)); + fireEvent.click(getByText('Remove selected')); }); - fireEvent.click(getByTitle(dataSources[0].title)); - expect(onChangeMock).toHaveBeenCalledWith([{ id: 'id1', title: 'title1' }]); + expect(onChangeMock).toHaveBeenCalledWith([assignedDataSourcesConnections[0]]); }); - it('should call onChange when deleting selected data source', async () => { - const onChangeMock = jest.fn(); - const { getByLabelText } = setup({ - onChange: onChangeMock, - selectedDataSources: [{ id: '', title: '' }], + it('should close associate data sources modal', async () => { + const { getByText, queryByText, getByTestId } = setup({ + assignedDataSourceConnections: [], }); - expect(onChangeMock).not.toHaveBeenCalled(); - await act(() => { - fireEvent.click(getByLabelText('Delete data source')); + + fireEvent.click(getByTestId('workspace-creator-dataSources-assign-button')); + await waitFor(() => { + expect( + getByText( + 'Add data sources that will be available in the workspace. If a selected data source has related Direct Query connection, they will also be available in the workspace.' + ) + ).toBeInTheDocument(); }); - expect(onChangeMock).toHaveBeenCalledWith([]); + fireEvent.click(getByText('Close')); + expect( + queryByText( + 'Add data sources that will be available in the workspace. If a selected data source has related Direct Query connection, they will also be available in the workspace.' + ) + ).toBeNull(); }); }); diff --git a/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.tsx b/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.tsx index 9c990d7e7195..b1ec518d77c1 100644 --- a/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.tsx +++ b/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.tsx @@ -3,149 +3,149 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useCallback, useEffect, useState } from 'react'; -import { - EuiSmallButton, - EuiCompressedFormRow, - EuiSpacer, - EuiFlexGroup, - EuiFlexItem, - EuiButtonIcon, - EuiCompressedComboBox, - EuiComboBoxOptionOption, - EuiFormLabel, -} from '@elastic/eui'; +import React, { useState } from 'react'; +import { EuiSpacer, EuiFlexItem, EuiSmallButton, EuiFlexGroup, EuiPanel } from '@elastic/eui'; import { i18n } from '@osd/i18n'; -import { SavedObjectsStart } from '../../../../../core/public'; -import { getDataSourcesList } from '../../utils'; -import { DataSource } from '../../../common/types'; +import { SavedObjectsStart, CoreStart } from '../../../../../core/public'; +import { DataSourceConnection } from '../../../common/types'; import { WorkspaceFormError } from './types'; +import { AssociationDataSourceModal } from '../workspace_detail/association_data_source_modal'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { WorkspaceClient } from '../../workspace_client'; +import { AssociationDataSourceModalMode } from '../../../common/constants'; +import { DataSourceConnectionTable } from './data_source_connection_table'; export interface SelectDataSourcePanelProps { errors?: { [key: number]: WorkspaceFormError }; savedObjects: SavedObjectsStart; - selectedDataSources: DataSource[]; - onChange: (value: DataSource[]) => void; + assignedDataSourceConnections: DataSourceConnection[]; + onChange: (value: DataSourceConnection[]) => void; + showDataSourceManagement: boolean; } export const SelectDataSourcePanel = ({ - errors, onChange, - selectedDataSources, + assignedDataSourceConnections, savedObjects, + showDataSourceManagement, }: SelectDataSourcePanelProps) => { - const [dataSourcesOptions, setDataSourcesOptions] = useState([]); - useEffect(() => { - if (!savedObjects) return; - getDataSourcesList(savedObjects.client, ['*']).then((result) => { - const options = result.map(({ title, id }) => ({ - label: title, - value: id, - })); - setDataSourcesOptions(options); - }); - }, [savedObjects, setDataSourcesOptions]); - const handleAddNewOne = useCallback(() => { - onChange?.([ - ...selectedDataSources, - { - title: '', - id: '', - }, - ]); - }, [onChange, selectedDataSources]); + const [modalVisible, setModalVisible] = useState(false); + const [selectedItems, setSelectedItems] = useState([]); + const [toggleIdSelected, setToggleIdSelected] = useState( + AssociationDataSourceModalMode.OpenSearchConnections + ); + const { + services: { notifications, http, chrome }, + } = useOpenSearchDashboards<{ CoreStart: CoreStart; workspaceClient: WorkspaceClient }>(); + + const handleAssignDataSourceConnections = (newDataSourceConnections: DataSourceConnection[]) => { + setModalVisible(false); + onChange([...assignedDataSourceConnections, ...newDataSourceConnections]); + }; + + const handleUnassignDataSources = (dataSourceConnections: DataSourceConnection[]) => { + onChange( + assignedDataSourceConnections.filter( + ({ id }: DataSourceConnection) => !dataSourceConnections.some((item) => item.id === id) + ) + ); + }; - const handleSelect = useCallback( - (selectedOptions, index) => { - const newOption = selectedOptions[0] - ? // Select new data source - { - title: selectedOptions[0].label, - id: selectedOptions[0].value, - } - : // Click reset button - { - title: '', - id: '', - }; - const newSelectedOptions = [...selectedDataSources]; - newSelectedOptions.splice(index, 1, newOption); + const handleSingleDataSourceUnAssign = (connection: DataSourceConnection) => { + handleUnassignDataSources([connection]); + }; - onChange(newSelectedOptions); - }, - [onChange, selectedDataSources] + const renderTableContent = () => { + return ( + + + + ); + }; + + const addOpenSearchConnectionsButton = ( + { + setToggleIdSelected(AssociationDataSourceModalMode.OpenSearchConnections); + setModalVisible(true); + }} + data-test-subj="workspace-creator-dataSources-assign-button" + > + {i18n.translate('workspace.form.selectDataSourcePanel.addNew', { + defaultMessage: 'Add OpenSearch connections', + })} + ); - const handleDelete = useCallback( - (index) => { - const newSelectedOptions = [...selectedDataSources]; - newSelectedOptions.splice(index, 1); + const addDirectQueryConnectionsButton = ( + { + setToggleIdSelected(AssociationDataSourceModalMode.DirectQueryConnections); + setModalVisible(true); + }} + data-test-subj="workspace-creator-dqc-assign-button" + > + {i18n.translate('workspace.form.selectDataSourcePanel.addNewDQCs', { + defaultMessage: 'Add direct query connections', + })} + + ); - onChange(newSelectedOptions); - }, - [onChange, selectedDataSources] + const removeButton = ( + { + handleUnassignDataSources(selectedItems); + }} + data-test-subj="workspace-creator-dataSources-remove-button" + > + {i18n.translate('workspace.form.selectDataSourcePanel.remove', { + defaultMessage: 'Remove selected', + })} + ); return (
- - {i18n.translate('workspace.form.selectDataSource.subTitle', { - defaultMessage: 'Data source', - })} - - - {selectedDataSources.map(({ id, title }, index) => ( - - - - handleSelect(selectedOptions, index)} - placeholder="Select" - /> - - - handleDelete(index)} - isDisabled={false} - /> - - - - ))} - - - {i18n.translate('workspace.form.selectDataSourcePanel.addNew', { - defaultMessage: 'Add New', - })} - + + + {showDataSourceManagement && + selectedItems.length > 0 && + assignedDataSourceConnections.length > 0 && ( + {removeButton} + )} + {showDataSourceManagement && ( + {addOpenSearchConnectionsButton} + )} + {showDataSourceManagement && ( + {addDirectQueryConnectionsButton} + )} + + + + {assignedDataSourceConnections.length > 0 && renderTableContent()} + + {modalVisible && chrome && ( + setModalVisible(false)} + handleAssignDataSourceConnections={handleAssignDataSourceConnections} + http={http} + mode={toggleIdSelected} + notifications={notifications} + logos={chrome.logos} + /> + )}
); }; diff --git a/src/plugins/workspace/public/components/workspace_form/types.ts b/src/plugins/workspace/public/components/workspace_form/types.ts index 0bf8881cd3bd..7ca175f794be 100644 --- a/src/plugins/workspace/public/components/workspace_form/types.ts +++ b/src/plugins/workspace/public/components/workspace_form/types.ts @@ -6,7 +6,7 @@ import type { ApplicationStart, SavedObjectsStart } from '../../../../../core/public'; import type { WorkspacePermissionMode } from '../../../common/constants'; import type { WorkspaceOperationType, WorkspacePermissionItemType } from './constants'; -import { DataSource } from '../../../common/types'; +import { DataSourceConnection } from '../../../common/types'; import { DataSourceManagementPluginSetup } from '../../../../../plugins/data_source_management/public'; import { WorkspaceUseCase } from '../../types'; @@ -34,7 +34,7 @@ export interface WorkspaceFormSubmitData { features: string[]; color?: string; permissionSettings?: WorkspacePermissionSetting[]; - selectedDataSources?: DataSource[]; + selectedDataSourceConnections?: DataSourceConnection[]; } export enum WorkspaceFormErrorCode { @@ -61,14 +61,14 @@ export interface WorkspaceFormError { export type WorkspaceFormErrors = { [key in keyof Omit< WorkspaceFormSubmitData, - 'permissionSettings' | 'description' | 'selectedDataSources' + 'permissionSettings' | 'description' | 'selectedDataSourceConnections' >]?: WorkspaceFormError; } & { permissionSettings?: { overall?: WorkspaceFormError; fields?: { [key: number]: WorkspaceFormError }; }; - selectedDataSources?: { [key: number]: WorkspaceFormError }; + selectedDataSourceConnections?: { [key: number]: WorkspaceFormError }; }; export interface WorkspaceFormProps { @@ -91,7 +91,7 @@ export interface WorkspaceFormDataState extends Omit { name: string; useCase: string | undefined; - selectedDataSources: DataSource[]; + selectedDataSourceConnections: DataSourceConnection[]; permissionSettings: Array< Pick & Partial >; diff --git a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts index 703255455ec4..74cf11982f4d 100644 --- a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts +++ b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts @@ -8,7 +8,7 @@ import { htmlIdGenerator, EuiColorPickerProps } from '@elastic/eui'; import { useApplications } from '../../hooks'; import { getFirstUseCaseOfFeatureConfigs, isUseCaseFeatureConfig } from '../../utils'; -import { DataSource } from '../../../common/types'; +import { DataSourceConnection } from '../../../common/types'; import { getUseCaseFeatureConfig } from '../../../common/utils'; import { WorkspaceFormProps, @@ -51,9 +51,12 @@ export const useWorkspaceForm = ({ WorkspaceFormDataState['permissionSettings'] >(initialPermissionSettingsRef.current); - const [selectedDataSources, setSelectedDataSources] = useState( - defaultValues?.selectedDataSources && defaultValues.selectedDataSources.length > 0 - ? defaultValues.selectedDataSources + const [selectedDataSourceConnections, setSelectedDataSourceConnections] = useState< + DataSourceConnection[] + >( + defaultValues?.selectedDataSourceConnections && + defaultValues.selectedDataSourceConnections.length > 0 + ? defaultValues.selectedDataSourceConnections : [] ); @@ -67,7 +70,7 @@ export const useWorkspaceForm = ({ useCase: selectedUseCase, color, permissionSettings, - selectedDataSources, + selectedDataSourceConnections, }); const getFormDataRef = useRef(getFormData); getFormDataRef.current = getFormData; @@ -122,7 +125,7 @@ export const useWorkspaceForm = ({ color: currentFormData.color || '#FFFFFF', features: currentFormData.features, permissionSettings: currentFormData.permissionSettings as WorkspacePermissionSetting[], - selectedDataSources: currentFormData.selectedDataSources, + selectedDataSourceConnections: currentFormData.selectedDataSourceConnections, }); }, [onSubmit, permissionEnabled] @@ -159,6 +162,6 @@ export const useWorkspaceForm = ({ handleColorChange, handleUseCaseChange, setPermissionSettings, - setSelectedDataSources, + setSelectedDataSourceConnections, }; }; diff --git a/src/plugins/workspace/public/components/workspace_form/utils.test.ts b/src/plugins/workspace/public/components/workspace_form/utils.test.ts index 3a45165044d7..03cea502f573 100644 --- a/src/plugins/workspace/public/components/workspace_form/utils.test.ts +++ b/src/plugins/workspace/public/components/workspace_form/utils.test.ts @@ -9,9 +9,15 @@ import { convertPermissionsToPermissionSettings, getNumberOfChanges, getNumberOfErrors, + isWorkspacePermissionSetting, } from './utils'; import { WorkspacePermissionMode } from '../../../common/constants'; -import { WorkspacePermissionItemType } from './constants'; +import { + WorkspacePermissionItemType, + optionIdToWorkspacePermissionModesMap, + PermissionModeId, +} from './constants'; +import { DataSourceConnectionType } from '../../../common/types'; import { WorkspaceFormErrorCode } from './types'; describe('convertPermissionSettingsToPermissions', () => { @@ -337,15 +343,17 @@ describe('validateWorkspaceForm', () => { validateWorkspaceForm( { name: 'test', - selectedDataSources: [ + selectedDataSourceConnections: [ { id: '', - title: 'title', + name: 'title', + connectionType: DataSourceConnectionType.OpenSearchConnection, + type: 'OpenSearch', }, ], }, false - ).selectedDataSources + ).selectedDataSourceConnections ).toEqual({ 0: { code: WorkspaceFormErrorCode.InvalidDataSource, message: 'Invalid data source' }, }); @@ -356,19 +364,23 @@ describe('validateWorkspaceForm', () => { validateWorkspaceForm( { name: 'test', - selectedDataSources: [ + selectedDataSourceConnections: [ { id: 'id', - title: 'title1', + name: 'title1', + connectionType: DataSourceConnectionType.OpenSearchConnection, + type: 'OpenSearch', }, { id: 'id', - title: 'title2', + name: 'title2', + connectionType: DataSourceConnectionType.OpenSearchConnection, + type: 'OpenSearch', }, ], }, false - ).selectedDataSources + ).selectedDataSourceConnections ).toEqual({ '1': { code: WorkspaceFormErrorCode.DuplicateDataSource, message: 'Duplicate data sources' }, }); @@ -379,7 +391,7 @@ describe('getNumberOfErrors', () => { it('should calculate the error number of data sources form', () => { expect( getNumberOfErrors({ - selectedDataSources: { + selectedDataSourceConnections: { 0: { code: WorkspaceFormErrorCode.InvalidDataSource, message: 'Invalid data source' }, }, }) @@ -617,3 +629,72 @@ describe('getNumberOfChanges', () => { ).toEqual(3); }); }); + +describe('isWorkspacePermissionSetting', () => { + it('should return true for a valid user permission setting', () => { + const validUserPermissionSetting = { + modes: optionIdToWorkspacePermissionModesMap[PermissionModeId.Read], + type: WorkspacePermissionItemType.User, + userId: 'user123', + }; + const result = isWorkspacePermissionSetting(validUserPermissionSetting); + expect(result).toBe(true); + }); + + it('should return true for a valid group permission setting', () => { + const validGroupPermissionSetting = { + modes: optionIdToWorkspacePermissionModesMap[PermissionModeId.Owner], + type: WorkspacePermissionItemType.Group, + group: 'group456', + }; + const result = isWorkspacePermissionSetting(validGroupPermissionSetting); + expect(result).toBe(true); + }); + + it('should return false if modes is missing', () => { + const permissionSettingWithoutModes = { + type: WorkspacePermissionItemType.User, + userId: 'user123', + }; + const result = isWorkspacePermissionSetting(permissionSettingWithoutModes); + expect(result).toBe(false); + }); + + it('should return false if modes are invalid', () => { + const permissionSettingWithInvalidModes = { + modes: ['invalid' as WorkspacePermissionMode], + type: WorkspacePermissionItemType.User, + userId: 'user123', + }; + const result = isWorkspacePermissionSetting(permissionSettingWithInvalidModes); + expect(result).toBe(false); + }); + + it('should return false if type is invalid', () => { + const permissionSettingWithInvalidType = { + modes: optionIdToWorkspacePermissionModesMap[PermissionModeId.Owner], + type: 'invalid', + userId: 'user123', + }; + const result = isWorkspacePermissionSetting(permissionSettingWithInvalidType); + expect(result).toBe(false); + }); + + it('should return false if userId is missing for user type', () => { + const permissionSettingWithoutUserId = { + modes: optionIdToWorkspacePermissionModesMap[PermissionModeId.Owner], + type: WorkspacePermissionItemType.User, + }; + const result = isWorkspacePermissionSetting(permissionSettingWithoutUserId); + expect(result).toBe(false); + }); + + it('should return false if group is missing for group type', () => { + const permissionSettingWithoutGroup = { + modes: optionIdToWorkspacePermissionModesMap[PermissionModeId.Owner], + type: WorkspacePermissionItemType.Group, + }; + const result = isWorkspacePermissionSetting(permissionSettingWithoutGroup); + expect(result).toBe(false); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/utils.ts b/src/plugins/workspace/public/components/workspace_form/utils.ts index 161def1f3f6b..5e03c724889a 100644 --- a/src/plugins/workspace/public/components/workspace_form/utils.ts +++ b/src/plugins/workspace/public/components/workspace_form/utils.ts @@ -25,7 +25,7 @@ import { WorkspaceUserGroupPermissionSetting, WorkspaceUserPermissionSetting, } from './types'; -import { DataSource } from '../../../common/types'; +import { DataSourceConnection } from '../../../common/types'; import { validateWorkspaceColor } from '../../../common/utils'; export const isValidFormTextInput = (input?: string) => { @@ -48,8 +48,8 @@ export const getNumberOfErrors = (formErrors: WorkspaceFormErrors) => { if (formErrors.permissionSettings?.overall) { numberOfErrors += 1; } - if (formErrors.selectedDataSources) { - numberOfErrors += Object.keys(formErrors.selectedDataSources).length; + if (formErrors.selectedDataSourceConnections) { + numberOfErrors += Object.keys(formErrors.selectedDataSourceConnections).length; } if (formErrors.features) { numberOfErrors += 1; @@ -226,7 +226,7 @@ const validateUserGroupPermissionSetting = ( } }; -const validatePermissionSetting = ( +const validatePermissionSettings = ( permissionSettings?: Array< Pick & Partial > @@ -289,17 +289,17 @@ const validatePermissionSetting = ( : {}), }; }; -export const isSelectedDataSourcesDuplicated = ( - selectedDataSources: DataSource[], - row: DataSource -) => selectedDataSources.some((ds) => ds.id === row.id); +export const isSelectedDataSourceConnectionsDuplicated = ( + selectedDataSourceConnections: DataSourceConnection[], + row: DataSourceConnection +) => selectedDataSourceConnections.some((connection) => connection.id === row.id); export const validateWorkspaceForm = ( formData: Partial, isPermissionEnabled: boolean ) => { const formErrors: WorkspaceFormErrors = {}; - const { name, permissionSettings, color, features, selectedDataSources } = formData; + const { name, permissionSettings, color, features, selectedDataSourceConnections } = formData; if (name && name.trim()) { if (!isValidFormTextInput(name)) { formErrors.name = { @@ -334,12 +334,12 @@ export const validateWorkspaceForm = ( }; } if (isPermissionEnabled) { - formErrors.permissionSettings = validatePermissionSetting(permissionSettings); + formErrors.permissionSettings = validatePermissionSettings(permissionSettings); } - if (selectedDataSources) { + if (selectedDataSourceConnections) { const dataSourcesErrors: { [key: number]: WorkspaceFormError } = {}; - for (let i = 0; i < selectedDataSources.length; i++) { - const row = selectedDataSources[i]; + for (let i = 0; i < selectedDataSourceConnections.length; i++) { + const row = selectedDataSourceConnections[i]; if (!row.id) { dataSourcesErrors[i] = { code: WorkspaceFormErrorCode.InvalidDataSource, @@ -347,7 +347,9 @@ export const validateWorkspaceForm = ( defaultMessage: 'Invalid data source', }), }; - } else if (isSelectedDataSourcesDuplicated(selectedDataSources.slice(0, i), row)) { + } else if ( + isSelectedDataSourceConnectionsDuplicated(selectedDataSourceConnections.slice(0, i), row) + ) { dataSourcesErrors[i] = { code: WorkspaceFormErrorCode.DuplicateDataSource, message: i18n.translate('workspace.form.permission.invalidate.group', { @@ -357,7 +359,7 @@ export const validateWorkspaceForm = ( } } if (Object.keys(dataSourcesErrors).length > 0) { - formErrors.selectedDataSources = dataSourcesErrors; + formErrors.selectedDataSourceConnections = dataSourcesErrors; } } return formErrors; @@ -447,6 +449,34 @@ const isSamePermissionSetting = (a: PermissionSettingLike, b: PermissionSettingL ); }; +export const isWorkspacePermissionSetting = ( + permissionSetting: PermissionSettingLike +): permissionSetting is WorkspacePermissionSetting => { + const { modes, type, userId, group } = permissionSetting; + if (!modes) { + return false; + } + const arrayStringify = (array: string[]) => array.sort().join(); + const stringifyModes = arrayStringify(modes); + if ( + Object.values(optionIdToWorkspacePermissionModesMap).every( + (validModes) => arrayStringify([...validModes]) !== stringifyModes + ) + ) { + return false; + } + if (type !== WorkspacePermissionItemType.User && type !== WorkspacePermissionItemType.Group) { + return false; + } + if (type === WorkspacePermissionItemType.User && !userId) { + return false; + } + if (type === WorkspacePermissionItemType.Group && !group) { + return false; + } + return true; +}; + export const getNumberOfChanges = ( newFormData: Partial, initialFormData: Partial diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form_context.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form_context.tsx index 417921f170d3..9ac317f1ec07 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form_context.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form_context.tsx @@ -5,16 +5,17 @@ import React, { createContext, useContext, FormEventHandler, ReactNode } from 'react'; import { EuiColorPickerOutput } from '@elastic/eui/src/components/color_picker/color_picker'; -import { DataSource } from '../../../common/types'; -import { WorkspaceFormProps, WorkspaceFormErrors, WorkspacePermissionSetting } from './types'; +import { DataSourceConnection } from '../../../common/types'; +import { WorkspaceFormProps, WorkspaceFormErrors } from './types'; import { PublicAppInfo } from '../../../../../core/public'; import { useWorkspaceForm } from './use_workspace_form'; +import { WorkspaceFormDataState } from '../workspace_form'; interface WorkspaceFormContextProps { formId: string; setName: React.Dispatch>; setDescription: React.Dispatch>; - formData: any; + formData: WorkspaceFormDataState; isEditing: boolean; formErrors: WorkspaceFormErrors; setIsEditing: React.Dispatch>; @@ -26,11 +27,9 @@ interface WorkspaceFormContextProps { handleColorChange: (text: string, output: EuiColorPickerOutput) => void; handleUseCaseChange: (newUseCase: string) => void; setPermissionSettings: React.Dispatch< - React.SetStateAction< - Array & Partial> - > + React.SetStateAction >; - setSelectedDataSources: React.Dispatch>; + setSelectedDataSourceConnections: React.Dispatch>; } const initialContextValue: WorkspaceFormContextProps = {} as WorkspaceFormContextProps; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx index 00560f7c033d..86f0d0688714 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx @@ -18,6 +18,9 @@ import { WorkspacePermissionItemType, optionIdToWorkspacePermissionModesMap, PermissionModeId, + PERMISSION_TYPE_LABEL_ID, + PERMISSION_COLLABORATOR_LABEL_ID, + PERMISSION_ACCESS_LEVEL_LABEL_ID, } from './constants'; import { getPermissionModeId } from './utils'; @@ -148,6 +151,7 @@ export const WorkspacePermissionSettingInput = ({ onChange={(value) => onTypeChange(value, index)} disabled={userOrGroupDisabled || !isEditing} data-test-subj="workspace-typeOptions" + aria-labelledby={PERMISSION_TYPE_LABEL_ID} />
@@ -166,6 +170,7 @@ export const WorkspacePermissionSettingInput = ({ defaultMessage: 'Enter group name or group ID', }) } + aria-labelledby={PERMISSION_COLLABORATOR_LABEL_ID} /> @@ -176,6 +181,7 @@ export const WorkspacePermissionSettingInput = ({ onChange={handlePermissionModeOptionChange} disabled={userOrGroupDisabled || !isEditing} data-test-subj="workspace-permissionModeOptions" + aria-labelledby={PERMISSION_ACCESS_LEVEL_LABEL_ID} /> diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx index 8ea255b83b36..845708d7ecbf 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx @@ -10,6 +10,7 @@ import { EuiFlexItem, EuiCompressedFormRow, EuiSpacer, + EuiFormLabel, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { WorkspaceFormError, WorkspacePermissionSetting } from './types'; @@ -17,6 +18,9 @@ import { WorkspacePermissionItemType, optionIdToWorkspacePermissionModesMap, PermissionModeId, + PERMISSION_TYPE_LABEL_ID, + PERMISSION_COLLABORATOR_LABEL_ID, + PERMISSION_ACCESS_LEVEL_LABEL_ID, } from './constants'; import { WorkspacePermissionSettingInput, @@ -130,35 +134,30 @@ export const WorkspacePermissionSettingPanel = ({ ); return ( -
- + <> + - + {i18n.translate('workspaceForm.permissionSetting.typeLabel', { defaultMessage: 'Type', })} - > - <> - + - + {i18n.translate('workspaceForm.permissionSetting.collaboratorLabel', { defaultMessage: 'Collaborator', })} - > - <> - + - - + + {i18n.translate('workspaceForm.permissionSetting.accessLevelLabel', { defaultMessage: 'Access level', })} - > - <> - + + {permissionSettings.map((item, index) => ( @@ -195,6 +194,6 @@ export const WorkspacePermissionSettingPanel = ({ })} )} -
+ ); }; diff --git a/src/plugins/workspace/public/components/workspace_list/index.tsx b/src/plugins/workspace/public/components/workspace_list/index.tsx index 23235345f75e..176f0dfdcc39 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.tsx @@ -409,6 +409,7 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { type: 'button', description: 'Delete workspace', 'data-test-subj': 'workspace-list-delete-icon', + available: () => isDashboardAdmin, render: (item: WorkspaceAttribute) => { return ( ', () => { let coreStartMock: CoreStart; @@ -91,7 +90,52 @@ describe('', () => { expect(screen.getByTestId('workspace-menu-item-recent-workspace-2')).toBeInTheDocument(); }); - it('should display current workspace name and use case name', () => { + it('should be able to display empty state when the workspace list is empty', () => { + coreStartMock.workspaces.workspaceList$.next([]); + render(); + const selectButton = screen.getByTestId('workspace-select-button'); + fireEvent.click(selectButton); + expect(screen.getByText(/no workspace available/i)).toBeInTheDocument(); + }); + + it('should be able to perform search and filter and the results will be shown in both all and recent section', () => { + coreStartMock.workspaces.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1', features: [] }, + { id: 'test-2', name: 'test 2', features: [] }, + ]); + jest + .spyOn(recentWorkspaceManager, 'getRecentWorkspaces') + .mockReturnValue([{ id: 'workspace-1', timestamp: 1234567890 }]); + render(); + + const selectButton = screen.getByTestId('workspace-select-button'); + fireEvent.click(selectButton); + + const searchInput = screen.getByRole('searchbox'); + fireEvent.change(searchInput, { target: { value: 'works' } }); + expect(screen.getByTestId('workspace-menu-item-recent-workspace-1')).toBeInTheDocument(); + expect(screen.getByTestId('workspace-menu-item-recent-workspace-1')).toBeInTheDocument(); + }); + + it('should be able to display empty state when seach is not found', () => { + coreStartMock.workspaces.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1', features: [] }, + { id: 'test-2', name: 'test 2', features: [] }, + ]); + jest + .spyOn(recentWorkspaceManager, 'getRecentWorkspaces') + .mockReturnValue([{ id: 'workspace-1', timestamp: 1234567890 }]); + render(); + + const selectButton = screen.getByTestId('workspace-select-button'); + fireEvent.click(selectButton); + + const searchInput = screen.getByRole('searchbox'); + fireEvent.change(searchInput, { target: { value: 'noitems' } }); + expect(screen.getByText(/no workspace available/i)).toBeInTheDocument(); + }); + + it('should display current workspace name, use case name and associated icon', () => { coreStartMock.workspaces.currentWorkspace$.next({ id: 'workspace-1', name: 'workspace 1', @@ -102,6 +146,7 @@ describe('', () => { fireEvent.click(screen.getByTestId('workspace-select-button')); expect(screen.getByTestId('workspace-menu-current-workspace-name')).toBeInTheDocument(); expect(screen.getByTestId('workspace-menu-current-use-case')).toBeInTheDocument(); + expect(screen.getByTestId('current-workspace-icon-wsObservability')).toBeInTheDocument(); expect(screen.getByText('Observability')).toBeInTheDocument(); }); @@ -155,28 +200,6 @@ describe('', () => { }); }); - it('should navigate to workspace management page', () => { - coreStartMock.workspaces.currentWorkspace$.next({ - id: 'workspace-1', - name: 'workspace 1', - features: ['use-case-observability'], - }); - const navigateToWorkspaceDetail = jest.spyOn(workspaceUtils, 'navigateToWorkspaceDetail'); - render(); - - fireEvent.click(screen.getByTestId('workspace-select-button')); - const button = screen.getByText(/Manage workspace/i); - fireEvent.click(button); - expect(navigateToWorkspaceDetail).toBeCalled(); - }); - - it('should navigate to workspaces management page', () => { - render(); - fireEvent.click(screen.getByTestId('workspace-select-button')); - fireEvent.click(screen.getByText(/manage workspaces/i)); - expect(coreStartMock.application.navigateToApp).toHaveBeenCalledWith('workspace_list'); - }); - it('should navigate to create workspace page', () => { render(); fireEvent.click(screen.getByTestId('workspace-select-button')); @@ -188,7 +211,7 @@ describe('', () => { render(); fireEvent.click(screen.getByTestId('workspace-select-button')); - fireEvent.click(screen.getByText(/View all/i)); + fireEvent.click(screen.getByText(/manage/i)); expect(coreStartMock.application.navigateToApp).toHaveBeenCalledWith('workspace_list'); }); @@ -203,7 +226,7 @@ describe('', () => { render(); fireEvent.click(screen.getByTestId('workspace-select-button')); - expect(screen.getByText(/View all/i)).toBeInTheDocument(); + expect(screen.queryByText(/manage/i)).not.toBeInTheDocument(); expect(screen.queryByText(/create workspaces/i)).toBeNull(); }); }); diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx index d70b6c4419a0..bf36fc3844ff 100644 --- a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx @@ -9,21 +9,20 @@ import { useObservable } from 'react-use'; import { EuiText, EuiPanel, + EuiButton, EuiPopover, - EuiToolTip, - EuiFlexItem, - EuiFlexGroup, - EuiSmallButtonEmpty, - EuiSmallButton, EuiButtonIcon, + EuiFlexItem, EuiIcon, + EuiFlexGroup, + EuiHorizontalRule, + EuiButtonEmpty, } from '@elastic/eui'; import { BehaviorSubject } from 'rxjs'; import { WORKSPACE_CREATE_APP_ID, WORKSPACE_LIST_APP_ID } from '../../../common/constants'; import { CoreStart, WorkspaceObject } from '../../../../../core/public'; import { getFirstUseCaseOfFeatureConfigs } from '../../utils'; import { WorkspaceUseCase } from '../../types'; -import { navigateToWorkspaceDetail } from '../utils/workspace'; import { validateWorkspaceColor } from '../../../common/utils'; import { WorkspacePickerContent } from '../workspace_picker_content/workspace_picker_content'; @@ -35,16 +34,8 @@ const createWorkspaceButton = i18n.translate('workspace.menu.button.createWorksp defaultMessage: 'Create workspace', }); -const viewAllButton = i18n.translate('workspace.menu.button.viewAll', { - defaultMessage: 'View all', -}); - -const manageWorkspaceButton = i18n.translate('workspace.menu.button.manageWorkspace', { - defaultMessage: 'Manage workspace', -}); - const manageWorkspacesButton = i18n.translate('workspace.menu.button.manageWorkspaces', { - defaultMessage: 'Manage workspaces', + defaultMessage: 'Manage', }); const getValidWorkspaceColor = (color?: string) => @@ -97,8 +88,9 @@ export const WorkspaceMenu = ({ coreStart, registeredUseCases$ }: Props) => { closePopover={closePopover} panelPaddingSize="s" anchorPosition="downCenter" + repositionOnScroll={true} > - + { <> - - - - {currentWorkspaceName} - - - - - {getUseCase(currentWorkspace)?.title ?? ''} - - - { - closePopover(); - navigateToWorkspaceDetail(coreStart, currentWorkspace.id); - }} + + {currentWorkspaceName} + - {manageWorkspaceButton} - + {getUseCase(currentWorkspace)?.title ?? ''} + ) : ( <> - + - {currentWorkspaceName} - - - { - closePopover(); - coreStart.application.navigateToApp(WORKSPACE_LIST_APP_ID); - }} - > - {manageWorkspacesButton} - + {currentWorkspaceName} )} - + + setPopover(false)} /> - - - - { - closePopover(); - coreStart.application.navigateToApp(WORKSPACE_LIST_APP_ID); - }} - > - {viewAllButton} - - - {isDashboardAdmin && ( + + {isDashboardAdmin && ( + + + + + { + closePopover(); + coreStart.application.navigateToApp(WORKSPACE_LIST_APP_ID); + }} + > + {manageWorkspacesButton} + + + - { @@ -197,12 +180,12 @@ export const WorkspaceMenu = ({ coreStart, registeredUseCases$ }: Props) => { coreStart.application.navigateToApp(WORKSPACE_CREATE_APP_ID); }} > - {createWorkspaceButton} - + {createWorkspaceButton} + - )} - - + + + )} ); }; diff --git a/src/plugins/workspace/public/components/workspace_picker_content/workspace_picker_content.tsx b/src/plugins/workspace/public/components/workspace_picker_content/workspace_picker_content.tsx index 8aa9e8e97823..72fdd67b7367 100644 --- a/src/plugins/workspace/public/components/workspace_picker_content/workspace_picker_content.tsx +++ b/src/plugins/workspace/public/components/workspace_picker_content/workspace_picker_content.tsx @@ -2,13 +2,22 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ - import { i18n } from '@osd/i18n'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { useObservable } from 'react-use'; -import { EuiTitle, EuiListGroup, EuiListGroupItem, EuiIcon } from '@elastic/eui'; +import { + EuiTitle, + EuiIcon, + EuiPanel, + EuiSpacer, + EuiText, + EuiFieldSearch, + EuiListGroup, + EuiListGroupItem, + EuiEmptyPrompt, +} from '@elastic/eui'; + import { BehaviorSubject } from 'rxjs'; -import { MAX_WORKSPACE_PICKER_NUM } from '../../../common/constants'; import { CoreStart, WorkspaceObject } from '../../../../../core/public'; import { recentWorkspaceManager } from '../../recent_workspace_manager'; import { WorkspaceUseCase } from '../../types'; @@ -19,6 +28,10 @@ const allWorkspacesTitle = i18n.translate('workspace.menu.title.allWorkspaces', defaultMessage: 'All workspaces', }); +const searchFieldPlaceholder = i18n.translate('workspace.menu.search.placeholder', { + defaultMessage: 'Search workspace name', +}); + const recentWorkspacesTitle = i18n.translate('workspace.menu.title.recentWorkspaces', { defaultMessage: 'Recent workspaces', }); @@ -38,20 +51,39 @@ export const WorkspacePickerContent = ({ onClickWorkspace, }: Props) => { const workspaceList = useObservable(coreStart.workspaces.workspaceList$, []); + const isDashboardAdmin = coreStart.application.capabilities?.dashboards?.isDashboardAdmin; const availableUseCases = useObservable(registeredUseCases$, []); + const [search, setSearch] = useState(''); - const filteredWorkspaceList = useMemo(() => { - return workspaceList.slice(0, MAX_WORKSPACE_PICKER_NUM); - }, [workspaceList]); - - const filteredRecentWorkspaces = useMemo(() => { + const recentWorkspaces = useMemo(() => { return recentWorkspaceManager .getRecentWorkspaces() .map((workspace) => workspaceList.find((ws) => ws.id === workspace.id)) - .filter((workspace): workspace is WorkspaceObject => workspace !== undefined) - .slice(0, MAX_WORKSPACE_PICKER_NUM); + .filter((workspace): workspace is WorkspaceObject => workspace !== undefined); }, [workspaceList]); + const queryFromList = ({ list, query }: { list: WorkspaceObject[]; query: string }) => { + if (!list || list.length === 0) { + return []; + } + + if (query && query.trim() !== '') { + const normalizedQuery = query.toLowerCase(); + const result = list.filter((item) => item.name.toLowerCase().includes(normalizedQuery)); + return result; + } + + return list; + }; + + const queriedWorkspace = useMemo(() => { + return queryFromList({ list: workspaceList, query: search }); + }, [workspaceList, search]); + + const queriedRecentWorkspace = useMemo(() => { + return queryFromList({ list: recentWorkspaces, query: search }); + }, [recentWorkspaces, search]); + const getUseCase = (workspace: WorkspaceObject) => { if (!workspace.features) { return; @@ -60,10 +92,44 @@ export const WorkspacePickerContent = ({ return availableUseCases.find((useCase) => useCase.id === useCaseId); }; + const getEmptyStatePrompt = () => { + return ( + +

+ {i18n.translate('workspace.picker.empty.state.title', { + defaultMessage: 'No workspace available', + })} +

+ + } + body={ + +

+ {isDashboardAdmin + ? i18n.translate('workspace.picker.empty.state.description.admin', { + defaultMessage: 'Create a workspace to get start', + }) + : i18n.translate('workspace.picker.empty.state.description.noAdmin', { + defaultMessage: + 'Contact your administrator to create a workspace or to be added to an existing one', + })} +

+
+ } + /> + ); + }; + const getWorkspaceListGroup = (filterWorkspaceList: WorkspaceObject[], itemType: string) => { const listItems = filterWorkspaceList.map((workspace: WorkspaceObject) => { const useCase = getUseCase(workspace); const useCaseURL = getUseCaseUrl(useCase, workspace, coreStart.application, coreStart.http); + return ( - -

{itemType === 'all' ? allWorkspacesTitle : recentWorkspacesTitle}

- - } - /> - {listItems} - + <> + +

{itemType === 'all' ? allWorkspacesTitle : recentWorkspacesTitle}

+
+ + + {listItems} + + + ); }; return ( <> - {filteredRecentWorkspaces.length > 0 && - getWorkspaceListGroup(filteredRecentWorkspaces, 'recent')} - {filteredWorkspaceList.length > 0 && getWorkspaceListGroup(filteredWorkspaceList, 'all')} + setSearch(e.target.value)} + placeholder={searchFieldPlaceholder} + /> + + + + {queriedRecentWorkspace.length > 0 && + getWorkspaceListGroup(queriedRecentWorkspace, 'recent')} + + {queriedWorkspace.length > 0 && getWorkspaceListGroup(queriedWorkspace, 'all')} + + {queriedWorkspace.length === 0 && getEmptyStatePrompt()} + ); }; diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index 2620b4703670..49132b64f467 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -266,28 +266,46 @@ export const getDirectQueryConnections = async (dataSourceId: string, http: Http return directQueryConnections; }; -// Helper function to merge data sources with direct query connections -export const mergeDataSourcesWithConnections = ( - assignedDataSources: DataSource[], - directQueryConnections: DataSourceConnection[] -): DataSourceConnection[] => { - const dataSources: DataSourceConnection[] = []; - assignedDataSources.forEach((ds) => { - const relatedConnections = directQueryConnections.filter( - (directQueryConnection) => directQueryConnection.parentId === ds.id - ); - - dataSources.push({ +export const convertDataSourcesToOpenSearchConnections = ( + dataSources: DataSource[] +): DataSourceConnection[] => + dataSources.map((ds) => { + return { id: ds.id, type: ds.dataSourceEngineType, connectionType: DataSourceConnectionType.OpenSearchConnection, name: ds.title, description: ds.description, + relatedConnections: [], + }; + }); + +export const fulfillRelatedConnections = ( + connections: DataSourceConnection[], + directQueryConnections: DataSourceConnection[] +) => { + return connections.map((connection) => { + const relatedConnections = directQueryConnections.filter( + (directQueryConnection) => directQueryConnection.parentId === connection.id + ); + return { + ...connection, relatedConnections, - }); + }; }); +}; - return [...dataSources, ...directQueryConnections]; +// Helper function to merge data sources with direct query connections +export const mergeDataSourcesWithConnections = ( + dataSources: DataSource[], + directQueryConnections: DataSourceConnection[] +): DataSourceConnection[] => { + const openSearchConnections = convertDataSourcesToOpenSearchConnections(dataSources); + + return [ + ...fulfillRelatedConnections(openSearchConnections, directQueryConnections), + ...directQueryConnections, + ].sort((a, b) => a.name.localeCompare(b.name)); }; // If all connected data sources are serverless, will only allow to select essential use case. @@ -385,8 +403,9 @@ export function prependWorkspaceToBreadcrumbs( return; } - const homeBreadcrumb: ChromeBreadcrumb = { + const homeBreadcrumb: ChromeBreadcrumb & { home: boolean } = { text: 'Home', + home: true, onClick: () => { core.application.navigateToApp('home'); }, @@ -474,21 +493,28 @@ export const getUseCaseUrl = ( return useCaseURL; }; +export const fetchDataSourceConnectionsByDataSourceIds = async ( + dataSourceIds: string[], + http: HttpSetup | undefined +) => { + const directQueryConnectionsPromises = dataSourceIds.map((dataSourceId) => + getDirectQueryConnections(dataSourceId, http!).catch(() => []) + ); + const directQueryConnectionsResult = await Promise.all(directQueryConnectionsPromises); + return directQueryConnectionsResult.flat(); +}; + export const fetchDataSourceConnections = async ( assignedDataSources: DataSource[], http: HttpSetup | undefined, notifications: NotificationsStart | undefined ) => { try { - const directQueryConnectionsPromises = assignedDataSources.map((ds) => - getDirectQueryConnections(ds.id, http!).catch(() => []) + const directQueryConnections = await fetchDataSourceConnectionsByDataSourceIds( + assignedDataSources.map((ds) => ds.id), + http ); - const directQueryConnectionsResult = await Promise.all(directQueryConnectionsPromises); - const directQueryConnections = directQueryConnectionsResult.flat(); - return mergeDataSourcesWithConnections( - assignedDataSources, - directQueryConnections - ).sort((a, b) => a.name.localeCompare(b.name)); + return mergeDataSourcesWithConnections(assignedDataSources, directQueryConnections); } catch (error) { notifications?.toasts.addDanger( i18n.translate('workspace.detail.dataSources.error.message', { diff --git a/src/plugins/workspace/server/permission_control/client.test.ts b/src/plugins/workspace/server/permission_control/client.test.ts index a585710da54d..4dfb3197b0dd 100644 --- a/src/plugins/workspace/server/permission_control/client.test.ts +++ b/src/plugins/workspace/server/permission_control/client.test.ts @@ -10,7 +10,7 @@ import { httpServiceMock, savedObjectsClientMock, } from '../../../../core/server/mocks'; -import * as utilsExports from '../utils'; +import * as utilsExports from '../../../../core/server/utils/auth_info'; describe('PermissionControl', () => { jest.spyOn(utilsExports, 'getPrincipalsFromRequest').mockImplementation(() => ({ diff --git a/src/plugins/workspace/server/permission_control/client.ts b/src/plugins/workspace/server/permission_control/client.ts index 0850690325f1..3d052c3fe2f6 100644 --- a/src/plugins/workspace/server/permission_control/client.ts +++ b/src/plugins/workspace/server/permission_control/client.ts @@ -16,7 +16,7 @@ import { HttpAuth, } from '../../../../core/server'; import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../../common/constants'; -import { getPrincipalsFromRequest } from '../utils'; +import { getPrincipalsFromRequest } from '../../../../core/server/utils'; export type SavedObjectsPermissionControlContract = Pick< SavedObjectsPermissionControl, diff --git a/src/plugins/workspace/server/plugin.test.ts b/src/plugins/workspace/server/plugin.test.ts index 433fb2703f00..b9a402c306f9 100644 --- a/src/plugins/workspace/server/plugin.test.ts +++ b/src/plugins/workspace/server/plugin.test.ts @@ -7,12 +7,14 @@ import { OnPostAuthHandler, OnPreRoutingHandler } from 'src/core/server'; import { coreMock, httpServerMock, uiSettingsServiceMock } from '../../../core/server/mocks'; import { WorkspacePlugin } from './plugin'; import { getWorkspaceState, updateWorkspaceState } from '../../../core/server/utils'; +import * as serverUtils from '../../../core/server/utils/auth_info'; import * as utilsExports from './utils'; import { SavedObjectsPermissionControl } from './permission_control/client'; describe('Workspace server plugin', () => { afterEach(() => { jest.clearAllMocks(); + jest.restoreAllMocks(); }); it('#setup', async () => { @@ -132,7 +134,7 @@ describe('Workspace server plugin', () => { it('with yml config', async () => { jest - .spyOn(utilsExports, 'getPrincipalsFromRequest') + .spyOn(serverUtils, 'getPrincipalsFromRequest') .mockImplementation(() => ({ users: [`user1`] })); jest .spyOn(utilsExports, 'getOSDAdminConfigFromYMLConfig') @@ -150,7 +152,7 @@ describe('Workspace server plugin', () => { }); it('uninstall security plugin', async () => { - jest.spyOn(utilsExports, 'getPrincipalsFromRequest').mockImplementation(() => ({})); + jest.spyOn(serverUtils, 'getPrincipalsFromRequest').mockImplementation(() => ({})); await workspacePlugin.setup(setupMock); const toolKitMock = httpServerMock.createToolkit(); @@ -164,7 +166,7 @@ describe('Workspace server plugin', () => { }); it('should clear saved objects cache', async () => { - jest.spyOn(utilsExports, 'getPrincipalsFromRequest').mockImplementation(() => ({})); + jest.spyOn(serverUtils, 'getPrincipalsFromRequest').mockImplementation(() => ({})); const clearSavedObjectsCacheMock = jest .spyOn(SavedObjectsPermissionControl.prototype, 'clearSavedObjectsCache') .mockImplementationOnce(() => {}); diff --git a/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts b/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts index 6aeb2ebc8610..607d55d8d5dd 100644 --- a/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts +++ b/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts @@ -16,7 +16,7 @@ import { SavedObjectsClientContract, } from '../../../../../core/server'; import { httpServerMock } from '../../../../../../src/core/server/mocks'; -import * as utilsExports from '../../utils'; +import * as utilsExports from '../../../../../core/server/utils/auth_info'; import { updateWorkspaceState } from '../../../../../core/server/utils'; const repositoryKit = (() => { diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts index 85d372b76177..ea105cc21b67 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts @@ -9,8 +9,9 @@ import { WorkspaceSavedObjectsClientWrapper } from './workspace_saved_objects_cl import { httpServerMock } from '../../../../core/server/mocks'; import { DATA_SOURCE_SAVED_OBJECT_TYPE } from '../../../data_source/common'; -const DASHBOARD_ADMIN = 'dashnoard_admin'; -const NO_DASHBOARD_ADMIN = 'no_dashnoard_admin'; +const DASHBOARD_ADMIN = 'dashboard_admin'; +const NO_DASHBOARD_ADMIN = 'no_dashboard_admin'; +const DATASOURCE_ADMIN = 'dataSource_admin'; const generateWorkspaceSavedObjectsClientWrapper = (role = NO_DASHBOARD_ADMIN) => { const savedObjectsStore = [ @@ -107,10 +108,18 @@ const generateWorkspaceSavedObjectsClientWrapper = (role = NO_DASHBOARD_ADMIN) = }; }), deleteByWorkspace: jest.fn(), + addToWorkspaces: jest.fn(), + deleteFromWorkspaces: jest.fn(), }; const requestMock = httpServerMock.createOpenSearchDashboardsRequest(); updateWorkspaceState(requestMock, { requestWorkspaceId: 'mock-request-workspace-id' }); - if (role === DASHBOARD_ADMIN) updateWorkspaceState(requestMock, { isDashboardAdmin: true }); + if (role === DASHBOARD_ADMIN) { + updateWorkspaceState(requestMock, { isDashboardAdmin: true }); + } + if (role === DATASOURCE_ADMIN) { + updateWorkspaceState(requestMock, { isDataSourceAdmin: true }); + } + const wrapperOptions = { client: clientMock, request: requestMock, @@ -600,6 +609,17 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { workspaces: ['workspace-1'], }); }); + + it('should not validate data source when user is data source admin', async () => { + const { wrapper } = generateWorkspaceSavedObjectsClientWrapper(DATASOURCE_ADMIN); + const result = await wrapper.get('data-source', 'workspace-1-data-source'); + expect(result).toEqual({ + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + id: 'workspace-1-data-source', + attributes: { title: 'Workspace 1 data source' }, + workspaces: ['workspace-1'], + }); + }); }); describe('bulk get', () => { it("should call permission validate with object's workspace and throw permission error", async () => { @@ -772,6 +792,23 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { workspaces: ['workspace-1'], }); }); + it('should call client.find without ACLSearchParams and workspaceOperator', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper( + DATASOURCE_ADMIN + ); + await wrapper.find({ + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + }); + expect(clientMock.find).toHaveBeenCalledWith({ + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + }); + await wrapper.find({ + type: [DATA_SOURCE_SAVED_OBJECT_TYPE], + }); + expect(clientMock.find).toHaveBeenCalledWith({ + type: [DATA_SOURCE_SAVED_OBJECT_TYPE], + }); + }); }); describe('deleteByWorkspace', () => { @@ -912,6 +949,68 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { expect(clientMock.deleteByWorkspace).toHaveBeenCalledWith('not-permitted-workspace'); expect(permissionControlMock.validate).not.toHaveBeenCalled(); }); + it('should bypass permission check for call client.addToWorkspaces', async () => { + await wrapper.addToWorkspaces( + DATA_SOURCE_SAVED_OBJECT_TYPE, + 'data-source-id', + ['workspace-1'], + {} + ); + expect(clientMock.addToWorkspaces).toHaveBeenCalledWith( + DATA_SOURCE_SAVED_OBJECT_TYPE, + 'data-source-id', + ['workspace-1'], + {} + ); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + }); + it('should bypass permission check for call client.deleteFromWorkspaces', async () => { + await wrapper.deleteFromWorkspaces( + DATA_SOURCE_SAVED_OBJECT_TYPE, + 'data-source-id', + ['workspace-1'], + {} + ); + expect(clientMock.deleteFromWorkspaces).toHaveBeenCalledWith( + DATA_SOURCE_SAVED_OBJECT_TYPE, + 'data-source-id', + ['workspace-1'], + {} + ); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + }); + }); + + describe('addToWorkspaces', () => { + it('should throw error when non dashboard admin add data source to workspaces', async () => { + const { wrapper } = generateWorkspaceSavedObjectsClientWrapper(); + + let errorCatch; + try { + await wrapper.addToWorkspaces(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id', [ + 'workspace-id', + ]); + } catch (e) { + errorCatch = e; + } + expect(errorCatch.message).toEqual('Invalid permission, please contact OSD admin'); + }); + }); + + describe('deleteFromWorkspaces', () => { + it('should throw error when non dashboard admin delete data source from workspaces', async () => { + const { wrapper } = generateWorkspaceSavedObjectsClientWrapper(); + + let errorCatch; + try { + await wrapper.deleteFromWorkspaces(DATA_SOURCE_SAVED_OBJECT_TYPE, 'data-source-id', [ + 'workspace-id', + ]); + } catch (e) { + errorCatch = e; + } + expect(errorCatch.message).toEqual('Invalid permission, please contact OSD admin'); + }); }); }); }); 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 c5025da8c9b9..1c366f22fff9 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 @@ -415,6 +415,9 @@ export class WorkspaceSavedObjectsClientWrapper { const objectToGet = await wrapperOptions.client.get(type, id, options); if (objectToGet.type === DATA_SOURCE_SAVED_OBJECT_TYPE) { + if (isDataSourceAdmin) { + return objectToGet; + } const hasPermission = this.validateDataSourcePermissions( objectToGet, wrapperOptions.request @@ -475,6 +478,16 @@ export class WorkspaceSavedObjectsClientWrapper { const findWithWorkspacePermissionControl = async ( options: SavedObjectsFindOptions ) => { + if ( + isDataSourceAdmin && + options?.type && + (options.type === DATA_SOURCE_SAVED_OBJECT_TYPE || + (Array.isArray(options.type) && + options.type.length === 1 && + options.type[0] === DATA_SOURCE_SAVED_OBJECT_TYPE)) + ) { + return await wrapperOptions.client.find(options); + } const principals = this.permissionControl.getPrincipalsFromRequest(wrapperOptions.request); const permittedWorkspaceIds = ( await this.getWorkspaceTypeEnabledClient(wrapperOptions.request).find({ @@ -535,7 +548,35 @@ export class WorkspaceSavedObjectsClientWrapper { return await wrapperOptions.client.deleteByWorkspace(workspace, options); }; - const isDashboardAdmin = getWorkspaceState(wrapperOptions.request)?.isDashboardAdmin; + const addToWorkspacesWithPermissionControl = async ( + type: string, + id: string, + targetWorkspaces: string[], + options: SavedObjectsBaseOptions = {} + ) => { + // Only dashboard admin can assign data source to workspace + if (type === DATA_SOURCE_SAVED_OBJECT_TYPE) { + throw generateOSDAdminPermissionError(); + } + // In current version, only the type is data-source that will call addToWorkspaces + return await wrapperOptions.client.addToWorkspaces(type, id, targetWorkspaces, options); + }; + + const deleteFromWorkspacesWithPermissionControl = async ( + type: string, + id: string, + targetWorkspaces: string[], + options: SavedObjectsBaseOptions = {} + ) => { + // Only dashboard admin can unassign data source to workspace + if (type === DATA_SOURCE_SAVED_OBJECT_TYPE) { + throw generateOSDAdminPermissionError(); + } + // In current version, only the type is data-source will that call deleteFromWorkspaces + return await wrapperOptions.client.deleteFromWorkspaces(type, id, targetWorkspaces, options); + }; + + const { isDashboardAdmin, isDataSourceAdmin } = getWorkspaceState(wrapperOptions.request) || {}; if (isDashboardAdmin) { return wrapperOptions.client; } @@ -555,6 +596,8 @@ export class WorkspaceSavedObjectsClientWrapper { update: updateWithWorkspacePermissionControl, bulkUpdate: bulkUpdateWithWorkspacePermissionControl, deleteByWorkspace: deleteByWorkspaceWithPermissionControl, + addToWorkspaces: addToWorkspacesWithPermissionControl, + deleteFromWorkspaces: deleteFromWorkspacesWithPermissionControl, }; }; diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts index b21f31e3accd..a52ab42183fb 100644 --- a/src/plugins/workspace/server/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -135,11 +135,6 @@ export type IResponse = error?: string; }; -export interface AuthInfo { - backend_roles?: string[]; - user_name?: string; -} - export interface WorkspacePluginSetup { client: IWorkspaceClientImpl; } diff --git a/src/plugins/workspace/server/utils.test.ts b/src/plugins/workspace/server/utils.test.ts index ba7532c216eb..f57c92fb4cdc 100644 --- a/src/plugins/workspace/server/utils.test.ts +++ b/src/plugins/workspace/server/utils.test.ts @@ -13,7 +13,6 @@ import { import { generateRandomId, getOSDAdminConfigFromYMLConfig, - getPrincipalsFromRequest, updateDashboardAdminStateForRequest, transferCurrentUserInPermissions, getDataSourcesList, @@ -24,7 +23,6 @@ import { Observable, of } from 'rxjs'; import { DEFAULT_DATA_SOURCE_UI_SETTINGS_ID } from '../../data_source_management/common'; describe('workspace utils', () => { - const mockAuth = httpServiceMock.createAuth(); it('should generate id with the specified size', () => { expect(generateRandomId(6)).toHaveLength(6); }); @@ -38,58 +36,6 @@ describe('workspace utils', () => { expect(ids.size).toBe(NUM_OF_ID); }); - it('should return empty map when request do not have authentication', () => { - const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); - mockAuth.get.mockReturnValueOnce({ - status: AuthStatus.unknown, - state: { - authInfo: { - user_name: 'bar', - backend_roles: ['foo'], - }, - }, - }); - const result = getPrincipalsFromRequest(mockRequest, mockAuth); - expect(result).toEqual({}); - }); - - it('should return normally when request has authentication', () => { - const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); - mockAuth.get.mockReturnValueOnce({ - status: AuthStatus.authenticated, - state: { - authInfo: { - user_name: 'bar', - backend_roles: ['foo'], - }, - }, - }); - const result = getPrincipalsFromRequest(mockRequest, mockAuth); - expect(result.users).toEqual(['bar']); - expect(result.groups).toEqual(['foo']); - }); - - it('should throw error when request is not authenticated', () => { - const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); - mockAuth.get.mockReturnValueOnce({ - status: AuthStatus.unauthenticated, - state: {}, - }); - expect(() => getPrincipalsFromRequest(mockRequest, mockAuth)).toThrow('NOT_AUTHORIZED'); - }); - - it('should throw error when authentication status is not expected', () => { - const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); - mockAuth.get.mockReturnValueOnce({ - // @ts-ignore - status: 'foo', - state: {}, - }); - expect(() => getPrincipalsFromRequest(mockRequest, mockAuth)).toThrow( - 'UNEXPECTED_AUTHORIZATION_STATUS' - ); - }); - it('should be dashboard admin when users match configUsers', () => { const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); const groups: string[] = ['dashboard_admin']; diff --git a/src/plugins/workspace/server/utils.ts b/src/plugins/workspace/server/utils.ts index 9f144c7eb1c3..b8c2b7613839 100644 --- a/src/plugins/workspace/server/utils.ts +++ b/src/plugins/workspace/server/utils.ts @@ -7,17 +7,12 @@ import crypto from 'crypto'; import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; import { - AuthStatus, - HttpAuth, OpenSearchDashboardsRequest, - Principals, - PrincipalType, SharedGlobalConfig, Permissions, SavedObjectsClientContract, IUiSettingsClient, } from '../../../core/server'; -import { AuthInfo } from './types'; import { updateWorkspaceState } from '../../../core/server/utils'; import { DEFAULT_DATA_SOURCE_UI_SETTINGS_ID } from '../../data_source_management/common'; import { CURRENT_USER_PLACEHOLDER } from '../common/constants'; @@ -29,37 +24,6 @@ export const generateRandomId = (size: number) => { return crypto.randomBytes(size).toString('base64url').slice(0, size); }; -export const getPrincipalsFromRequest = ( - request: OpenSearchDashboardsRequest, - auth?: HttpAuth -): Principals => { - const payload: Principals = {}; - const authInfoResp = auth?.get(request); - if (authInfoResp?.status === AuthStatus.unknown) { - /** - * Login user have access to all the workspaces when no authentication is presented. - */ - return payload; - } - - if (authInfoResp?.status === AuthStatus.authenticated) { - const authInfo = authInfoResp?.state as { authInfo: AuthInfo } | null; - if (authInfo?.authInfo?.backend_roles) { - payload[PrincipalType.Groups] = authInfo.authInfo.backend_roles; - } - if (authInfo?.authInfo?.user_name) { - payload[PrincipalType.Users] = [authInfo.authInfo.user_name]; - } - return payload; - } - - if (authInfoResp?.status === AuthStatus.unauthenticated) { - throw new Error('NOT_AUTHORIZED'); - } - - throw new Error('UNEXPECTED_AUTHORIZATION_STATUS'); -}; - export const updateDashboardAdminStateForRequest = ( request: OpenSearchDashboardsRequest, groups: string[],