diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 6b33b77a6f7..ee1cb536377 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -34,6 +34,7 @@ import { FormattedMessage } from '@osd/i18n/react'; import { BehaviorSubject, combineLatest, merge, Observable, of, ReplaySubject } from 'rxjs'; import { flatMap, map, takeUntil } from 'rxjs/operators'; import { EuiLink } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; import { mountReactNode } from '../utils/mount'; import { InternalApplicationStart } from '../application'; import { DocLinksStart } from '../doc_links'; @@ -41,7 +42,7 @@ import { HttpStart } from '../http'; import { InjectedMetadataStart } from '../injected_metadata'; import { NotificationsStart } from '../notifications'; import { IUiSettingsClient } from '../ui_settings'; -import { OPENSEARCH_DASHBOARDS_ASK_OPENSEARCH_LINK } from './constants'; +import { OPENSEARCH_DASHBOARDS_ASK_OPENSEARCH_LINK, WORKSPACE_APP_ID } from './constants'; import { ChromeDocTitle, DocTitleService } from './doc_title'; import { ChromeNavControls, NavControlsService } from './nav_controls'; import { ChromeNavLinks, NavLinksService, ChromeNavLink } from './nav_links'; @@ -180,6 +181,41 @@ export class ChromeService { docTitle.reset(); }); + const getWorkspaceUrl = (id: string) => { + return workspaces?.formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_APP_ID, { + path: '/', + absolute: true, + }), + id + ); + }; + + const exitWorkspace = async () => { + let result; + try { + result = await workspaces?.client.exitWorkspace(); + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.exit.failed', { + defaultMessage: 'Failed to exit workspace', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + return; + } + if (!result?.success) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.exit.failed', { + defaultMessage: 'Failed to exit workspace', + }), + text: result?.error, + }); + return; + } + await application.navigateToApp('home'); + }; + const setIsNavDrawerLocked = (isLocked: boolean) => { isNavDrawerLocked$.next(isLocked); localStorage.setItem(IS_LOCKED_KEY, `${isLocked}`); @@ -245,7 +281,6 @@ export class ChromeService { badge$={badge$.pipe(takeUntil(this.stop$))} basePath={http.basePath} breadcrumbs$={breadcrumbs$.pipe(takeUntil(this.stop$))} - customNavLink$={customNavLink$.pipe(takeUntil(this.stop$))} opensearchDashboardsDocLink={docLinks.links.opensearchDashboards.introduction} forceAppSwitcherNavigation$={navLinks.getForceAppSwitcherNavigation$()} helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))} @@ -254,6 +289,7 @@ export class ChromeService { isVisible$={this.isVisible$} opensearchDashboardsVersion={injectedMetadata.getOpenSearchDashboardsVersion()} navLinks$={navLinks.getNavLinks$()} + customNavLink$={customNavLink$.pipe(takeUntil(this.stop$))} recentlyAccessed$={recentlyAccessed.get$()} navControlsLeft$={navControls.getLeft$()} navControlsCenter$={navControls.getCenter$()} @@ -261,10 +297,13 @@ export class ChromeService { navControlsExpandedCenter$={navControls.getExpandedCenter$()} navControlsExpandedRight$={navControls.getExpandedRight$()} onIsLockedUpdate={setIsNavDrawerLocked} + exitWorkspace={exitWorkspace} + getWorkspaceUrl={getWorkspaceUrl} isLocked$={getIsNavDrawerLocked$} branding={injectedMetadata.getBranding()} survey={injectedMetadata.getSurvey()} currentWorkspace$={workspaces.client.currentWorkspace$} + workspaceList$={workspaces.client.workspaceList$} /> ), diff --git a/src/core/public/chrome/constants.ts b/src/core/public/chrome/constants.ts index 5008f8b4a69..6de7c01f1d1 100644 --- a/src/core/public/chrome/constants.ts +++ b/src/core/public/chrome/constants.ts @@ -31,3 +31,9 @@ export const OPENSEARCH_DASHBOARDS_ASK_OPENSEARCH_LINK = 'https://forum.opensearch.org/'; export const GITHUB_CREATE_ISSUE_LINK = 'https://github.com/opensearch-project/OpenSearch-Dashboards/issues/new/choose'; + +export const WORKSPACE_APP_ID = 'workspace'; + +export const PATHS = { + list: '/list', +}; diff --git a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx index 4df3f68ec90..9d9e06ca567 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx @@ -80,8 +80,11 @@ function mockProps() { closeNav: () => {}, navigateToApp: () => Promise.resolve(), navigateToUrl: () => Promise.resolve(), + exitWorkspace: () => {}, + getWorkspaceUrl: (id: string) => '', customNavLink$: new BehaviorSubject(undefined), currentWorkspace$: workspacesServiceMock.createStartContract().client.currentWorkspace$, + workspaceList$: workspacesServiceMock.createStartContract().client.workspaceList$, branding: { darkMode: false, mark: { diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index 2b7fec10684..d7d15694b23 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -33,7 +33,6 @@ import { EuiCollapsibleNav, EuiCollapsibleNavGroup, EuiFlexItem, - EuiHorizontalRule, EuiListGroup, EuiListGroupItem, EuiShowFor, @@ -41,17 +40,18 @@ import { } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { groupBy, sortBy } from 'lodash'; -import React, { Fragment, useRef } from 'react'; +import React, { useRef } from 'react'; import useObservable from 'react-use/lib/useObservable'; import * as Rx from 'rxjs'; import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem } from '../..'; import { AppCategory } from '../../../../types'; -import { InternalApplicationStart } from '../../../application/types'; +import { InternalApplicationStart } from '../../../application'; import { HttpStart } from '../../../http'; import { OnIsLockedUpdate } from './'; -import { createEuiListItem, createRecentNavLink, isModifiedOrPrevented } from './nav_link'; +import { createEuiListItem, isModifiedOrPrevented, createWorkspaceNavLink } from './nav_link'; import { ChromeBranding } from '../../chrome_service'; import { WorkspaceAttribute } from '../../../workspace'; +import { WORKSPACE_APP_ID, PATHS } from '../../constants'; function getAllCategories(allCategorizedLinks: Record) { const allCategories = {} as Record; @@ -103,7 +103,10 @@ interface Props { navigateToUrl: InternalApplicationStart['navigateToUrl']; customNavLink$: Rx.Observable; branding: ChromeBranding; + exitWorkspace: () => void; + getWorkspaceUrl: (id: string) => string; currentWorkspace$: Rx.BehaviorSubject; + workspaceList$: Rx.BehaviorSubject; } export function CollapsibleNav({ @@ -114,6 +117,8 @@ export function CollapsibleNav({ homeHref, storage = window.localStorage, onIsLockedUpdate, + exitWorkspace, + getWorkspaceUrl, closeNav, navigateToApp, navigateToUrl, @@ -121,13 +126,12 @@ export function CollapsibleNav({ ...observables }: Props) { const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); - const recentlyAccessed = useObservable(observables.recentlyAccessed$, []); - const customNavLink = useObservable(observables.customNavLink$, undefined); const appId = useObservable(observables.appId$, ''); const currentWorkspace = useObservable(observables.currentWorkspace$); + const workspaceList = useObservable(observables.workspaceList$, []).slice(0, 5); const lockRef = useRef(null); - const filterdLinks = getFilterLinks(currentWorkspace, navLinks); - const groupedNavLinks = groupBy(filterdLinks, (link) => link?.category?.id); + const filteredLinks = getFilterLinks(currentWorkspace, navLinks); + const groupedNavLinks = groupBy(filteredLinks, (link) => link?.category?.id); const { undefined: unknowns = [], ...allCategorizedLinks } = groupedNavLinks; const categoryDictionary = getAllCategories(allCategorizedLinks); const orderedCategories = getOrderedCategories(allCategorizedLinks, categoryDictionary); @@ -202,172 +206,235 @@ export function CollapsibleNav({ onClose={closeNav} outsideClickCloses={false} > - {customNavLink && ( - - + + {/* Home, Alerts, Favorites, Projects and Admin outside workspace */} + {!currentWorkspace && ( + <> + { + closeNav(); + await navigateToApp('home'); + }} + iconType={'logoOpenSearch'} + title={i18n.translate('core.ui.primaryNavSection.home', { + defaultMessage: 'Home', + })} + /> + + + +

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

+
+ +

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

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

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

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

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

+
-
- - -
- )} - - {/* Recently viewed */} - setIsCategoryOpen('recentlyViewed', isCategoryOpen, storage)} - data-test-subj="collapsibleNavGroup-recentlyViewed" - > - {recentlyAccessed.length > 0 ? ( - { - // TODO #64541 - // Can remove icon from recent links completely - const { iconType, onClick, ...hydratedLink } = createRecentNavLink( - link, - navLinks, - basePath, - navigateToUrl - ); + + + )} - return { - ...hydratedLink, - 'data-test-subj': 'collapsibleNavAppLink--recent', - onClick: (event) => { - if (!isModifiedOrPrevented(event)) { - closeNav(); - onClick(event); - } - }, - }; - })} - maxWidth="none" - color="subdued" - gutterSize="none" - size="s" - className="osdCollapsibleNav__recentsListGroup" - /> - ) : ( - -

- {i18n.translate('core.ui.EmptyRecentlyViewed', { - defaultMessage: 'No recently viewed items', + {/* Workspace name and Overview inside workspace */} + {currentWorkspace && ( + <> + + { + window.location.href = getWorkspaceUrl(currentWorkspace.id); + }} + iconType={'grid'} + title={i18n.translate('core.ui.primaryNavSection.overview', { + defaultMessage: 'Overview', })} -

-
+ /> + )} -
- + {/* OpenSearchDashboards, Observability, Security, and Management sections inside workspace */} + {currentWorkspace && + orderedCategories.map((categoryName) => { + const category = categoryDictionary[categoryName]!; + const opensearchLinkLogo = + category.id === 'opensearchDashboards' ? customSideMenuLogo() : category.euiIconType; - - {/* OpenSearchDashboards, Observability, Security, and Management sections */} - {orderedCategories.map((categoryName) => { - const category = categoryDictionary[categoryName]!; - const opensearchLinkLogo = - category.id === 'opensearchDashboards' ? customSideMenuLogo() : category.euiIconType; + return ( + + setIsCategoryOpen(category.id, isCategoryOpen, storage) + } + data-test-subj={`collapsibleNavGroup-${category.id}`} + data-test-opensearch-logo={opensearchLinkLogo} + > + readyForEUI(link))} + maxWidth="none" + color="subdued" + gutterSize="none" + size="s" + /> + + ); + })} - return ( - setIsCategoryOpen(category.id, isCategoryOpen, storage)} - data-test-subj={`collapsibleNavGroup-${category.id}`} - data-test-opensearch-logo={opensearchLinkLogo} - > - readyForEUI(link))} - maxWidth="none" - color="subdued" - gutterSize="none" - size="s" - /> + {/* Things with no category (largely for custom plugins) inside workspace */} + {currentWorkspace && + unknowns.map((link, i) => ( + + + + - ); - })} - - {/* Things with no category (largely for custom plugins) */} - {unknowns.map((link, i) => ( - - - - - - ))} + ))} - {/* Docking button only for larger screens that can support it*/} - - - + + + {/* Exit workspace button only within a workspace*/} + {currentWorkspace && ( { - onIsLockedUpdate(!isLocked); - if (lockRef.current) { - lockRef.current.focus(); - } - }} - iconType={isLocked ? 'lock' : 'lockOpen'} + label={i18n.translate('core.ui.primaryNavSection.exitWorkspaceLabel', { + defaultMessage: 'Exit workspace', + })} + aria-label={i18n.translate('core.ui.primaryNavSection.exitWorkspaceLabel', { + defaultMessage: 'Exit workspace', + })} + onClick={exitWorkspace} + iconType={'exit'} /> - - - + )} + {/* Docking button only for larger screens that can support it*/} + { + + { + onIsLockedUpdate(!isLocked); + if (lockRef.current) { + lockRef.current.focus(); + } + }} + iconType={isLocked ? 'lock' : 'lockOpen'} + /> + + } + + ); diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx index cd969fcc727..08d05057538 100644 --- a/src/core/public/chrome/ui/header/header.test.tsx +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -70,6 +70,8 @@ function mockProps() { isLocked$: new BehaviorSubject(false), loadingCount$: new BehaviorSubject(0), onIsLockedUpdate: () => {}, + exitWorkspace: () => {}, + getWorkspaceUrl: (id: string) => '', branding: { darkMode: false, logo: { defaultUrl: '/' }, @@ -78,6 +80,7 @@ function mockProps() { }, survey: '/', currentWorkspace$: workspacesServiceMock.createStartContract().client.currentWorkspace$, + workspaceList$: workspacesServiceMock.createStartContract().client.workspaceList$, }; } diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 02899b1b85f..95fef0362a2 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -90,9 +90,12 @@ export interface HeaderProps { isLocked$: Observable; loadingCount$: ReturnType; onIsLockedUpdate: OnIsLockedUpdate; + exitWorkspace: () => void; + getWorkspaceUrl: (id: string) => string; branding: ChromeBranding; survey: string | undefined; currentWorkspace$: BehaviorSubject; + workspaceList$: BehaviorSubject; } export function Header({ @@ -101,6 +104,8 @@ export function Header({ application, basePath, onIsLockedUpdate, + exitWorkspace, + getWorkspaceUrl, homeHref, branding, survey, @@ -255,6 +260,8 @@ export function Header({ navigateToApp={application.navigateToApp} navigateToUrl={application.navigateToUrl} onIsLockedUpdate={onIsLockedUpdate} + exitWorkspace={exitWorkspace} + getWorkspaceUrl={getWorkspaceUrl} closeNav={() => { setIsNavOpen(false); if (toggleCollapsibleNavRef.current) { @@ -264,6 +271,7 @@ export function Header({ customNavLink$={observables.customNavLink$} branding={branding} currentWorkspace$={observables.currentWorkspace$} + workspaceList$={observables.workspaceList$} /> diff --git a/src/core/public/chrome/ui/header/nav_link.tsx b/src/core/public/chrome/ui/header/nav_link.tsx index 11ff0b472bd..8281b1ee2f9 100644 --- a/src/core/public/chrome/ui/header/nav_link.tsx +++ b/src/core/public/chrome/ui/header/nav_link.tsx @@ -31,7 +31,12 @@ import { EuiIcon } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import React from 'react'; -import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem, CoreStart } from '../../..'; +import { + ChromeNavLink, + ChromeRecentlyAccessedHistoryItem, + CoreStart, + WorkspaceAttribute, +} from '../../..'; import { HttpStart } from '../../../http'; import { InternalApplicationStart } from '../../../application/types'; import { relativeToAbsolute } from '../../nav_links/to_nav_link'; @@ -148,3 +153,34 @@ export function createRecentNavLink( }, }; } + +export interface WorkspaceNavLink { + label: string; + title: string; + 'aria-label': string; +} + +export function createWorkspaceNavLink( + href: string, + workspace: WorkspaceAttribute, + navLinks: ChromeNavLink[] +): WorkspaceNavLink { + const label = workspace.name; + let titleAndAriaLabel = label; + const navLink = navLinks.find((nl) => href.startsWith(nl.baseUrl)); + if (navLink) { + titleAndAriaLabel = i18n.translate('core.ui.workspaceLinks.linkItem.screenReaderLabel', { + defaultMessage: '{workspaceItemLinkName}, type: {pageType}', + values: { + workspaceItemLinkName: label, + pageType: navLink.title, + }, + }); + } + + return { + label, + title: titleAndAriaLabel, + 'aria-label': titleAndAriaLabel, + }; +} diff --git a/src/plugins/workspace/public/components/workspace_app.tsx b/src/plugins/workspace/public/components/workspace_app.tsx index ae2720d75b3..ec31f511da9 100644 --- a/src/plugins/workspace/public/components/workspace_app.tsx +++ b/src/plugins/workspace/public/components/workspace_app.tsx @@ -5,11 +5,11 @@ import React, { useEffect } from 'react'; import { I18nProvider } from '@osd/i18n/react'; -import { Route, Switch, useLocation } from 'react-router-dom'; - +import { Route, Switch, Redirect, useLocation } from 'react-router-dom'; import { ROUTES } from './routes'; import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; import { createBreadcrumbsFromPath } from './utils/breadcrumbs'; +import { PATHS } from '../../common/constants'; export const WorkspaceApp = ({ appBasePath }: { appBasePath: string }) => { const { @@ -31,6 +31,7 @@ export const WorkspaceApp = ({ appBasePath }: { appBasePath: string }) => { {ROUTES.map(({ path, Component, exact }) => ( } exact={exact ?? false} /> ))} + ); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 4925015306f..4933cda2a43 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -112,12 +112,6 @@ export class WorkspacesPlugin implements Plugin<{}, {}> { public start(core: CoreStart) { mountDropdownList(core); - - core.chrome.setCustomNavLink({ - title: i18n.translate('workspace.nav.title', { defaultMessage: 'Workspace Overview' }), - baseUrl: core.http.basePath.get(), - href: core.application.getUrlForApp(WORKSPACE_APP_ID, { path: PATHS.update }), - }); this._changeSavedObjectCurrentWorkspace(); return {}; }