diff --git a/changelogs/fragments/7214.yml b/changelogs/fragments/7214.yml new file mode 100644 index 000000000000..7369e06aa40a --- /dev/null +++ b/changelogs/fragments/7214.yml @@ -0,0 +1,2 @@ +feat: +- [DataSource] Restrict to edit data source on the DSM UI. ([#7214](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7214)) \ No newline at end of file diff --git a/changelogs/fragments/7230.yml b/changelogs/fragments/7230.yml new file mode 100644 index 000000000000..5a7be07b21ed --- /dev/null +++ b/changelogs/fragments/7230.yml @@ -0,0 +1,2 @@ +feat: +- [navigation-next] Add new left navigation ([#7230](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7230)) \ No newline at end of file diff --git a/changelogs/fragments/7241.yml b/changelogs/fragments/7241.yml new file mode 100644 index 000000000000..fc0c3bd7ded7 --- /dev/null +++ b/changelogs/fragments/7241.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace] Support workspace detail page ([#7241](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7241)) \ No newline at end of file diff --git a/changelogs/fragments/7279.yml b/changelogs/fragments/7279.yml new file mode 100644 index 000000000000..794bed4a448d --- /dev/null +++ b/changelogs/fragments/7279.yml @@ -0,0 +1,2 @@ +fix: +- Unassign data source before deleteByWorkspace ([#7279](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7279)) \ No newline at end of file diff --git a/changelogs/fragments/7291.yml b/changelogs/fragments/7291.yml new file mode 100644 index 000000000000..8d1d6b4f8a63 --- /dev/null +++ b/changelogs/fragments/7291.yml @@ -0,0 +1,2 @@ +deprecate: +- Remove data enhancements config and readonly flag. Removes dead url link, ([#7291](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7291)) \ No newline at end of file diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 61f7522b5dae..7eaae83a1365 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -321,6 +321,12 @@ # AWSSigV4: # enabled: true +# Optional setting that controls the permissions of data source to create, update and delete. +# "none": The data source is readonly for all users. +# "dashboard_admin": The data source can only be managed by dashboard admin. +# "all": The data source can be managed by all users. Default to "all". +# data_source.manageableBy: "all" + # 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" @@ -353,9 +359,6 @@ # This publishes the Application Usage and UI Metrics into the saved object, which can be accessed by /api/stats?extended=true&legacy=true&exclude_usage=false # usageCollection.uiMetric.enabled: false -# Set the value to true to enable enhancements for the data plugin -# data.enhancements.enabled: false - # Set the backend roles in groups or users, whoever has the backend roles or exactly match the user ids defined in this config will be regard as dashboard admin. # Dashboard admin will have the access to all the workspaces(workspace.enabled: true) and objects inside OpenSearch Dashboards. # opensearchDashboards.dashboardAdmin.groups: ["dashboard_admin"] diff --git a/package.json b/package.json index 5ae716531b86..b7de9f2f50bf 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "start": "scripts/use_node scripts/opensearch_dashboards --dev", "start:docker": "scripts/use_node scripts/opensearch_dashboards --dev --opensearch.hosts=$OPENSEARCH_HOSTS --opensearch.ignoreVersionMismatch=true --server.host=$SERVER_HOST", "start:security": "scripts/use_node scripts/opensearch_dashboards --dev --security", - "start:enhancements": "scripts/use_node scripts/opensearch_dashboards --dev --data.enhancements.enabled=true --data_source.enabled=true --uiSettings.overrides['query:enhancements:enabled']=true --uiSettings.overrides['query:dataSource:readOnly']=false", + "start:enhancements": "scripts/use_node scripts/opensearch_dashboards --dev --data_source.enabled=true --uiSettings.overrides['query:enhancements:enabled']=true", "debug": "scripts/use_node --nolazy --inspect scripts/opensearch_dashboards --dev", "debug-break": "scripts/use_node --nolazy --inspect-brk scripts/opensearch_dashboards --dev", "lint": "yarn run lint:es && yarn run lint:style", diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index dd595c31f456..446b04d4d8b1 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -71,9 +71,11 @@ const createStartContractMock = () => { registerLeft: jest.fn(), registerCenter: jest.fn(), registerRight: jest.fn(), + registerLeftBottom: jest.fn(), getLeft$: jest.fn(), getCenter$: jest.fn(), getRight$: jest.fn(), + getLeftBottom$: jest.fn(), }, navGroup: { getNavGroupsMap$: jest.fn(() => new BehaviorSubject({})), diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index c6e600d39fd6..fcc178bf918e 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -294,6 +294,7 @@ export class ChromeService { navControlsRight$={navControls.getRight$()} navControlsExpandedCenter$={navControls.getExpandedCenter$()} navControlsExpandedRight$={navControls.getExpandedRight$()} + navControlsLeftBottom$={navControls.getLeftBottom$()} onIsLockedUpdate={setIsNavDrawerLocked} isLocked$={getIsNavDrawerLocked$} branding={injectedMetadata.getBranding()} @@ -302,7 +303,9 @@ export class ChromeService { collapsibleNavHeaderRender={this.collapsibleNavHeaderRender} sidecarConfig$={sidecarConfig$} navGroupEnabled={navGroup.getNavGroupEnabled()} - currentNavgroup$={navGroup.getCurrentNavGroup$()} + currentNavGroup$={navGroup.getCurrentNavGroup$()} + navGroupsMap$={navGroup.getNavGroupsMap$()} + setCurrentNavGroup={navGroup.setCurrentNavGroup} /> ), diff --git a/src/core/public/chrome/nav_controls/nav_controls_service.test.ts b/src/core/public/chrome/nav_controls/nav_controls_service.test.ts index 6e2a71537e17..a3d73168c789 100644 --- a/src/core/public/chrome/nav_controls/nav_controls_service.test.ts +++ b/src/core/public/chrome/nav_controls/nav_controls_service.test.ts @@ -143,4 +143,24 @@ describe('RecentlyAccessed#start()', () => { ]); }); }); + + describe('expanded left bottom controls', () => { + it('allows registration', async () => { + const navControls = getStart(); + const nc = { mount: jest.fn() }; + navControls.registerLeftBottom(nc); + expect(await navControls.getLeftBottom$().pipe(take(1)).toPromise()).toEqual([nc]); + }); + + it('sorts controls by order property', async () => { + const navControls = getStart(); + const nc1 = { mount: jest.fn(), order: 10 }; + const nc2 = { mount: jest.fn(), order: 0 }; + const nc3 = { mount: jest.fn(), order: 20 }; + navControls.registerLeftBottom(nc1); + navControls.registerLeftBottom(nc2); + navControls.registerLeftBottom(nc3); + expect(await navControls.getLeftBottom$().pipe(take(1)).toPromise()).toEqual([nc2, nc1, nc3]); + }); + }); }); diff --git a/src/core/public/chrome/nav_controls/nav_controls_service.ts b/src/core/public/chrome/nav_controls/nav_controls_service.ts index 57298dac39ff..19135cbf866c 100644 --- a/src/core/public/chrome/nav_controls/nav_controls_service.ts +++ b/src/core/public/chrome/nav_controls/nav_controls_service.ts @@ -62,12 +62,16 @@ export interface ChromeNavControls { registerRight(navControl: ChromeNavControl): void; /** Register a nav control to be presented on the top-center side of the chrome header. */ registerCenter(navControl: ChromeNavControl): void; + /** Register a nav control to be presented on the left-bottom side of the left navigation. */ + registerLeftBottom(navControl: ChromeNavControl): void; /** @internal */ getLeft$(): Observable; /** @internal */ getRight$(): Observable; /** @internal */ getCenter$(): Observable; + /** @internal */ + getLeftBottom$(): Observable; } /** @internal */ @@ -82,6 +86,7 @@ export class NavControlsService { const navControlsExpandedCenter$ = new BehaviorSubject>( new Set() ); + const navControlsLeftBottom$ = new BehaviorSubject>(new Set()); return { // In the future, registration should be moved to the setup phase. This @@ -105,6 +110,11 @@ export class NavControlsService { new Set([...navControlsExpandedCenter$.value.values(), navControl]) ), + registerLeftBottom: (navControl: ChromeNavControl) => + navControlsLeftBottom$.next( + new Set([...navControlsLeftBottom$.value.values(), navControl]) + ), + getLeft$: () => navControlsLeft$.pipe( map((controls) => sortBy([...controls.values()], 'order')), @@ -130,6 +140,11 @@ export class NavControlsService { map((controls) => sortBy([...controls.values()], 'order')), takeUntil(this.stop$) ), + getLeftBottom$: () => + navControlsLeftBottom$.pipe( + map((controls) => sortBy([...controls.values()], 'order')), + takeUntil(this.stop$) + ), }; } diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap new file mode 100644 index 000000000000..61fb739ad6c2 --- /dev/null +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap @@ -0,0 +1,527 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render correctly 1`] = ` +
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+`; + +exports[` should render correctly 2`] = ` +
+
+
+
+
+
+
+
+
+
+
+`; + +exports[` should show all use case by default and able to click see all 1`] = ` +
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+`; + +exports[` should render correctly 1`] = ` +
+
+ +
+
+`; 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 ec32ff17cf95..597cb26f7e45 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 @@ -304,7 +304,7 @@ exports[`Header handles visibility and lock changes 1`] = ` "thrownError": null, } } - currentNavgroup$={ + currentNavGroup$={ BehaviorSubject { "_isScalar": false, "_value": undefined, @@ -1739,6 +1739,17 @@ exports[`Header handles visibility and lock changes 1`] = ` "thrownError": null, } } + navControlsLeftBottom$={ + BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + } + } navControlsRight$={ BehaviorSubject { "_isScalar": false, @@ -1789,6 +1800,17 @@ exports[`Header handles visibility and lock changes 1`] = ` } } navGroupEnabled={false} + navGroupsMap$={ + BehaviorSubject { + "_isScalar": false, + "_value": Object {}, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + } + } navLinks$={ BehaviorSubject { "_isScalar": false, @@ -1977,6 +1999,7 @@ exports[`Header handles visibility and lock changes 1`] = ` "thrownError": null, } } + setCurrentNavGroup={[MockFunction]} sidecarConfig$={ BehaviorSubject { "_isScalar": false, @@ -7173,7 +7196,7 @@ exports[`Header renders condensed header 1`] = ` "thrownError": null, } } - currentNavgroup$={ + currentNavGroup$={ BehaviorSubject { "_isScalar": false, "_value": undefined, @@ -8490,6 +8513,17 @@ exports[`Header renders condensed header 1`] = ` "thrownError": null, } } + navControlsLeftBottom$={ + BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + } + } navControlsRight$={ BehaviorSubject { "_isScalar": false, @@ -8540,6 +8574,17 @@ exports[`Header renders condensed header 1`] = ` } } navGroupEnabled={false} + navGroupsMap$={ + BehaviorSubject { + "_isScalar": false, + "_value": Object {}, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + } + } navLinks$={ BehaviorSubject { "_isScalar": false, @@ -8678,6 +8723,7 @@ exports[`Header renders condensed header 1`] = ` "thrownError": null, } } + setCurrentNavGroup={[MockFunction]} sidecarConfig$={ BehaviorSubject { "_isScalar": false, diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.scss b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.scss new file mode 100644 index 000000000000..50e822bae295 --- /dev/null +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.scss @@ -0,0 +1,49 @@ +.context-nav-wrapper { + border: none !important; + + .nav-link-item { + padding: $ouiSize / 4 $ouiSize; + border-radius: $ouiSize; + box-shadow: none; + margin-bottom: 0; + margin-top: 0; + + &::after { + display: none; + } + + .nav-link-item-btn { + margin-bottom: 0; + + &::after { + display: none; + } + } + } + + .nav-nested-item { + .nav-link-item-btn { + padding-left: 0; + padding-right: 0; + } + } + + .left-navigation-wrapper { + display: flex; + flex-direction: column; + border-right: $ouiBorderThin; + } + + .scrollable-container { + flex: 1; + } + + .bottom-container { + padding: 0 $ouiSize; + display: flex; + } + + .nav-controls-padding { + padding: $ouiSize; + } +} diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.test.tsx new file mode 100644 index 000000000000..b08029553b50 --- /dev/null +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.test.tsx @@ -0,0 +1,226 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { BehaviorSubject } from 'rxjs'; +import { fireEvent, render } from '@testing-library/react'; +import { StubBrowserStorage } from 'test_utils/stub_browser_storage'; +import { + CollapsibleNavGroupEnabled, + CollapsibleNavGroupEnabledProps, + NavGroups, +} from './collapsible_nav_group_enabled'; +import { ChromeNavLink } from '../../nav_links'; +import { ChromeRegistrationNavLink, NavGroupItemInMap } from '../../nav_group'; +import { httpServiceMock } from '../../../mocks'; +import { getLogos } from '../../../../common'; +import { ALL_USE_CASE_ID, DEFAULT_NAV_GROUPS } from '../../../../public'; +import { CollapsibleNavTopProps } from './collapsible_nav_group_enabled_top'; + +jest.mock('./collapsible_nav_group_enabled_top', () => ({ + CollapsibleNavTop: (props: CollapsibleNavTopProps) => ( + + ), +})); + +const mockBasePath = httpServiceMock.createSetupContract({ basePath: '/test' }).basePath; + +describe('', () => { + const getMockedNavLink = ( + navLink: Partial + ): ChromeNavLink & ChromeRegistrationNavLink => ({ + baseUrl: '', + href: '', + id: '', + title: '', + ...navLink, + }); + it('should render correctly', () => { + const navigateToApp = jest.fn(); + const onNavItemClick = jest.fn(); + const { container, getByTestId } = render( + + ); + expect(container).toMatchSnapshot(); + expect(container.querySelectorAll('.nav-link-item-btn').length).toEqual(5); + fireEvent.click(getByTestId('collapsibleNavAppLink-pure')); + expect(navigateToApp).toBeCalledWith('pure'); + }); +}); + +describe('', () => { + function mockProps( + props?: Partial & { + navGroupsMap?: Record; + } + ): CollapsibleNavGroupEnabledProps { + const currentNavGroup$ = new BehaviorSubject(undefined); + const navGroupsMap$ = new BehaviorSubject>({ + [ALL_USE_CASE_ID]: { + ...DEFAULT_NAV_GROUPS[ALL_USE_CASE_ID], + navLinks: [ + { + id: 'link-in-all', + title: 'link-in-all', + }, + ], + }, + [DEFAULT_NAV_GROUPS.observability.id]: { + ...DEFAULT_NAV_GROUPS.observability, + navLinks: [ + { + id: 'link-in-observability', + title: 'link-in-observability', + showInAllNavGroup: true, + }, + ], + }, + ...props?.navGroupsMap, + }); + return { + appId$: new BehaviorSubject('test'), + basePath: mockBasePath, + id: 'collapsibe-nav', + isLocked: false, + isNavOpen: false, + navLinks$: new BehaviorSubject([ + { + id: 'link-in-all', + title: 'link-in-all', + baseUrl: '', + href: '', + }, + { + id: 'link-in-observability', + title: 'link-in-observability', + baseUrl: '', + href: '', + }, + { + id: 'link-in-analytics', + title: 'link-in-analytics', + baseUrl: '', + href: '', + }, + ]), + storage: new StubBrowserStorage(), + onIsLockedUpdate: () => {}, + closeNav: () => {}, + navigateToApp: () => Promise.resolve(), + navigateToUrl: () => Promise.resolve(), + customNavLink$: new BehaviorSubject(undefined), + logos: getLogos({}, mockBasePath.serverBasePath), + navGroupsMap$, + navControlsLeftBottom$: new BehaviorSubject([]), + currentNavGroup$, + setCurrentNavGroup: (val: string | undefined) => { + if (val) { + const currentNavGroup = navGroupsMap$.getValue()[val]; + if (currentNavGroup) { + currentNavGroup$.next(currentNavGroup); + } + } else { + currentNavGroup$.next(undefined); + } + }, + ...props, + }; + } + it('should render correctly', () => { + const props = mockProps({ + isNavOpen: true, + navGroupsMap: { + [DEFAULT_NAV_GROUPS.analytics.id]: { + ...DEFAULT_NAV_GROUPS.analytics, + navLinks: [ + { + id: 'link-in-analytics', + title: 'link-in-analytics', + showInAllNavGroup: true, + }, + ], + }, + }, + }); + const { container } = render(); + expect(container).toMatchSnapshot(); + const { container: isNavOpenCloseContainer } = render( + + ); + expect(isNavOpenCloseContainer).toMatchSnapshot(); + }); + + it('should render correctly when only one visible use case is provided', () => { + const props = mockProps(); + const { getAllByTestId } = render(); + expect(getAllByTestId('collapsibleNavAppLink-link-in-observability').length).toEqual(1); + }); + + it('should show all use case by default and able to click see all', async () => { + const props = mockProps({ + navGroupsMap: { + [DEFAULT_NAV_GROUPS.analytics.id]: { + ...DEFAULT_NAV_GROUPS.analytics, + navLinks: [ + { + id: 'link-in-analytics', + title: 'link-in-analytics', + showInAllNavGroup: true, + }, + ], + }, + }, + }); + const { container, getAllByTestId, getByTestId } = render( + + ); + fireEvent.click(getAllByTestId('collapsibleNavAppLink-link-in-analytics')[1]); + expect(getAllByTestId('collapsibleNavAppLink-link-in-analytics').length).toEqual(1); + expect(container).toMatchSnapshot(); + fireEvent.click(getByTestId('back')); + expect(getAllByTestId('collapsibleNavAppLink-link-in-analytics').length).toEqual(2); + }); +}); diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx new file mode 100644 index 000000000000..0575dc997fc7 --- /dev/null +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx @@ -0,0 +1,347 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './collapsible_nav_group_enabled.scss'; +import { + EuiFlexItem, + EuiFlyout, + EuiSideNavItemType, + EuiSideNav, + EuiPanel, + EuiText, + EuiHorizontalRule, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import React, { useMemo } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import * as Rx from 'rxjs'; +import classNames from 'classnames'; +import { ChromeNavControl, ChromeNavLink } from '../..'; +import { NavGroupStatus } from '../../../../types'; +import { InternalApplicationStart } from '../../../application/types'; +import { HttpStart } from '../../../http'; +import { OnIsLockedUpdate } from './'; +import { createEuiListItem } from './nav_link'; +import type { Logos } from '../../../../common/types'; +import { + ChromeNavGroupServiceStartContract, + ChromeRegistrationNavLink, + NavGroupItemInMap, +} from '../../nav_group'; +import { + fulfillRegistrationLinksToChromeNavLinks, + getOrderedLinksOrCategories, + LinkItem, + LinkItemType, +} from '../../utils'; +import { ALL_USE_CASE_ID } from '../../../../../core/utils'; +import { CollapsibleNavTop } from './collapsible_nav_group_enabled_top'; +import { HeaderNavControls } from './header_nav_controls'; + +export interface CollapsibleNavGroupEnabledProps { + appId$: InternalApplicationStart['currentAppId$']; + basePath: HttpStart['basePath']; + id: string; + isLocked: boolean; + isNavOpen: boolean; + navLinks$: Rx.Observable; + storage?: Storage; + onIsLockedUpdate: OnIsLockedUpdate; + closeNav: () => void; + navigateToApp: InternalApplicationStart['navigateToApp']; + navigateToUrl: InternalApplicationStart['navigateToUrl']; + customNavLink$: Rx.Observable; + logos: Logos; + navGroupsMap$: Rx.Observable>; + navControlsLeftBottom$: Rx.Observable; + currentNavGroup$: Rx.Observable; + setCurrentNavGroup: ChromeNavGroupServiceStartContract['setCurrentNavGroup']; +} + +interface NavGroupsProps { + navLinks: ChromeNavLink[]; + suffix?: React.ReactElement; + style?: React.CSSProperties; + appId?: string; + navigateToApp: InternalApplicationStart['navigateToApp']; + onNavItemClick: ( + event: React.MouseEvent, + navItem: ChromeNavLink + ) => void; +} + +const titleForSeeAll = i18n.translate('core.ui.primaryNav.seeAllLabel', { + defaultMessage: 'See all...', +}); + +export function NavGroups({ + navLinks, + suffix, + style, + appId, + navigateToApp, + onNavItemClick, +}: NavGroupsProps) { + const createNavItem = ({ + link, + className, + }: { + link: ChromeNavLink; + className?: string; + }): EuiSideNavItemType<{}> => { + const euiListItem = createEuiListItem({ + link, + appId, + dataTestSubj: `collapsibleNavAppLink-${link.id}`, + navigateToApp, + onClick: (event) => { + onNavItemClick(event, link); + }, + }); + + return { + id: `${link.id}-${link.title}`, + name: {link.title}, + onClick: euiListItem.onClick, + href: euiListItem.href, + emphasize: euiListItem.isActive, + className: `nav-link-item ${className || ''}`, + buttonClassName: 'nav-link-item-btn', + 'data-test-subj': euiListItem['data-test-subj'], + 'aria-label': link.title, + }; + }; + const createSideNavItem = (navLink: LinkItem, className?: string): EuiSideNavItemType<{}> => { + if (navLink.itemType === LinkItemType.LINK) { + if (navLink.link.title === titleForSeeAll) { + const navItem = createNavItem({ + link: navLink.link, + }); + + return { + ...navItem, + name: {navItem.name}, + emphasize: false, + }; + } + + return createNavItem({ + link: navLink.link, + className, + }); + } + + if (navLink.itemType === LinkItemType.PARENT_LINK && navLink.link) { + return { + ...createNavItem({ link: navLink.link }), + forceOpen: true, + items: navLink.links.map((subNavLink) => createSideNavItem(subNavLink, 'nav-nested-item')), + }; + } + + if (navLink.itemType === LinkItemType.CATEGORY) { + return { + id: navLink.category?.id ?? '', + name:
{navLink.category?.label ?? ''}
, + items: navLink.links?.map((link) => createSideNavItem(link)), + 'aria-label': navLink.category?.label, + }; + } + + return {} as EuiSideNavItemType<{}>; + }; + const orderedLinksOrCategories = getOrderedLinksOrCategories(navLinks); + const sideNavItems = orderedLinksOrCategories + .map((navLink) => createSideNavItem(navLink)) + .filter((item): item is EuiSideNavItemType<{}> => !!item); + return ( + + + {suffix} + + ); +} + +export function CollapsibleNavGroupEnabled({ + basePath, + id, + isLocked, + isNavOpen, + storage = window.localStorage, + onIsLockedUpdate, + closeNav, + navigateToApp, + navigateToUrl, + logos, + setCurrentNavGroup, + ...observables +}: CollapsibleNavGroupEnabledProps) { + const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); + const appId = useObservable(observables.appId$, ''); + const navGroupsMap = useObservable(observables.navGroupsMap$, {}); + const currentNavGroup = useObservable(observables.currentNavGroup$, undefined); + + const onGroupClick = ( + e: React.MouseEvent, + group: NavGroupItemInMap + ) => { + const fulfilledLinks = fulfillRegistrationLinksToChromeNavLinks( + navGroupsMap[group.id]?.navLinks, + navLinks + ); + setCurrentNavGroup(group.id); + + // the `navGroupsMap[group.id]?.navLinks` has already been sorted + const firstLink = fulfilledLinks[0]; + if (firstLink) { + const propsForEui = createEuiListItem({ + link: firstLink, + appId, + dataTestSubj: 'collapsibleNavAppLink', + navigateToApp, + }); + propsForEui.onClick(e); + } + }; + + const navLinksForRender: ChromeNavLink[] = useMemo(() => { + if (currentNavGroup) { + return fulfillRegistrationLinksToChromeNavLinks( + navGroupsMap[currentNavGroup.id].navLinks || [], + navLinks + ); + } + + const visibleUseCases = Object.values(navGroupsMap).filter( + (group) => group.type === undefined && group.status !== NavGroupStatus.Hidden + ); + + if (visibleUseCases.length === 1) { + return fulfillRegistrationLinksToChromeNavLinks( + navGroupsMap[visibleUseCases[0].id].navLinks || [], + navLinks + ); + } + + const navLinksForAll: ChromeRegistrationNavLink[] = []; + + // Append all the links that do not have use case info to keep backward compatible + const linkIdsWithUseGroupInfo = Object.values(navGroupsMap).reduce((total, navGroup) => { + return [...total, ...navGroup.navLinks.map((navLink) => navLink.id)]; + }, [] as string[]); + navLinks + .filter((link) => !linkIdsWithUseGroupInfo.includes(link.id)) + .forEach((navLink) => { + navLinksForAll.push(navLink); + }); + + // Append all the links registered to all use case + navGroupsMap[ALL_USE_CASE_ID]?.navLinks.forEach((navLink) => { + navLinksForAll.push(navLink); + }); + + // Append use case section into left navigation + Object.values(navGroupsMap) + .filter((group) => !group.type) + .forEach((group) => { + const categoryInfo = { + id: group.id, + label: group.title, + order: group.order, + }; + const linksForAllUseCaseWithinNavGroup = group.navLinks + .filter((navLink) => navLink.showInAllNavGroup) + .map((navLink) => ({ + ...navLink, + category: categoryInfo, + })); + + navLinksForAll.push(...linksForAllUseCaseWithinNavGroup); + + if (linksForAllUseCaseWithinNavGroup.length) { + navLinksForAll.push({ + id: group.navLinks[0].id, + title: titleForSeeAll, + order: Number.MAX_SAFE_INTEGER, + category: categoryInfo, + }); + } + }); + + return fulfillRegistrationLinksToChromeNavLinks(navLinksForAll, navLinks); + }, [navLinks, navGroupsMap, currentNavGroup]); + + const width = useMemo(() => { + if (!isNavOpen) { + return 50; + } + + return 270; + }, [isNavOpen]); + + return ( + +
+
+ + {!isNavOpen ? null : ( + <> + setCurrentNavGroup(undefined)} + currentNavGroup={currentNavGroup} + shouldShrinkNavigation={!isNavOpen} + onClickShrink={closeNav} + /> + { + if (navItem.title === titleForSeeAll && navItem.category?.id) { + const navGroup = navGroupsMap[navItem.category.id]; + onGroupClick(event, navGroup); + } + }} + appId={appId} + /> + + )} + +
+ +
+ +
+
+
+ ); +} diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.test.tsx new file mode 100644 index 000000000000..294992c3926f --- /dev/null +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.test.tsx @@ -0,0 +1,111 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { ChromeNavLink } from '../../nav_links'; +import { ChromeRegistrationNavLink } from '../../nav_group'; +import { httpServiceMock } from '../../../mocks'; +import { getLogos } from '../../../../common'; +import { CollapsibleNavTop } from './collapsible_nav_group_enabled_top'; + +const mockBasePath = httpServiceMock.createSetupContract({ basePath: '/test' }).basePath; + +describe('', () => { + const getMockedNavLink = ( + navLink: Partial + ): ChromeNavLink & ChromeRegistrationNavLink => ({ + baseUrl: '', + href: '', + id: '', + title: '', + ...navLink, + }); + const mockedNavLinks = [ + getMockedNavLink({ + id: 'home', + title: 'home link', + }), + getMockedNavLink({ + id: 'subLink', + title: 'subLink', + parentNavLinkId: 'pure', + }), + getMockedNavLink({ + id: 'link-in-category', + title: 'link-in-category', + category: { + id: 'category-1', + label: 'category-1', + }, + }), + getMockedNavLink({ + id: 'link-in-category-2', + title: 'link-in-category-2', + category: { + id: 'category-1', + label: 'category-1', + }, + }), + getMockedNavLink({ + id: 'sub-link-in-category', + title: 'sub-link-in-category', + parentNavLinkId: 'link-in-category', + category: { + id: 'category-1', + label: 'category-1', + }, + }), + ]; + const getMockedProps = () => { + return { + navLinks: mockedNavLinks, + navigateToApp: jest.fn(), + navGroupsMap: {}, + logos: getLogos({}, mockBasePath.serverBasePath), + shouldShrinkNavigation: false, + }; + }; + it('should render home icon', async () => { + const { findByTestId } = render(); + await findByTestId('collapsibleNavHome'); + }); + + it('should render back icon', async () => { + const { findByTestId } = render( + + ); + await findByTestId('collapsibleNavBackButton'); + }); + + it('should render expand icon', async () => { + const { findByTestId } = render( + + ); + await findByTestId('collapsibleNavShrinkButton'); + }); +}); diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.tsx new file mode 100644 index 000000000000..9e89155a8e4e --- /dev/null +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled_top.tsx @@ -0,0 +1,116 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useMemo } from 'react'; +import { Logos } from 'opensearch-dashboards/public'; +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, +} from '@elastic/eui'; +import { InternalApplicationStart } from 'src/core/public/application'; +import { i18n } from '@osd/i18n'; +import { createEuiListItem } from './nav_link'; +import { NavGroupItemInMap } from '../../nav_group'; +import { ChromeNavLink } from '../../nav_links'; + +export interface CollapsibleNavTopProps { + navLinks: ChromeNavLink[]; + navGroupsMap: Record; + currentNavGroup?: NavGroupItemInMap; + navigateToApp: InternalApplicationStart['navigateToApp']; + logos: Logos; + onClickBack?: () => void; + onClickShrink?: () => void; + shouldShrinkNavigation: boolean; +} + +export const CollapsibleNavTop = ({ + navLinks, + navGroupsMap, + currentNavGroup, + navigateToApp, + logos, + onClickBack, + onClickShrink, + shouldShrinkNavigation, +}: CollapsibleNavTopProps) => { + const homeLink = useMemo(() => navLinks.find((link) => link.id === 'home'), [navLinks]); + + const shouldShowBackButton = useMemo( + () => + !shouldShrinkNavigation && + Object.values(navGroupsMap).filter((item) => !item.type).length > 1 && + currentNavGroup, + [navGroupsMap, currentNavGroup, shouldShrinkNavigation] + ); + + const shouldShowHomeLink = useMemo(() => { + if (!homeLink || shouldShrinkNavigation) return false; + + return !shouldShowBackButton; + }, [shouldShowBackButton, homeLink, shouldShrinkNavigation]); + + const homeLinkProps = useMemo(() => { + if (shouldShowHomeLink) { + const propsForHomeIcon = createEuiListItem({ + link: homeLink as ChromeNavLink, + appId: 'home', + dataTestSubj: 'collapsibleNavHome', + navigateToApp, + }); + return { + 'data-test-subj': propsForHomeIcon['data-test-subj'], + onClick: propsForHomeIcon.onClick, + href: propsForHomeIcon.href, + }; + } + + return {}; + }, [shouldShowHomeLink, homeLink, navigateToApp]); + + return ( +
+ + + {shouldShowHomeLink ? ( + + + + + + ) : null} + {shouldShowBackButton ? ( + + + + {i18n.translate('core.ui.primaryNav.backButtonLabel', { + defaultMessage: 'Back', + })} + + + ) : null} + + + + + +
+ ); +}; diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx index ee4d660e1dd3..16e77a353cc6 100644 --- a/src/core/public/chrome/ui/header/header.test.tsx +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -78,7 +78,10 @@ function mockProps() { paddingSize: 640, }), navGroupEnabled: false, - currentNavgroup$: new BehaviorSubject(undefined), + currentNavGroup$: new BehaviorSubject(undefined), + navGroupsMap$: new BehaviorSubject({}), + navControlsLeftBottom$: new BehaviorSubject([]), + setCurrentNavGroup: jest.fn(() => {}), }; } @@ -168,4 +171,18 @@ describe('Header', () => { expect(component).toMatchSnapshot(); }); + + it('renders new header when feature flag is turned on', () => { + const branding = { + useExpandedHeader: false, + }; + const props = { + ...mockProps(), + branding, + }; + + const component = mountWithIntl(
); + + expect(component.find('CollapsibleNavGroupEnabled').exists()).toBeTruthy(); + }); }); diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 7fd2b8d82080..06767103509b 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -66,7 +66,8 @@ import { HeaderActionMenu } from './header_action_menu'; import { HeaderLogo } from './header_logo'; import type { Logos } from '../../../../common/types'; import { ISidecarConfig, getOsdSidecarPaddingStyle } from '../../../overlays'; -import { NavGroupItemInMap } from '../../nav_group'; +import { CollapsibleNavGroupEnabled } from './collapsible_nav_group_enabled'; +import { ChromeNavGroupServiceStartContract, NavGroupItemInMap } from '../../nav_group'; export interface HeaderProps { opensearchDashboardsVersion: string; application: InternalApplicationStart; @@ -88,6 +89,7 @@ export interface HeaderProps { navControlsRight$: Observable; navControlsExpandedCenter$: Observable; navControlsExpandedRight$: Observable; + navControlsLeftBottom$: Observable; basePath: HttpStart['basePath']; isLocked$: Observable; loadingCount$: ReturnType; @@ -97,7 +99,9 @@ export interface HeaderProps { survey: string | undefined; sidecarConfig$: Observable; navGroupEnabled: boolean; - currentNavgroup$: Observable; + currentNavGroup$: Observable; + navGroupsMap$: Observable>; + setCurrentNavGroup: ChromeNavGroupServiceStartContract['setCurrentNavGroup']; } export function Header({ @@ -112,6 +116,7 @@ export function Header({ logos, collapsibleNavHeaderRender, navGroupEnabled, + setCurrentNavGroup, ...observables }: HeaderProps) { const isVisible = useObservable(observables.isVisible$, false); @@ -225,7 +230,7 @@ export function Header({ @@ -260,28 +265,54 @@ export function Header({
- { - setIsNavOpen(false); - if (toggleCollapsibleNavRef.current) { - toggleCollapsibleNavRef.current.focus(); - } - }} - customNavLink$={observables.customNavLink$} - logos={logos} - /> + {navGroupEnabled ? ( + { + setIsNavOpen(false); + if (toggleCollapsibleNavRef.current) { + toggleCollapsibleNavRef.current.focus(); + } + }} + customNavLink$={observables.customNavLink$} + logos={logos} + navGroupsMap$={observables.navGroupsMap$} + navControlsLeftBottom$={observables.navControlsLeftBottom$} + currentNavGroup$={observables.currentNavGroup$} + setCurrentNavGroup={setCurrentNavGroup} + /> + ) : ( + { + setIsNavOpen(false); + if (toggleCollapsibleNavRef.current) { + toggleCollapsibleNavRef.current.focus(); + } + }} + customNavLink$={observables.customNavLink$} + logos={logos} + /> + )} ); diff --git a/src/core/public/chrome/ui/header/header_nav_controls.tsx b/src/core/public/chrome/ui/header/header_nav_controls.tsx index 82ac5792a1cd..6f6fc50eadb9 100644 --- a/src/core/public/chrome/ui/header/header_nav_controls.tsx +++ b/src/core/public/chrome/ui/header/header_nav_controls.tsx @@ -38,9 +38,10 @@ import { HeaderExtension } from './header_extension'; interface Props { navControls$: Observable; side?: 'left' | 'right'; + className?: HTMLElement['className']; } -export function HeaderNavControls({ navControls$, side }: Props) { +export function HeaderNavControls({ navControls$, side, className }: Props) { const navControls = useObservable(navControls$, []); if (!navControls) { @@ -55,6 +56,7 @@ export function HeaderNavControls({ navControls$, side }: Props) { diff --git a/src/core/public/chrome/ui/header/nav_link.tsx b/src/core/public/chrome/ui/header/nav_link.tsx index 7f849df9b8ec..a80ce86507aa 100644 --- a/src/core/public/chrome/ui/header/nav_link.tsx +++ b/src/core/public/chrome/ui/header/nav_link.tsx @@ -53,7 +53,7 @@ interface Props { appId?: string; basePath?: HttpStart['basePath']; dataTestSubj: string; - onClick?: Function; + onClick?: (event: React.MouseEvent) => void; navigateToApp: CoreStart['application']['navigateToApp']; externalLink?: boolean; } @@ -79,7 +79,7 @@ export function createEuiListItem({ /* Use href and onClick to support "open in new tab" and SPA navigation in the same link */ onClick(event: React.MouseEvent) { if (!isModifiedOrPrevented(event)) { - onClick(); + onClick(event); } if ( diff --git a/src/core/server/rendering/views/template.tsx b/src/core/server/rendering/views/template.tsx index 6106f3ef3059..992cd117be6a 100644 --- a/src/core/server/rendering/views/template.tsx +++ b/src/core/server/rendering/views/template.tsx @@ -79,8 +79,6 @@ export const Template: FunctionComponent = ({ * ToDo: Custom branded favicons will not work correctly across all browsers with * these `link` elements and single type. Try to guess the image and use only one. * - * Favicons (generated from https://realfavicongenerator.net/) - * * For user customized favicon using yml file: * If user inputs a valid URL, we guarantee basic favicon customization, such as * browser favicon(Chrome, Firefox, Safari, and Edge), apple touch icon, safari diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts index 49277b894d12..d22dbaf1b7ac 100644 --- a/src/core/utils/default_app_categories.ts +++ b/src/core/utils/default_app_categories.ts @@ -108,4 +108,11 @@ export const DEFAULT_APP_CATEGORIES: Record = Object.freeze }), order: 3000, }, + manage: { + id: 'manage', + label: i18n.translate('core.ui.manageNav.label', { + defaultMessage: 'Manage', + }), + order: 7000, + }, }); diff --git a/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx b/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx index 7fa0b9ddd2c0..fff7a9f1b357 100644 --- a/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx +++ b/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx @@ -36,6 +36,7 @@ import { i18n } from '@osd/i18n'; import { I18nProvider } from '@osd/i18n/react'; import { StartServicesAccessor } from 'src/core/public'; +import { EuiPageContent } from '@elastic/eui'; import { AdvancedSettings } from './advanced_settings'; import { ManagementAppMountParams } from '../../../management/public'; import { ComponentRegistry } from '../types'; @@ -59,7 +60,7 @@ const readOnlyBadge = { export async function mountManagementSection( getStartServices: StartServicesAccessor, - params: ManagementAppMountParams, + params: ManagementAppMountParams & { wrapInPage?: boolean }, componentRegistry: ComponentRegistry['start'] ) { params.setBreadcrumbs(crumb); @@ -71,21 +72,31 @@ export async function mountManagementSection( chrome.setBadge(readOnlyBadge); } + const content = ( + + + + + + + + ); + ReactDOM.render( - - - - - - - + {params.wrapInPage ? ( + + {content} + + ) : ( + content + )} , params.element ); diff --git a/src/plugins/advanced_settings/public/plugin.test.ts b/src/plugins/advanced_settings/public/plugin.test.ts new file mode 100644 index 000000000000..2ff2a08b8077 --- /dev/null +++ b/src/plugins/advanced_settings/public/plugin.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { managementPluginMock } from '../../management/public/mocks'; +import { coreMock } from '../../../core/public/mocks'; +import { AdvancedSettingsPlugin } from './plugin'; +import { homePluginMock } from '../../home/public/mocks'; + +describe('AdvancedSettingsPlugin', () => { + it('setup successfully', () => { + const pluginInstance = new AdvancedSettingsPlugin(); + const setupMock = coreMock.createSetup(); + expect(() => + pluginInstance.setup(setupMock, { + management: managementPluginMock.createSetupContract(), + home: homePluginMock.createSetupContract(), + }) + ).not.toThrow(); + expect(setupMock.application.register).toBeCalledTimes(1); + }); +}); diff --git a/src/plugins/advanced_settings/public/plugin.ts b/src/plugins/advanced_settings/public/plugin.ts index 8412816c1ebf..53ead80d8057 100644 --- a/src/plugins/advanced_settings/public/plugin.ts +++ b/src/plugins/advanced_settings/public/plugin.ts @@ -29,11 +29,12 @@ */ import { i18n } from '@osd/i18n'; -import { CoreSetup, CoreStart, Plugin } from 'opensearch-dashboards/public'; +import { AppMountParameters, CoreSetup, CoreStart, Plugin } from 'opensearch-dashboards/public'; import { FeatureCatalogueCategory } from '../../home/public'; import { ComponentRegistry } from './component_registry'; import { AdvancedSettingsSetup, AdvancedSettingsStart, AdvancedSettingsPluginSetup } from './types'; import { setupTopNavThemeButton } from './register_nav_control'; +import { DEFAULT_NAV_GROUPS, AppNavLinkStatus } from '../../../core/public'; const component = new ComponentRegistry(); @@ -41,6 +42,10 @@ const title = i18n.translate('advancedSettings.advancedSettingsLabel', { defaultMessage: 'Advanced settings', }); +const titleInGroup = i18n.translate('advancedSettings.applicationSettingsLabel', { + defaultMessage: 'Application settings', +}); + export class AdvancedSettingsPlugin implements Plugin { public setup(core: CoreSetup, { management, home }: AdvancedSettingsPluginSetup) { @@ -58,6 +63,39 @@ export class AdvancedSettingsPlugin }, }); + core.application.register({ + id: 'settings', + title, + navLinkStatus: core.chrome.navGroup.getNavGroupEnabled() + ? AppNavLinkStatus.visible + : AppNavLinkStatus.hidden, + mount: async (params: AppMountParameters) => { + const { mountManagementSection } = await import( + './management_app/mount_management_section' + ); + const [coreStart] = await core.getStartServices(); + + return mountManagementSection( + core.getStartServices, + { + ...params, + basePath: core.http.basePath.get(), + setBreadcrumbs: coreStart.chrome.setBreadcrumbs, + wrapInPage: true, + }, + component.start + ); + }, + }); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.settingsAndSetup, [ + { + id: 'settings', + title: titleInGroup, + order: 100, + }, + ]); + if (home) { home.featureCatalogue.register({ id: 'advanced_settings', diff --git a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap index 3023109dcc07..68f84dc3394e 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -214,9 +214,11 @@ exports[`dashboard listing hideWriteControls 1`] = ` "navControls": Object { "getCenter$": [MockFunction], "getLeft$": [MockFunction], + "getLeftBottom$": [MockFunction], "getRight$": [MockFunction], "registerCenter": [MockFunction], "registerLeft": [MockFunction], + "registerLeftBottom": [MockFunction], "registerRight": [MockFunction], }, "navGroup": Object { @@ -1400,9 +1402,11 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "navControls": Object { "getCenter$": [MockFunction], "getLeft$": [MockFunction], + "getLeftBottom$": [MockFunction], "getRight$": [MockFunction], "registerCenter": [MockFunction], "registerLeft": [MockFunction], + "registerLeftBottom": [MockFunction], "registerRight": [MockFunction], }, "navGroup": Object { @@ -2647,9 +2651,11 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "navControls": Object { "getCenter$": [MockFunction], "getLeft$": [MockFunction], + "getLeftBottom$": [MockFunction], "getRight$": [MockFunction], "registerCenter": [MockFunction], "registerLeft": [MockFunction], + "registerLeftBottom": [MockFunction], "registerRight": [MockFunction], }, "navGroup": Object { @@ -3894,9 +3900,11 @@ exports[`dashboard listing renders table rows 1`] = ` "navControls": Object { "getCenter$": [MockFunction], "getLeft$": [MockFunction], + "getLeftBottom$": [MockFunction], "getRight$": [MockFunction], "registerCenter": [MockFunction], "registerLeft": [MockFunction], + "registerLeftBottom": [MockFunction], "registerRight": [MockFunction], }, "navGroup": Object { @@ -5141,9 +5149,11 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "navControls": Object { "getCenter$": [MockFunction], "getLeft$": [MockFunction], + "getLeftBottom$": [MockFunction], "getRight$": [MockFunction], "registerCenter": [MockFunction], "registerLeft": [MockFunction], + "registerLeftBottom": [MockFunction], "registerRight": [MockFunction], }, "navGroup": Object { diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap index 72303e9ca32b..a48e917ce8e3 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap @@ -202,9 +202,11 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "navControls": Object { "getCenter$": [MockFunction], "getLeft$": [MockFunction], + "getLeftBottom$": [MockFunction], "getRight$": [MockFunction], "registerCenter": [MockFunction], "registerLeft": [MockFunction], + "registerLeftBottom": [MockFunction], "registerRight": [MockFunction], }, "navGroup": Object { @@ -1210,9 +1212,11 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "navControls": Object { "getCenter$": [MockFunction], "getLeft$": [MockFunction], + "getLeftBottom$": [MockFunction], "getRight$": [MockFunction], "registerCenter": [MockFunction], "registerLeft": [MockFunction], + "registerLeftBottom": [MockFunction], "registerRight": [MockFunction], }, "navGroup": Object { @@ -2218,9 +2222,11 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "navControls": Object { "getCenter$": [MockFunction], "getLeft$": [MockFunction], + "getLeftBottom$": [MockFunction], "getRight$": [MockFunction], "registerCenter": [MockFunction], "registerLeft": [MockFunction], + "registerLeftBottom": [MockFunction], "registerRight": [MockFunction], }, "navGroup": Object { @@ -3226,9 +3232,11 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "navControls": Object { "getCenter$": [MockFunction], "getLeft$": [MockFunction], + "getLeftBottom$": [MockFunction], "getRight$": [MockFunction], "registerCenter": [MockFunction], "registerLeft": [MockFunction], + "registerLeftBottom": [MockFunction], "registerRight": [MockFunction], }, "navGroup": Object { @@ -4234,9 +4242,11 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "navControls": Object { "getCenter$": [MockFunction], "getLeft$": [MockFunction], + "getLeftBottom$": [MockFunction], "getRight$": [MockFunction], "registerCenter": [MockFunction], "registerLeft": [MockFunction], + "registerLeftBottom": [MockFunction], "registerRight": [MockFunction], }, "navGroup": Object { @@ -5242,9 +5252,11 @@ exports[`Dashboard top nav render with all components 1`] = ` "navControls": Object { "getCenter$": [MockFunction], "getLeft$": [MockFunction], + "getLeftBottom$": [MockFunction], "getRight$": [MockFunction], "registerCenter": [MockFunction], "registerLeft": [MockFunction], + "registerLeftBottom": [MockFunction], "registerRight": [MockFunction], }, "navGroup": Object { diff --git a/src/plugins/dashboard/public/plugin.test.tsx b/src/plugins/dashboard/public/plugin.test.tsx new file mode 100644 index 000000000000..5ca81d5b77e8 --- /dev/null +++ b/src/plugins/dashboard/public/plugin.test.tsx @@ -0,0 +1,30 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { coreMock } from '../../../core/public/mocks'; +import { DashboardPlugin } from './plugin'; +import { dataPluginMock } from '../../data/public/mocks'; +import { embeddablePluginMock } from '../../embeddable/public/mocks'; +import { opensearchDashboardsLegacyPluginMock } from '../../opensearch_dashboards_legacy/public/mocks'; +import { urlForwardingPluginMock } from '../../url_forwarding/public/mocks'; +import { uiActionsPluginMock } from '../../ui_actions/public/mocks'; + +describe('DashboardPlugin', () => { + it('setup successfully', () => { + const setupMock = coreMock.createSetup(); + const initializerContext = coreMock.createPluginInitializerContext(); + const pluginInstance = new DashboardPlugin(initializerContext); + expect(() => + pluginInstance.setup(setupMock, { + data: dataPluginMock.createSetupContract(), + embeddable: embeddablePluginMock.createSetupContract(), + opensearchDashboardsLegacy: opensearchDashboardsLegacyPluginMock.createSetupContract(), + urlForwarding: urlForwardingPluginMock.createSetupContract(), + uiActions: uiActionsPluginMock.createSetupContract(), + }) + ).not.toThrow(); + expect(setupMock.chrome.navGroup.addNavLinksToGroup).toBeCalledTimes(5); + }); +}); diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index dd874d3419f2..afa3b6daf281 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -84,7 +84,7 @@ import { OpenSearchDashboardsLegacyStart, } from '../../opensearch_dashboards_legacy/public'; import { FeatureCatalogueCategory, HomePublicPluginSetup } from '../../../plugins/home/public'; -import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; +import { DEFAULT_APP_CATEGORIES, DEFAULT_NAV_GROUPS } from '../../../core/public'; import { ACTION_CLONE_PANEL, @@ -452,6 +452,43 @@ export class DashboardPlugin }; core.application.register(app); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.observability, [ + { + id: app.id, + order: 300, + category: undefined, + }, + ]); + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS['security-analytics'], [ + { + id: app.id, + order: 300, + category: undefined, + }, + ]); + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.analytics, [ + { + id: app.id, + order: 300, + category: undefined, + }, + ]); + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.search, [ + { + id: app.id, + order: 300, + category: undefined, + }, + ]); + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.all, [ + { + id: app.id, + order: 300, + category: undefined, + }, + ]); + urlForwarding.forwardApp( DashboardConstants.DASHBOARDS_ID, DashboardConstants.DASHBOARDS_ID, diff --git a/src/plugins/data/common/constants.ts b/src/plugins/data/common/constants.ts index a00adb0b6290..863877322ad9 100644 --- a/src/plugins/data/common/constants.ts +++ b/src/plugins/data/common/constants.ts @@ -62,6 +62,5 @@ export const UI_SETTINGS = { FILTERS_EDITOR_SUGGEST_VALUES: 'filterEditor:suggestValues', QUERY_ENHANCEMENTS_ENABLED: 'query:enhancements:enabled', QUERY_DATAFRAME_HYDRATION_STRATEGY: 'query:dataframe:hydrationStrategy', - QUERY_DATA_SOURCE_READONLY: 'query:dataSource:readOnly', SEARCH_QUERY_LANGUAGE_BLOCKLIST: 'search:queryLanguageBlocklist', } as const; diff --git a/src/plugins/data/config.ts b/src/plugins/data/config.ts index 53252fb74b42..186691f3c917 100644 --- a/src/plugins/data/config.ts +++ b/src/plugins/data/config.ts @@ -32,7 +32,6 @@ import { schema, TypeOf } from '@osd/config-schema'; export const configSchema = schema.object({ enhancements: schema.object({ - enabled: schema.boolean({ defaultValue: false }), supportedAppNames: schema.arrayOf(schema.string(), { defaultValue: ['discover'], }), diff --git a/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx b/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx index 3ea715418ab1..2f6f47659cdc 100644 --- a/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx +++ b/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx @@ -75,8 +75,6 @@ export default function QueryEditorTopRow(props: QueryEditorTopRowProps) { const opensearchDashboards = useOpenSearchDashboards(); const { uiSettings, storage, appName } = opensearchDashboards.services; - const isDataSourceReadOnly = uiSettings.get(UI_SETTINGS.QUERY_DATA_SOURCE_READONLY); - const queryLanguage = props.query && props.query.language; const queryUiEnhancement = (queryLanguage && @@ -197,7 +195,6 @@ export default function QueryEditorTopRow(props: QueryEditorTopRowProps) { return ( !Array.isArray(props.indexPatterns!) || compact(props.indexPatterns!).length === 0 || - !isDataSourceReadOnly || fromUser(query!.query).includes( typeof props.indexPatterns[0] === 'string' ? props.indexPatterns[0] diff --git a/src/plugins/data/public/ui/settings/settings.ts b/src/plugins/data/public/ui/settings/settings.ts index 10a72d66cdab..f90bee5cf47e 100644 --- a/src/plugins/data/public/ui/settings/settings.ts +++ b/src/plugins/data/public/ui/settings/settings.ts @@ -35,7 +35,7 @@ export class Settings { private readonly queryEnhancements: Map, private readonly queryEditorExtensionMap: Record ) { - this.isEnabled = this.config.enabled; + this.isEnabled = false; this.setUserQueryEnhancementsEnabled(this.isEnabled); this.enhancedAppNames = this.isEnabled ? this.config.supportedAppNames : []; } diff --git a/src/plugins/data/server/ui_settings.ts b/src/plugins/data/server/ui_settings.ts index ca24151e681d..dfe27d16c252 100644 --- a/src/plugins/data/server/ui_settings.ts +++ b/src/plugins/data/server/ui_settings.ts @@ -750,19 +750,6 @@ export function getUiSettings(): Record> { category: ['search'], schema: schema.string(), }, - [UI_SETTINGS.QUERY_DATA_SOURCE_READONLY]: { - name: i18n.translate('data.advancedSettings.query.dataSource.readOnlyTitle', { - defaultMessage: 'Read-only data source in query editor', - }), - value: true, - description: i18n.translate('data.advancedSettings.query.dataSource.readOnlyText', { - defaultMessage: - 'When enabled, the search bar prevents modifying the data source in the query input. ' + - 'Experimental: Requires query enhancements enabled.', - }), - category: ['search'], - schema: schema.boolean(), - }, [UI_SETTINGS.SEARCH_QUERY_LANGUAGE_BLOCKLIST]: { name: i18n.translate('data.advancedSettings.searchQueryLanguageBlocklistTitle', { defaultMessage: 'Additional query languages blocklist', diff --git a/src/plugins/data_source/common/data_sources/types.ts b/src/plugins/data_source/common/data_sources/types.ts index cde21f648c61..bd147ac00c04 100644 --- a/src/plugins/data_source/common/data_sources/types.ts +++ b/src/plugins/data_source/common/data_sources/types.ts @@ -60,3 +60,9 @@ export enum DataSourceEngineType { Elasticsearch = 'Elasticsearch', NA = 'No Engine Type Available', } + +export enum ManageableBy { + All = 'all', + DashboardAdmin = 'dashboard_admin', + None = 'none', +} diff --git a/src/plugins/data_source/config.ts b/src/plugins/data_source/config.ts index 30824b486257..36c298cde119 100644 --- a/src/plugins/data_source/config.ts +++ b/src/plugins/data_source/config.ts @@ -59,6 +59,10 @@ export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), }), }), + manageableBy: schema.oneOf( + [schema.literal('all'), schema.literal('dashboard_admin'), schema.literal('none')], + { defaultValue: 'all' } + ), }); export type DataSourcePluginConfigType = TypeOf; diff --git a/src/plugins/data_source/server/plugin.ts b/src/plugins/data_source/server/plugin.ts index bbf5a89d1b53..fa3085a63935 100644 --- a/src/plugins/data_source/server/plugin.ts +++ b/src/plugins/data_source/server/plugin.ts @@ -33,6 +33,8 @@ import { registerTestConnectionRoute } from './routes/test_connection'; import { registerFetchDataSourceMetaDataRoute } from './routes/fetch_data_source_metadata'; import { AuthenticationMethodRegistry, IAuthenticationMethodRegistry } from './auth_registry'; import { CustomApiSchemaRegistry } from './schema_registry'; +import { ManageableBy } from '../common/data_sources'; +import { getWorkspaceState } from '../../../../src/core/server/utils'; export class DataSourcePlugin implements Plugin { private readonly logger: Logger; @@ -81,6 +83,25 @@ export class DataSourcePlugin implements Plugin ({ + dataSource: { + canManage: false, + }, + })); + + core.capabilities.registerSwitcher((request) => { + const { requestWorkspaceId, isDashboardAdmin } = getWorkspaceState(request); + // User can not manage data source in the workspace. + const canManage = + (manageableBy === ManageableBy.All && !requestWorkspaceId) || + (manageableBy === ManageableBy.DashboardAdmin && + isDashboardAdmin !== false && + !requestWorkspaceId); + + return { dataSource: { canManage } }; + }); + core.logging.configure( this.config$.pipe( map((dataSourceConfig) => ({ diff --git a/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_home_panel.test.tsx b/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_home_panel.test.tsx index cbc1f015c6f3..017f450c11ae 100644 --- a/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_home_panel.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_home_panel.test.tsx @@ -41,6 +41,7 @@ describe('DataSourceHomePanel', () => { http: {}, savedObjects: {}, uiSettings: {}, + application: { capabilities: { dataSource: { canManage: true } } }, }, }; diff --git a/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_home_panel.tsx b/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_home_panel.tsx index 3cedbb9641c0..219e37e93345 100644 --- a/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_home_panel.tsx +++ b/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_home_panel.tsx @@ -30,11 +30,17 @@ export const DataSourceHomePanel: React.FC = ({ featureFlagStatus, ...props }) => { - const { setBreadcrumbs, notifications, http, savedObjects, uiSettings } = useOpenSearchDashboards< - DataSourceManagementContext - >().services; + const { + setBreadcrumbs, + notifications, + http, + savedObjects, + uiSettings, + application, + } = useOpenSearchDashboards().services; const [selectedTabId, setSelectedTabId] = useState('manageDirectQueryDataSources'); + const canManageDataSource = !!application.capabilities?.dataSource?.canManage; useEffect(() => { setBreadcrumbs(getListBreadcrumbs()); @@ -80,9 +86,11 @@ export const DataSourceHomePanel: React.FC = ({ - - - + {canManageDataSource ? ( + + + + ) : null} diff --git a/src/plugins/data_source_management/public/components/data_source_table/__snapshots__/data_source_table.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_table/__snapshots__/data_source_table.test.tsx.snap index dabd8d387e19..842c3e3d07ba 100644 --- a/src/plugins/data_source_management/public/components/data_source_table/__snapshots__/data_source_table.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_table/__snapshots__/data_source_table.test.tsx.snap @@ -2111,3 +2111,1105 @@ exports[`DataSourceTable should get datasources successful should render normall `; + +exports[`DataSourceTable should not manage datasources when canManageDataSource is false should render empty table 1`] = ` + + + +
+ + +
+ +
+ + + No Data Source Connections have been created yet. + + +
+
+ +
+ +
+ + +
+ + + +`; diff --git a/src/plugins/data_source_management/public/components/data_source_table/data_source_table.test.tsx b/src/plugins/data_source_management/public/components/data_source_table/data_source_table.test.tsx index b0cb56bdac62..acb4d5d7d853 100644 --- a/src/plugins/data_source_management/public/components/data_source_table/data_source_table.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_table/data_source_table.test.tsx @@ -24,7 +24,10 @@ const tableColumnHeaderButtonIdentifier = 'EuiTableHeaderCell .euiTableHeaderBut const emptyStateIdentifier = '[data-test-subj="datasourceTableEmptyState"]'; describe('DataSourceTable', () => { - const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); + const mockedContext = { + ...mockManagementPlugin.createDataSourceManagementContext(), + application: { capabilities: { dataSource: { canManage: true } } }, + }; const uiSettings = mockedContext.uiSettings; let component: ReactWrapper, React.Component<{}, {}, any>>; const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; @@ -169,4 +172,36 @@ describe('DataSourceTable', () => { expect(component.find(confirmModalIdentifier).exists()).toBe(false); }); }); + + describe('should not manage datasources when canManageDataSource is false', () => { + const mockedContextWithFalseManage = { + ...mockManagementPlugin.createDataSourceManagementContext(), + application: { capabilities: { dataSource: { canManage: false } } }, + }; + beforeEach(async () => { + spyOn(utils, 'getDataSources').and.returnValue(Promise.reject()); + await act(async () => { + component = await mount( + wrapWithIntl( + + ), + { + wrappingComponent: OpenSearchDashboardsContextProvider, + wrappingComponentProps: { + services: mockedContextWithFalseManage, + }, + } + ); + }); + component.update(); + }); + test('should render empty table', () => { + expect(component).toMatchSnapshot(); + expect(component.find(emptyStateIdentifier).exists()).toBe(true); + }); + }); }); diff --git a/src/plugins/data_source_management/public/components/data_source_table/data_source_table.tsx b/src/plugins/data_source_management/public/components/data_source_table/data_source_table.tsx index 3426b947d3b1..9a4264f1ec4f 100644 --- a/src/plugins/data_source_management/public/components/data_source_table/data_source_table.tsx +++ b/src/plugins/data_source_management/public/components/data_source_table/data_source_table.tsx @@ -53,6 +53,7 @@ export const DataSourceTable = ({ history }: RouteComponentProps) => { savedObjects, notifications: { toasts }, uiSettings, + application, } = useOpenSearchDashboards().services; /* Component state variables */ @@ -61,6 +62,7 @@ export const DataSourceTable = ({ history }: RouteComponentProps) => { const [isLoading, setIsLoading] = React.useState(false); const [isDeleting, setIsDeleting] = React.useState(false); const [confirmDeleteVisible, setConfirmDeleteVisible] = React.useState(false); + const canManageDataSource = !!application.capabilities?.dataSource?.canManage; /* useEffectOnce hook to avoid these methods called multiple times when state is updated. */ useEffectOnce(() => { @@ -111,11 +113,11 @@ export const DataSourceTable = ({ history }: RouteComponentProps) => { }; const renderToolsRight = () => { - return ( + return canManageDataSource ? ( {renderDeleteButton()} - ); + ) : null; }; const search = { @@ -323,7 +325,7 @@ export const DataSourceTable = ({ history }: RouteComponentProps) => { /> - {createButtonEmptyState} + {canManageDataSource ? createButtonEmptyState : null} diff --git a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx index 876e36bf56ff..73357cafa62e 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx @@ -90,6 +90,7 @@ describe('Datasource Management: Edit Datasource Form', () => { onSetDefaultDataSource={mockFn} handleTestConnection={mockFn} displayToastMessage={mockFn} + canManageDataSource={true} /> ), { @@ -261,6 +262,7 @@ describe('Datasource Management: Edit Datasource Form', () => { handleSubmit={mockFn} handleTestConnection={mockFn} displayToastMessage={mockFn} + canManageDataSource={true} /> ), { @@ -373,6 +375,7 @@ describe('Datasource Management: Edit Datasource Form', () => { onSetDefaultDataSource={mockFn} handleTestConnection={mockFn} displayToastMessage={mockFn} + canManageDataSource={true} /> ), { @@ -593,6 +596,7 @@ describe('With Registered Authentication', () => { onSetDefaultDataSource={jest.fn()} handleTestConnection={jest.fn()} displayToastMessage={jest.fn()} + canManageDataSource={true} /> ), { @@ -634,6 +638,7 @@ describe('With Registered Authentication', () => { onSetDefaultDataSource={jest.fn()} handleTestConnection={jest.fn()} displayToastMessage={jest.fn()} + canManageDataSource={true} /> ), { diff --git a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx index e227e5e2087c..74ec7362381a 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx @@ -56,6 +56,7 @@ export interface EditDataSourceProps { onDeleteDataSource?: () => Promise; onSetDefaultDataSource: () => Promise; displayToastMessage: (info: ToastMessageItem) => void; + canManageDataSource: boolean; } export interface EditDataSourceState { formErrorsByField: CreateEditDataSourceValidation; @@ -644,6 +645,7 @@ export class EditDataSourceForm extends React.Component ); }; @@ -1119,24 +1121,26 @@ export class EditDataSourceForm extends React.Component - - - - - + {this.props.canManageDataSource ? ( + + + + + + ) : null} ); diff --git a/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.test.tsx b/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.test.tsx index 5a23b72881a1..4953e21647c7 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.test.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.test.tsx @@ -34,6 +34,7 @@ describe('Datasource Management: Edit Datasource Header', () => { dataSourceName={dataSourceName} onClickSetDefault={mockFn} isDefault={false} + canManageDataSource={true} /> ), { @@ -87,6 +88,7 @@ describe('Datasource Management: Edit Datasource Header', () => { dataSourceName={dataSourceName} onClickSetDefault={mockFn} isDefault={false} + canManageDataSource={true} /> ), { @@ -116,6 +118,7 @@ describe('Datasource Management: Edit Datasource Header', () => { dataSourceName={dataSourceName} onClickSetDefault={onClickSetDefault} isDefault={isDefaultDataSourceState} + canManageDataSource={true} /> ), { @@ -152,6 +155,7 @@ describe('Datasource Management: Edit Datasource Header', () => { dataSourceName={dataSourceName} onClickSetDefault={onClickSetDefault} isDefault={isDefaultDataSourceState} + canManageDataSource={true} /> ), { @@ -174,4 +178,35 @@ describe('Datasource Management: Edit Datasource Header', () => { ); }); }); + describe('should not manage data source', () => { + beforeEach(() => { + component = mount( + wrapWithIntl( +
+ ), + { + wrappingComponent: OpenSearchDashboardsContextProvider, + wrappingComponentProps: { + services: mockedContext, + }, + } + ); + }); + test('should not show delete', () => { + expect(component.find(headerTitleIdentifier).last().text()).toBe(dataSourceName); + expect(component.find(deleteIconIdentifier).exists()).toBe(false); + }); + test('should not show default icon', () => { + expect(component.find(setDefaultButtonIdentifier).exists()).toBe(false); + }); + }); }); diff --git a/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.tsx b/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.tsx index 264647882574..dece5cb78b2b 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.tsx @@ -29,6 +29,7 @@ export const Header = ({ onClickSetDefault, dataSourceName, isDefault, + canManageDataSource, }: { showDeleteIcon: boolean; isFormValid: boolean; @@ -37,6 +38,7 @@ export const Header = ({ onClickSetDefault: () => void; dataSourceName: string; isDefault: boolean; + canManageDataSource: boolean; }) => { /* State Variables */ const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); @@ -175,11 +177,15 @@ export const Header = ({ {/* Test default button */} - {renderDefaultIcon()} + {canManageDataSource ? ( + {renderDefaultIcon()} + ) : null} {/* Test connection button */} {renderTestConnectionButton()} {/* Delete icon button */} - {showDeleteIcon ? renderDeleteButton() : null} + {canManageDataSource ? ( + {showDeleteIcon ? renderDeleteButton() : null} + ) : null} diff --git a/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.test.tsx b/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.test.tsx index d8d175a920d9..f53744d67716 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.test.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.test.tsx @@ -28,7 +28,10 @@ const formIdentifier = 'EditDataSourceForm'; const notFoundIdentifier = '[data-test-subj="dataSourceNotFound"]'; describe('Datasource Management: Edit Datasource Wizard', () => { - const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); + const mockedContext = { + ...mockManagementPlugin.createDataSourceManagementContext(), + application: { capabilities: { dataSource: { canManage: true } } }, + }; const uiSettings = mockedContext.uiSettings; mockedContext.authenticationMethodRegistry.registerAuthenticationMethod( noAuthCredentialAuthMethod diff --git a/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.tsx b/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.tsx index 334a34322ec2..fbf63aaecb0d 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/edit_data_source.tsx @@ -46,6 +46,7 @@ export const EditDataSource: React.FunctionComponent().services; const dataSourceID: string = props.match.params.id; @@ -162,6 +163,7 @@ export const EditDataSource: React.FunctionComponent ) : null} {isLoading || !dataSource?.endpoint ? : null} diff --git a/src/plugins/data_source_management/public/management_app/mount_management_section.tsx b/src/plugins/data_source_management/public/management_app/mount_management_section.tsx index bd93edb53119..15de75088b48 100644 --- a/src/plugins/data_source_management/public/management_app/mount_management_section.tsx +++ b/src/plugins/data_source_management/public/management_app/mount_management_section.tsx @@ -10,6 +10,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Route, Router, Switch } from 'react-router-dom'; import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { EuiPageContent } from '@elastic/eui'; import { ManagementAppMountParams } from '../../../management/public'; import { OpenSearchDashboardsContextProvider } from '../../../opensearch_dashboards_react/public'; @@ -27,7 +28,7 @@ export interface DataSourceManagementStartDependencies { export async function mountManagementSection( getStartServices: StartServicesAccessor, - params: ManagementAppMountParams, + params: ManagementAppMountParams & { wrapInPage?: boolean }, authMethodsRegistry: AuthenticationMethodRegistry, featureFlagStatus: boolean ) { @@ -48,32 +49,42 @@ export async function mountManagementSection( authenticationMethodRegistry: authMethodsRegistry, }; + const content = ( + + + + + + {featureFlagStatus && ( + + + + )} + + + + {featureFlagStatus && ( + + + + )} + + + + + + ); + ReactDOM.render( - - - - - - {featureFlagStatus && ( - - - - )} - - - - {featureFlagStatus && ( - - - - )} - - - - - + {params.wrapInPage ? ( + + {content} + + ) : ( + content + )} , params.element diff --git a/src/plugins/data_source_management/public/plugin.ts b/src/plugins/data_source_management/public/plugin.ts index 8bdec0d7d5bd..59f9cc91d613 100644 --- a/src/plugins/data_source_management/public/plugin.ts +++ b/src/plugins/data_source_management/public/plugin.ts @@ -4,7 +4,14 @@ */ import { DataSourcePluginSetup } from 'src/plugins/data_source/public'; -import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; +import { + AppMountParameters, + CoreSetup, + CoreStart, + DEFAULT_APP_CATEGORIES, + DEFAULT_NAV_GROUPS, + Plugin, +} from '../../../core/public'; import { PLUGIN_NAME } from '../common'; import { createDataSourceSelector } from './components/data_source_selector/create_data_source_selector'; @@ -106,6 +113,79 @@ export class DataSourceManagementPlugin return undefined; } + /** + * The data sources features in observability has the same name as `DSM_APP_ID` + * Add a suffix to avoid duplication + */ + const DSM_APP_ID_FOR_STANDARD_APPLICATION = `${DSM_APP_ID}_core`; + + if (core.chrome.navGroup.getNavGroupEnabled()) { + core.application.register({ + id: DSM_APP_ID_FOR_STANDARD_APPLICATION, + title: PLUGIN_NAME, + order: 100, + mount: async (params: AppMountParameters) => { + const { mountManagementSection } = await import('./management_app'); + const [coreStart] = await core.getStartServices(); + + return mountManagementSection( + core.getStartServices, + { + ...params, + basePath: core.http.basePath.get(), + setBreadcrumbs: coreStart.chrome.setBreadcrumbs, + wrapInPage: true, + }, + this.authMethodsRegistry, + featureFlagStatus + ); + }, + }); + } + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.dataAdministration, [ + { + id: DSM_APP_ID_FOR_STANDARD_APPLICATION, + category: { + id: DSM_APP_ID_FOR_STANDARD_APPLICATION, + label: PLUGIN_NAME, + order: 200, + }, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.observability, [ + { + id: DSM_APP_ID_FOR_STANDARD_APPLICATION, + category: DEFAULT_APP_CATEGORIES.manage, + order: 100, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.search, [ + { + id: DSM_APP_ID_FOR_STANDARD_APPLICATION, + category: DEFAULT_APP_CATEGORIES.manage, + order: 100, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS['security-analytics'], [ + { + id: DSM_APP_ID_FOR_STANDARD_APPLICATION, + category: DEFAULT_APP_CATEGORIES.manage, + order: 100, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.analytics, [ + { + id: DSM_APP_ID_FOR_STANDARD_APPLICATION, + category: DEFAULT_APP_CATEGORIES.manage, + order: 100, + }, + ]); + const registerAuthenticationMethod = (authMethod: AuthenticationMethod) => { if (this.started) { throw new Error( diff --git a/src/plugins/dev_tools/public/dev_tools_icon.tsx b/src/plugins/dev_tools/public/dev_tools_icon.tsx new file mode 100644 index 000000000000..933b7af0037f --- /dev/null +++ b/src/plugins/dev_tools/public/dev_tools_icon.tsx @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiButtonIcon } from '@elastic/eui'; +import { CoreStart } from 'opensearch-dashboards/public'; + +export function DevToolsIcon({ core, appId }: { core: CoreStart; appId: string }) { + return ( + core.application.navigateToApp(appId)} + /> + ); +} diff --git a/src/plugins/dev_tools/public/plugin.ts b/src/plugins/dev_tools/public/plugin.ts index 6bc40adc5d54..6355d769e66d 100644 --- a/src/plugins/dev_tools/public/plugin.ts +++ b/src/plugins/dev_tools/public/plugin.ts @@ -48,6 +48,7 @@ import { CreateDevToolArgs, DevToolApp, createDevToolApp } from './dev_tool'; import './index.scss'; import { ManagementOverViewPluginSetup } from '../../management_overview/public'; import { toMountPoint } from '../../opensearch_dashboards_react/public'; +import { DevToolsIcon } from './dev_tools_icon'; export interface DevToolsSetupDependencies { urlForwarding: UrlForwardingSetup; @@ -135,7 +136,18 @@ export class DevToolsPlugin implements Plugin { } public start(core: CoreStart) { - if (this.getSortedDevTools().length === 0) { + if (core.chrome.navGroup.getNavGroupEnabled()) { + core.chrome.navControls.registerLeftBottom({ + order: 4, + mount: toMountPoint( + React.createElement(DevToolsIcon, { + core, + appId: this.id, + }) + ), + }); + } + if (this.getSortedDevTools().length === 0 || core.chrome.navGroup.getNavGroupEnabled()) { this.appStateUpdater.next(() => ({ navLinkStatus: AppNavLinkStatus.hidden })); } else { // Register right navigation for dev tool only when console and futureNavigation are both enabled. diff --git a/src/plugins/discover/public/plugin.test.ts b/src/plugins/discover/public/plugin.test.ts new file mode 100644 index 000000000000..ead12ffc7d79 --- /dev/null +++ b/src/plugins/discover/public/plugin.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { coreMock } from '../../../core/public/mocks'; +import { DiscoverPlugin } from './plugin'; +import { dataPluginMock } from '../../data/public/mocks'; +import { embeddablePluginMock } from '../../embeddable/public/mocks'; +import { opensearchDashboardsLegacyPluginMock } from '../../opensearch_dashboards_legacy/public/mocks'; +import { urlForwardingPluginMock } from '../../url_forwarding/public/mocks'; +import { uiActionsPluginMock } from '../../ui_actions/public/mocks'; +import { visualizationsPluginMock } from '../../visualizations/public/mocks'; + +describe('DiscoverPlugin', () => { + it('setup successfully', () => { + const setupMock = coreMock.createSetup(); + const initializerContext = coreMock.createPluginInitializerContext(); + const pluginInstance = new DiscoverPlugin(initializerContext); + expect(() => + pluginInstance.setup(setupMock, { + data: dataPluginMock.createSetupContract(), + embeddable: embeddablePluginMock.createSetupContract(), + opensearchDashboardsLegacy: opensearchDashboardsLegacyPluginMock.createSetupContract(), + urlForwarding: urlForwardingPluginMock.createSetupContract(), + uiActions: uiActionsPluginMock.createSetupContract(), + visualizations: visualizationsPluginMock.createSetupContract(), + dataExplorer: { + registerView: jest.fn(), + }, + }) + ).not.toThrow(); + expect(setupMock.chrome.navGroup.addNavLinksToGroup).toBeCalledTimes(5); + }); +}); diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index 8b46889a8e36..8ac4ca23ba9b 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -33,7 +33,7 @@ import { lazy } from 'react'; import { DataPublicPluginStart, DataPublicPluginSetup, opensearchFilters } from '../../data/public'; import { SavedObjectLoader } from '../../saved_objects/public'; import { url } from '../../opensearch_dashboards_utils/public'; -import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; +import { DEFAULT_APP_CATEGORIES, DEFAULT_NAV_GROUPS } from '../../../core/public'; import { UrlGeneratorState } from '../../share/public'; import { DocViewInput, DocViewInputFn } from './application/doc_views/doc_views_types'; import { generateDocViewsUrl } from './application/components/doc_views/generate_doc_views_url'; @@ -291,6 +291,46 @@ export class DiscoverPlugin }, }); + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.observability, [ + { + id: PLUGIN_ID, + category: undefined, + order: 300, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS['security-analytics'], [ + { + id: PLUGIN_ID, + category: undefined, + order: 300, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.analytics, [ + { + id: PLUGIN_ID, + category: undefined, + order: 200, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.search, [ + { + id: PLUGIN_ID, + category: undefined, + order: 200, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.all, [ + { + id: PLUGIN_ID, + category: undefined, + order: 200, + }, + ]); + plugins.urlForwarding.forwardApp('doc', 'discover', (path) => { return `#${path}`; }); diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx index 473424984c2f..1b1aa65225b1 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.tsx @@ -33,7 +33,7 @@ import { FormattedMessage } from '@osd/i18n/react'; import React, { ReactElement } from 'react'; import { CoreSetup } from 'src/core/public'; -import { EuiContextMenuItem, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; +import { EuiContextMenuItem, EuiFlyoutBody, EuiFlyoutHeader, EuiText } from '@elastic/eui'; import { EmbeddableStart } from 'src/plugins/embeddable/public'; import { IContainer } from '../../../../containers'; @@ -163,11 +163,11 @@ export class AddPanelFlyout extends React.Component { return ( <> - +

-
+
{savedObjectsFinder} diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_options_menu.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_options_menu.tsx index 66dd676e8040..d9582214034e 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_options_menu.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_options_menu.tsx @@ -128,6 +128,7 @@ export class PanelOptionsMenu extends React.Component ); diff --git a/src/plugins/home/public/mocks/index.ts b/src/plugins/home/public/mocks/index.ts index da84d42dd5ca..84927b73dd49 100644 --- a/src/plugins/home/public/mocks/index.ts +++ b/src/plugins/home/public/mocks/index.ts @@ -32,12 +32,14 @@ import { featureCatalogueRegistryMock } from '../services/feature_catalogue/feat import { environmentServiceMock } from '../services/environment/environment.mock'; import { configSchema } from '../../config'; import { tutorialServiceMock } from '../services/tutorials/tutorial_service.mock'; +import { sectionTypeMock } from '../plugin.test.mocks'; const createSetupContract = () => ({ featureCatalogue: featureCatalogueRegistryMock.createSetup(), environment: environmentServiceMock.createSetup(), tutorials: tutorialServiceMock.createSetup(), config: configSchema.validate({}), + sectionTypes: sectionTypeMock.setup(), }); export const homePluginMock = { diff --git a/src/plugins/home/public/plugin.test.mocks.ts b/src/plugins/home/public/plugin.test.mocks.ts index 67d30bcf714a..5b37c2896d59 100644 --- a/src/plugins/home/public/plugin.test.mocks.ts +++ b/src/plugins/home/public/plugin.test.mocks.ts @@ -32,6 +32,7 @@ import { featureCatalogueRegistryMock } from './services/feature_catalogue/featu import { environmentServiceMock } from './services/environment/environment.mock'; import { tutorialServiceMock } from './services/tutorials/tutorial_service.mock'; import { sectionTypeServiceMock } from './services/section_type/section_type.mock'; +import { FeatureCatalogueCategory } from './services/feature_catalogue'; export const registryMock = featureCatalogueRegistryMock.create(); export const environmentMock = environmentServiceMock.create(); @@ -42,4 +43,5 @@ jest.doMock('./services', () => ({ EnvironmentService: jest.fn(() => environmentMock), TutorialService: jest.fn(() => tutorialMock), SectionTypeService: jest.fn(() => sectionTypeMock), + FeatureCatalogueCategory, })); diff --git a/src/plugins/home/public/plugin.ts b/src/plugins/home/public/plugin.ts index a9e4cb263e88..3256963d6c0a 100644 --- a/src/plugins/home/public/plugin.ts +++ b/src/plugins/home/public/plugin.ts @@ -64,6 +64,7 @@ import { PLUGIN_ID, HOME_APP_BASE_PATH, IMPORT_SAMPLE_DATA_APP_ID } from '../com import { DataSourcePluginStart } from '../../data_source/public'; import { workWithDataSection } from './application/components/homepage/sections/work_with_data'; import { learnBasicsSection } from './application/components/homepage/sections/learn_basics'; +import { DEFAULT_NAV_GROUPS } from '../../../core/public'; export interface HomePluginStartDependencies { data: DataPublicPluginStart; @@ -135,7 +136,9 @@ export class HomePublicPlugin core.application.register({ id: PLUGIN_ID, title: 'Home', - navLinkStatus: AppNavLinkStatus.hidden, + navLinkStatus: core.chrome.navGroup.getNavGroupEnabled() + ? undefined + : AppNavLinkStatus.hidden, mount: async (params: AppMountParameters) => { const [coreStart] = await core.getStartServices(); setCommonService(); @@ -148,6 +151,13 @@ export class HomePublicPlugin workspaceAvailability: WorkspaceAvailability.outsideWorkspace, }); + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.all, [ + { + id: PLUGIN_ID, + title: 'Home', + }, + ]); + // Register import sample data as a standalone app so that it is available inside workspace. core.application.register({ id: IMPORT_SAMPLE_DATA_APP_ID, diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/indices_list/__snapshots__/indices_list.test.tsx.snap b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/indices_list/__snapshots__/indices_list.test.tsx.snap index aaba80f050c7..5eef8fd09f9c 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/indices_list/__snapshots__/indices_list.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/step_index_pattern/components/indices_list/__snapshots__/indices_list.test.tsx.snap @@ -94,6 +94,7 @@ exports[`IndicesList should change pages 1`] = ` , ] } + size="s" /> @@ -187,6 +188,7 @@ exports[`IndicesList should change per page 1`] = ` , ] } + size="s" /> @@ -297,6 +299,7 @@ exports[`IndicesList should highlight the query in the matches 1`] = ` , ] } + size="s" /> @@ -398,6 +401,7 @@ exports[`IndicesList should render normally 1`] = ` , ] } + size="s" /> @@ -563,6 +567,7 @@ exports[`IndicesList updating props should render all new indices 1`] = ` , ] } + size="s" /> diff --git a/src/plugins/index_pattern_management/public/management_app/mount_management_section.tsx b/src/plugins/index_pattern_management/public/management_app/mount_management_section.tsx index af37e6ddb719..020fb0ae56a4 100644 --- a/src/plugins/index_pattern_management/public/management_app/mount_management_section.tsx +++ b/src/plugins/index_pattern_management/public/management_app/mount_management_section.tsx @@ -37,6 +37,7 @@ import { I18nProvider } from '@osd/i18n/react'; import { StartServicesAccessor } from 'src/core/public'; import { DataSourcePluginSetup } from 'src/plugins/data_source/public'; +import { EuiPageContent } from '@elastic/eui'; import { OpenSearchDashboardsContextProvider } from '../../../opensearch_dashboards_react/public'; import { ManagementAppMountParams } from '../../../management/public'; import { @@ -60,7 +61,7 @@ const readOnlyBadge = { export async function mountManagementSection( getStartServices: StartServicesAccessor, - params: ManagementAppMountParams, + params: ManagementAppMountParams & { wrapInPage?: boolean }, getMlCardState: () => MlCardState, dataSource?: DataSourcePluginSetup ) { @@ -94,25 +95,35 @@ export async function mountManagementSection( hideLocalCluster, }; + const content = ( + + + + + + + + + + + + + + + + + ); + ReactDOM.render( - - - - - - - - - - - - - - - - + {params.wrapInPage ? ( + + {content} + + ) : ( + content + )} , params.element diff --git a/src/plugins/index_pattern_management/public/plugin.test.ts b/src/plugins/index_pattern_management/public/plugin.test.ts new file mode 100644 index 000000000000..e207af770af3 --- /dev/null +++ b/src/plugins/index_pattern_management/public/plugin.test.ts @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { coreMock } from '../../../core/public/mocks'; +import { IndexPatternManagementPlugin } from './plugin'; +import { urlForwardingPluginMock } from '../../url_forwarding/public/mocks'; +import { managementPluginMock } from '../../management/public/mocks'; + +describe('DiscoverPlugin', () => { + it('setup successfully', () => { + const setupMock = coreMock.createSetup(); + const initializerContext = coreMock.createPluginInitializerContext(); + const pluginInstance = new IndexPatternManagementPlugin(initializerContext); + expect(() => + pluginInstance.setup(setupMock, { + urlForwarding: urlForwardingPluginMock.createSetupContract(), + management: managementPluginMock.createSetupContract(), + }) + ).not.toThrow(); + expect(setupMock.application.register).toBeCalledTimes(1); + expect(setupMock.chrome.navGroup.addNavLinksToGroup).toBeCalledTimes(5); + }); +}); diff --git a/src/plugins/index_pattern_management/public/plugin.ts b/src/plugins/index_pattern_management/public/plugin.ts index 98eaab6160ee..7ee82dbcc3b0 100644 --- a/src/plugins/index_pattern_management/public/plugin.ts +++ b/src/plugins/index_pattern_management/public/plugin.ts @@ -29,7 +29,13 @@ */ import { i18n } from '@osd/i18n'; -import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + AppMountParameters, +} from 'src/core/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; import { DataSourcePluginSetup, DataSourcePluginStart } from 'src/plugins/data_source/public'; import { UrlForwardingSetup } from '../../url_forwarding/public'; @@ -40,6 +46,7 @@ import { } from './service'; import { ManagementSetup } from '../../management/public'; +import { DEFAULT_NAV_GROUPS, AppStatus, DEFAULT_APP_CATEGORIES } from '../../../core/public'; export interface IndexPatternManagementSetupDependencies { management: ManagementSetup; @@ -115,6 +122,73 @@ export class IndexPatternManagementPlugin }, }); + core.application.register({ + id: IPM_APP_ID, + title: sectionsHeader, + status: core.chrome.navGroup.getNavGroupEnabled() + ? AppStatus.accessible + : AppStatus.inaccessible, + mount: async (params: AppMountParameters) => { + const { mountManagementSection } = await import('./management_app'); + const [coreStart] = await core.getStartServices(); + + return mountManagementSection( + core.getStartServices, + { + ...params, + basePath: core.http.basePath.get(), + setBreadcrumbs: coreStart.chrome.setBreadcrumbs, + wrapInPage: true, + }, + () => this.indexPatternManagementService.environmentService.getEnvironment().ml(), + dataSource + ); + }, + }); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.analytics, [ + { + id: IPM_APP_ID, + category: DEFAULT_APP_CATEGORIES.manage, + order: 200, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.observability, [ + { + id: IPM_APP_ID, + category: DEFAULT_APP_CATEGORIES.manage, + order: 200, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.search, [ + { + id: IPM_APP_ID, + category: DEFAULT_APP_CATEGORIES.manage, + order: 200, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS['security-analytics'], [ + { + id: IPM_APP_ID, + category: DEFAULT_APP_CATEGORIES.manage, + order: 200, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.dataAdministration, [ + { + id: IPM_APP_ID, + category: { + id: IPM_APP_ID, + label: sectionsHeader, + order: 100, + }, + }, + ]); + return this.indexPatternManagementService.setup({ httpClient: core.http }); } diff --git a/src/plugins/management/public/components/settings_icon.tsx b/src/plugins/management/public/components/settings_icon.tsx new file mode 100644 index 000000000000..7c5de3e2393c --- /dev/null +++ b/src/plugins/management/public/components/settings_icon.tsx @@ -0,0 +1,61 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useRef } from 'react'; +import { EuiButtonIcon, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; +import { CoreStart } from 'opensearch-dashboards/public'; +import { useObservable } from 'react-use'; +import { Observable } from 'rxjs'; +import { DEFAULT_NAV_GROUPS, NavGroupItemInMap } from '../../../../core/public'; + +export function SettingsIcon({ core }: { core: CoreStart }) { + const [isPopoverOpen, setPopover] = useState(false); + const navGroupsMapRef = useRef>>( + core.chrome.navGroup.getNavGroupsMap$() + ); + const navGroupMap = useObservable(navGroupsMapRef.current, undefined); + const onItemClick = (groupId: string) => { + setPopover(false); + core.chrome.navGroup.setCurrentNavGroup(groupId); + if (navGroupMap) { + const firstNavItem = navGroupMap[groupId]?.navLinks[0]; + if (firstNavItem?.id) { + core.application.navigateToApp(firstNavItem.id); + } + } + }; + const items = [ + onItemClick(DEFAULT_NAV_GROUPS.settingsAndSetup.id)} + > + {DEFAULT_NAV_GROUPS.settingsAndSetup.title} + , + onItemClick(DEFAULT_NAV_GROUPS.dataAdministration.id)} + > + {DEFAULT_NAV_GROUPS.dataAdministration.title} + , + ]; + + return ( + setPopover(true)} + /> + } + isOpen={isPopoverOpen} + closePopover={() => setPopover(false)} + ownFocus={false} + > + + + ); +} diff --git a/src/plugins/management/public/plugin.ts b/src/plugins/management/public/plugin.ts index 81a970a0fc48..0e4676297a1e 100644 --- a/src/plugins/management/public/plugin.ts +++ b/src/plugins/management/public/plugin.ts @@ -28,6 +28,7 @@ * under the License. */ +import React from 'react'; import { i18n } from '@osd/i18n'; import { BehaviorSubject } from 'rxjs'; import { ManagementSetup, ManagementStart } from './types'; @@ -50,6 +51,8 @@ import { getSectionsServiceStartPrivate, } from './management_sections_service'; import { ManagementOverViewPluginSetup } from '../../management_overview/public'; +import { toMountPoint } from '../../opensearch_dashboards_react/public'; +import { SettingsIcon } from './components/settings_icon'; interface ManagementSetupDependencies { home?: HomePublicPluginSetup; @@ -79,6 +82,9 @@ export class ManagementPlugin implements Plugin section.getAppsEnabled().length > 0); - if (!this.hasAnyEnabledApps) { + if (core.chrome.navGroup.getNavGroupEnabled()) { + this.appUpdater.next(() => { + return { + navLinkStatus: AppNavLinkStatus.hidden, + }; + }); + } else if (!this.hasAnyEnabledApps) { this.appUpdater.next(() => { return { status: AppStatus.inaccessible, @@ -121,6 +133,17 @@ export class ManagementPlugin implements Plugin { const { element } = params; const [core] = await getStartServices(); diff --git a/src/plugins/opensearch_dashboards_overview/public/plugin.ts b/src/plugins/opensearch_dashboards_overview/public/plugin.ts index 3af37fc8cfbb..f774acf0651f 100644 --- a/src/plugins/opensearch_dashboards_overview/public/plugin.ts +++ b/src/plugins/opensearch_dashboards_overview/public/plugin.ts @@ -83,7 +83,12 @@ export class OpenSearchDashboardsOverviewPlugin if (!hasOpenSearchDashboardsApp) { return { status: AppStatus.inaccessible, navLinkStatus: AppNavLinkStatus.hidden }; } else { - return { status: AppStatus.accessible, navLinkStatus: AppNavLinkStatus.default }; + return { + status: AppStatus.accessible, + navLinkStatus: core.chrome.navGroup.getNavGroupEnabled() + ? AppNavLinkStatus.hidden + : AppNavLinkStatus.default, + }; } }; }) diff --git a/src/plugins/opensearch_dashboards_utils/public/history/redirect_when_missing.tsx b/src/plugins/opensearch_dashboards_utils/public/history/redirect_when_missing.tsx index 2fe7823b89a6..6256bffd2697 100644 --- a/src/plugins/opensearch_dashboards_utils/public/history/redirect_when_missing.tsx +++ b/src/plugins/opensearch_dashboards_utils/public/history/redirect_when_missing.tsx @@ -28,7 +28,7 @@ * under the License. */ -import React, { Fragment } from 'react'; +import React from 'react'; import { History } from 'history'; import { i18n } from '@osd/i18n'; import { EuiLoadingSpinner } from '@elastic/eui'; @@ -37,10 +37,9 @@ import ReactDOM from 'react-dom'; import { ApplicationStart, HttpStart, ToastsSetup } from 'opensearch-dashboards/public'; import { SavedObjectNotFound } from '..'; -const ReactMarkdown = React.lazy(() => import('react-markdown')); const ErrorRenderer = (props: { children: string }) => ( }> - + {props.children} ); diff --git a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx index 1c9bf676d5b3..0c98f365b39d 100644 --- a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx +++ b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx @@ -33,7 +33,7 @@ import ReactDOM from 'react-dom'; import { Router, Switch, Route } from 'react-router-dom'; import { I18nProvider } from '@osd/i18n/react'; import { i18n } from '@osd/i18n'; -import { EuiLoadingSpinner } from '@elastic/eui'; +import { EuiLoadingSpinner, EuiPageContent } from '@elastic/eui'; import { CoreSetup } from 'src/core/public'; import { DataSourceManagementPluginSetup } from 'src/plugins/data_source_management/public'; import { ManagementAppMountParams } from '../../../management/public'; @@ -44,7 +44,7 @@ import { getAllowedTypes } from './../lib'; interface MountParams { core: CoreSetup; serviceRegistry: ISavedObjectsManagementServiceRegistry; - mountParams: ManagementAppMountParams; + mountParams: ManagementAppMountParams & { wrapInPage?: boolean }; dataSourceEnabled: boolean; dataSourceManagement?: DataSourceManagementPluginSetup; } @@ -69,6 +69,11 @@ export const mountManagementSection = async ({ if (allowedObjectTypes === undefined) { allowedObjectTypes = await getAllowedTypes(coreStart.http); } + // Restrict user to manage data source in the saved object management page according the manageableBy flag. + const showDataSource = !!coreStart.application.capabilities?.dataSource?.canManage; + allowedObjectTypes = showDataSource + ? allowedObjectTypes + : allowedObjectTypes.filter((type) => type !== 'data-source'); coreStart.chrome.docTitle.change(title); @@ -84,43 +89,53 @@ export const mountManagementSection = async ({ return children! as React.ReactElement; }; + const content = ( + + + + + }> + + + + + + + }> + + + + + + + ); + ReactDOM.render( - - - - - }> - - - - - - - }> - - - - - - + {mountParams.wrapInPage ? ( + + {content} + + ) : ( + content + )} , element ); diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index 831daa0df443..bbf9fcbe141e 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -29,7 +29,7 @@ */ import { i18n } from '@osd/i18n'; -import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { AppMountParameters, CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { DataSourcePluginSetup } from 'src/plugins/data_source/public'; import { DataSourceManagementPluginSetup } from 'src/plugins/data_source_management/public'; @@ -61,6 +61,7 @@ import { } from './services'; import { registerServices } from './register_services'; import { bootstrap } from './ui_actions_bootstrap'; +import { DEFAULT_NAV_GROUPS, DEFAULT_APP_CATEGORIES } from '../../../core/public'; export interface SavedObjectsManagementPluginSetup { actions: SavedObjectsManagementActionServiceSetup; @@ -151,6 +152,71 @@ export class SavedObjectsManagementPlugin }, }); + if (core.chrome.navGroup.getNavGroupEnabled()) { + core.application.register({ + id: 'objects', + title: i18n.translate('savedObjectsManagement.assets.label', { + defaultMessage: 'Assets', + }), + mount: async (params: AppMountParameters) => { + const { mountManagementSection } = await import('./management_section'); + const [coreStart] = await core.getStartServices(); + + return mountManagementSection({ + core, + serviceRegistry: this.serviceRegistry, + mountParams: { + ...params, + basePath: core.http.basePath.get(), + setBreadcrumbs: coreStart.chrome.setBreadcrumbs, + wrapInPage: true, + }, + dataSourceEnabled: !!dataSource, + dataSourceManagement, + }); + }, + }); + } + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.settingsAndSetup, [ + { + id: 'objects', + order: 300, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.observability, [ + { + id: 'objects', + category: DEFAULT_APP_CATEGORIES.manage, + order: 300, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.search, [ + { + id: 'objects', + category: DEFAULT_APP_CATEGORIES.manage, + order: 300, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS['security-analytics'], [ + { + id: 'objects', + category: DEFAULT_APP_CATEGORIES.manage, + order: 300, + }, + ]); + + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.analytics, [ + { + id: 'objects', + category: DEFAULT_APP_CATEGORIES.manage, + order: 300, + }, + ]); + // sets up the context mappings and registers any triggers/actions for the plugin bootstrap(uiActions); diff --git a/src/plugins/share/public/components/__snapshots__/share_context_menu.test.tsx.snap b/src/plugins/share/public/components/__snapshots__/share_context_menu.test.tsx.snap index 95d3026f66d3..32cef3fcec57 100644 --- a/src/plugins/share/public/components/__snapshots__/share_context_menu.test.tsx.snap +++ b/src/plugins/share/public/components/__snapshots__/share_context_menu.test.tsx.snap @@ -55,7 +55,7 @@ exports[`shareContextMenuExtensions should sort ascending on sort order first an }, ] } - size="m" + size="s" /> `; @@ -79,7 +79,7 @@ exports[`should only render permalink panel when there are no other panels 1`] = }, ] } - size="m" + size="s" /> `; @@ -132,7 +132,7 @@ exports[`should render context menu panel when there are more than one panel 1`] }, ] } - size="m" + size="s" /> `; diff --git a/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap b/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap index d3501fc655ac..6b40556c74cf 100644 --- a/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap +++ b/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap @@ -175,7 +175,13 @@ exports[`NewVisModal filter for visualization types should render as expected 1`
- New Visualization +
+

+ New Visualization +

+
- - New Visualization - +
+

+ + New Visualization + +

+
+
@@ -1296,7 +1312,13 @@ exports[`NewVisModal should render as expected 1`] = `
- New Visualization +
+

+ New Visualization +

+
- - New Visualization - +
+

+ + New Visualization + +

+
+
diff --git a/src/plugins/visualizations/public/wizard/type_selection/type_selection.tsx b/src/plugins/visualizations/public/wizard/type_selection/type_selection.tsx index 6c5449e0e1ac..9f4e5f66e6c2 100644 --- a/src/plugins/visualizations/public/wizard/type_selection/type_selection.tsx +++ b/src/plugins/visualizations/public/wizard/type_selection/type_selection.tsx @@ -43,6 +43,7 @@ import { EuiModalHeaderTitle, EuiScreenReaderOnly, EuiSpacer, + EuiText, EuiTitle, } from '@elastic/eui'; @@ -96,10 +97,14 @@ class TypeSelection extends React.Component - + +

+ +

+
diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index c146efef1fab..3a2ffc133747 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -71,6 +71,7 @@ import { } from './services'; import { visualizeFieldAction } from './actions/visualize_field_action'; import { createVisualizeUrlGenerator } from './url_generator'; +import { DEFAULT_NAV_GROUPS } from '../../../core/public'; export interface VisualizePluginStartDependencies { data: DataPublicPluginStart; @@ -150,8 +151,10 @@ export class VisualizePlugin setUISettings(core.uiSettings); uiActions.addTriggerAction(VISUALIZE_FIELD_TRIGGER, visualizeFieldAction); + const visualizeAppId = 'visualize'; + core.application.register({ - id: 'visualize', + id: visualizeAppId, title: 'Visualize', order: 8000, euiIconType: 'inputOutput', @@ -225,6 +228,35 @@ export class VisualizePlugin }, }); + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.observability, [ + { + id: visualizeAppId, + category: DEFAULT_APP_CATEGORIES.dashboardAndReport, + order: 200, + }, + ]); + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS['security-analytics'], [ + { + id: visualizeAppId, + category: DEFAULT_APP_CATEGORIES.dashboardAndReport, + order: 200, + }, + ]); + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.analytics, [ + { + id: visualizeAppId, + category: DEFAULT_APP_CATEGORIES.dashboardAndReport, + order: 200, + }, + ]); + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.search, [ + { + id: visualizeAppId, + category: DEFAULT_APP_CATEGORIES.analyzeSearch, + order: 400, + }, + ]); + urlForwarding.forwardApp('visualize', 'visualize'); if (home) { diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index a1f9e7d38e40..bdf9aa8ddfc5 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -9,13 +9,12 @@ import { AppCategory } from '../../../core/types'; export const WORKSPACE_FATAL_ERROR_APP_ID = 'workspace_fatal_error'; export const WORKSPACE_CREATE_APP_ID = 'workspace_create'; export const WORKSPACE_LIST_APP_ID = 'workspace_list'; -export const WORKSPACE_UPDATE_APP_ID = 'workspace_update'; -export const WORKSPACE_OVERVIEW_APP_ID = 'workspace_overview'; +export const WORKSPACE_DETAIL_APP_ID = 'workspace_detail'; /** * Since every workspace always have overview and update page, these features will be selected by default * and can't be changed in the workspace form feature selector */ -export const DEFAULT_SELECTED_FEATURES_IDS = [WORKSPACE_UPDATE_APP_ID, WORKSPACE_OVERVIEW_APP_ID]; +export const DEFAULT_SELECTED_FEATURES_IDS = [WORKSPACE_DETAIL_APP_ID]; export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; export const WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace_conflict_control'; diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx index ca78a106835d..3e9cb3a506eb 100644 --- a/src/plugins/workspace/public/application.tsx +++ b/src/plugins/workspace/public/application.tsx @@ -9,13 +9,11 @@ import { AppMountParameters, ScopedHistory } from '../../../core/public'; import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public'; import { WorkspaceFatalError } from './components/workspace_fatal_error'; import { WorkspaceCreatorApp } from './components/workspace_creator_app'; -import { WorkspaceUpdaterApp } from './components/workspace_updater_app'; import { WorkspaceListApp, WorkspaceListAppProps } from './components/workspace_list_app'; -import { WorkspaceUpdaterProps } from './components/workspace_updater'; import { Services } from './types'; import { WorkspaceCreatorProps } from './components/workspace_creator/workspace_creator'; -import { WorkspaceOverviewApp } from './components/workspace_overview_app'; -import { WorkspaceOverviewProps } from './components/workspace_overview/workspace_overview'; +import { WorkspaceDetailApp } from './components/workspace_detail_app'; +import { WorkspaceDetailProps } from './components/workspace_detail/workspace_detail'; export const renderCreatorApp = ( { element }: AppMountParameters, @@ -34,23 +32,6 @@ export const renderCreatorApp = ( }; }; -export const renderUpdaterApp = ( - { element }: AppMountParameters, - services: Services, - props: WorkspaceUpdaterProps -) => { - ReactDOM.render( - - - , - element - ); - - return () => { - ReactDOM.unmountComponentAtNode(element); - }; -}; - export const renderFatalErrorApp = (params: AppMountParameters, services: Services) => { const { element } = params; const history = params.history as ScopedHistory<{ error?: string }>; @@ -82,14 +63,14 @@ export const renderListApp = ( }; }; -export const renderOverviewApp = ( +export const renderDetailApp = ( { element }: AppMountParameters, services: Services, - props: WorkspaceOverviewProps + props: WorkspaceDetailProps ) => { ReactDOM.render( - + , element ); diff --git a/src/plugins/workspace/public/components/utils/workspace.test.ts b/src/plugins/workspace/public/components/utils/workspace.test.ts index 926455feed34..5676b0d1aa99 100644 --- a/src/plugins/workspace/public/components/utils/workspace.test.ts +++ b/src/plugins/workspace/public/components/utils/workspace.test.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { switchWorkspace, navigateToWorkspaceUpdatePage } from './workspace'; +import { navigateToWorkspaceDetail } from './workspace'; import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; jest.mock('../../../../../core/public/utils'); @@ -20,7 +20,7 @@ describe('workspace utils', () => { coreStartMock.application.navigateToUrl = mockNavigateToUrl; }); - describe('switchWorkspace', () => { + describe('navigateToWorkspaceDetail', () => { it('should redirect if newUrl is returned', () => { Object.defineProperty(window, 'location', { value: { @@ -30,35 +30,7 @@ describe('workspace utils', () => { }); // @ts-ignore formatUrlWithWorkspaceId.mockImplementation(() => 'new_url'); - switchWorkspace({ application: coreStartMock.application, http: coreStartMock.http }, ''); - expect(mockNavigateToUrl).toHaveBeenCalledWith('new_url'); - }); - - it('should not redirect if newUrl is not returned', () => { - Object.defineProperty(window, 'location', { - value: { - href: defaultUrl, - }, - writable: true, - }); - // @ts-ignore - formatUrlWithWorkspaceId.mockImplementation(() => ''); - switchWorkspace({ application: coreStartMock.application, http: coreStartMock.http }, ''); - expect(mockNavigateToUrl).not.toBeCalled(); - }); - }); - - describe('navigateToWorkspaceUpdatePage', () => { - it('should redirect if newUrl is returned', () => { - Object.defineProperty(window, 'location', { - value: { - href: defaultUrl, - }, - writable: true, - }); - // @ts-ignore - formatUrlWithWorkspaceId.mockImplementation(() => 'new_url'); - navigateToWorkspaceUpdatePage( + navigateToWorkspaceDetail( { application: coreStartMock.application, http: coreStartMock.http }, '' ); @@ -74,7 +46,7 @@ describe('workspace utils', () => { }); // @ts-ignore formatUrlWithWorkspaceId.mockImplementation(() => ''); - navigateToWorkspaceUpdatePage( + navigateToWorkspaceDetail( { application: coreStartMock.application, http: coreStartMock.http }, '' ); diff --git a/src/plugins/workspace/public/components/utils/workspace.ts b/src/plugins/workspace/public/components/utils/workspace.ts index 63ed5953dbfa..c7ba243bdf91 100644 --- a/src/plugins/workspace/public/components/utils/workspace.ts +++ b/src/plugins/workspace/public/components/utils/workspace.ts @@ -3,28 +3,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { WORKSPACE_OVERVIEW_APP_ID, WORKSPACE_UPDATE_APP_ID } from '../../../common/constants'; +import { WORKSPACE_DETAIL_APP_ID } from '../../../common/constants'; import { CoreStart } from '../../../../../core/public'; import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; type Core = Pick; -export const switchWorkspace = ({ application, http }: Core, id: string) => { +export const navigateToWorkspaceDetail = ({ application, http }: Core, id: string) => { const newUrl = formatUrlWithWorkspaceId( - application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { - absolute: true, - }), - id, - http.basePath - ); - if (newUrl) { - application.navigateToUrl(newUrl); - } -}; - -export const navigateToWorkspaceUpdatePage = ({ application, http }: Core, id: string) => { - const newUrl = formatUrlWithWorkspaceId( - application.getUrlForApp(WORKSPACE_UPDATE_APP_ID, { + application.getUrlForApp(WORKSPACE_DETAIL_APP_ID, { absolute: true, }), id, 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 ac996e10b871..8550e0c4fa91 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 @@ -66,7 +66,7 @@ const WorkspaceCreator = ({ }, }, navigateToApp, - getUrlForApp: jest.fn(() => '/app/workspace_overview'), + getUrlForApp: jest.fn(() => '/app/workspace_detail'), applications$: new BehaviorSubject>(PublicAPPInfoMap as any), }, notifications: { 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 16832138be96..88c46e973c00 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -11,7 +11,7 @@ import { BehaviorSubject } from 'rxjs'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { WorkspaceForm, WorkspaceFormSubmitData, WorkspaceOperationType } from '../workspace_form'; -import { WORKSPACE_OVERVIEW_APP_ID } from '../../../common/constants'; +import { WORKSPACE_DETAIL_APP_ID } from '../../../common/constants'; import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; import { WorkspaceClient } from '../../workspace_client'; import { convertPermissionSettingsToPermissions } from '../workspace_form'; @@ -63,7 +63,7 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { // Redirect page after one second, leave one second time to show create successful toast. window.setTimeout(() => { window.location.href = formatUrlWithWorkspaceId( - application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + application.getUrlForApp(WORKSPACE_DETAIL_APP_ID, { absolute: true, }), newWorkspaceId, diff --git a/src/plugins/workspace/public/components/workspace_detail/__snapshots__/workspace_detail.test.tsx.snap b/src/plugins/workspace/public/components/workspace_detail/__snapshots__/workspace_detail.test.tsx.snap new file mode 100644 index 000000000000..24a70b2a0352 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_detail/__snapshots__/workspace_detail.test.tsx.snap @@ -0,0 +1,154 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`WorkspaceDetail render workspace detail page normally 1`] = ` +
+
+
+
+
+
+

+
+
+ foo +
+
+

+
+
+
+
+
+
+
+
+
+ + + +
+
+
+
+
+
+
+
+
+
+ + About + +
+

+ this is my foo workspace description +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/src/plugins/workspace/public/components/workspace_updater/index.tsx b/src/plugins/workspace/public/components/workspace_detail/index.tsx similarity index 51% rename from src/plugins/workspace/public/components/workspace_updater/index.tsx rename to src/plugins/workspace/public/components/workspace_detail/index.tsx index b00065a00f64..c4a94b2a6899 100644 --- a/src/plugins/workspace/public/components/workspace_updater/index.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/index.tsx @@ -3,4 +3,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { WorkspaceUpdater, WorkspaceUpdaterProps } from './workspace_updater'; +export { WorkspaceDetail } from './workspace_detail'; 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 new file mode 100644 index 000000000000..030cc03fee99 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx @@ -0,0 +1,123 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { BehaviorSubject } from 'rxjs'; +import { PublicAppInfo, WorkspaceObject } from 'opensearch-dashboards/public'; +import { coreMock } from '../../../../../core/public/mocks'; +import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; +import { WORKSPACE_USE_CASES } from '../../../common/constants'; +import { WorkspaceDetail } from './workspace_detail'; + +// all applications +const PublicAPPInfoMap = new Map([ + ['alerting', { id: 'alerting', title: 'alerting' }], + ['home', { id: 'home', title: 'home' }], +]); + +const mockCoreStart = coreMock.createStart(); + +const workspaceObject = { + id: 'foo_id', + name: 'foo', + description: 'this is my foo workspace description', + features: ['use-case-observability'], + color: '', + icon: '', + reserved: false, +}; + +const createWorkspacesSetupContractMockWithValue = (workspace?: WorkspaceObject) => { + const currentWorkspace = workspace ? workspace : workspaceObject; + const currentWorkspaceId$ = new BehaviorSubject(currentWorkspace.id); + const workspaceList$ = new BehaviorSubject([currentWorkspace]); + const currentWorkspace$ = new BehaviorSubject(currentWorkspace); + const initialized$ = new BehaviorSubject(true); + return { + currentWorkspaceId$, + workspaceList$, + currentWorkspace$, + initialized$, + }; +}; + +const WorkspaceDetailPage = (props: any) => { + const workspacesService = props.workspacesService || createWorkspacesSetupContractMockWithValue(); + const { Provider } = createOpenSearchDashboardsReactContext({ + ...mockCoreStart, + ...{ + application: { + ...mockCoreStart.application, + applications$: new BehaviorSubject>(PublicAPPInfoMap as any), + capabilities: { + ...mockCoreStart.application.capabilities, + workspaces: { + permissionEnabled: true, + }, + }, + }, + workspaces: workspacesService, + savedObjects: { + ...mockCoreStart.savedObjects, + client: { + ...mockCoreStart.savedObjects.client, + find: jest.fn().mockResolvedValue({ + savedObjects: [], + }), + }, + }, + }, + }); + + const registeredUseCases$ = new BehaviorSubject([ + WORKSPACE_USE_CASES.observability, + WORKSPACE_USE_CASES['security-analytics'], + WORKSPACE_USE_CASES.analytics, + WORKSPACE_USE_CASES.search, + ]); + + return ( + + + + ); +}; + +describe('WorkspaceDetail', () => { + it('render workspace detail page normally', async () => { + const { container } = render(WorkspaceDetailPage({})); + expect(container).toMatchSnapshot(); + }); + + it('default selected tab is overview', async () => { + const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); + render(WorkspaceDetailPage({ workspacesService: workspaceService })); + expect(screen.queryByText('foo')).not.toBeNull(); + expect(document.querySelector('#overview')).toHaveClass('euiTab-isSelected'); + }); + + it('click on collaborators tab will workspace update page with permission', async () => { + const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); + const { getByText } = render(WorkspaceDetailPage({ workspacesService: workspaceService })); + await act(async () => { + fireEvent.click(getByText('Collaborators')); + }); + expect(document.querySelector('#collaborators')).toHaveClass('euiTab-isSelected'); + await waitFor(() => { + expect(screen.queryByText('Manage access and permissions')).not.toBeNull(); + }); + }); + + it('click on settings tab will show workspace update page', async () => { + const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); + const { getByText } = render(WorkspaceDetailPage({ workspacesService: workspaceService })); + fireEvent.click(getByText('Settings')); + expect(document.querySelector('#settings')).toHaveClass('euiTab-isSelected'); + await waitFor(() => { + expect(screen.queryByText('Enter details')).not.toBeNull(); + }); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx new file mode 100644 index 000000000000..8e1df0bd6372 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx @@ -0,0 +1,104 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + EuiPage, + EuiPageBody, + EuiPageHeader, + EuiFlexItem, + EuiTabbedContent, + EuiFlexGroup, + EuiPanel, +} from '@elastic/eui'; + +import { useObservable } from 'react-use'; +import { i18n } from '@osd/i18n'; +import { CoreStart } from 'opensearch-dashboards/public'; +import { BehaviorSubject } from 'rxjs'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { WorkspaceUseCase } from '../../types'; +import { WorkspaceDetailContent } from './workspace_detail_content'; +import { WorkspaceUpdater } from './workspace_updater'; +import { DetailTab } from '../workspace_form/constants'; + +export interface WorkspaceDetailProps { + registeredUseCases$: BehaviorSubject; +} + +export const WorkspaceDetail = (props: WorkspaceDetailProps) => { + const { + services: { workspaces, application }, + } = useOpenSearchDashboards(); + + const currentWorkspace = useObservable(workspaces.currentWorkspace$); + const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; + + if (!currentWorkspace) { + return null; + } + + const pageTitle = ( + + {currentWorkspace?.name} + + ); + + const detailTabs = [ + { + id: DetailTab.Overview, + name: i18n.translate('workspace.overview.tabTitle', { + defaultMessage: 'Overview', + }), + content: , + }, + { + id: DetailTab.Settings, + name: i18n.translate('workspace.overview.setting.tabTitle', { + defaultMessage: 'Settings', + }), + content: ( + + ), + }, + ...(isPermissionEnabled + ? [ + { + id: DetailTab.Collaborators, + name: i18n.translate('workspace.overview.collaborators.tabTitle', { + defaultMessage: 'Collaborators', + }), + content: ( + + ), + }, + ] + : []), + ]; + + return ( + <> + + + + + + + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_overview/workspace_overview_content.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_detail_content.tsx similarity index 88% rename from src/plugins/workspace/public/components/workspace_overview/workspace_overview_content.tsx rename to src/plugins/workspace/public/components/workspace_detail/workspace_detail_content.tsx index 6b2ad0fbe689..3e3010e4143d 100644 --- a/src/plugins/workspace/public/components/workspace_overview/workspace_overview_content.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_detail_content.tsx @@ -15,9 +15,9 @@ import { import React from 'react'; import { useObservable } from 'react-use'; import { of } from 'rxjs'; -import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; -export const WorkspaceOverviewContent = () => { +export const WorkspaceDetailContent = () => { const { services: { workspaces }, } = useOpenSearchDashboards(); diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_updater.test.tsx similarity index 82% rename from src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx rename to src/plugins/workspace/public/components/workspace_detail/workspace_updater.test.tsx index f3bae92ffbdf..8f28fdd816dc 100644 --- a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_updater.test.tsx @@ -10,6 +10,7 @@ import { BehaviorSubject } from 'rxjs'; import { coreMock, workspacesServiceMock } from '../../../../../core/public/mocks'; import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; +import { DetailTab } from '../workspace_form/constants'; import { WORKSPACE_USE_CASES } from '../../../common/constants'; import { WorkspaceUpdater as WorkspaceUpdaterComponent, @@ -26,9 +27,9 @@ const PublicAPPInfoMap = new Map([ ['dashboards', { id: 'dashboards', title: 'Dashboards' }], ]); const createWorkspacesSetupContractMockWithValue = () => { - const currentWorkspaceId$ = new BehaviorSubject('abljlsds'); + const currentWorkspaceId$ = new BehaviorSubject('workspaceId'); const currentWorkspace = { - id: 'abljlsds', + id: 'workspaceId', name: 'test1', description: 'test1', features: ['use-case-observability'], @@ -95,7 +96,7 @@ const WorkspaceUpdater = ( }, }, navigateToApp, - getUrlForApp: jest.fn(() => '/app/workspace_overview'), + getUrlForApp: jest.fn(() => '/app/workspace_detail'), applications$: new BehaviorSubject>(PublicAPPInfoMap as any), }, workspaces: workspacesService, @@ -166,12 +167,17 @@ describe('WorkspaceUpdater', () => { it('cannot render when the name of the current workspace is empty', async () => { const mockedWorkspacesService = workspacesServiceMock.createSetupContract(); - const { container } = render(); + const { container } = render( + + ); expect(container).toMatchInlineSnapshot(`
`); }); it('cannot update workspace with invalid name', async () => { - const { getByTestId } = render(); + const { getByTestId } = render(); await waitFor(renderCompleted); @@ -183,7 +189,7 @@ describe('WorkspaceUpdater', () => { }); it('cancel update workspace', async () => { - const { findByText, getByTestId } = render(); + const { findByText, getByTestId } = render(); await waitFor(renderCompleted); fireEvent.click(getByTestId('workspaceForm-bottomBar-cancelButton')); @@ -193,7 +199,9 @@ describe('WorkspaceUpdater', () => { }); it('update workspace successfully', async () => { - const { getByTestId, getAllByTestId, getAllByLabelText } = render(); + const { getByTestId, getAllByLabelText } = render( + + ); await waitFor(renderCompleted); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); @@ -215,6 +223,46 @@ describe('WorkspaceUpdater', () => { fireEvent.click(getByTestId('workspaceUseCase-observability')); fireEvent.click(getByTestId('workspaceUseCase-analytics')); + act(() => { + fireEvent.click(getAllByLabelText('Delete data source')[0]); + }); + + fireEvent.click(getByTestId('workspaceForm-bottomBar-updateButton')); + expect(workspaceClientUpdate).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + name: 'test workspace name', + color: '#000000', + description: 'test workspace description', + features: expect.arrayContaining(['use-case-analytics']), + }), + { + permissions: { + library_write: { + users: ['foo'], + }, + write: { + users: ['foo'], + }, + }, + dataSources: ['id2'], + } + ); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + expect(notificationToastsAddDanger).not.toHaveBeenCalled(); + await waitFor(() => { + expect(setHrefSpy).toHaveBeenCalledWith(expect.stringMatching(/workspace_detail$/)); + }); + }); + + it('update workspace permission successfully', async () => { + const { getByTestId, getAllByTestId } = render( + + ); + await waitFor(() => expect(screen.queryByText('Manage access and permissions')).not.toBeNull()); + const userIdInput = getAllByTestId('comboBoxSearchInput')[0]; fireEvent.click(userIdInput); @@ -223,18 +271,13 @@ describe('WorkspaceUpdater', () => { }); fireEvent.blur(userIdInput); - await act(() => { - fireEvent.click(getAllByLabelText('Delete data source')[0]); - }); - fireEvent.click(getByTestId('workspaceForm-bottomBar-updateButton')); expect(workspaceClientUpdate).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - name: 'test workspace name', - color: '#000000', - description: 'test workspace description', - features: expect.arrayContaining(['use-case-analytics']), + name: 'test1', + description: 'test1', + features: expect.arrayContaining(['use-case-observability']), }), { permissions: { @@ -245,7 +288,7 @@ describe('WorkspaceUpdater', () => { users: ['test user id'], }, }, - dataSources: ['id2'], + dataSources: ['id1', 'id2'], } ); await waitFor(() => { @@ -253,13 +296,13 @@ describe('WorkspaceUpdater', () => { }); expect(notificationToastsAddDanger).not.toHaveBeenCalled(); await waitFor(() => { - expect(setHrefSpy).toHaveBeenCalledWith(expect.stringMatching(/workspace_overview$/)); + expect(setHrefSpy).toHaveBeenCalledWith(expect.stringMatching(/workspace_detail$/)); }); }); it('should show danger toasts after update workspace failed', async () => { workspaceClientUpdate.mockReturnValue({ result: false, success: false }); - const { getByTestId } = render(); + const { getByTestId } = render(); await waitFor(renderCompleted); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); @@ -278,7 +321,7 @@ describe('WorkspaceUpdater', () => { workspaceClientUpdate.mockImplementation(() => { throw new Error('update workspace failed'); }); - const { getByTestId } = render(); + const { getByTestId } = render(); await waitFor(renderCompleted); const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); @@ -295,7 +338,9 @@ describe('WorkspaceUpdater', () => { it('should show danger toasts when currentWorkspace is missing after click update button', async () => { const mockedWorkspacesService = workspacesServiceMock.createSetupContract(); - const { getByTestId } = render(); + const { getByTestId } = render( + + ); await waitFor(renderCompleted); diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_updater.tsx similarity index 90% rename from src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx rename to src/plugins/workspace/public/components/workspace_detail/workspace_updater.tsx index 42d8ceb616fc..7ba838303a89 100644 --- a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_updater.tsx @@ -4,31 +4,31 @@ */ import React, { useCallback, useEffect, useState } from 'react'; -import { EuiPage, EuiPageBody, EuiPageHeader, EuiPageContent } from '@elastic/eui'; +import { EuiPage, EuiPageBody, EuiPageContent, EuiSpacer } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { useObservable } from 'react-use'; import { BehaviorSubject, of } from 'rxjs'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; -import { WORKSPACE_OVERVIEW_APP_ID } from '../../../common/constants'; +import { WORKSPACE_DETAIL_APP_ID } from '../../../common/constants'; import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; import { WorkspaceAttributeWithPermission } from '../../../../../core/types'; import { WorkspaceClient } from '../../workspace_client'; import { - WorkspaceForm, WorkspaceFormSubmitData, WorkspaceOperationType, convertPermissionsToPermissionSettings, convertPermissionSettingsToPermissions, + WorkspaceDetailForm, } from '../workspace_form'; import { getDataSourcesList } from '../../utils'; import { DataSource } from '../../../common/types'; +import { DetailTab } from '../workspace_form/constants'; import { DataSourceManagementPluginSetup } from '../../../../../plugins/data_source_management/public'; import { WorkspaceUseCase } from '../../types'; export interface WorkspaceUpdaterProps { registeredUseCases$: BehaviorSubject; - hideTitle?: boolean; - maxWidth?: number | string; + detailTab?: DetailTab; } function getFormDataFromWorkspace( @@ -64,7 +64,6 @@ export const WorkspaceUpdater = (props: WorkspaceUpdaterProps) => { workspaceClient: WorkspaceClient; dataSourceManagement?: DataSourceManagementPluginSetup; }>(); - const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null)); const availableUseCases = useObservable(props.registeredUseCases$, []); @@ -101,7 +100,7 @@ export const WorkspaceUpdater = (props: WorkspaceUpdaterProps) => { // Redirect page after one second, leave one second time to show update successful toast. window.setTimeout(() => { window.location.href = formatUrlWithWorkspaceId( - application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + application.getUrlForApp(WORKSPACE_DETAIL_APP_ID, { absolute: true, }), currentWorkspace.id, @@ -146,21 +145,22 @@ export const WorkspaceUpdater = (props: WorkspaceUpdaterProps) => { return ( - {!props.hideTitle ? : null} + {application && savedObjects && ( - diff --git a/src/plugins/workspace/public/components/workspace_overview_app.tsx b/src/plugins/workspace/public/components/workspace_detail_app.tsx similarity index 83% rename from src/plugins/workspace/public/components/workspace_overview_app.tsx rename to src/plugins/workspace/public/components/workspace_detail_app.tsx index c581aad9d322..2236cd67c139 100644 --- a/src/plugins/workspace/public/components/workspace_overview_app.tsx +++ b/src/plugins/workspace/public/components/workspace_detail_app.tsx @@ -9,9 +9,9 @@ import { CoreStart } from 'opensearch-dashboards/public'; import { useObservable } from 'react-use'; import { EuiBreadcrumb } from '@elastic/eui'; import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; -import { WorkspaceOverview, WorkspaceOverviewProps } from './workspace_overview/workspace_overview'; +import { WorkspaceDetail, WorkspaceDetailProps } from './workspace_detail/workspace_detail'; -export const WorkspaceOverviewApp = (props: WorkspaceOverviewProps) => { +export const WorkspaceDetailApp = (props: WorkspaceDetailProps) => { const { services: { workspaces, chrome, application }, } = useOpenSearchDashboards(); @@ -40,7 +40,7 @@ export const WorkspaceOverviewApp = (props: WorkspaceOverviewProps) => { return ( - + ); }; diff --git a/src/plugins/workspace/public/components/workspace_form/constants.ts b/src/plugins/workspace/public/components/workspace_form/constants.ts index 1f4e4260a343..2a2c7142d6f0 100644 --- a/src/plugins/workspace/public/components/workspace_form/constants.ts +++ b/src/plugins/workspace/public/components/workspace_form/constants.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { i18n } from '@osd/i18n'; import { WorkspacePermissionMode } from '../../../common/constants'; export enum WorkspaceOperationType { @@ -31,3 +32,25 @@ export const optionIdToWorkspacePermissionModesMap: { ], [PermissionModeId.Owner]: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], }; + +export const workspaceDetailsTitle = i18n.translate('workspace.form.workspaceDetails.title', { + defaultMessage: 'Enter details', +}); + +export const workspaceUseCaseTitle = i18n.translate('workspace.form.workspaceUseCase.title', { + defaultMessage: 'Choose one or more focus areas', +}); + +export const selectDataSourceTitle = i18n.translate('workspace.form.selectDataSource.title', { + defaultMessage: 'Associate data source', +}); + +export const usersAndPermissionsTitle = i18n.translate('workspace.form.usersAndPermissions.title', { + defaultMessage: 'Manage access and permissions', +}); + +export enum DetailTab { + Settings = 'settings', + Collaborators = 'collaborators', + Overview = 'overview', +} diff --git a/src/plugins/workspace/public/components/workspace_form/index.ts b/src/plugins/workspace/public/components/workspace_form/index.ts index 416592ff2006..31addf5a641e 100644 --- a/src/plugins/workspace/public/components/workspace_form/index.ts +++ b/src/plugins/workspace/public/components/workspace_form/index.ts @@ -4,6 +4,7 @@ */ export { WorkspaceForm } from './workspace_form'; +export { WorkspaceDetailForm } from './workspace_detail_form'; export { WorkspaceFormSubmitData } from './types'; export { WorkspaceOperationType } from './constants'; export { 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 472a98a460d4..e7bc280882c1 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 @@ -7,13 +7,13 @@ import React, { useCallback, useEffect, useState } from 'react'; import { EuiButton, EuiFormRow, - EuiText, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiButtonIcon, EuiComboBox, EuiComboBoxOptionOption, + EuiFormLabel, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { SavedObjectsStart } from '../../../../../core/public'; @@ -88,13 +88,11 @@ export const SelectDataSourcePanel = ({ return (
- - - {i18n.translate('workspace.form.selectDataSource.subTitle', { - defaultMessage: 'Data source', - })} - - + + {i18n.translate('workspace.form.selectDataSource.subTitle', { + defaultMessage: 'Data source', + })} + {selectedDataSources.map(({ id, title }, index) => ( { expect(onSubmitMock).toHaveBeenCalledWith( expect.objectContaining({ name: 'test-workspace-name', - features: ['use-case-observability', 'workspace_update', 'workspace_overview'], + features: ['use-case-observability', 'workspace_detail'], }) ); }); diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.scss b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.scss new file mode 100644 index 000000000000..12d655151605 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.scss @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +.workspace-detail-form-group { + width: 15%; +} diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx new file mode 100644 index 000000000000..2e8b7cb32415 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx @@ -0,0 +1,146 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './workspace_detail_form.scss'; +import React, { useRef } from 'react'; +import { EuiPanel, EuiSpacer, EuiForm, EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; + +import { WorkspaceBottomBar } from './workspace_bottom_bar'; +import { WorkspaceFormProps } from './types'; +import { useWorkspaceForm } from './use_workspace_form'; +import { WorkspaceUseCase } from './workspace_use_case'; +import { WorkspacePermissionSettingPanel } from './workspace_permission_setting_panel'; +import { SelectDataSourcePanel } from './select_data_source_panel'; +import { EnterDetailsPanel } from './workspace_enter_details_panel'; +import { + DetailTab, + WorkspaceOperationType, + selectDataSourceTitle, + usersAndPermissionsTitle, + workspaceDetailsTitle, + workspaceUseCaseTitle, +} from './constants'; +import { WorkspaceCreateActionPanel } from './workspace_create_action_panel'; +import { WorkspaceFormErrorCallout } from './workspace_form_error_callout'; + +interface FormGroupProps { + title: string; + children: React.ReactNode; +} + +const FormGroup = ({ title, children }: FormGroupProps) => ( + + + +

{title}

+
+
+ {children} +
+); + +export const WorkspaceDetailForm = (props: WorkspaceFormProps) => { + const { + detailTab, + application, + savedObjects, + defaultValues, + operationType, + availableUseCases, + dataSourceManagement: isDataSourceEnabled, + } = props; + const { + formId, + formData, + formErrors, + numberOfErrors, + numberOfChanges, + handleFormSubmit, + handleColorChange, + handleUseCasesChange, + setPermissionSettings, + handleNameInputChange, + setSelectedDataSources, + handleDescriptionChange, + } = useWorkspaceForm(props); + + const isDashboardAdmin = application?.capabilities?.dashboards?.isDashboardAdmin ?? false; + + const disabledUserOrGroupInputIdsRef = useRef( + defaultValues?.permissionSettings?.map((item) => item.id) ?? [] + ); + + return ( + + {numberOfErrors > 0 && ( + <> + + + + )} + + {detailTab === DetailTab.Collaborators && ( + + + + )} + {detailTab === DetailTab.Settings && ( + <> + + + + + + + + + {isDashboardAdmin && isDataSourceEnabled && ( + + + + )} + + )} + + + {operationType === WorkspaceOperationType.Create && ( + + )} + {operationType === WorkspaceOperationType.Update && ( + + )} + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_enter_details_panel.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_enter_details_panel.tsx new file mode 100644 index 000000000000..649890151204 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_enter_details_panel.tsx @@ -0,0 +1,111 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiColorPicker, + EuiFieldText, + EuiFormRow, + EuiSpacer, + EuiText, + EuiTextArea, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import React from 'react'; +import { EuiColorPickerOutput } from '@elastic/eui/src/components/color_picker/color_picker'; +import { WorkspaceFormErrors } from './types'; + +export interface EnterDetailsPanelProps { + formErrors: WorkspaceFormErrors; + name?: string; + description?: string; + color?: string; + readOnly: boolean; + handleNameInputChange: React.ChangeEventHandler; + handleDescriptionChange: React.ChangeEventHandler; + handleColorChange: (text: string, output: EuiColorPickerOutput) => void; +} + +export const EnterDetailsPanel = ({ + formErrors, + name, + description, + color, + readOnly, + handleNameInputChange, + handleDescriptionChange, + handleColorChange, +}: EnterDetailsPanelProps) => { + return ( + <> + + + + + Description - optional + + } + > + <> + + {i18n.translate('workspace.form.workspaceDetails.description.introduction', { + defaultMessage: + 'Help others understand the purpose of this workspace by providing an overview of the workspace you’re creating.', + })} + + + + + +
+ + {i18n.translate('workspace.form.workspaceDetails.color.helpText', { + defaultMessage: 'Accent color for your workspace', + })} + + + +
+
+ + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx index 89039c27c7f1..07c86ef15ab0 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx @@ -4,18 +4,7 @@ */ import React, { useRef } from 'react'; -import { - EuiPanel, - EuiSpacer, - EuiTitle, - EuiForm, - EuiFormRow, - EuiFieldText, - EuiText, - EuiTextArea, - EuiColorPicker, -} from '@elastic/eui'; -import { i18n } from '@osd/i18n'; +import { EuiPanel, EuiSpacer, EuiTitle, EuiForm } from '@elastic/eui'; import { WorkspaceBottomBar } from './workspace_bottom_bar'; import { WorkspaceFormProps } from './types'; @@ -26,6 +15,13 @@ import { WorkspaceOperationType } from './constants'; import { WorkspaceFormErrorCallout } from './workspace_form_error_callout'; import { WorkspaceCreateActionPanel } from './workspace_create_action_panel'; import { SelectDataSourcePanel } from './select_data_source_panel'; +import { EnterDetailsPanel } from './workspace_enter_details_panel'; +import { + selectDataSourceTitle, + usersAndPermissionsTitle, + workspaceDetailsTitle, + workspaceUseCaseTitle, +} from './constants'; export const WorkspaceForm = (props: WorkspaceFormProps) => { const { @@ -51,9 +47,7 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { setSelectedDataSources, handleDescriptionChange, } = useWorkspaceForm(props); - const workspaceDetailsTitle = i18n.translate('workspace.form.workspaceDetails.title', { - defaultMessage: 'Enter details', - }); + const disabledUserOrGroupInputIdsRef = useRef( defaultValues?.permissionSettings?.map((item) => item.id) ?? [] ); @@ -73,111 +67,35 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => {

{workspaceDetailsTitle}

- - - - - Description - optional - - } - > - <> - - {i18n.translate('workspace.form.workspaceDetails.description.introduction', { - defaultMessage: - 'Help others understand the purpose of this workspace by providing an overview of the workspace you’re creating.', - })} - - - - - -
- - {i18n.translate('workspace.form.workspaceDetails.color.helpText', { - defaultMessage: 'Accent color for your workspace', - })} - - - -
-
+ -

- {i18n.translate('workspace.form.workspaceUseCase.title', { - defaultMessage: 'Choose one or more focus areas', - })} -

+

{workspaceUseCaseTitle}

- - - +
{permissionEnabled && ( -

- {i18n.translate('workspace.form.usersAndPermissions.title', { - defaultMessage: 'Manage access and permissions', - })} -

+

{usersAndPermissionsTitle}

{ {isDashboardAdmin && isDataSourceEnabled && ( -

- {i18n.translate('workspace.form.selectDataSource.title', { - defaultMessage: 'Associate data source', - })} -

+

{selectDataSourceTitle}

) => { const onChangeMock = jest.fn(); + const formErrors: WorkspaceFormErrors = {}; const renderResult = render( ) => { ]} value={[]} onChange={onChangeMock} + formErrors={formErrors} {...options} /> ); diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx index ffb38da7dbbb..06a00cefa39b 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx @@ -4,10 +4,11 @@ */ import React, { useCallback } from 'react'; -import { EuiCheckableCard, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; - -import './workspace_use_case.scss'; +import { i18n } from '@osd/i18n'; +import { EuiCheckableCard, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiText } from '@elastic/eui'; import { WorkspaceUseCase as WorkspaceUseCaseObject } from '../../types'; +import { WorkspaceFormErrors } from './types'; +import './workspace_use_case.scss'; interface WorkspaceUseCaseCardProps { id: string; @@ -48,10 +49,16 @@ const WorkspaceUseCaseCard = ({ export interface WorkspaceUseCaseProps { value: string[]; onChange: (newValue: string[]) => void; + formErrors: WorkspaceFormErrors; availableUseCases: WorkspaceUseCaseObject[]; } -export const WorkspaceUseCase = ({ availableUseCases, value, onChange }: WorkspaceUseCaseProps) => { +export const WorkspaceUseCase = ({ + value, + onChange, + formErrors, + availableUseCases, +}: WorkspaceUseCaseProps) => { const handleCardChange = useCallback( (id: string) => { if (!value.includes(id)) { @@ -64,20 +71,29 @@ export const WorkspaceUseCase = ({ availableUseCases, value, onChange }: Workspa ); return ( - - {availableUseCases - .filter((item) => !item.systematic) - .map(({ id, title, description }) => ( - - - - ))} - + + + {availableUseCases + .filter((item) => !item.systematic) + .map(({ id, title, description }) => ( + + + + ))} + + ); }; diff --git a/src/plugins/workspace/public/components/workspace_list/index.test.tsx b/src/plugins/workspace/public/components/workspace_list/index.test.tsx index f02faec520a6..5e55205c196e 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.test.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.test.tsx @@ -8,7 +8,7 @@ import { BehaviorSubject, of } from 'rxjs'; import { render, fireEvent, screen } from '@testing-library/react'; import { I18nProvider } from '@osd/i18n/react'; import { coreMock } from '../../../../../core/public/mocks'; -import { switchWorkspace, navigateToWorkspaceUpdatePage } from '../utils/workspace'; +import { navigateToWorkspaceDetail } from '../utils/workspace'; import { OpenSearchDashboardsContextProvider } from '../../../../../plugins/opensearch_dashboards_react/public'; import { WORKSPACE_USE_CASES } from '../../../common/constants'; import { WorkspaceList } from './index'; @@ -97,14 +97,14 @@ describe('WorkspaceList', () => { const { getByText } = render(getWrapWorkspaceListInContext()); const nameLink = getByText('name1'); fireEvent.click(nameLink); - expect(switchWorkspace).toBeCalled(); + expect(navigateToWorkspaceDetail).toBeCalled(); }); it('should be able to update workspace after clicking name', async () => { const { getAllByTestId } = render(getWrapWorkspaceListInContext()); const editIcon = getAllByTestId('workspace-list-edit-icon')[0]; fireEvent.click(editIcon); - expect(navigateToWorkspaceUpdatePage).toBeCalled(); + expect(navigateToWorkspaceDetail).toBeCalled(); }); it('should be able to call delete modal after clicking delete button', async () => { diff --git a/src/plugins/workspace/public/components/workspace_list/index.tsx b/src/plugins/workspace/public/components/workspace_list/index.tsx index 313364a94a18..0d2e3c79082d 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.tsx @@ -20,7 +20,7 @@ import { i18n } from '@osd/i18n'; import { debounce } from '../../../../../core/public'; import { WorkspaceAttribute } from '../../../../../core/public'; import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public'; -import { switchWorkspace, navigateToWorkspaceUpdatePage } from '../utils/workspace'; +import { navigateToWorkspaceDetail } from '../utils/workspace'; import { WORKSPACE_CREATE_APP_ID } from '../../../common/constants'; @@ -58,16 +58,7 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { const handleSwitchWorkspace = useCallback( (id: string) => { if (application && http) { - switchWorkspace({ application, http }, id); - } - }, - [application, http] - ); - - const handleUpdateWorkspace = useCallback( - (id: string) => { - if (application && http) { - navigateToWorkspaceUpdatePage({ application, http }, id); + navigateToWorkspaceDetail({ application, http }, id); } }, [application, http] @@ -139,7 +130,7 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { icon: 'pencil', type: 'icon', description: 'Edit workspace', - onClick: ({ id }: WorkspaceAttribute) => handleUpdateWorkspace(id), + onClick: ({ id }: WorkspaceAttribute) => handleSwitchWorkspace(id), 'data-test-subj': 'workspace-list-edit-icon', }, { diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx index c63b232bb232..85ee89482724 100644 --- a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx @@ -74,7 +74,7 @@ describe('', () => { fireEvent.click(screen.getByText(/workspace 1/i)); expect(window.location.assign).toHaveBeenCalledWith( - 'https://test.com/w/workspace-1/app/workspace_overview' + 'https://test.com/w/workspace-1/app/workspace_detail' ); Object.defineProperty(window, 'location', { 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 5b16b9766b22..986711a8b3d6 100644 --- a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx @@ -22,7 +22,7 @@ import type { EuiContextMenuPanelItemDescriptor } from '@elastic/eui'; import { WORKSPACE_CREATE_APP_ID, WORKSPACE_LIST_APP_ID, - WORKSPACE_OVERVIEW_APP_ID, + WORKSPACE_DETAIL_APP_ID, } from '../../../common/constants'; import { cleanWorkspaceId, formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; import { CoreStart, WorkspaceObject } from '../../../../../core/public'; @@ -69,7 +69,7 @@ export const WorkspaceMenu = ({ coreStart }: Props) => { const workspaceToItem = (workspace: WorkspaceObject) => { const workspaceURL = formatUrlWithWorkspaceId( - coreStart.application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + coreStart.application.getUrlForApp(WORKSPACE_DETAIL_APP_ID, { absolute: false, }), workspace.id, diff --git a/src/plugins/workspace/public/components/workspace_overview/__snapshots__/workspace_overview.test.tsx.snap b/src/plugins/workspace/public/components/workspace_overview/__snapshots__/workspace_overview.test.tsx.snap deleted file mode 100644 index 650f0775b8e4..000000000000 --- a/src/plugins/workspace/public/components/workspace_overview/__snapshots__/workspace_overview.test.tsx.snap +++ /dev/null @@ -1,314 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`WorkspaceOverview render workspace overview page normally 1`] = ` -
-
-
-
-
-
-

-
-
- foo -
-
-

-
-
-
-
-

- Start working -

-
-
-
-
-
- - - - -
- -
-
-
-
-
- - - - -
- -
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
- - - -
-
-
-
-
-
-
-
-
-
- - About - -
-

- this is my foo workspace description -

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`; diff --git a/src/plugins/workspace/public/components/workspace_overview/all_get_started_cards.ts b/src/plugins/workspace/public/components/workspace_overview/all_get_started_cards.ts deleted file mode 100644 index ad7dcad86bb8..000000000000 --- a/src/plugins/workspace/public/components/workspace_overview/all_get_started_cards.ts +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { GetStartCard } from './types'; -import { WORKSPACE_APP_CATEGORIES } from '../../../common/constants'; - -/** - * All getting start cards - */ -export const getStartCards: GetStartCard[] = [ - // getStarted - { - id: '', // set id as empty so that it will always show up - featureDescription: 'Discover pre-loaded datasets before adding your own.', - featureName: 'Sample Datasets', - link: '/app/import_sample_data', - category: WORKSPACE_APP_CATEGORIES.getStarted, - }, - { - id: 'workspace_create', - featureDescription: 'Build a collaborative hub for your team.', - featureName: 'Workspaces', - link: '/app/workspace_create', - category: WORKSPACE_APP_CATEGORIES.getStarted, - }, - { - id: 'datasources', - featureDescription: 'Seamlessly integrate your data sources.', - featureName: 'Data Sources', - link: '/app/datasources', - category: WORKSPACE_APP_CATEGORIES.getStarted, - }, - { - id: 'management', - featureDescription: 'Unlock seamless data access.', - featureName: 'Index Patterns', - link: '/app/management/opensearch-dashboards/indexPatterns', - category: WORKSPACE_APP_CATEGORIES.getStarted, - }, - { - id: 'integrations', - featureDescription: 'Gain instant insights with pre-configured log dashboards.', - featureName: 'Integrations', - link: '/app/integrations', - category: WORKSPACE_APP_CATEGORIES.getStarted, - }, - // dashboardAndReport - { - id: 'dashboards', - featureDescription: 'Gain clarity and visibility with dynamic data visualization tools.', - featureName: 'Dashboards', - link: '/app/dashboards', - category: WORKSPACE_APP_CATEGORIES.dashboardAndReport, - }, - { - id: 'visualize', - featureDescription: - 'Unlock insightful data exploration with powerful visualization and aggregation tools.', - featureName: 'Visualizations', - link: '/app/visualize', - category: WORKSPACE_APP_CATEGORIES.dashboardAndReport, - }, - { - id: 'maps-dashboards', - featureDescription: 'Unlock spatial insights with multi-layer map visualizations.', - featureName: 'Maps', - link: '/app/maps-dashboards', - category: WORKSPACE_APP_CATEGORIES.dashboardAndReport, - }, - { - id: 'observability-notebooks', - featureDescription: 'Gain real-time visibility with dynamic, data-powered report generation.', - featureName: 'Notebooks', - link: '/app/observability-notebooks', - category: WORKSPACE_APP_CATEGORIES.dashboardAndReport, - }, - { - id: 'reports-dashboards', - featureDescription: 'Collaborate effectively with multi-format report sharing.', - featureName: 'Reports', - link: '/app/reports-dashboards', - category: WORKSPACE_APP_CATEGORIES.dashboardAndReport, - }, - // investigate - { - id: 'discover', - featureDescription: 'Uncover insights with raw data exploration.', - featureName: 'Discover', - link: '/app/data-explorer/discover', - category: WORKSPACE_APP_CATEGORIES.investigate, - }, - { - id: 'observability-traces', - featureDescription: 'Unveil performance bottlenecks with event flow visualization.', - featureName: 'Traces', - link: '/app/observability-traces', - category: WORKSPACE_APP_CATEGORIES.investigate, - }, - { - id: 'observability-metrics', - featureDescription: 'Transform logs into actionable visualizations with metric extraction.', - featureName: 'Metrics', - link: '/app/observability-metrics', - category: WORKSPACE_APP_CATEGORIES.investigate, - }, - { - id: 'observability-applications', - featureDescription: - 'Gain comprehensive system visibility with unified log, trace, and metric analysis.', - featureName: 'Applications', - link: '/app/observability-applications', - category: WORKSPACE_APP_CATEGORIES.investigate, - }, - // detect - { - id: 'alerting', - featureDescription: 'Proactively identify risks with customizable alter triggers.', - featureName: 'Alerts', - link: '/app/alerting', - category: WORKSPACE_APP_CATEGORIES.detect, - }, - { - id: 'anomaly-detection-dashboards', - featureDescription: 'Unveil anomalies with real-time data monitoring.', - featureName: 'Anomaly Detectors', - link: '/app/anomaly-detection-dashboards#/detectors', - category: WORKSPACE_APP_CATEGORIES.detect, - }, - { - id: 'opensearch_security_analytics_dashboards', - featureDescription: 'Receive timely notifications with detector-driven alert configuration.', - featureName: 'Threat Alerts', - link: '/app/opensearch_security_analytics_dashboards#/alerts', - category: WORKSPACE_APP_CATEGORIES.detect, - }, - { - id: 'opensearch_security_analytics_dashboards', - featureDescription: 'Proactively safeguard your systems with customizable detection rules.', - featureName: 'Threat Detectors', - link: '/app/opensearch_security_analytics_dashboards#/detectors', - category: WORKSPACE_APP_CATEGORIES.detect, - }, - { - id: 'opensearch_security_analytics_dashboards', - featureDescription: 'Tailor detection capabilities with flexible rule management.', - featureName: 'Detection Rules', - link: '/app/opensearch_security_analytics_dashboards#/rules', - category: WORKSPACE_APP_CATEGORIES.detect, - }, - { - id: 'opensearch_security_analytics_dashboards', - featureDescription: 'Detect multi-system threats with correlation rule builder.', - featureName: 'Correlations', - link: '/app/opensearch_security_analytics_dashboards#/correlations', - category: WORKSPACE_APP_CATEGORIES.detect, - }, - { - id: 'opensearch_security_analytics_dashboards', - featureDescription: 'Uncover hidden patterns and trends with detector finding analysis.', - featureName: 'Findings', - link: '/app/opensearch_security_analytics_dashboards#/findings', - category: WORKSPACE_APP_CATEGORIES.investigate, - }, - // build search solutions - { - id: 'searchRelevance', - featureDescription: 'Optimize query performance with side-by-side comparison.', - featureName: 'Compare Search Results', - link: '/app/searchRelevance', - category: WORKSPACE_APP_CATEGORIES.searchSolution, - }, -]; diff --git a/src/plugins/workspace/public/components/workspace_overview/getting_start_card.test.tsx b/src/plugins/workspace/public/components/workspace_overview/getting_start_card.test.tsx deleted file mode 100644 index 107568751cbc..000000000000 --- a/src/plugins/workspace/public/components/workspace_overview/getting_start_card.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { fireEvent, render } from '@testing-library/react'; -import React from 'react'; -import { WorkspaceOverviewCard } from './getting_start_card'; -import { coreMock } from '../../../../../core/public/mocks'; -import { DEFAULT_APP_CATEGORIES } from '../../../../../core/public'; -import { GetStartCard } from './types'; - -describe('WorkspaceOverviewCard', () => { - const featureName = 'Visualizations'; - const featureDescription = 'this is a description'; - const card = { - id: 'visualize', - featureDescription, - featureName, - link: '/app/visualize', - category: DEFAULT_APP_CATEGORIES.dashboardAndReport, - }; - const mockCoreStart = coreMock.createStart(); - const renderWorkspaceCard = (_card: GetStartCard) => { - return ( - - ); - }; - - it('render getting start card normally', async () => { - const { container } = render(renderWorkspaceCard(card)); - expect(container).toHaveTextContent(`with Visualizations`); - expect(container).toHaveTextContent(featureDescription); - }); - - it('click on card will navigate to related URL', async () => { - const { getByTestId } = render(renderWorkspaceCard(card)); - fireEvent.click(getByTestId(featureName)); - expect(mockCoreStart.application.getUrlForApp).not.toHaveBeenCalled(); - expect(mockCoreStart.application.navigateToUrl).toHaveBeenCalledWith( - 'http://localhost/w/test/app/visualize' - ); - }); - - it('click on card will navigate to specified app if no link provided', async () => { - const { getByTestId } = render(renderWorkspaceCard({ ...card, link: undefined })); - fireEvent.click(getByTestId(featureName)); - expect(mockCoreStart.application.getUrlForApp).toHaveBeenCalledWith('visualize'); - expect(mockCoreStart.application.navigateToUrl).toHaveBeenCalledWith( - 'http://localhost/w/test/app/visualize' - ); - }); -}); diff --git a/src/plugins/workspace/public/components/workspace_overview/getting_start_card.tsx b/src/plugins/workspace/public/components/workspace_overview/getting_start_card.tsx deleted file mode 100644 index a25451129fb0..000000000000 --- a/src/plugins/workspace/public/components/workspace_overview/getting_start_card.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { EuiCard, EuiFlexGroup, EuiFlexItem, EuiText, EuiTextColor } from '@elastic/eui'; -import React from 'react'; -import { ApplicationStart, IBasePath } from 'opensearch-dashboards/public'; -import { FormattedMessage } from '@osd/i18n/react'; -import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; -import { GetStartCard } from './types'; - -export interface WorkspaceOverviewCardProps { - card: GetStartCard; - workspaceId: string; - basePath: IBasePath; - application: ApplicationStart; -} - -export const WorkspaceOverviewCard = ({ - card, - application, - workspaceId, - basePath, -}: WorkspaceOverviewCardProps) => { - return ( - -

{card.featureDescription}

- - } - description={''} - footer={ - - - - - - - - - - } - onClick={() => { - let url = card.link; - if (!url && card.id) { - url = application.getUrlForApp(card.id); - } - - if (workspaceId && url) { - application.navigateToUrl(formatUrlWithWorkspaceId(url, workspaceId, basePath)); - } - }} - /> - ); -}; diff --git a/src/plugins/workspace/public/components/workspace_overview/getting_start_modal.test.tsx b/src/plugins/workspace/public/components/workspace_overview/getting_start_modal.test.tsx deleted file mode 100644 index d63459a1dfe0..000000000000 --- a/src/plugins/workspace/public/components/workspace_overview/getting_start_modal.test.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { fireEvent, render } from '@testing-library/react'; -import React from 'react'; -import { coreMock } from '../../../../../core/public/mocks'; -import { - WorkspaceOverviewGettingStartModal, - WorkspaceOverviewGettingStartModalProps, -} from './getting_start_modal'; -import { GetStartCard } from './types'; -import { waitFor } from '@testing-library/dom'; -import { WORKSPACE_APP_CATEGORIES } from '../../../common/constants'; - -// see https://github.com/elastic/eui/issues/5271 as workaround to render EuiSelectable correctly -jest.mock('react-virtualized-auto-sizer', () => ({ children }: any) => - children({ height: 600, width: 300 }) -); - -describe('WorkspaceOverviewGettingStartModal', () => { - const mockCoreStart = coreMock.createStart(); - const closeModal = jest.fn(); - const renderWorkspaceCardModal = (cards: GetStartCard[]) => { - const props: WorkspaceOverviewGettingStartModalProps = { - availableCards: cards, - onCloseModal: closeModal, - application: mockCoreStart.application, - basePath: mockCoreStart.http.basePath, - workspaceId: 'foo', - }; - return ; - }; - - it('render getting start card modal normally with empty cards', async () => { - const { getByTestId } = render(renderWorkspaceCardModal([])); - await waitFor(() => expect(getByTestId('category_single_selection')).toHaveTextContent('All')); - }); - - it('render getting start card modal normally', async () => { - const cards = [ - { - id: 'home', - featureDescription: 'Discover pre-loaded datasets before adding your own.', - featureName: 'Sample Datasets', - link: '/app/home#/tutorial_directory', - category: WORKSPACE_APP_CATEGORIES.getStarted, - }, - { - id: 'dashboards', - featureDescription: 'Gain clarity and visibility with dynamic data visualization tools.', - featureName: 'Dashboards', - link: '/app/dashboards', - category: WORKSPACE_APP_CATEGORIES.dashboardAndReport, - }, - ]; - const { queryByText, getByTestId } = render(renderWorkspaceCardModal(cards)); - expect(getByTestId('category_single_selection')).toHaveTextContent('All'); - expect(getByTestId('category_single_selection')).toHaveTextContent('Get started'); - expect(getByTestId('category_single_selection')).toHaveTextContent('Dashboard and report'); - expect( - queryByText('Gain clarity and visibility with dynamic data visualization tools.') - ).not.toBeNull(); - expect(queryByText('Discover pre-loaded datasets before adding your own.')).not.toBeNull(); - }); - - it('click on category to filter cards', async () => { - const cards = [ - { - id: 'home', - featureDescription: 'Discover pre-loaded datasets before adding your own.', - featureName: 'Sample Datasets', - link: '/app/home#/tutorial_directory', - category: WORKSPACE_APP_CATEGORIES.getStarted, - }, - { - id: 'dashboards', - featureDescription: 'Gain clarity and visibility with dynamic data visualization tools.', - featureName: 'Dashboards', - link: '/app/dashboards', - category: WORKSPACE_APP_CATEGORIES.dashboardAndReport, - }, - ]; - const { queryByText, getByTitle } = render(renderWorkspaceCardModal(cards)); - // click `Get started` category - fireEvent.click(getByTitle('Get started')); - expect( - queryByText('Gain clarity and visibility with dynamic data visualization tools.') - ).toBeNull(); - expect(queryByText('Discover pre-loaded datasets before adding your own.')).not.toBeNull(); - - // click `Dashboard and report` category - fireEvent.click(getByTitle('Dashboard and report')); - expect( - queryByText('Gain clarity and visibility with dynamic data visualization tools.') - ).not.toBeNull(); - expect(queryByText('Discover pre-loaded datasets before adding your own.')).toBeNull(); - }); - - it('click on close will close the modal', async () => { - const { getByTestId } = render(renderWorkspaceCardModal([])); - fireEvent.click(getByTestId('close')); - expect(closeModal).toHaveBeenCalled(); - }); -}); diff --git a/src/plugins/workspace/public/components/workspace_overview/getting_start_modal.tsx b/src/plugins/workspace/public/components/workspace_overview/getting_start_modal.tsx deleted file mode 100644 index c2bfc104cc05..000000000000 --- a/src/plugins/workspace/public/components/workspace_overview/getting_start_modal.tsx +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { ReactNode, useState } from 'react'; -import { - EuiFlexItem, - EuiText, - slugify, - EuiTitle, - EuiSpacer, - EuiFlexGrid, - EuiModal, - EuiModalHeader, - EuiModalBody, - EuiModalHeaderTitle, - EuiModalFooter, - EuiButton, - EuiFlexGroup, - EuiPanel, - EuiSelectable, - EuiSelectableOption, -} from '@elastic/eui'; -import { WorkspaceOverviewCard, WorkspaceOverviewCardProps } from './getting_start_card'; -import { GetStartCard } from './types'; -import './index.scss'; - -export interface WorkspaceOverviewGettingStartModalProps - extends Omit { - onCloseModal: () => void; - availableCards: GetStartCard[]; -} - -export const WorkspaceOverviewGettingStartModal = ( - props: WorkspaceOverviewGettingStartModalProps -) => { - const ALL = 'All'; - const [selectedItemName, setSelectedItem] = useState(ALL); - const { onCloseModal, availableCards } = props; - - const categories: string[] = [ - ...new Set( - availableCards.map((card) => { - return card?.category?.label || ALL; - }) - ), - ]; - - const options: EuiSelectableOption[] = [ALL, ...categories].map((category) => { - return { - label: category, - checked: selectedItemName === category ? 'on' : undefined, - className: 'gettingStartCategoryItem', - }; - }); - - const categorySelection = ( - { - const selectedOption = newOptions.find((option) => option.checked === 'on'); - setSelectedItem(selectedOption?.label || ALL); - }} - singleSelection={true} - listProps={{ - bordered: false, - rowHeight: 48, - onFocusBadge: false, - windowProps: { - className: 'gettingStartCategoryItemList', - }, - }} - > - {(list) => { - return list; - }} - - ); - - const cardList: ReactNode[] = categories - .filter((category) => category === selectedItemName || selectedItemName === ALL) - .map((category) => { - const cards = availableCards.filter((card) => { - return card.category?.label === category; - }); - - return ( -
- -

{category}

-
- - - {cards.map((card) => { - return ( - - - - ); - })} - - -
- ); - }); - - return ( - - - -

Define your path forward

- Discover tailored solutions for your unique objectives -
-
- - - - - {categorySelection} - - - - {cardList} - - - - - - - Close - - -
- ); -}; diff --git a/src/plugins/workspace/public/components/workspace_overview/index.scss b/src/plugins/workspace/public/components/workspace_overview/index.scss deleted file mode 100644 index 5c878370220c..000000000000 --- a/src/plugins/workspace/public/components/workspace_overview/index.scss +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -.gettingStartModel { - width: calc(90vw - #{$euiSize}); -} - -.gettingStartModel_body { - overflow-y: auto; - height: calc(75vh - 200px); -} - -.gettingStartCategoryItem { - border: none !important; -} - -.gettingStartCategory { - div { - &:focus-within { - outline: none; - animation: none !important; - } - } -} - -.gettingStartCategoryItemList { - mask: none !important; -} diff --git a/src/plugins/workspace/public/components/workspace_overview/types.ts b/src/plugins/workspace/public/components/workspace_overview/types.ts deleted file mode 100644 index e9ba69402cc9..000000000000 --- a/src/plugins/workspace/public/components/workspace_overview/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { App } from 'opensearch-dashboards/public'; - -export interface GetStartCard extends Partial { - /** - * feature Name - */ - featureName: string; - /** - * card description - */ - featureDescription: string; - /** - * destination when the card been clicked - */ - link?: string; -} diff --git a/src/plugins/workspace/public/components/workspace_overview/workspace_overview.test.tsx b/src/plugins/workspace/public/components/workspace_overview/workspace_overview.test.tsx deleted file mode 100644 index c6205d5945ee..000000000000 --- a/src/plugins/workspace/public/components/workspace_overview/workspace_overview.test.tsx +++ /dev/null @@ -1,257 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import React from 'react'; -import { coreMock } from '../../../../../core/public/mocks'; -import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; -import { BehaviorSubject } from 'rxjs'; -import { PublicAppInfo, WorkspaceObject } from 'opensearch-dashboards/public'; -import { - IS_WORKSPACE_OVERVIEW_COLLAPSED_KEY, - WorkspaceOverview, - WorkspaceOverviewProps, -} from './workspace_overview'; -import { WORKSPACE_USE_CASES } from '../../../common/constants'; - -// all applications -const PublicAPPInfoMap = new Map([ - ['alerting', { id: 'alerting', title: 'alerting' }], - ['home', { id: 'home', title: 'home' }], -]); - -const mockCoreStart = coreMock.createStart(); - -const createWorkspacesSetupContractMockWithValue = (workspace?: WorkspaceObject) => { - const currentWorkspace = workspace - ? workspace - : { - id: 'foo_id', - name: 'foo', - description: 'this is my foo workspace description', - features: ['alerting'], - color: '', - icon: '', - reserved: false, - }; - const currentWorkspaceId$ = new BehaviorSubject(currentWorkspace.id); - const workspaceList$ = new BehaviorSubject([currentWorkspace]); - const currentWorkspace$ = new BehaviorSubject(currentWorkspace); - const initialized$ = new BehaviorSubject(true); - return { - currentWorkspaceId$, - workspaceList$, - currentWorkspace$, - initialized$, - }; -}; - -const WorkspaceOverviewPage = ( - props: Partial< - WorkspaceOverviewProps & { - workspacesService: ReturnType; - } - > -) => { - const workspacesService = props.workspacesService || createWorkspacesSetupContractMockWithValue(); - const { Provider } = createOpenSearchDashboardsReactContext({ - ...mockCoreStart, - ...{ - application: { - ...mockCoreStart.application, - applications$: new BehaviorSubject>(PublicAPPInfoMap as any), - }, - workspaces: workspacesService, - savedObjects: { - ...mockCoreStart.savedObjects, - client: { - ...mockCoreStart.savedObjects.client, - find: jest.fn().mockResolvedValue({ - savedObjects: [], - }), - }, - }, - }, - }); - const registeredUseCases$ = new BehaviorSubject([ - WORKSPACE_USE_CASES.observability, - WORKSPACE_USE_CASES['security-analytics'], - WORKSPACE_USE_CASES.analytics, - WORKSPACE_USE_CASES.search, - ]); - - return ( - - - - ); -}; - -const setLocalStorage = jest.fn(); -const localStorageMock = { - getItem: jest.fn(), - setItem: setLocalStorage, - removeItem: jest.fn(), - key: jest.fn(), - clear: jest.fn(), -}; - -describe('WorkspaceOverview', () => { - const localStorage = window.localStorage; - - beforeAll(() => { - // Mock localStorage globally - Object.defineProperty(window, 'localStorage', { - value: localStorageMock, - }); - }); - - afterAll(() => { - Object.defineProperty(window, 'localStorage', { - value: localStorage, - }); - }); - - it('render workspace overview page normally', async () => { - const { container } = render(WorkspaceOverviewPage({})); - expect(container).toMatchSnapshot(); - }); - - it('filter getting start cards when workspace features is `*`', async () => { - const workspaceObject = { - id: 'foo_id', - name: 'foo', - description: 'this is my foo workspace description', - features: ['*'], - color: '', - icon: '', - reserved: false, - }; - const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); - const { getByTestId } = render(WorkspaceOverviewPage({ workspacesService: workspaceService })); - expect(getByTestId('workspaceGetStartCards')).toHaveTextContent('Sample Datasets'); - // see more - expect(getByTestId('workspaceGetStartCards')).toHaveTextContent( - 'Explore more paths to kick-start your OpenSearch journey.' - ); - }); - - it('filter getting start cards when workspace features is subset of all features', async () => { - const workspaceObject = { - id: 'foo_id', - name: 'foo', - description: 'this is my foo workspace description', - features: ['alerting', 'home'], - color: '', - icon: '', - reserved: false, - }; - const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); - const { getByTestId } = render(WorkspaceOverviewPage({ workspacesService: workspaceService })); - expect(getByTestId('workspaceGetStartCards')).toHaveTextContent('with Sample Datasets'); - expect(getByTestId('workspaceGetStartCards')).toHaveTextContent('with Alerts'); - // no see more - expect(getByTestId('workspaceGetStartCards')).not.toHaveTextContent( - 'Explore more paths to kick-start your OpenSearch journey.' - ); - }); - - it('getting start section is expanded by default', async () => { - const workspaceObject = { - id: 'foo_id', - name: 'foo', - description: 'this is my foo workspace description', - features: ['alerting', 'home'], - color: '', - icon: '', - reserved: false, - }; - const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); - const { getByTestId } = render(WorkspaceOverviewPage({ workspacesService: workspaceService })); - expect(getByTestId('Collapse')).toBeVisible(); - }); - - it('getting start section visible setting will saved to localStorage', async () => { - const workspaceObject = { - id: 'foo_id', - name: 'foo', - description: 'this is my foo workspace description', - features: ['alerting', 'home'], - color: '', - icon: '', - reserved: false, - }; - const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); - const { getByTestId } = render(WorkspaceOverviewPage({ workspacesService: workspaceService })); - expect(getByTestId('Collapse')).toBeVisible(); - fireEvent.click(getByTestId('Collapse')); - expect(getByTestId('Expand')).toBeVisible(); - expect(localStorageMock.setItem).toHaveBeenCalledWith( - IS_WORKSPACE_OVERVIEW_COLLAPSED_KEY + '_foo_id', - 'true' - ); - // click on Collapse - fireEvent.click(getByTestId('Expand')); - expect(getByTestId('Collapse')).toBeVisible(); - expect(localStorageMock.setItem).toHaveBeenCalledWith( - IS_WORKSPACE_OVERVIEW_COLLAPSED_KEY + '_foo_id', - 'false' - ); - }); - - it('click on library tab will redirect to saved objects page', async () => { - const workspaceObject = { - id: 'foo_id', - name: 'foo', - description: 'this is my foo workspace description', - features: ['alerting', 'home'], - color: '', - icon: '', - reserved: false, - }; - const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); - const { getByText } = render(WorkspaceOverviewPage({ workspacesService: workspaceService })); - fireEvent.click(getByText('Library')); - - expect(mockCoreStart.application.navigateToApp).toHaveBeenCalledWith('management', { - path: 'opensearch-dashboards/objects', - }); - }); - - it('click on settings tab will show workspace update page', async () => { - const workspaceObject = { - id: 'foo_id', - name: 'foo', - description: 'this is my foo workspace description', - features: ['alerting', 'home'], - color: '', - icon: '', - reserved: false, - }; - const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); - const { getByText } = render(WorkspaceOverviewPage({ workspacesService: workspaceService })); - fireEvent.click(getByText('Settings')); - await waitFor(() => { - expect(screen.queryByText('Enter details')).not.toBeNull(); - // title is hidden - expect(screen.queryByText('Update Workspace')).toBeNull(); - }); - }); - - it('default selected tab is overview', async () => { - const workspaceObject = { - id: 'foo_id', - name: 'foo', - description: 'this is my foo workspace description', - features: ['alerting', 'home'], - color: '', - icon: '', - reserved: false, - }; - const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); - render(WorkspaceOverviewPage({ workspacesService: workspaceService })); - expect(document.querySelector('#overview')).toHaveClass('euiTab-isSelected'); - }); -}); diff --git a/src/plugins/workspace/public/components/workspace_overview/workspace_overview.tsx b/src/plugins/workspace/public/components/workspace_overview/workspace_overview.tsx deleted file mode 100644 index 41b3b624a1e2..000000000000 --- a/src/plugins/workspace/public/components/workspace_overview/workspace_overview.tsx +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useState, useMemo, ReactNode, useEffect } from 'react'; -import { - EuiPage, - EuiPageBody, - EuiPageHeader, - EuiSpacer, - EuiFlexItem, - EuiText, - EuiTabbedContent, - EuiTitle, - EuiFlexGroup, - EuiPanel, - EuiButtonEmpty, -} from '@elastic/eui'; - -import { useObservable } from 'react-use'; -import { i18n } from '@osd/i18n'; -import { App, CoreStart, PublicAppInfo } from 'opensearch-dashboards/public'; -import { BehaviorSubject } from 'rxjs'; -import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; -import { WorkspaceOverviewSettings } from './workspace_overview_settings'; -import { WorkspaceOverviewContent } from './workspace_overview_content'; -import { getStartCards } from './all_get_started_cards'; -import { isAppAccessibleInWorkspace } from '../../utils'; -import { WorkspaceOverviewCard } from './getting_start_card'; -import { WorkspaceOverviewGettingStartModal } from './getting_start_modal'; -import { WorkspaceUseCase } from '../../types'; - -export const IS_WORKSPACE_OVERVIEW_COLLAPSED_KEY = 'workspace:overview_collapsed'; - -export interface WorkspaceOverviewProps { - registeredUseCases$: BehaviorSubject; -} - -export const WorkspaceOverview = (props: WorkspaceOverviewProps) => { - const { - services: { workspaces, application, http }, - } = useOpenSearchDashboards(); - - const currentWorkspace = useObservable(workspaces.currentWorkspace$); - const currentWorkspaceId = useObservable(workspaces.currentWorkspaceId$); - const availableUseCases = useObservable(props.registeredUseCases$, []); - - // workspace level setting - const workspaceOverviewCollapsedKey = `${IS_WORKSPACE_OVERVIEW_COLLAPSED_KEY}_${ - currentWorkspaceId || '' - }`; - - const [isModalVisible, setIsModalVisible] = useState(false); - const [isGettingStartCardsCollapsed, setIsGettingStartCardsCollapsed] = useState(false); - - useEffect(() => { - setIsGettingStartCardsCollapsed(localStorage.getItem(workspaceOverviewCollapsedKey) === 'true'); - }, [workspaceOverviewCollapsedKey]); - - /** - * all available cards based on workspace selected features - */ - const availableCards = useMemo(() => { - if (!currentWorkspace) return []; - return getStartCards.filter( - (card) => - !card.id || isAppAccessibleInWorkspace(card as App, currentWorkspace, availableUseCases) - ); - }, [currentWorkspace, availableUseCases]); - - if (!currentWorkspace) { - return null; - } - - const pageTitle = ( - - {currentWorkspace?.name} - - ); - - const tabs = [ - { - id: 'overview', - name: i18n.translate('workspace.overview.tabTitle', { - defaultMessage: 'Overview', - }), - content: , - }, - { - id: 'library', - name: i18n.translate('workspace.overview.library.tabTitle', { - defaultMessage: 'Library', - }), - content: <>, - }, - { - id: 'settings', - name: i18n.translate('workspace.overview.setting.tabTitle', { - defaultMessage: 'Settings', - }), - content: , - }, - ]; - - const collapseButton = ( - { - const newValue = !isGettingStartCardsCollapsed; - setIsGettingStartCardsCollapsed(newValue); - localStorage.setItem(workspaceOverviewCollapsedKey, newValue ? 'true' : 'false'); - }} - > - {isGettingStartCardsCollapsed ? 'Expand' : 'Collapse'} - - ); - - const rightSideItems: ReactNode[] = isGettingStartCardsCollapsed ? [collapseButton] : []; - - return ( - <> - - - {!isGettingStartCardsCollapsed ? ( - <> - -

- {i18n.translate('workspace.overview.startWorking.title', { - defaultMessage: 'Start working', - })} -

-
- - - {availableCards.slice(0, 5).map((card, i) => { - return ( - - - - ); - })} - {availableCards.length > 5 ? ( - - { - setIsModalVisible(true); - }} - > - - {i18n.translate('workspace.overview.seeMore.description', { - defaultMessage: - 'Explore more paths to kick-start your OpenSearch journey.', - })} - - - - ) : null} - - - - {collapseButton} - - - ) : null} -
-
- - - { - if (tab.id === 'library') { - application.navigateToApp('management', { - path: 'opensearch-dashboards/objects', - }); - } - }} - /> - {isModalVisible ? ( - { - setIsModalVisible(false); - }} - availableCards={availableCards} - workspaceId={currentWorkspace.id} - basePath={http.basePath} - application={application} - /> - ) : null} - - - - ); -}; diff --git a/src/plugins/workspace/public/components/workspace_overview/workspace_overview_settings.tsx b/src/plugins/workspace/public/components/workspace_overview/workspace_overview_settings.tsx deleted file mode 100644 index 3cb5b1325daa..000000000000 --- a/src/plugins/workspace/public/components/workspace_overview/workspace_overview_settings.tsx +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { WorkspaceUpdater } from '../workspace_updater'; -import { WorkspaceOverviewProps } from './workspace_overview'; - -export const WorkspaceOverviewSettings = ({ registeredUseCases$ }: WorkspaceOverviewProps) => { - return ( - - ); -}; diff --git a/src/plugins/workspace/public/components/workspace_updater_app.tsx b/src/plugins/workspace/public/components/workspace_updater_app.tsx deleted file mode 100644 index ab106b5c4b7a..000000000000 --- a/src/plugins/workspace/public/components/workspace_updater_app.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useEffect } from 'react'; -import { I18nProvider } from '@osd/i18n/react'; -import { i18n } from '@osd/i18n'; -import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; -import { WorkspaceUpdater, WorkspaceUpdaterProps } from './workspace_updater'; - -export const WorkspaceUpdaterApp = (props: WorkspaceUpdaterProps) => { - const { - services: { chrome }, - } = useOpenSearchDashboards(); - - /** - * set breadcrumbs to chrome - */ - useEffect(() => { - chrome?.setBreadcrumbs([ - { - text: i18n.translate('workspace.workspaceUpdateTitle', { - defaultMessage: 'Update workspace', - }), - }, - ]); - }, [chrome]); - - return ( - - - - ); -}; diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index ca5ed3fd8bc8..d3fff9a3f577 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -14,7 +14,7 @@ import { DEFAULT_NAV_GROUPS, AppNavLinkStatus, } from '../../../core/public'; -import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; +import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_DETAIL_APP_ID } from '../common/constants'; import { savedObjectsManagementPluginMock } from '../../saved_objects_management/public/mocks'; import { managementPluginMock } from '../../management/public/mocks'; import { UseCaseService } from './services/use_case_service'; @@ -38,7 +38,7 @@ describe('Workspace plugin', () => { savedObjectsManagement: savedObjectManagementSetupMock, management: managementPluginMock.createSetupContract(), }); - expect(setupMock.application.register).toBeCalledTimes(5); + expect(setupMock.application.register).toBeCalledTimes(4); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(savedObjectManagementSetupMock.columns.register).toBeCalledTimes(1); }); @@ -51,7 +51,7 @@ describe('Workspace plugin', () => { workspacePlugin.start(coreStart); coreStart.workspaces.currentWorkspaceId$.next('foo'); expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo'); - expect(setupMock.application.register).toBeCalledTimes(5); + expect(setupMock.application.register).toBeCalledTimes(4); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0); }); @@ -88,7 +88,7 @@ describe('Workspace plugin', () => { await workspacePlugin.setup(setupMock, { management: managementPluginMock.createSetupContract(), }); - expect(setupMock.application.register).toBeCalledTimes(5); + expect(setupMock.application.register).toBeCalledTimes(4); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(workspaceClientMock.enterWorkspace).toBeCalledWith('workspaceId'); expect(setupMock.getStartServices).toBeCalledTimes(1); @@ -145,7 +145,7 @@ describe('Workspace plugin', () => { management: managementPluginMock.createSetupContract(), }); currentAppIdSubscriber?.next(WORKSPACE_FATAL_ERROR_APP_ID); - expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_OVERVIEW_APP_ID); + expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_DETAIL_APP_ID); windowSpy.mockRestore(); }); @@ -181,7 +181,7 @@ describe('Workspace plugin', () => { ); }); - it('#start add workspace overview page to breadcrumbs when start', async () => { + it('#start add workspace detail page to breadcrumbs when start', async () => { const startMock = coreMock.createStart(); const workspaceObject = { id: 'foo', @@ -204,7 +204,7 @@ describe('Workspace plugin', () => { ); }); - it('#start do not add workspace overview page to breadcrumbs when already exists', async () => { + it('#start do not add workspace detail page to breadcrumbs when already exists', async () => { const startMock = coreMock.createStart(); const workspaceObject = { id: 'foo', diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 8a6e29c49137..ec67754cee3c 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -24,9 +24,8 @@ import { } from '../../../core/public'; import { WORKSPACE_FATAL_ERROR_APP_ID, - WORKSPACE_OVERVIEW_APP_ID, + WORKSPACE_DETAIL_APP_ID, WORKSPACE_CREATE_APP_ID, - WORKSPACE_UPDATE_APP_ID, WORKSPACE_LIST_APP_ID, } from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; @@ -162,7 +161,7 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> } /** - * Add workspace overview page to breadcrumbs + * Add workspace detail page to breadcrumbs * @param core CoreStart * @private */ @@ -179,7 +178,7 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> const workspaceBreadcrumb: ChromeBreadcrumb = { text: currentWorkspace.name, onClick: () => { - core.application.navigateToApp(WORKSPACE_OVERVIEW_APP_ID); + core.application.navigateToApp(WORKSPACE_DETAIL_APP_ID); }, }; const homeBreadcrumb: ChromeBreadcrumb = { @@ -246,7 +245,7 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> const [{ application }] = await core.getStartServices(); const currentAppIdSubscription = application.currentAppId$.subscribe((currentAppId) => { if (currentAppId === WORKSPACE_FATAL_ERROR_APP_ID) { - application.navigateToApp(WORKSPACE_OVERVIEW_APP_ID); + application.navigateToApp(WORKSPACE_DETAIL_APP_ID); } currentAppIdSubscription.unsubscribe(); }); @@ -293,32 +292,17 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> }); /** - * register workspace overview page + * register workspace detail page */ core.application.register({ - id: WORKSPACE_OVERVIEW_APP_ID, - title: i18n.translate('workspace.settings.workspaceOverview', { - defaultMessage: 'Workspace Overview', + id: WORKSPACE_DETAIL_APP_ID, + title: i18n.translate('workspace.settings.workspaceDetail', { + defaultMessage: 'Workspace Detail', }), navLinkStatus: AppNavLinkStatus.hidden, async mount(params: AppMountParameters) { - const { renderOverviewApp } = await import('./application'); - return mountWorkspaceApp(params, renderOverviewApp); - }, - }); - - /** - * register workspace update page - */ - core.application.register({ - id: WORKSPACE_UPDATE_APP_ID, - title: i18n.translate('workspace.settings.workspaceUpdate', { - defaultMessage: 'Update Workspace', - }), - navLinkStatus: AppNavLinkStatus.hidden, - async mount(params: AppMountParameters) { - const { renderUpdaterApp } = await import('./application'); - return mountWorkspaceApp(params, renderUpdaterApp); + const { renderDetailApp } = await import('./application'); + return mountWorkspaceApp(params, renderDetailApp); }, }); @@ -365,6 +349,19 @@ export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> */ savedObjectsManagement?.columns.register(getWorkspaceColumn(core)); + /** + * Add workspace list to settings and setup group + */ + core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.settingsAndSetup, [ + { + id: WORKSPACE_LIST_APP_ID, + order: 150, + title: i18n.translate('workspace.settings.workspaceSettings', { + defaultMessage: 'Workspace settings', + }), + }, + ]); + return {}; } diff --git a/src/plugins/workspace/server/workspace_client.test.ts b/src/plugins/workspace/server/workspace_client.test.ts index 208def26ad02..4339d9380341 100644 --- a/src/plugins/workspace/server/workspace_client.test.ts +++ b/src/plugins/workspace/server/workspace_client.test.ts @@ -180,4 +180,20 @@ describe('#WorkspaceClient', () => { expect(mockCheckAndSetDefaultDataSource).toHaveBeenCalledWith(uiSettingsClient, ['id1'], true); }); + + it('delete# should unassign data source before deleting related saved objects', async () => { + const client = new WorkspaceClient(coreSetup, logger); + await client.setup(coreSetup); + client?.setSavedObjects(savedObjects); + client?.setUiSettings(uiSettings); + + await client.delete(mockRequestDetail, mockWorkspaceId); + + expect(deleteFromWorkspaces).toHaveBeenCalledWith(DATA_SOURCE_SAVED_OBJECT_TYPE, 'id1', [ + mockWorkspaceId, + ]); + expect(deleteFromWorkspaces).toHaveBeenCalledWith(DATA_SOURCE_SAVED_OBJECT_TYPE, 'id2', [ + mockWorkspaceId, + ]); + }); }); diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index 159136e1304f..fe88436f38e5 100644 --- a/src/plugins/workspace/server/workspace_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -306,6 +306,21 @@ export class WorkspaceClient implements IWorkspaceClientImpl { }), }; } + + // When workspace is to be deleted, unassign all assigned data source before deleting saved object by workspace. + const selectedDataSources = await getDataSourcesList(savedObjectClient, [id]); + if (selectedDataSources.length > 0) { + const promises = []; + for (const dataSource of selectedDataSources) { + promises.push( + savedObjectClient.deleteFromWorkspaces(DATA_SOURCE_SAVED_OBJECT_TYPE, dataSource.id, [ + id, + ]) + ); + } + await Promise.all(promises); + } + await savedObjectClient.deleteByWorkspace(id); // delete workspace itself at last, deleteByWorkspace depends on the workspace to do permission check await savedObjectClient.delete(WORKSPACE_TYPE, id);