diff --git a/changelogs/fragments/7637.yml b/changelogs/fragments/7637.yml new file mode 100644 index 00000000000..02db1c24d3d --- /dev/null +++ b/changelogs/fragments/7637.yml @@ -0,0 +1,2 @@ +feat: +- Introduce the redesign page and applications headers behind a switch ([#7637](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7637)) diff --git a/package.json b/package.json index cfaa5811892..36a7032a8ac 100644 --- a/package.json +++ b/package.json @@ -145,7 +145,7 @@ "dependencies": { "@aws-crypto/client-node": "^3.1.1", "@elastic/datemath": "5.0.3", - "@elastic/eui": "npm:@opensearch-project/oui@1.8.1", + "@elastic/eui": "npm:@opensearch-project/oui@1.9.0", "@elastic/good": "^9.0.1-kibana3", "@elastic/numeral": "npm:@amoo-miki/numeral@2.6.0", "@elastic/request-crypto": "2.0.0", diff --git a/packages/osd-ui-framework/package.json b/packages/osd-ui-framework/package.json index 25ebdb4bb72..74eb5c34c58 100644 --- a/packages/osd-ui-framework/package.json +++ b/packages/osd-ui-framework/package.json @@ -23,7 +23,7 @@ "enzyme-adapter-react-16": "^1.9.1" }, "devDependencies": { - "@elastic/eui": "npm:@opensearch-project/oui@1.8.1", + "@elastic/eui": "npm:@opensearch-project/oui@1.9.0", "@osd/babel-preset": "1.0.0", "@osd/optimizer": "1.0.0", "comment-stripper": "^0.0.4", diff --git a/packages/osd-ui-shared-deps/package.json b/packages/osd-ui-shared-deps/package.json index 32268bb8180..23f05e9a250 100644 --- a/packages/osd-ui-shared-deps/package.json +++ b/packages/osd-ui-shared-deps/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@elastic/charts": "31.1.0", - "@elastic/eui": "npm:@opensearch-project/oui@1.8.1", + "@elastic/eui": "npm:@opensearch-project/oui@1.9.0", "@elastic/numeral": "npm:@amoo-miki/numeral@2.6.0", "@opensearch/datemath": "5.0.3", "@osd/i18n": "1.0.0", diff --git a/src/core/public/application/__snapshots__/application_service.test.ts.snap b/src/core/public/application/__snapshots__/application_service.test.ts.snap index a6c9eb27e33..687977044cd 100644 --- a/src/core/public/application/__snapshots__/application_service.test.ts.snap +++ b/src/core/public/application/__snapshots__/application_service.test.ts.snap @@ -81,7 +81,13 @@ exports[`#start() getComponent returns renderable JSX tree 1`] = ` } mounters={Map {}} setAppActionMenu={[Function]} + setAppBadgeControls={[Function]} + setAppBottomControls={[Function]} + setAppCenterControls={[Function]} + setAppDescriptionControls={[Function]} setAppLeaveHandler={[Function]} + setAppLeftControls={[Function]} + setAppRightControls={[Function]} setIsMounting={[Function]} /> `; diff --git a/src/core/public/application/application_service.mock.ts b/src/core/public/application/application_service.mock.ts index b70a34095f0..e3897f746cf 100644 --- a/src/core/public/application/application_service.mock.ts +++ b/src/core/public/application/application_service.mock.ts @@ -65,6 +65,12 @@ const createStartContractMock = (): jest.Mocked => { navigateToUrl: jest.fn(), getUrlForApp: jest.fn(), registerMountContext: jest.fn(), + setAppLeftControls: jest.fn(), + setAppCenterControls: jest.fn(), + setAppRightControls: jest.fn(), + setAppBadgeControls: jest.fn(), + setAppDescriptionControls: jest.fn(), + setAppBottomControls: jest.fn(), }; }; @@ -98,6 +104,18 @@ const createInternalStartContractMock = (): jest.Mocked(undefined), + currentLeftControls$: new BehaviorSubject(undefined), + currentCenterControls$: new BehaviorSubject(undefined), + currentRightControls$: new BehaviorSubject(undefined), + currentBadgeControls$: new BehaviorSubject(undefined), + currentDescriptionControls$: new BehaviorSubject(undefined), + currentBottomControls$: new BehaviorSubject(undefined), + setAppLeftControls: jest.fn(), + setAppCenterControls: jest.fn(), + setAppRightControls: jest.fn(), + setAppBadgeControls: jest.fn(), + setAppDescriptionControls: jest.fn(), + setAppBottomControls: jest.fn(), getComponent: jest.fn(), getUrlForApp: jest.fn(), navigateToApp: jest.fn().mockImplementation((appId) => currentAppId$.next(appId)), diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index 28e4a19f7e4..aeacd44989e 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -35,7 +35,7 @@ import { } from './application_service.test.mocks'; import { createElement } from 'react'; -import { BehaviorSubject, Subject } from 'rxjs'; +import { BehaviorSubject, Subject, Observable } from 'rxjs'; import { bufferCount, take, takeUntil } from 'rxjs/operators'; import { shallow, mount } from 'enzyme'; @@ -51,7 +51,9 @@ import { AppStatus, AppUpdater, WorkspaceAvailability, + InternalApplicationStart, } from './types'; +import { MountPoint } from '../types'; import { act } from 'react-dom/test-utils'; import { workspacesServiceMock } from '../mocks'; @@ -937,6 +939,34 @@ describe('#start()', () => { expect(setupDeps.redirectTo).not.toHaveBeenCalled(); }); }); + + describe('AppControls', () => { + test.each(['Left', 'Center', 'Right', 'Badge', 'Description', 'Bottom'])( + 'records the App%sControls', + async (container) => { + const { register } = service.setup(setupDeps); + + register(Symbol(), createApp({ id: `app${container}` })); + const appStart = await service.start(startDeps); + const setControls = appStart[ + `setApp${container}Controls` as keyof InternalApplicationStart + ] as (mount: MountPoint | undefined) => void; + const currentControls$ = appStart[ + `current${container}Controls$` as keyof InternalApplicationStart + ] as Observable; + + const oldMountPoint = jest.fn(); + const expectedMountPoint = jest.fn(); + + await appStart.navigateToApp(`app${container}`); + setControls(oldMountPoint); + setControls(expectedMountPoint); + + const mountPoint = await currentControls$.pipe(take(1)).toPromise(); + expect(mountPoint).toBe(expectedMountPoint); + } + ); + }); }); describe('#stop()', () => { diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 76747490a30..630d97476b0 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -37,6 +37,7 @@ import { RecursiveReadonly } from '@osd/utility-types'; import { MountPoint } from '../types'; import { HttpSetup, HttpStart } from '../http'; import { OverlayStart } from '../overlays'; +import { HeaderControlsContainer } from '../chrome/constants'; import { ContextSetup, IContextContainer } from '../context'; import { PluginOpaqueId } from '../plugins'; import { AppRouter } from './ui'; @@ -104,6 +105,12 @@ interface AppUpdaterWrapper { interface AppInternalState { leaveHandler?: AppLeaveHandler; actionMenu?: MountPoint; + leftControls?: MountPoint; + centerControls?: MountPoint; + rightControls?: MountPoint; + badgeControls?: MountPoint; + descriptionControls?: MountPoint; + bottomControls?: MountPoint; } /** @@ -117,6 +124,15 @@ export class ApplicationService { private readonly appInternalStates = new Map(); private currentAppId$ = new BehaviorSubject(undefined); private currentActionMenu$ = new BehaviorSubject(undefined); + + // HeaderControls + private currentLeftControls$ = new BehaviorSubject(undefined); + private currentCenterControls$ = new BehaviorSubject(undefined); + private currentRightControls$ = new BehaviorSubject(undefined); + private currentBadgeControls$ = new BehaviorSubject(undefined); + private currentDescriptionControls$ = new BehaviorSubject(undefined); + private currentBottomControls$ = new BehaviorSubject(undefined); + private readonly statusUpdaters$ = new BehaviorSubject>(new Map()); private readonly subscriptions: Subscription[] = []; private stop$ = new Subject(); @@ -291,6 +307,15 @@ export class ApplicationService { this.currentAppId$.subscribe(() => this.refreshCurrentActionMenu()); + this.currentAppId$.subscribe(() => this.refreshCurrentControls(HeaderControlsContainer.LEFT)); + this.currentAppId$.subscribe(() => this.refreshCurrentControls(HeaderControlsContainer.CENTER)); + this.currentAppId$.subscribe(() => this.refreshCurrentControls(HeaderControlsContainer.RIGHT)); + this.currentAppId$.subscribe(() => this.refreshCurrentControls(HeaderControlsContainer.BADGE)); + this.currentAppId$.subscribe(() => + this.refreshCurrentControls(HeaderControlsContainer.DESCRIPTION) + ); + this.currentAppId$.subscribe(() => this.refreshCurrentControls(HeaderControlsContainer.BOTTOM)); + return { applications$: applications$.pipe( map((apps) => new Map([...apps.entries()].map(([id, app]) => [id, getAppInfo(app)]))), @@ -306,6 +331,46 @@ export class ApplicationService { distinctUntilChanged(), takeUntil(this.stop$) ), + + // HeaderControls + currentLeftControls$: this.currentLeftControls$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), + currentCenterControls$: this.currentCenterControls$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), + currentRightControls$: this.currentRightControls$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), + currentBadgeControls$: this.currentBadgeControls$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), + currentDescriptionControls$: this.currentDescriptionControls$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), + currentBottomControls$: this.currentBottomControls$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), + + setAppLeftControls: (mount: MountPoint | undefined) => + this.setAppLeftControls(this.currentAppId$.value, mount), + setAppCenterControls: (mount: MountPoint | undefined) => + this.setAppCenterControls(this.currentAppId$.value, mount), + setAppRightControls: (mount: MountPoint | undefined) => + this.setAppRightControls(this.currentAppId$.value, mount), + setAppBadgeControls: (mount: MountPoint | undefined) => + this.setAppBadgeControls(this.currentAppId$.value, mount), + setAppDescriptionControls: (mount: MountPoint | undefined) => + this.setAppDescriptionControls(this.currentAppId$.value, mount), + setAppBottomControls: (mount: MountPoint | undefined) => + this.setAppBottomControls(this.currentAppId$.value, mount), + history: this.history!, registerMountContext: this.mountContext.registerContext, getUrlForApp: ( @@ -339,6 +404,12 @@ export class ApplicationService { appStatuses$={applicationStatuses$} setAppLeaveHandler={this.setAppLeaveHandler} setAppActionMenu={this.setAppActionMenu} + setAppLeftControls={this.setAppLeftControls} + setAppCenterControls={this.setAppCenterControls} + setAppRightControls={this.setAppRightControls} + setAppBadgeControls={this.setAppBadgeControls} + setAppDescriptionControls={this.setAppDescriptionControls} + setAppBottomControls={this.setAppBottomControls} setIsMounting={(isMounting) => httpLoadingCount$.next(isMounting ? 1 : 0)} /> ); @@ -367,6 +438,71 @@ export class ApplicationService { this.currentActionMenu$.next(currentActionMenu); }; + private setAppLeftControls = (appPath: string | undefined, mount: MountPoint | undefined) => + this.setAppControls(appPath, mount, HeaderControlsContainer.LEFT); + + private setAppCenterControls = (appPath: string | undefined, mount: MountPoint | undefined) => + this.setAppControls(appPath, mount, HeaderControlsContainer.CENTER); + + private setAppRightControls = (appPath: string | undefined, mount: MountPoint | undefined) => + this.setAppControls(appPath, mount, HeaderControlsContainer.RIGHT); + + private setAppBadgeControls = (appPath: string | undefined, mount: MountPoint | undefined) => + this.setAppControls(appPath, mount, HeaderControlsContainer.BADGE); + + private setAppDescriptionControls = ( + appPath: string | undefined, + mount: MountPoint | undefined + ) => this.setAppControls(appPath, mount, HeaderControlsContainer.DESCRIPTION); + + private setAppBottomControls = (appPath: string | undefined, mount: MountPoint | undefined) => + this.setAppControls(appPath, mount, HeaderControlsContainer.BOTTOM); + + private setAppControls = ( + appPath: string | undefined, + mount: MountPoint | undefined, + container: HeaderControlsContainer + ) => { + if (!appPath) return; + + this.appInternalStates.set(appPath, { + ...(this.appInternalStates.get(appPath) ?? {}), + [`${container}Controls`]: mount, + }); + + this.refreshCurrentControls(container); + }; + + private refreshCurrentControls = (container: HeaderControlsContainer) => { + const appId = this.currentAppId$.getValue(); + switch (container) { + case HeaderControlsContainer.LEFT: + return this.currentLeftControls$.next( + appId ? this.appInternalStates.get(appId)?.leftControls : undefined + ); + case HeaderControlsContainer.CENTER: + return this.currentCenterControls$.next( + appId ? this.appInternalStates.get(appId)?.centerControls : undefined + ); + case HeaderControlsContainer.RIGHT: + return this.currentRightControls$.next( + appId ? this.appInternalStates.get(appId)?.rightControls : undefined + ); + case HeaderControlsContainer.BADGE: + return this.currentBadgeControls$.next( + appId ? this.appInternalStates.get(appId)?.badgeControls : undefined + ); + case HeaderControlsContainer.DESCRIPTION: + return this.currentDescriptionControls$.next( + appId ? this.appInternalStates.get(appId)?.descriptionControls : undefined + ); + case HeaderControlsContainer.BOTTOM: + return this.currentBottomControls$.next( + appId ? this.appInternalStates.get(appId)?.bottomControls : undefined + ); + } + }; + private async shouldNavigate(overlays: OverlayStart): Promise { const currentAppId = this.currentAppId$.value; if (currentAppId === undefined) { @@ -402,6 +538,12 @@ export class ApplicationService { this.stop$.next(); this.currentAppId$.complete(); this.currentActionMenu$.complete(); + this.currentLeftControls$.complete(); + this.currentCenterControls$.complete(); + this.currentRightControls$.complete(); + this.currentBadgeControls$.complete(); + this.currentDescriptionControls$.complete(); + this.currentBottomControls$.complete(); this.statusUpdaters$.complete(); this.subscriptions.forEach((sub) => sub.unsubscribe()); window.removeEventListener('beforeunload', this.onBeforeUnload); diff --git a/src/core/public/application/integration_tests/router.test.tsx b/src/core/public/application/integration_tests/router.test.tsx index 7e1bc437ca9..88876f65f05 100644 --- a/src/core/public/application/integration_tests/router.test.tsx +++ b/src/core/public/application/integration_tests/router.test.tsx @@ -70,6 +70,12 @@ describe('AppRouter', () => { appStatuses$={mountersToAppStatus$()} setAppLeaveHandler={noop} setAppActionMenu={noop} + setAppLeftControls={noop} + setAppCenterControls={noop} + setAppRightControls={noop} + setAppBadgeControls={noop} + setAppDescriptionControls={noop} + setAppBottomControls={noop} setIsMounting={noop} /> ); diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 7546b49620a..63cbc560556 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -35,7 +35,7 @@ import { RecursiveReadonly } from '@osd/utility-types'; import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; import { MountPoint } from '../types'; import { Capabilities } from './capabilities'; -import { ChromeStart } from '../chrome'; +import { ChromeStart, HeaderVariant } from '../chrome'; import { IContextProvider } from '../context'; import { DocLinksStart } from '../doc_links'; import { HttpStart } from '../http'; @@ -227,6 +227,11 @@ export interface App { */ chromeless?: boolean; + /** + * The application-wide header variant to use. Defaults to `page`. + */ + headerVariant?: HeaderVariant; + /** * A mount function called when the user navigates to this app's route. May have signature of {@link AppMount} or * {@link AppMountDeprecated}. @@ -535,6 +540,13 @@ export interface AppMountParameters { * ``` */ setHeaderActionMenu: (menuMount: MountPoint | undefined) => void; + + setHeaderLeftControls: (menuMount: MountPoint | undefined) => void; + setHeaderCenterControls: (menuMount: MountPoint | undefined) => void; + setHeaderRightControls: (menuMount: MountPoint | undefined) => void; + setHeaderBadgeControls: (menuMount: MountPoint | undefined) => void; + setHeaderDescriptionControls: (menuMount: MountPoint | undefined) => void; + setHeaderBottomControls: (menuMount: MountPoint | undefined) => void; /** * Optional datasource id to pass while mounting app */ @@ -828,6 +840,13 @@ export interface ApplicationStart { * An observable that emits the current application id and each subsequent id update. */ currentAppId$: Observable; + + setAppLeftControls: (mount: MountPoint | undefined) => void; + setAppCenterControls: (mount: MountPoint | undefined) => void; + setAppRightControls: (mount: MountPoint | undefined) => void; + setAppBadgeControls: (mount: MountPoint | undefined) => void; + setAppDescriptionControls: (mount: MountPoint | undefined) => void; + setAppBottomControls: (mount: MountPoint | undefined) => void; } /** @internal */ @@ -858,6 +877,19 @@ export interface InternalApplicationStart extends Omit; + /** + * The potential header controls set by the currently mounted app. + * Consumed by the chrome header. + * + * @internal + */ + currentLeftControls$: Observable; + currentCenterControls$: Observable; + currentRightControls$: Observable; + currentBadgeControls$: Observable; + currentDescriptionControls$: Observable; + currentBottomControls$: Observable; + /** * The global history instance, exposed only to Core. * @internal diff --git a/src/core/public/application/ui/app_container.test.tsx b/src/core/public/application/ui/app_container.test.tsx index e9e2caed02e..071dc4ed9ac 100644 --- a/src/core/public/application/ui/app_container.test.tsx +++ b/src/core/public/application/ui/app_container.test.tsx @@ -43,6 +43,12 @@ describe('AppContainer', () => { const setAppLeaveHandler = jest.fn(); const setAppActionMenu = jest.fn(); const setIsMounting = jest.fn(); + const setAppLeftControls = jest.fn(); + const setAppRightControls = jest.fn(); + const setAppCenterControls = jest.fn(); + const setAppBadgeControls = jest.fn(); + const setAppDescriptionControls = jest.fn(); + const setAppBottomControls = jest.fn(); beforeEach(() => { setAppLeaveHandler.mockClear(); @@ -89,6 +95,12 @@ describe('AppContainer', () => { mounter={mounter} setAppLeaveHandler={setAppLeaveHandler} setAppActionMenu={setAppActionMenu} + setAppLeftControls={setAppLeftControls} + setAppCenterControls={setAppCenterControls} + setAppRightControls={setAppRightControls} + setAppBadgeControls={setAppBadgeControls} + setAppDescriptionControls={setAppDescriptionControls} + setAppBottomControls={setAppBottomControls} setIsMounting={setIsMounting} createScopedHistory={(appPath: string) => // Create a history using the appPath as the current location @@ -130,6 +142,12 @@ describe('AppContainer', () => { mounter={mounter} setAppLeaveHandler={setAppLeaveHandler} setAppActionMenu={setAppActionMenu} + setAppLeftControls={setAppLeftControls} + setAppCenterControls={setAppCenterControls} + setAppRightControls={setAppRightControls} + setAppBadgeControls={setAppBadgeControls} + setAppDescriptionControls={setAppDescriptionControls} + setAppBottomControls={setAppBottomControls} setIsMounting={setIsMounting} createScopedHistory={(appPath: string) => // Create a history using the appPath as the current location @@ -172,6 +190,12 @@ describe('AppContainer', () => { mounter={mounter} setAppLeaveHandler={setAppLeaveHandler} setAppActionMenu={setAppActionMenu} + setAppLeftControls={setAppLeftControls} + setAppCenterControls={setAppCenterControls} + setAppRightControls={setAppRightControls} + setAppBadgeControls={setAppBadgeControls} + setAppDescriptionControls={setAppDescriptionControls} + setAppBottomControls={setAppBottomControls} setIsMounting={setIsMounting} createScopedHistory={(appPath: string) => // Create a history using the appPath as the current location diff --git a/src/core/public/application/ui/app_container.tsx b/src/core/public/application/ui/app_container.tsx index b7d0619a0f9..8e81db2af34 100644 --- a/src/core/public/application/ui/app_container.tsx +++ b/src/core/public/application/ui/app_container.tsx @@ -52,6 +52,12 @@ interface Props { appStatus: AppStatus; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; setAppActionMenu: (appId: string, mount: MountPoint | undefined) => void; + setAppLeftControls: (appId: string, mount: MountPoint | undefined) => void; + setAppCenterControls: (appId: string, mount: MountPoint | undefined) => void; + setAppRightControls: (appId: string, mount: MountPoint | undefined) => void; + setAppBadgeControls: (appId: string, mount: MountPoint | undefined) => void; + setAppDescriptionControls: (appId: string, mount: MountPoint | undefined) => void; + setAppBottomControls: (appId: string, mount: MountPoint | undefined) => void; createScopedHistory: (appUrl: string) => ScopedHistory; setIsMounting: (isMounting: boolean) => void; } @@ -62,6 +68,12 @@ export const AppContainer: FunctionComponent = ({ appPath, setAppLeaveHandler, setAppActionMenu, + setAppLeftControls, + setAppCenterControls, + setAppRightControls, + setAppBadgeControls, + setAppDescriptionControls, + setAppBottomControls, createScopedHistory, appStatus, setIsMounting, @@ -99,6 +111,13 @@ export const AppContainer: FunctionComponent = ({ element: elementRef.current!, onAppLeave: (handler) => setAppLeaveHandler(appId, handler), setHeaderActionMenu: (menuMount) => setAppActionMenu(appId, menuMount), + setHeaderLeftControls: (menuMount) => setAppLeftControls(appId, menuMount), + setHeaderCenterControls: (menuMount) => setAppCenterControls(appId, menuMount), + setHeaderRightControls: (menuMount) => setAppRightControls(appId, menuMount), + setHeaderBadgeControls: (menuMount) => setAppBadgeControls(appId, menuMount), + setHeaderDescriptionControls: (menuMount) => + setAppDescriptionControls(appId, menuMount), + setHeaderBottomControls: (menuMount) => setAppBottomControls(appId, menuMount), })) || null; } catch (e) { // TODO: add error UI @@ -122,6 +141,12 @@ export const AppContainer: FunctionComponent = ({ createScopedHistory, setAppLeaveHandler, setAppActionMenu, + setAppLeftControls, + setAppRightControls, + setAppCenterControls, + setAppBadgeControls, + setAppDescriptionControls, + setAppBottomControls, appPath, setIsMounting, ]); diff --git a/src/core/public/application/ui/app_router.tsx b/src/core/public/application/ui/app_router.tsx index 9cfada1f333..e5de0b40647 100644 --- a/src/core/public/application/ui/app_router.tsx +++ b/src/core/public/application/ui/app_router.tsx @@ -45,6 +45,12 @@ interface Props { appStatuses$: Observable>; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; setAppActionMenu: (appId: string, mount: MountPoint | undefined) => void; + setAppLeftControls: (appId: string, mount: MountPoint | undefined) => void; + setAppCenterControls: (appId: string, mount: MountPoint | undefined) => void; + setAppRightControls: (appId: string, mount: MountPoint | undefined) => void; + setAppBadgeControls: (appId: string, mount: MountPoint | undefined) => void; + setAppDescriptionControls: (appId: string, mount: MountPoint | undefined) => void; + setAppBottomControls: (appId: string, mount: MountPoint | undefined) => void; setIsMounting: (isMounting: boolean) => void; } @@ -57,6 +63,12 @@ export const AppRouter: FunctionComponent = ({ mounters, setAppLeaveHandler, setAppActionMenu, + setAppLeftControls, + setAppCenterControls, + setAppRightControls, + setAppBadgeControls, + setAppDescriptionControls, + setAppBottomControls, appStatuses$, setIsMounting, }) => { @@ -79,7 +91,19 @@ export const AppRouter: FunctionComponent = ({ appPath={path} appStatus={appStatuses.get(appId) ?? AppStatus.inaccessible} createScopedHistory={createScopedHistory} - {...{ appId, mounter, setAppLeaveHandler, setAppActionMenu, setIsMounting }} + {...{ + appId, + mounter, + setAppLeaveHandler, + setAppLeftControls, + setAppCenterControls, + setAppRightControls, + setAppBadgeControls, + setAppDescriptionControls, + setAppBottomControls, + setAppActionMenu, + setIsMounting, + }} /> )} /> @@ -101,7 +125,18 @@ export const AppRouter: FunctionComponent = ({ appId={id ?? appId} appStatus={appStatuses.get(appId) ?? AppStatus.inaccessible} createScopedHistory={createScopedHistory} - {...{ mounter, setAppLeaveHandler, setAppActionMenu, setIsMounting }} + {...{ + mounter, + setAppLeaveHandler, + setAppLeftControls, + setAppCenterControls, + setAppRightControls, + setAppBadgeControls, + setAppDescriptionControls, + setAppBottomControls, + setAppActionMenu, + setIsMounting, + }} /> ); }} diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index 8e8d8c893cc..c6323218667 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -86,6 +86,8 @@ const createStartContractMock = () => { setAppTitle: jest.fn(), setIsVisible: jest.fn(), getIsVisible$: jest.fn(), + setHeaderVariant: jest.fn(), + getHeaderVariant$: jest.fn(), addApplicationClass: jest.fn(), removeApplicationClass: jest.fn(), getApplicationClasses$: jest.fn(), @@ -104,6 +106,7 @@ const createStartContractMock = () => { }; startContract.navLinks.getAll.mockReturnValue([]); startContract.getIsVisible$.mockReturnValue(new BehaviorSubject(false)); + startContract.getHeaderVariant$.mockReturnValue(new BehaviorSubject(undefined)); startContract.getApplicationClasses$.mockReturnValue(new BehaviorSubject(['class-name'])); startContract.getBadge$.mockReturnValue(new BehaviorSubject({} as ChromeBadge)); startContract.getBreadcrumbs$.mockReturnValue(new BehaviorSubject([{} as ChromeBreadcrumb])); diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index 0ee05b1739d..082ffbfa16e 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -42,11 +42,19 @@ import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock'; import { ChromeService } from './chrome_service'; import { getAppInfo } from '../application/utils'; import { overlayServiceMock, workspacesServiceMock } from '../mocks'; +import { HeaderVariant } from './constants'; class FakeApp implements App { - public title = `${this.id} App`; + public title: string; public mount = () => () => {}; - constructor(public id: string, public chromeless?: boolean) {} + + constructor( + public id: string, + public chromeless?: boolean, + public headerVariant?: HeaderVariant + ) { + this.title = `${this.id} App`; + } } const store = new Map(); const originalLocalStorage = window.localStorage; @@ -279,6 +287,68 @@ describe('start', () => { }); }); + describe('header variant', () => { + it('emits undefined when no application is mounted', async () => { + const { chrome, service } = await start(); + const promise = chrome.getHeaderVariant$().pipe(toArray()).toPromise(); + + chrome.setHeaderVariant(HeaderVariant.PAGE); + chrome.setHeaderVariant(HeaderVariant.APPLICATION); + chrome.setHeaderVariant(HeaderVariant.PAGE); + service.stop(); + + await expect(promise).resolves.toMatchInlineSnapshot(`Array []`); + }); + + it('emits application-wide value until manually overridden', async () => { + const startDeps = defaultStartDeps([ + new FakeApp('alpha', undefined, HeaderVariant.APPLICATION), + ]); + const { navigateToApp } = startDeps.application; + const { chrome, service } = await start({ startDeps }); + + const promise = chrome.getHeaderVariant$().pipe(toArray()).toPromise(); + + await navigateToApp('alpha'); + + chrome.setHeaderVariant(HeaderVariant.PAGE); + chrome.setHeaderVariant(HeaderVariant.APPLICATION); + + service.stop(); + + await expect(promise).resolves.toMatchInlineSnapshot(` + Array [ + "${HeaderVariant.APPLICATION}", + "${HeaderVariant.PAGE}", + "${HeaderVariant.APPLICATION}", + ] + `); + }); + + it('emits application-wide value after override is removed', async () => { + const startDeps = defaultStartDeps([new FakeApp('alpha', undefined, HeaderVariant.PAGE)]); + const { navigateToApp } = startDeps.application; + const { chrome, service } = await start({ startDeps }); + + const promise = chrome.getHeaderVariant$().pipe(toArray()).toPromise(); + + await navigateToApp('alpha'); + + chrome.setHeaderVariant(HeaderVariant.APPLICATION); + chrome.setHeaderVariant(); + + service.stop(); + + await expect(promise).resolves.toMatchInlineSnapshot(` + Array [ + "${HeaderVariant.PAGE}", + "${HeaderVariant.APPLICATION}", + "${HeaderVariant.PAGE}", + ] + `); + }); + }); + describe('application classes', () => { it('updates/emits the application classes', async () => { const { chrome, service } = await start(); diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 7c4a33769b8..ef6d606aa0a 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -30,8 +30,17 @@ import { EuiBreadcrumb, IconType } from '@elastic/eui'; import React from 'react'; -import { FormattedMessage } from '@osd/i18n/react'; -import { BehaviorSubject, combineLatest, merge, Observable, of, ReplaySubject } from 'rxjs'; +import ReactDOM from 'react-dom'; +import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; +import { + BehaviorSubject, + combineLatest, + merge, + Observable, + of, + ReplaySubject, + Subscription, +} from 'rxjs'; import { flatMap, map, takeUntil } from 'rxjs/operators'; import { parse } from 'url'; import { EuiLink } from '@elastic/eui'; @@ -42,13 +51,13 @@ 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 { HeaderVariant, OPENSEARCH_DASHBOARDS_ASK_OPENSEARCH_LINK } from './constants'; import { ChromeDocTitle, DocTitleService } from './doc_title'; import { ChromeNavControls, NavControlsService } from './nav_controls'; import { ChromeNavLinks, NavLinksService, ChromeNavLink } from './nav_links'; import { ChromeRecentlyAccessed, RecentlyAccessedService } from './recently_accessed'; import { Header } from './ui'; -import { ChromeHelpExtensionMenuLink } from './ui/header/header_help_menu'; +import { ChromeHelpExtensionMenuLink, HeaderHelpMenu } from './ui/header/header_help_menu'; import { Branding, WorkspacesStart } from '../'; import { getLogos } from '../../common'; import type { Logos } from '../../common/types'; @@ -120,12 +129,16 @@ type CollapsibleNavHeaderRender = () => JSX.Element | null; export class ChromeService { private isVisible$!: Observable; private isForceHidden$!: BehaviorSubject; + private headerVariant$!: Observable; + private headerVariantOverride$!: BehaviorSubject; private readonly stop$ = new ReplaySubject(1); private readonly navControls = new NavControlsService(); private readonly navLinks = new NavLinksService(); private readonly recentlyAccessed = new RecentlyAccessedService(); private readonly docTitle = new DocTitleService(); private readonly navGroup = new ChromeNavGroupService(); + private useUpdatedHeader = false; + private updatedHeaderSubscription: Subscription | undefined; private collapsibleNavHeaderRender?: CollapsibleNavHeaderRender; constructor(private readonly params: ConstructorParams) {} @@ -162,6 +175,28 @@ export class ChromeService { ); } + private initHeaderVariant(application: StartDeps['application']) { + this.headerVariantOverride$ = new BehaviorSubject(undefined); + + const appHeaderVariant$ = application.currentAppId$.pipe( + flatMap((appId) => + application.applications$.pipe( + map( + (applications) => + (appId && applications.has(appId) && applications.get(appId)!.headerVariant) as + | HeaderVariant + | undefined + ) + ) + ) + ); + + this.headerVariant$ = combineLatest([appHeaderVariant$, this.headerVariantOverride$]).pipe( + map(([appHeaderVariant, headerVariantOverride]) => headerVariantOverride || appHeaderVariant), + takeUntil(this.stop$) + ); + } + public setup({ uiSettings }: SetupDeps): ChromeSetup { const navGroup = this.navGroup.setup({ uiSettings }); return { @@ -189,6 +224,13 @@ export class ChromeService { workspaces, }: StartDeps): Promise { this.initVisibility(application); + this.initHeaderVariant(application); + + this.updatedHeaderSubscription = uiSettings + .get$('home:useNewHomePage', false) + .subscribe((value) => { + this.useUpdatedHeader = value; + }); const appTitle$ = new BehaviorSubject('Overview'); const applicationClasses$ = new BehaviorSubject>(new Set()); @@ -231,6 +273,29 @@ export class ChromeService { const logos = getLogos(injectedMetadata.getBranding(), http.basePath.serverBasePath); + // Add Help menu + if (this.useUpdatedHeader) { + navControls.registerLeftBottom({ + order: 9000, + mount: (element: HTMLElement) => { + ReactDOM.render( + + + , + element + ); + return () => ReactDOM.unmountComponentAtNode(element); + }, + }); + } + const isIE = () => { const ua = window.navigator.userAgent; const msie = ua.indexOf('MSIE '); // IE 10 or older @@ -299,6 +364,7 @@ export class ChromeService { helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))} homeHref={application.getUrlForApp('home')} isVisible$={this.isVisible$} + headerVariant$={this.headerVariant$} opensearchDashboardsVersion={injectedMetadata.getOpenSearchDashboardsVersion()} navLinks$={navLinks.getNavLinks$()} recentlyAccessed$={recentlyAccessed.get$()} @@ -320,6 +386,7 @@ export class ChromeService { navGroupsMap$={navGroup.getNavGroupsMap$()} setCurrentNavGroup={navGroup.setCurrentNavGroup} workspaceList$={workspaces.workspaceList$} + useUpdatedHeader={this.useUpdatedHeader} /> ), @@ -329,6 +396,10 @@ export class ChromeService { setIsVisible: (isVisible: boolean) => this.isForceHidden$.next(!isVisible), + getHeaderVariant$: () => this.headerVariant$, + + setHeaderVariant: (variant?: HeaderVariant) => this.headerVariantOverride$.next(variant), + getApplicationClasses$: () => applicationClasses$.pipe( map((set) => [...set]), @@ -386,6 +457,7 @@ export class ChromeService { public stop() { this.navLinks.stop(); this.navGroup.stop(); + this.updatedHeaderSubscription?.unsubscribe(); this.stop$.next(); } } @@ -466,6 +538,16 @@ export interface ChromeStart { */ setIsVisible(isVisible: boolean): void; + /** + * Get an observable of the current header variant. + */ + getHeaderVariant$(): Observable; + + /** + * Set or unset the temporary variant for the header. + */ + setHeaderVariant(variant?: HeaderVariant): void; + /** * Get the current set of classNames that will be set on the application container. */ diff --git a/src/core/public/chrome/constants.ts b/src/core/public/chrome/constants.ts index 4f98257ea5f..ce65af852e0 100644 --- a/src/core/public/chrome/constants.ts +++ b/src/core/public/chrome/constants.ts @@ -37,3 +37,17 @@ export enum RightNavigationOrder { Settings = 10, DevTool = 20, } + +export enum HeaderControlsContainer { + LEFT = 'left', + CENTER = 'center', + RIGHT = 'right', + BADGE = 'badge', + DESCRIPTION = 'description', + BOTTOM = 'bottom', +} + +export enum HeaderVariant { + PAGE = 'page', + APPLICATION = 'application', +} diff --git a/src/core/public/chrome/index.ts b/src/core/public/chrome/index.ts index 5347266d9c3..5403634705c 100644 --- a/src/core/public/chrome/index.ts +++ b/src/core/public/chrome/index.ts @@ -58,6 +58,6 @@ export { } from './recently_accessed'; export { ChromeNavControl, ChromeNavControls } from './nav_controls'; export { ChromeDocTitle } from './doc_title'; -export { RightNavigationOrder } from './constants'; +export { RightNavigationOrder, HeaderVariant } from './constants'; export { ChromeRegistrationNavLink, ChromeNavGroupUpdater, NavGroupItemInMap } from './nav_group'; export { fulfillRegistrationLinksToChromeNavLinks, LinkItemType, getSortedNavLinks } from './utils'; 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 5489d9fcfdc..82aa02fe79a 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 @@ -201,6 +201,60 @@ exports[`Header handles visibility and lock changes 1`] = ` "thrownError": null, }, }, + "currentBadgeControls$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentBottomControls$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentCenterControls$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentDescriptionControls$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentLeftControls$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentRightControls$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, "getComponent": [MockFunction], "getUrlForApp": [MockFunction], "history": Object { @@ -225,6 +279,12 @@ exports[`Header handles visibility and lock changes 1`] = ` "navigateToApp": [MockFunction], "navigateToUrl": [MockFunction], "registerMountContext": [MockFunction], + "setAppBadgeControls": [MockFunction], + "setAppBottomControls": [MockFunction], + "setAppCenterControls": [MockFunction], + "setAppDescriptionControls": [MockFunction], + "setAppLeftControls": [MockFunction], + "setAppRightControls": [MockFunction], } } badge$={ @@ -337,6 +397,43 @@ exports[`Header handles visibility and lock changes 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, ], "thrownError": null, } @@ -541,6 +638,55 @@ exports[`Header handles visibility and lock changes 1`] = ` "thrownError": null, } } + headerVariant$={ + BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } helpExtension$={ BehaviorSubject { "_isScalar": false, @@ -2101,6 +2247,7 @@ exports[`Header handles visibility and lock changes 1`] = ` } } survey="/" + useUpdatedHeader={false} workspaceList$={ BehaviorSubject { "_isScalar": false, @@ -4084,6 +4231,43 @@ exports[`Header handles visibility and lock changes 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, ], "thrownError": null, } @@ -4137,6 +4321,7 @@ exports[`Header handles visibility and lock changes 1`] = ` "thrownError": null, } } + useUpdatedHeader={false} > @@ -4258,9 +4444,10 @@ exports[`Header handles visibility and lock changes 1`] = ` >
@@ -7022,7 +7210,7 @@ exports[`Header handles visibility and lock changes 1`] = ` `; -exports[`Header renders condensed header 1`] = ` +exports[`Header renders application header without title and breadcrumbs 1`] = `
- -
+ - -
- -
- - - - - - - - - - , - } - } - className="euiHeaderSectionItemButton" - color="text" + aria-pressed="false" + class="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItemButton newAppTopNavExpander" data-test-subj="toggleNavButton" - onClick={[Function]} + type="button" > - - - -
-
- + + , + } + } + className="euiHeaderSectionItemButton newAppTopNavExpander" + color="text" + data-test-subj="toggleNavButton" + onClick={[Function]} > -
- + + + + + + + + + + + + + + +
+ +
+ -
-
- -
- + } + workspaceList$={ + BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, }, "isStopped": false, - "syncErrorThrowable": false, + "syncErrorThrowable": true, "syncErrorThrown": false, "syncErrorValue": null, }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], - "thrownError": null, - } - } - logos={ - Object { - "AnimatedMark": Object { - "dark": Object { - "type": "default", - "url": "/ui/logos/opensearch_spinner_on_dark.svg", - }, - "light": Object { - "type": "default", - "url": "/ui/logos/opensearch_spinner_on_light.svg", - }, - "type": "default", - "url": "/ui/logos/opensearch_spinner_on_light.svg", - }, - "Application": Object { - "dark": Object { - "type": "default", - "url": "/ui/logos/opensearch_dashboards_on_dark.svg", - }, - "light": Object { - "type": "default", - "url": "/ui/logos/opensearch_dashboards_on_light.svg", - }, - "type": "default", - "url": "/ui/logos/opensearch_dashboards_on_light.svg", - }, - "CenterMark": Object { - "dark": Object { - "type": "default", - "url": "/ui/logos/opensearch_center_mark_on_dark.svg", - }, - "light": Object { - "type": "default", - "url": "/ui/logos/opensearch_center_mark_on_light.svg", - }, - "type": "default", - "url": "/ui/logos/opensearch_center_mark_on_light.svg", - }, - "Mark": Object { - "dark": Object { - "type": "default", - "url": "/ui/logos/opensearch_mark_on_dark.svg", - }, - "light": Object { - "type": "default", - "url": "/ui/logos/opensearch_mark_on_light.svg", - }, - "type": "default", - "url": "/ui/logos/opensearch_mark_on_light.svg", - }, - "OpenSearch": Object { - "dark": Object { - "type": "default", - "url": "/ui/logos/opensearch_on_dark.svg", - }, - "light": Object { - "type": "default", - "url": "/ui/logos/opensearch_on_light.svg", - }, - "type": "default", - "url": "/ui/logos/opensearch_on_light.svg", - }, - "colorScheme": "light", - } - } - navLinks$={ - BehaviorSubject { - "_isScalar": false, - "_value": Array [], - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [ - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], - "thrownError": null, + ], + "thrownError": null, + } } - } - navigateToApp={[MockFunction]} - > - - + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + initialFocus={false} + isOpen={false} + ownFocus={true} + panelPaddingSize="s" + repositionOnScroll={true} > - - - - + + -
-
-
-
+ class="euiHeaderSectionItemButton__content" + > + + +
-
-
- , - } - } - className="euiHeaderSectionItemButton header__homeLoaderNavButton" - color="text" - data-test-subj="homeLoader" - href="/" - onBlur={[Function]} - onClick={[Function]} - onFocus={[Function]} - > - - , } } + className="euiHeaderSectionItemButton headerRecentItemsButton" + color="text" + data-test-subj="recentItemsSectionButton" + onClick={[Function]} + size="s" > - - -
- - -
-
- - - - - -
+
+
- - -
-
- - - -
-
-
-
-
-
- - + + + +
+
+ + +
+ + +
+ +
+ +
+ +
+ + - - - - - - -
- -
-
- -
- -
- -
- -
- - -
+ + - + -
-
- -
+
+ +
+ + - + -
-
- -
- +
+
+
+ +
+
+
+
+ + + + + +`; + +exports[`Header renders condensed header 1`] = ` +
+
+
+ +
+ +
+ +
+ + + + + + + + + + , + } + } + className="euiHeaderSectionItemButton" + color="text" + data-test-subj="toggleNavButton" + onClick={[Function]} + > + + + +
+
+ +
+ +
+
+ +
+ + + + + + + + +
+ +
+
+
+
+
+
+ , + } + } + className="euiHeaderSectionItemButton header__homeLoaderNavButton" + color="text" + data-test-subj="homeLoader" + href="/" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + > + + + + + +
+ + + + + +
+
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + +
+ +
+
+ +
+ +
+ +
+ +
+ + +
+ +
+
+ +
+ +
+
+ +
+ + + + + + + + } + closePopover={[Function]} + data-test-subj="helpMenuButton" + display="inlineBlock" + hasArrow={true} + id="headerHelpMenu" + isOpen={false} + ownFocus={true} + panelPaddingSize="s" + repositionOnScroll={true} + > +
+
+ + + + + + + + + + + + , + } + } + className="euiHeaderSectionItemButton" + color="text" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + > + + + + + +
+
+
+
+
+
+
+
+ +
+ +
+ + + +
+
+`; + +exports[`Header renders page header with application title 1`] = ` +
+
+
+
+ +
+ + + + + + + + + + , + } + } + className="euiHeaderSectionItemButton newPageTopNavExpander" + color="text" + data-test-subj="toggleNavButton" + onClick={[Function]} + > + + + + +
+ +
+ + } + workspaceList$={ + BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], "closed": false, - "destination": Subscriber { + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], "_parentOrParents": null, - "_subscriptions": Array [ - [Circular], - ], + "_parentSubscriber": [Circular], + "_subscriptions": null, "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], }, "isStopped": false, - "syncErrorThrowable": true, + "syncErrorThrowable": false, "syncErrorThrown": false, "syncErrorValue": null, }, - "isStopped": true, - "observables": Array [ - BehaviorSubject { - "_isScalar": false, - "_value": undefined, - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [ - InnerSubscriber { - "_parentOrParents": [Circular], - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "index": 1, - "isStopped": false, - "outerIndex": 0, - "outerValue": undefined, - "parent": [Circular], - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], - "thrownError": null, - }, - [Circular], - ], - "resultSelector": undefined, + "isStopped": false, "syncErrorThrowable": true, "syncErrorThrown": false, "syncErrorValue": null, - "toRespond": 0, - "values": Array [ - undefined, - "", - ], }, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], - "thrownError": null, + ], + "thrownError": null, + } } - } - opensearchDashboardsDocLink="/docs" - opensearchDashboardsVersion="1.0.0" - surveyLink="/" - useDefaultContent={true} + > + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + initialFocus={false} + isOpen={false} + ownFocus={true} + panelPaddingSize="s" + repositionOnScroll={true} + > +
+
+ + + + + + + + + + , + } + } + className="euiHeaderSectionItemButton headerRecentItemsButton" + color="text" + data-test-subj="recentItemsSectionButton" + onClick={[Function]} + size="xs" + > + + + +
+
+
+
+
+
+
+
+ + + +
+ + + + +
+ + +
+ +
+ +
+ +
+

+ testTitle +

+
+
+
+
+ +
+ +
+ +
+ + +
+ +
+
+ +
+ +
+ +
+ + +
+ +
+
+
+ + +
+ +
+ +
+
+ +
+ +
+ +
+ + +
+ +
+
+ +
+ +
+ +
+ + +
+ +
+
+ +
+ - - - - - +
+ +
+ + +
+ -
-
- - - - - - - - - - - - , - } - } - className="euiHeaderSectionItemButton" - color="text" - onBlur={[Function]} - onClick={[Function]} - onFocus={[Function]} - > - - - - - -
-
- - - -
-
-
- -
- + } + side="right" + /> +
+
+
+
+
+ + +
+ +
+ +
+ + +
+ +
+ +
+ +
@@ -15303,9 +22231,10 @@ exports[`Header toggles primary navigation menu when clicked 1`] = ` >
diff --git a/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap index 5080b23e99c..441f43729e9 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap @@ -33,3 +33,18 @@ Array [ `; exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable 3`] = `null`; + +exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable with updated header 1`] = `null`; + +exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable with updated header 2`] = ` + + First + +`; + +exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable with updated header 3`] = `null`; diff --git a/src/core/public/chrome/ui/header/__snapshots__/recent_items.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/recent_items.test.tsx.snap index 208b6e181bb..b9813f1e7d1 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/recent_items.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/recent_items.test.tsx.snap @@ -10,7 +10,7 @@ exports[`Recent items should render base element normally 1`] = ` class="euiPopover__anchor" > + class="euiButtonContent euiButton__content" + > + + + + + +
@@ -154,25 +163,34 @@ Object {
- + class="euiButtonContent euiButton__content" + > + + + + + +
, @@ -251,25 +269,34 @@ Object {
- + class="euiButtonContent euiButton__content" + > + + + + + +
diff --git a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap index 4dc6ce29141..19ace22584f 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap @@ -75,25 +75,31 @@ Object {
- + class="euiButtonContent euiButton__content" + > + + + + +
@@ -106,25 +112,31 @@ Object {
- + class="euiButtonContent euiButton__content" + > + + + + +
, diff --git a/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap index 443ec2faa18..046869724c8 100644 --- a/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap @@ -209,27 +209,36 @@ Object {
- + + +
@@ -380,27 +389,36 @@ Object {
- + + +
, diff --git a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx index 3b679c2c047..8ba9867a898 100644 --- a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx @@ -11,6 +11,7 @@ import { EuiPanel, EuiSelectable, EuiPopoverTitle, + EuiSelectableOption, } from '@elastic/eui'; import { ApplicationStart, @@ -37,7 +38,6 @@ import { DataSourceItem } from '../data_source_item'; import { NoDataSource } from '../no_data_source'; import './data_source_selectable.scss'; import { DataSourceDropDownHeader } from '../drop_down_header'; -import '../button_title.scss'; import './data_source_selectable.scss'; import { DataSourceMenuPopoverButton } from '../popover_button/popover_button'; @@ -311,7 +311,7 @@ export class DataSourceSelectable extends React.Component< listProps={{ onFocusBadge: false, }} - options={this.state.dataSourceOptions} + options={this.state.dataSourceOptions as Array>} onChange={(newOptions) => this.onChange(newOptions)} singleSelection={'always'} data-test-subj={'dataSourceSelectable'} diff --git a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx index c2d2fe94d99..dbec565dc7b 100644 --- a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx @@ -250,7 +250,7 @@ export class DataSourceSelector extends React.Component< isDisabled={this.props.disabled} fullWidth={this.props.fullWidth || false} data-test-subj={'dataSourceSelectorComboBox'} - renderOption={(option) => ( + renderOption={(option: EuiComboBoxOptionOption) => ( @@ -60,6 +61,7 @@ exports[`DataSourceView Should return error when provided datasource has been fi button={ @@ -115,6 +117,7 @@ exports[`DataSourceView When selected option is local cluster and hide local Clu button={ @@ -161,6 +164,7 @@ exports[`DataSourceView should call getDataSourceById when only pass id with no button={ @@ -217,6 +221,7 @@ exports[`DataSourceView should render normally with local cluster not hidden 1`] button={ @@ -277,122 +282,31 @@ Object { >
- -
- - -
-
- ); }; diff --git a/src/plugins/discover/public/application/components/default_discover_table/_table_cell.scss b/src/plugins/discover/public/application/components/default_discover_table/_table_cell.scss index 980a335f35e..e5af22b2187 100644 --- a/src/plugins/discover/public/application/components/default_discover_table/_table_cell.scss +++ b/src/plugins/discover/public/application/components/default_discover_table/_table_cell.scss @@ -1,4 +1,4 @@ -$osdDocTableCellPadding: calc($ouiSizeM / 2); // corresponds to DataGrid medium cellPadding (6px) +$osdDocTableCellPadding: calc($euiSizeM / 2); // corresponds to DataGrid medium cellPadding (6px) .osdDocTable__detailsParent { border-top: none !important; @@ -47,7 +47,7 @@ $osdDocTableCellPadding: calc($ouiSizeM / 2); // corresponds to DataGrid medium top: 0; height: 100%; width: 100%; - background-image: linear-gradient(to right, transparent 0, $ouiColorEmptyShade 16px); + background-image: linear-gradient(to right, transparent 0, $euiColorEmptyShade 16px); z-index: 1; } diff --git a/src/plugins/discover/public/application/components/default_discover_table/_table_header.scss b/src/plugins/discover/public/application/components/default_discover_table/_table_header.scss index 59fa7c4dd4d..92c60f9eafe 100644 --- a/src/plugins/discover/public/application/components/default_discover_table/_table_header.scss +++ b/src/plugins/discover/public/application/components/default_discover_table/_table_header.scss @@ -4,7 +4,7 @@ // nested for specificity .docTableHeaderField { - padding: calc($ouiSizeM / 2); // corresponds to DataGrid medium cellPadding + padding: calc($euiSizeM / 2); // corresponds to DataGrid medium cellPadding } } diff --git a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx index 3e0b0084693..647b989f42e 100644 --- a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx +++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { DiscoverViewServices } from '../../../build_services'; import { SavedSearch } from '../../../saved_searches'; import { Adapters } from '../../../../../inspector/public'; -import { TopNavMenuData } from '../../../../../navigation/public'; +import { TopNavMenuData, TopNavMenuIconData } from '../../../../../navigation/public'; import { ISearchSource, unhashUrl } from '../../../opensearch_dashboards_services'; import { OnSaveProps, @@ -26,7 +26,7 @@ import { getRootBreadcrumbs } from '../../helpers/breadcrumbs'; import { syncQueryStateWithUrl } from '../../../../../data/public'; import { OpenSearchPanel } from './open_search_panel'; -export const getTopNavLinks = ( +const getLegacyTopNavLinks = ( services: DiscoverViewServices, inspectorAdapters: Adapters, savedSearch: SavedSearch, @@ -273,6 +273,242 @@ export const getTopNavLinks = ( return topNavLinksArray; }; +export const getTopNavLinks = ( + services: DiscoverViewServices, + inspectorAdapters: Adapters, + savedSearch: SavedSearch, + isEnhancementEnabled: boolean = false +) => { + const { + history, + inspector, + core, + capabilities, + share, + toastNotifications, + chrome, + store, + data: { query }, + osdUrlStateStorage, + uiSettings, + } = services; + + const showActionsInGroup = uiSettings.get('home:useNewHomePage'); + if (!showActionsInGroup) + return getLegacyTopNavLinks(services, inspectorAdapters, savedSearch, isEnhancementEnabled); + + const topNavLinksMap = new Map(); + + // New + const newSearch: TopNavMenuIconData = { + tooltip: i18n.translate('discover.localMenu.localMenu.newSearchTitle', { + defaultMessage: 'New', + }), + run() { + core.application.navigateToApp('discover', { + path: '#/', + }); + }, + testId: 'discoverNewButton', + ariaLabel: i18n.translate('discover.topNav.discoverNewButtonLabel', { + defaultMessage: `New Search`, + }), + iconType: 'plusInCircle', + controlType: 'icon', + }; + topNavLinksMap.set('new', newSearch); + + // Open + const openSearch: TopNavMenuIconData = { + tooltip: i18n.translate('discover.localMenu.openTitle', { + defaultMessage: 'Open', + }), + testId: 'discoverOpenButton', + ariaLabel: i18n.translate('discover.topNav.discoverOpenButtonLabel', { + defaultMessage: `Open Saved Search`, + }), + run: () => { + const flyoutSession = services.overlays.openFlyout( + toMountPoint( + + flyoutSession?.close?.().then()} + makeUrl={(searchId) => `#/view/${encodeURIComponent(searchId)}`} + /> + + ) + ); + }, + iconType: 'folderOpen', + controlType: 'icon', + }; + topNavLinksMap.set('open', openSearch); + + // Save + if (capabilities.discover?.save) { + const saveSearch: TopNavMenuIconData = { + tooltip: i18n.translate('discover.localMenu.saveTitle', { + defaultMessage: 'Save', + }), + testId: 'discoverSaveButton', + ariaLabel: i18n.translate('discover.topNav.discoverSaveButtonLabel', { + defaultMessage: `Save search`, + }), + run: async () => { + const onSave = async ({ + newTitle, + newCopyOnSave, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }: OnSaveProps) => { + const currentTitle = savedSearch.title; + savedSearch.title = newTitle; + savedSearch.copyOnSave = newCopyOnSave; + const saveOptions = { + confirmOverwrite: false, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }; + + const state: DiscoverState = store!.getState().discover; // store is defined before the view is loaded + + savedSearch.columns = state.columns; + savedSearch.sort = state.sort; + + try { + const id = await savedSearch.save(saveOptions); + + // If the title is a duplicate, the id will be an empty string. Checking for this condition here + if (id) { + toastNotifications.addSuccess({ + title: i18n.translate('discover.notifications.savedSearchTitle', { + defaultMessage: `Search '{savedSearchTitle}' was saved`, + values: { + savedSearchTitle: savedSearch.title, + }, + }), + 'data-test-subj': 'saveSearchSuccess', + }); + + if (id !== state.savedSearch) { + history().push(`/view/${encodeURIComponent(id)}`); + } else { + chrome.docTitle.change(savedSearch.lastSavedTitle); + chrome.setBreadcrumbs([...getRootBreadcrumbs(), { text: savedSearch.title }]); + } + + // set App state to clean + store!.dispatch({ type: setSavedSearchId.type, payload: id }); + + // starts syncing `_g` portion of url with query services + syncQueryStateWithUrl(query, osdUrlStateStorage); + + return { id }; + } + } catch (error) { + toastNotifications.addDanger({ + title: i18n.translate('discover.notifications.notSavedSearchTitle', { + defaultMessage: `Search '{savedSearchTitle}' was not saved.`, + values: { + savedSearchTitle: savedSearch.title, + }, + }), + text: (error as Error).message, + }); + + // Reset the original title + savedSearch.title = currentTitle; + + return { error }; + } + }; + + const saveModal = ( + {}} + title={savedSearch.title} + showCopyOnSave={!!savedSearch.id} + objectType="search" + description={i18n.translate('discover.localMenu.saveSaveSearchDescription', { + defaultMessage: + 'Save your Discover search so you can use it in visualizations and dashboards', + })} + showDescription={false} + /> + ); + showSaveModal(saveModal, core.i18n.Context); + }, + iconType: 'save', + controlType: 'icon', + }; + topNavLinksMap.set('save', saveSearch); + } + + // Share + if (share) { + const shareSearch: TopNavMenuIconData = { + tooltip: i18n.translate('discover.localMenu.shareTitle', { + defaultMessage: 'Share', + }), + testId: 'shareTopNavButton', + ariaLabel: i18n.translate('discover.topNav.discoverShareButtonLabel', { + defaultMessage: `Share search`, + }), + run: async (anchorElement) => { + const state: DiscoverState = store!.getState().discover; // store is defined before the view is loaded + const sharingData = await getSharingData({ + searchSource: savedSearch.searchSource, + state, + services, + }); + share?.toggleShareContextMenu({ + anchorElement, + allowEmbed: false, + allowShortUrl: capabilities.discover?.createShortUrl as boolean, + shareableUrl: unhashUrl(window.location.href), + objectId: savedSearch.id, + objectType: 'search', + sharingData: { + ...sharingData, + title: savedSearch.title, + }, + isDirty: !savedSearch.id || state.isDirty || false, + }); + }, + iconType: 'share', + controlType: 'icon', + }; + topNavLinksMap.set('share', shareSearch); + } + + const inspectSearch: TopNavMenuIconData = { + tooltip: i18n.translate('discover.localMenu.inspectTitle', { + defaultMessage: 'Inspect', + }), + testId: 'openInspectorButton', + ariaLabel: i18n.translate('discover.topNav.discoverInspectorButtonLabel', { + defaultMessage: `Open Inspector for search`, + }), + run() { + inspector.open(inspectorAdapters, { + title: savedSearch?.title || undefined, + }); + }, + iconType: 'inspect', + controlType: 'icon', + }; + topNavLinksMap.set('inspect', inspectSearch); + + // Order their appearance + return ['save', 'open', 'new', 'inspect', 'share'].reduce((acc, item) => { + const itemDef = topNavLinksMap.get(item); + if (itemDef) acc.push(itemDef); + + return acc; + }, [] as TopNavMenuData[]); +}; + // TODO: This does not seem to affect the share menu. need to look into it in future // const getFieldCounts = async () => { // // the field counts aren't set until we have the data back, diff --git a/src/plugins/discover/public/application/view_components/canvas/index.tsx b/src/plugins/discover/public/application/view_components/canvas/index.tsx index 26e32c6c2df..500c8ebdc80 100644 --- a/src/plugins/discover/public/application/view_components/canvas/index.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/index.tsx @@ -34,13 +34,18 @@ import { OpenSearchSearchHit } from '../../../application/doc_views/doc_views_ty import { buildColumns } from '../../utils/columns'; import './discover_canvas.scss'; import { getNewDiscoverSetting, setNewDiscoverSetting } from '../../components/utils/local_storage'; +import { HeaderVariant } from '../../../../../../core/public'; // eslint-disable-next-line import/no-default-export export default function DiscoverCanvas({ setHeaderActionMenu, history, optionalRef }: ViewProps) { const panelRef = useRef(null); const { data$, refetch$, indexPattern } = useDiscoverContext(); const { - services: { uiSettings, storage, capabilities }, + services: { + uiSettings, + capabilities, + chrome: { setHeaderVariant }, + }, } = useOpenSearchDashboards(); const { columns } = useSelector((state) => { const stateColumns = state.discover.columns; @@ -110,6 +115,13 @@ export default function DiscoverCanvas({ setHeaderActionMenu, history, optionalR } }, [dispatch, filteredColumns, indexPattern]); + useEffect(() => { + setHeaderVariant?.(HeaderVariant.APPLICATION); + return () => { + setHeaderVariant?.(); + }; + }, [setHeaderVariant]); + const timeField = indexPattern?.timeFieldName ? indexPattern.timeFieldName : undefined; const scrollToTop = () => { if (panelRef.current) { diff --git a/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx index 76d6c978909..fa3ee352499 100644 --- a/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx @@ -7,6 +7,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Query, TimeRange } from 'src/plugins/data/common'; import { createPortal } from 'react-dom'; import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; import { AppMountParameters } from '../../../../../../core/public'; import { connectStorageToQueryState, opensearchFilters } from '../../../../../data/public'; import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; @@ -20,6 +21,7 @@ import { useDispatch, setSavedQuery, useSelector } from '../../utils/state_manag import './discover_canvas.scss'; import { useDataSetManager } from '../utils/use_dataset_manager'; +import { TopNavMenuItemRenderType } from '../../../../../navigation/public'; export interface TopNavProps { opts: { @@ -35,6 +37,7 @@ export const TopNav = ({ opts, showSaveQuery, isEnhancementsEnabled }: TopNavPro const { services } = useOpenSearchDashboards(); const { inspectorAdapters, savedSearch, indexPattern } = useDiscoverContext(); const [indexPatterns, setIndexPatterns] = useState(undefined); + const [screenTitle, setScreenTitle] = useState(''); const state = useSelector((s) => s.discover); const dispatch = useDispatch(); @@ -51,6 +54,8 @@ export const TopNav = ({ opts, showSaveQuery, isEnhancementsEnabled }: TopNavPro uiSettings, } = services; + const showActionsInGroup = uiSettings.get('home:useNewHomePage'); + const topNavLinks = savedSearch ? getTopNavLinks(services, inspectorAdapters, savedSearch, isEnhancementsEnabled) : []; @@ -100,6 +105,15 @@ export const TopNav = ({ opts, showSaveQuery, isEnhancementsEnabled }: TopNavPro } }, [chrome, getUrlForApp, savedSearch?.id, savedSearch?.title]); + useEffect(() => { + setScreenTitle( + savedSearch?.title || + i18n.translate('discover.savedSearch.newTitle', { + defaultMessage: 'Untitled', + }) + ); + }, [savedSearch?.title]); + const showDatePicker = useMemo(() => (indexPattern ? indexPattern.isTimeBased() : false), [ indexPattern, ]); @@ -134,8 +148,8 @@ export const TopNav = ({ opts, showSaveQuery, isEnhancementsEnabled }: TopNavPro className={isEnhancementsEnabled ? 'topNav hidden' : ''} appName={PLUGIN_ID} config={topNavLinks} - showSearchBar - showDatePicker={showDatePicker} + showSearchBar={TopNavMenuItemRenderType.IN_PLACE} + showDatePicker={showDatePicker && TopNavMenuItemRenderType.IN_PORTAL} showSaveQuery={showSaveQuery} useDefaultBehaviors setMenuMountPoint={opts.setHeaderActionMenu} @@ -144,6 +158,8 @@ export const TopNav = ({ opts, showSaveQuery, isEnhancementsEnabled }: TopNavPro savedQueryId={state.savedQuery} onSavedQueryIdChange={updateSavedQueryId} datePickerRef={opts?.optionalRef?.datePickerRef} + groupActions={showActionsInGroup} + screenTitle={screenTitle} /> ); diff --git a/src/plugins/index_pattern_management/opensearch_dashboards.json b/src/plugins/index_pattern_management/opensearch_dashboards.json index 3f5dc47001b..27d77043624 100644 --- a/src/plugins/index_pattern_management/opensearch_dashboards.json +++ b/src/plugins/index_pattern_management/opensearch_dashboards.json @@ -4,7 +4,7 @@ "server": true, "ui": true, "optionalPlugins": ["dataSource"], - "requiredPlugins": ["management", "data", "urlForwarding"], + "requiredPlugins": ["management", "navigation", "data", "urlForwarding"], "requiredBundles": ["opensearchDashboardsReact", "opensearchDashboardsUtils"], "supportedOSDataSourceVersions": ">=1.0.0" } diff --git a/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx b/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx index 737182031f8..3a1d6323323 100644 --- a/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx +++ b/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx @@ -98,6 +98,7 @@ export const IndexPatternTable = ({ canSave, history }: Props) => { uiSettings, indexPatternManagementStart, chrome, + navigationUI: { HeaderControl }, docLinks, application, http, @@ -105,6 +106,7 @@ export const IndexPatternTable = ({ canSave, history }: Props) => { data, dataSourceEnabled, } = useOpenSearchDashboards().services; + const [indexPatterns, setIndexPatterns] = useState([]); const [creationOptions, setCreationOptions] = useState([]); const [sources, setSources] = useState([]); @@ -217,15 +219,51 @@ export const IndexPatternTable = ({ canSave, history }: Props) => { }), ]; - const createButton = canSave ? ( - - { + if (!canSave) return null; + + const button = ( + + + + ); + + return showActionsInHeader ? ( + - + ) : ( + {button} + ); + })(); + + const description = ( + + ); + const pageTitleAndDescription = showActionsInHeader ? ( + ) : ( - <> + + +

{title}

+
+ + +

{description}

+
+
); if (isLoadingSources || isLoadingIndexPatterns) { @@ -263,21 +301,8 @@ export const IndexPatternTable = ({ canSave, history }: Props) => { <> - - -

{title}

-
- - -

- -

-
-
- {createButton} + {pageTitleAndDescription} + {createButton}
@@ -118,7 +121,12 @@ export async function mountManagementSection( {params.wrapInPage ? ( - + {content} ) : ( diff --git a/src/plugins/index_pattern_management/public/plugin.ts b/src/plugins/index_pattern_management/public/plugin.ts index 3e98374b3c8..b830680e975 100644 --- a/src/plugins/index_pattern_management/public/plugin.ts +++ b/src/plugins/index_pattern_management/public/plugin.ts @@ -48,6 +48,7 @@ import { import { ManagementSetup } from '../../management/public'; import { DEFAULT_NAV_GROUPS, AppStatus, DEFAULT_APP_CATEGORIES } from '../../../core/public'; import { getScopedBreadcrumbs } from '../../opensearch_dashboards_react/public'; +import { NavigationPublicPluginStart } from '../../navigation/public'; export interface IndexPatternManagementSetupDependencies { management: ManagementSetup; @@ -57,6 +58,7 @@ export interface IndexPatternManagementSetupDependencies { export interface IndexPatternManagementStartDependencies { data: DataPublicPluginStart; + navigation: NavigationPublicPluginStart; dataSource?: DataSourcePluginStart; } diff --git a/src/plugins/index_pattern_management/public/types.ts b/src/plugins/index_pattern_management/public/types.ts index 7b2cd8575a7..9a0d92de52c 100644 --- a/src/plugins/index_pattern_management/public/types.ts +++ b/src/plugins/index_pattern_management/public/types.ts @@ -40,6 +40,7 @@ import { SavedObjectReference, } from 'src/core/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { NavigationPublicPluginStart } from 'src/plugins/navigation/public'; import { EuiTableFieldDataColumnType } from '@elastic/eui'; import { ManagementAppMountParams } from '../../management/public'; import { IndexPatternManagementStart } from './index'; @@ -50,6 +51,7 @@ export interface IndexPatternManagmentContext { application: ApplicationStart; savedObjects: SavedObjectsStart; uiSettings: IUiSettingsClient; + navigationUI: NavigationPublicPluginStart['ui']; notifications: NotificationsStart; overlays: OverlayStart; http: HttpSetup; diff --git a/src/plugins/navigation/public/index.ts b/src/plugins/navigation/public/index.ts index c409f066730..fe89f39c44d 100644 --- a/src/plugins/navigation/public/index.ts +++ b/src/plugins/navigation/public/index.ts @@ -35,7 +35,23 @@ export function plugin(initializerContext: PluginInitializerContext) { return new NavigationPublicPlugin(initializerContext); } -export { TopNavMenuData, TopNavMenu } from './top_nav_menu'; +export { + TopNavMenu, + TopNavMenuData, + TopNavMenuButtonData, + TopNavMenuSwitchData, + TopNavMenuIconData, + TopNavMenuLegacyData, + TopNavMenuItemRenderType, + TopNavControls, + TopNavControlData, + TopNavControlButtonData, + TopNavControlLinkData, + TopNavControlIconData, + TopNavControlTextData, + TopNavControlDescriptionData, + TopNavControlComponentData, +} from './top_nav_menu'; export { NavigationPublicPluginSetup, NavigationPublicPluginStart } from './types'; diff --git a/src/plugins/navigation/public/mocks.ts b/src/plugins/navigation/public/mocks.ts index 3c80117b9e7..24347f988b5 100644 --- a/src/plugins/navigation/public/mocks.ts +++ b/src/plugins/navigation/public/mocks.ts @@ -45,6 +45,7 @@ const createStartContract = (): jest.Mocked => { const startContract = { ui: { TopNavMenu: jest.fn(), + HeaderControl: jest.fn(), }, }; return startContract; diff --git a/src/plugins/navigation/public/plugin.ts b/src/plugins/navigation/public/plugin.ts index 031fdb5153a..a626f67210d 100644 --- a/src/plugins/navigation/public/plugin.ts +++ b/src/plugins/navigation/public/plugin.ts @@ -28,13 +28,13 @@ * under the License. */ -import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; +import { TopNavMenuExtensionsRegistry, createTopNav, createTopNavControl } from './top_nav_menu'; import { + NavigationPluginStartDependencies, NavigationPublicPluginSetup, NavigationPublicPluginStart, - NavigationPluginStartDependencies, } from './types'; -import { TopNavMenuExtensionsRegistry, createTopNav } from './top_nav_menu'; export class NavigationPublicPlugin implements Plugin { @@ -51,14 +51,15 @@ export class NavigationPublicPlugin } public start( - { i18n }: CoreStart, + { i18n, chrome }: CoreStart, { data }: NavigationPluginStartDependencies ): NavigationPublicPluginStart { const extensions = this.topNavMenuExtensionsRegistry.getAll(); return { ui: { - TopNavMenu: createTopNav(data, extensions, i18n), + TopNavMenu: createTopNav(data, extensions, i18n, chrome.navGroup.getNavGroupEnabled()), + HeaderControl: createTopNavControl(i18n), }, }; } diff --git a/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_controls.test.tsx.snap b/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_controls.test.tsx.snap new file mode 100644 index 00000000000..1c62a3c148d --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_controls.test.tsx.snap @@ -0,0 +1,122 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TopNavControls renders TopNavControlItems when controls are provided 1`] = ` + +`; diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index 988153a1c6a..a4de5f473fb 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -1,3 +1,69 @@ +@use "sass:map"; + .osdTopNavMenu { margin-right: $euiSizeXS; } + +.osdTopNavMenuGroupedActions { + background-color: $euiColorEmptyShade; + + // stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors + & > .euiSwitch, + & > .euiButton, + & > .euiButtonIcon, + & > .euiToolTipAnchor > .euiSwitch, + & > .euiToolTipAnchor > .euiButton, + & > .euiToolTipAnchor > .euiButtonIcon { + border-radius: 0; + border: $euiFormInputGroupBorder; + } + + // stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors + & > :not(:first-child), + & > .euiToolTipAnchor:not(:first-child) > .euiSwitch, + & > .euiToolTipAnchor:not(:first-child) > .euiButton, + & > .euiToolTipAnchor:not(:first-child) > .euiButtonIcon { + border-left-width: 0; + border-bottom-left-radius: 0; + border-top-left-radius: 0; + } + + // stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors + & > :not(:last-child), + & > .euiToolTipAnchor:not(:last-child) > .euiSwitch, + & > .euiToolTipAnchor:not(:last-child) > .euiButton, + & > .euiToolTipAnchor:not(:last-child) > .euiButtonIcon { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + + // border-right-color: map.get($euiSwitchColors, "text") !important; + } + + // stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors + & > .osdTopNavGroup-isDisabled:not(:last-child):has(+ .osdTopNavGroup-isDisabled), + & > .osdTopNavGroup-isDisabled.euiToolTipAnchor:not(:last-child):has(+ .osdTopNavGroup-isDisabled) > .euiButton, + & > .osdTopNavGroup-isDisabled.euiToolTipAnchor:not(:last-child):has(+ .osdTopNavGroup-isDisabled) > .euiButtonIcon { + // border-right-color: $euiButtonColorDisabled !important; + } +} + +.osdTopNavGroup { + &--button { + min-width: auto; + } +} + +.osdTopNavGroup-isDisabled { + cursor: not-allowed; +} + +.osdTopNavMenuScreenTitle { + // stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors + .euiText { + line-height: $euiFormControlCompressedHeight; + white-space: nowrap; + max-width: 18ch; + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/src/plugins/navigation/public/top_nav_menu/create_top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/create_top_nav_menu.tsx index dc4ce63e514..7af0bc49c0b 100644 --- a/src/plugins/navigation/public/top_nav_menu/create_top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/create_top_nav_menu.tsx @@ -28,16 +28,18 @@ * under the License. */ -import React from 'react'; import { I18nStart } from 'opensearch-dashboards/public'; +import React from 'react'; import { DataPublicPluginStart } from 'src/plugins/data/public'; -import { TopNavMenuProps, TopNavMenu } from './top_nav_menu'; +import { TopNavControls, TopNavControlsProps } from './top_nav_controls'; +import { TopNavMenu, TopNavMenuProps } from './top_nav_menu'; import { RegisteredTopNavMenuData } from './top_nav_menu_data'; export function createTopNav( data: DataPublicPluginStart, extraConfig: RegisteredTopNavMenuData[], - i18n: I18nStart + i18n: I18nStart, + groupActions?: boolean ) { return (props: TopNavMenuProps) => { const relevantConfig = extraConfig.filter( @@ -47,7 +49,17 @@ export function createTopNav( return ( - + + + ); + }; +} + +export function createTopNavControl(i18n: I18nStart) { + return (props: TopNavControlsProps) => { + return ( + + ); }; diff --git a/src/plugins/navigation/public/top_nav_menu/index.ts b/src/plugins/navigation/public/top_nav_menu/index.ts index fdbf95b95c2..1b84f550361 100644 --- a/src/plugins/navigation/public/top_nav_menu/index.ts +++ b/src/plugins/navigation/public/top_nav_menu/index.ts @@ -28,9 +28,28 @@ * under the License. */ -export { createTopNav } from './create_top_nav_menu'; -export { TopNavMenu, TopNavMenuProps } from './top_nav_menu'; -export { TopNavMenuData } from './top_nav_menu_data'; +export { createTopNav, createTopNavControl } from './create_top_nav_menu'; +export { TopNavMenu, TopNavMenuProps, TopNavMenuItemRenderType } from './top_nav_menu'; +export { TopNavControls, TopNavControlsProps } from './top_nav_controls'; +export { + TopNavControlData, + TopNavControlButtonData, + TopNavControlLinkData, + TopNavControlIconData, + TopNavControlTextData, + TopNavControlDescriptionData, + TopNavControlComponentData, +} from './top_nav_control_data'; +export { + TopNavMenuData, + TopNavMenuButtonData, + TopNavMenuSwitchData, + TopNavMenuIconData, + TopNavMenuLegacyData, + TopNavMenuSwitchAction, + TopNavMenuClickAction, + TopNavMenuAction, +} from './top_nav_menu_data'; export { TopNavMenuExtensionsRegistrySetup, TopNavMenuExtensionsRegistry, diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_control_data.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_control_data.tsx new file mode 100644 index 00000000000..83fa46e6950 --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_control_data.tsx @@ -0,0 +1,93 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiButtonProps, EuiTextProps, EuiHeaderLinkProps, EuiButtonIconProps } from '@elastic/eui'; + +export type TopNavControlAction = (targetElement: HTMLElement) => void; + +type RequireAtLeastOne = Pick> & + { + [K in Keys]-?: Required> & Partial>>; + }[Keys]; + +interface TopNavControlButtonOrLinkOrIconData { + // @deprecated + id?: string; + testId?: string; + className?: string; + isDisabled?: boolean | (() => boolean); + tooltip?: string | (() => string | undefined); + ariaLabel?: string; + target?: '_blank'; + iconSize?: EuiButtonProps['iconSize']; +} + +export type TopNavControlLinkData = TopNavControlButtonOrLinkOrIconData & + RequireAtLeastOne< + { + label: string; + isLoading?: boolean; + href?: string; + run?: TopNavControlAction; + iconType?: EuiHeaderLinkProps['iconType']; + iconSide?: EuiHeaderLinkProps['iconSide']; + color?: EuiHeaderLinkProps['color']; + controlType: 'link'; + }, + 'href' | 'run' + >; + +export type TopNavControlButtonData = TopNavControlButtonOrLinkOrIconData & + RequireAtLeastOne< + { + label: string; + isLoading?: boolean; + href?: string; + run?: TopNavControlAction; + iconType?: EuiButtonProps['iconType']; + iconSide?: EuiButtonProps['iconSide']; + color?: EuiButtonProps['color']; + fill?: EuiButtonProps['fill']; + controlType?: 'button'; + }, + 'href' | 'run' + >; + +export type TopNavControlIconData = TopNavControlButtonOrLinkOrIconData & + RequireAtLeastOne< + { + iconType: EuiButtonIconProps['iconType']; + ariaLabel: string; + href?: string; + run?: TopNavControlAction; + display?: EuiButtonIconProps['display']; + color?: EuiButtonIconProps['color']; + controlType: 'icon'; + }, + 'href' | 'run' + >; + +export interface TopNavControlTextData { + text: string; + className?: string; + textAlign?: EuiTextProps['textAlign']; + color?: EuiTextProps['color']; +} + +export interface TopNavControlDescriptionData { + description: string; +} + +export interface TopNavControlComponentData { + renderComponent: React.ReactElement; +} + +export type TopNavControlData = + | TopNavControlButtonData + | TopNavControlLinkData + | TopNavControlIconData + | TopNavControlTextData + | TopNavControlDescriptionData + | TopNavControlComponentData; diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.test.tsx new file mode 100644 index 00000000000..06266170d15 --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.test.tsx @@ -0,0 +1,108 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { EuiButton, EuiButtonIcon, EuiHeaderLink, EuiText, EuiToolTip } from '@elastic/eui'; +import { ShallowWrapper } from 'enzyme'; +import React from 'react'; +import { shallowWithIntl } from '../../../../test_utils/public/enzyme_helpers'; +import { TopNavControlData } from './top_nav_control_data'; +import { TopNavControlItem } from './top_nav_control_item'; + +// Mock props for different scenarios +const buttonProps: TopNavControlData = { + controlType: 'button', + label: 'Button', + run: jest.fn(), +}; + +const linkProps: TopNavControlData = { + controlType: 'link', + label: 'Link', + href: 'http://example.com', +}; + +const iconProps: TopNavControlData = { + controlType: 'icon', + iconType: 'user', + ariaLabel: 'Icon', + run: jest.fn(), +}; + +const textProps: TopNavControlData = { + text: 'Text Content', +}; + +const descriptionProps: TopNavControlData = { + description: 'Description Content', +}; + +const componentProps: TopNavControlData = { + renderComponent:
Custom Component
, +}; + +describe('TopNavControlItem', () => { + it('renders a button control', () => { + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiButton)).toHaveLength(1); + expect(wrapper.find(EuiButton).prop('onClick')).toBeDefined(); + }); + + it('renders a link control', () => { + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiHeaderLink)).toHaveLength(1); + expect(wrapper.find(EuiHeaderLink).prop('href')).toEqual(linkProps.href); + }); + + it('renders an icon control', () => { + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiButtonIcon)).toHaveLength(1); + expect(wrapper.find(EuiButtonIcon).prop('iconType')).toEqual(iconProps.iconType); + }); + + it('renders text content', () => { + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiText)).toHaveLength(1); + expect(wrapper.find(EuiText).children().text()).toEqual(textProps.text); + }); + + it('renders description content', () => { + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiText)).toHaveLength(1); + expect(wrapper.find(EuiText).children().text()).toEqual(descriptionProps.description); + }); + + it('renders a custom component', () => { + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.contains(componentProps.renderComponent)).toBe(true); + }); + + it('handles disabled state correctly', () => { + const disabledProps = { ...buttonProps, isDisabled: true }; + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiButton).prop('isDisabled')).toBe(true); + }); + + it('handles tooltip correctly', () => { + const tooltipProps = { ...buttonProps, tooltip: 'Tooltip text' }; + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiToolTip)).toHaveLength(1); + expect(wrapper.find(EuiToolTip).prop('content')).toEqual('Tooltip text'); + }); + + it('calls run function on button click', () => { + const mockEvent = { currentTarget: document.createElement('button') } as React.MouseEvent< + HTMLButtonElement + >; + const wrapper: ShallowWrapper = shallowWithIntl(); + wrapper.find(EuiButton).simulate('click', mockEvent); + expect(buttonProps.run).toHaveBeenCalledWith(mockEvent.currentTarget); + }); +}); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.tsx new file mode 100644 index 00000000000..ada02dba27b --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.tsx @@ -0,0 +1,150 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EuiButton, EuiHeaderLink, EuiButtonIcon, EuiText, EuiToolTip } from '@elastic/eui'; +import { upperFirst } from 'lodash'; +import React, { MouseEvent } from 'react'; +import { TopNavControlData } from './top_nav_control_data'; + +export function TopNavControlItem(props: TopNavControlData) { + if ('renderComponent' in props) return props.renderComponent; + + if ('text' in props) { + const { text, ...rest } = props; + return ( + + {text} + + ); + } + + if ('description' in props) { + return ( + + {props.description} + + ); + } + + function isDisabled(): boolean { + if ('isDisabled' in props) { + const val = typeof props.isDisabled === 'function' ? props.isDisabled() : props.isDisabled; + return val || false; + } + return false; + } + + function getTooltip(): string { + if ('tooltip' in props) { + const val = typeof props.tooltip === 'function' ? props.tooltip() : props.tooltip; + return val || ''; + } + return ''; + } + + function handleClick(e: MouseEvent) { + if ('run' in props && !isDisabled()) props.run?.(e.currentTarget); + } + + let component; + switch (props.controlType) { + case 'icon': + component = ( + + ); + break; + + case 'link': + component = ( + + {upperFirst(props.label || props.id)} + + ); + break; + + default: + component = ( + <> + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + {upperFirst(props.label || props.id)} + + + ); + } + + const tooltip = getTooltip(); + if (tooltip) { + return {component}; + } + return component; +} diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_controls.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_controls.test.tsx new file mode 100644 index 00000000000..34419f6229a --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_controls.test.tsx @@ -0,0 +1,67 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { mountWithIntl } from '../../../../test_utils/public/enzyme_helpers'; +import { MountPointPortal } from '../../../opensearch_dashboards_react/public'; +import { TopNavControlData } from './top_nav_control_data'; +import { TopNavControls, TopNavControlsProps } from './top_nav_controls'; + +// Mock props for different scenarios +const controls: TopNavControlData[] = [ + { controlType: 'button', label: 'Button', run: jest.fn() }, + { controlType: 'link', label: 'Link', href: 'http://example.com' }, +]; + +describe('TopNavControls', () => { + it('renders null when controls is not provided', () => { + const props: TopNavControlsProps = {}; + const wrapper = mountWithIntl(); + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('renders null when controls is an empty array', () => { + const props: TopNavControlsProps = { controls: [] }; + const wrapper = mountWithIntl(); + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('renders TopNavControlItems when controls are provided', () => { + const props: TopNavControlsProps = { controls }; + const wrapper = mountWithIntl(); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders MountPointPortal when setMountPoint is provided', () => { + const setMountPoint = jest.fn(); + const props: TopNavControlsProps = { controls, setMountPoint }; + const wrapper = mountWithIntl(); + expect(wrapper.find(MountPointPortal)).toHaveLength(1); + }); +}); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_controls.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_controls.tsx new file mode 100644 index 00000000000..4b1731143b6 --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_controls.tsx @@ -0,0 +1,71 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { ReactElement } from 'react'; +import { EuiHeaderSectionItem } from '@elastic/eui'; + +import { MountPoint } from 'opensearch-dashboards/public'; +import { MountPointPortal } from '../../../opensearch_dashboards_react/public'; +import { TopNavControlItem } from './top_nav_control_item'; +import { TopNavControlData } from './top_nav_control_data'; + +export interface TopNavControlsProps { + controls?: TopNavControlData[]; + className?: string; + setMountPoint?: (menuMount: MountPoint | undefined) => void; +} + +export function TopNavControls(props: TopNavControlsProps): ReactElement | null { + const { controls } = props; + + if (!Array.isArray(controls) || controls.length === 0) { + return null; + } + + function renderItems(): ReactElement[] { + return controls!.map((menuItem: TopNavControlData, i: number) => { + return ( + + + + ); + }); + } + + function renderLayout() { + const { setMountPoint } = props; + + return setMountPoint ? ( + {renderItems()} + ) : null; + } + + return renderLayout(); +} diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx index 99644e49bde..6e9b4fc1810 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx @@ -32,9 +32,10 @@ import React from 'react'; import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { MountPoint } from 'opensearch-dashboards/public'; -import { TopNavMenu } from './top_nav_menu'; +import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { TopNavMenu, TopNavMenuItemRenderType } from './top_nav_menu'; import { TopNavMenuData } from './top_nav_menu_data'; -import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; +import { applicationServiceMock, uiSettingsServiceMock } from '../../../../core/public/mocks'; import * as testUtils from '../../../data_source_management/public/components/utils'; import { DataSourceSelectionService } from '../../../data_source_management/public/service/data_source_selection_service'; @@ -222,5 +223,105 @@ describe('TopNavMenu', () => { // menu is rendered outside of the component expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(0); }); + + it('mounts the data source menu with group actions enabled', async () => { + spyOn(testUtils, 'getApplication').and.returnValue(applicationServiceMock); + spyOn(testUtils, 'getUiSettings').and.returnValue( + uiSettingsServiceMock.createStartContract() + ); + spyOn(testUtils, 'getHideLocalCluster').and.returnValue(true); + spyOn(testUtils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); + + const component = mountWithIntl( + + ); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + expect(component.find('.osdTopNavMenuScreenTitle').exists()).toBeFalsy(); + expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(0); + }); + + it('mounts without data source menu with group actions enabled and showSearchBar in portal', async () => { + spyOn(testUtils, 'getApplication').and.returnValue(applicationServiceMock); + spyOn(testUtils, 'getUiSettings').and.returnValue( + uiSettingsServiceMock.createStartContract() + ); + spyOn(testUtils, 'getHideLocalCluster').and.returnValue(false); + spyOn(testUtils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); + + const component = mountWithIntl( + + ); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + + await (() => { + expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(1); + expect(component.find('.osdTopNavMenuScreenTitle').exists()).toBeTruthy(); + }); + }); + + it('mounts without data source menu with group actions enabled and showSearchBar in place', async () => { + spyOn(testUtils, 'getApplication').and.returnValue(applicationServiceMock); + spyOn(testUtils, 'getUiSettings').and.returnValue( + uiSettingsServiceMock.createStartContract() + ); + spyOn(testUtils, 'getHideLocalCluster').and.returnValue(false); + spyOn(testUtils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); + + const component = mountWithIntl( + + ); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + + await (() => { + expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(1); + expect(component.find('.osdTopNavMenuScreenTitle').exists()).toBeTruthy(); + expect(component.find('.globalDatePicker').exists()).toBeTruthy(); + }); + }); }); }); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index 7bce0e01470..e8e3489fe28 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -28,32 +28,39 @@ * under the License. */ -import React, { ReactElement } from 'react'; -import { EuiHeaderLinks } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiHeaderLinks, EuiText } from '@elastic/eui'; import classNames from 'classnames'; +import React, { ReactElement, useRef } from 'react'; import { MountPoint } from '../../../../core/public'; -import { MountPointPortal } from '../../../opensearch_dashboards_react/public'; import { - StatefulSearchBarProps, DataPublicPluginStart, SearchBarProps, + StatefulSearchBarProps, } from '../../../data/public'; +import { DataSourceMenuProps, createDataSourceMenu } from '../../../data_source_management/public'; +import { MountPointPortal } from '../../../opensearch_dashboards_react/public'; import { TopNavMenuData } from './top_nav_menu_data'; import { TopNavMenuItem } from './top_nav_menu_item'; -import { DataSourceMenuProps, createDataSourceMenu } from '../../../data_source_management/public'; -export type TopNavMenuProps = StatefulSearchBarProps & - Omit & { +export enum TopNavMenuItemRenderType { + IN_PORTAL = 'in_portal', + IN_PLACE = 'in_place', + OMITTED = 'omitted', +} + +export type TopNavMenuProps = Omit & + Omit & { config?: TopNavMenuData[]; dataSourceMenuConfig?: DataSourceMenuProps; - showSearchBar?: boolean; + showSearchBar?: boolean | TopNavMenuItemRenderType; showQueryBar?: boolean; showQueryInput?: boolean; - showDatePicker?: boolean; + showDatePicker?: boolean | TopNavMenuItemRenderType; showFilterBar?: boolean; showDataSourceMenu?: boolean; data?: DataPublicPluginStart; + groupActions?: boolean; className?: string; datePickerRef?: any; /** @@ -90,11 +97,16 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { const { config, showSearchBar, + showDatePicker, showDataSourceMenu, dataSourceMenuConfig, + groupActions, + screenTitle, ...searchBarProps } = props; + const datePickerRef = useRef(null); + if ( (!config || config.length === 0) && (!showSearchBar || !props.data) && @@ -103,11 +115,17 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { return null; } - function renderItems(): ReactElement[] | null { + function renderItems(): ReactElement | ReactElement[] | null { if (!config || config.length === 0) return null; - return config.map((menuItem: TopNavMenuData, i: number) => { + const renderedItems = config.map((menuItem: TopNavMenuData, i: number) => { return ; }); + + return groupActions ? ( +
{renderedItems}
+ ) : ( + renderedItems + ); } function renderMenu(className: string): ReactElement | null { @@ -127,17 +145,84 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { return ; } - function renderSearchBar(): ReactElement | null { + function renderSearchBar(overrides: Partial = {}): ReactElement | null { // Validate presence of all required fields if (!showSearchBar || !props.data) return null; const { SearchBar } = props.data.ui; - return ; + return ( + + ); } function renderLayout() { const { setMenuMountPoint } = props; const menuClassName = classNames('osdTopNavMenu', props.className); + if (setMenuMountPoint) { + if (groupActions) { + switch (showSearchBar) { + case TopNavMenuItemRenderType.IN_PORTAL: + return ( + <> + + + + {screenTitle} + + {renderMenu(menuClassName)} + {renderSearchBar()} + + + + ); + + case false: + case TopNavMenuItemRenderType.OMITTED: + return ( + <> + + {renderMenu(menuClassName)} + + + ); + + // Show the SearchBar in-place + default: + if (showDatePicker === TopNavMenuItemRenderType.IN_PORTAL) { + return ( + <> + + + + {screenTitle} + + {renderMenu(menuClassName)} + +
+ + + + {renderSearchBar({ datePickerRef })} + + ); + } + + return ( + <> + + {renderMenu(menuClassName)} + + {renderSearchBar()} + + ); + } + } + + // Legacy rendering behavior when setMenuMountPoint is set return ( <> @@ -146,14 +231,14 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { {renderSearchBar()} ); - } else { - return ( - <> - {renderMenu(menuClassName)} - {renderSearchBar()} - - ); } + + return ( + <> + {renderMenu(menuClassName)} + {renderSearchBar()} + + ); } return renderLayout(); @@ -167,4 +252,5 @@ TopNavMenu.defaultProps = { showFilterBar: true, showDataSourceMenu: false, screenTitle: '', + groupActions: false, }; diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx index c7a3220a896..6596be3949e 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx @@ -28,12 +28,15 @@ * under the License. */ -import { EuiButtonProps } from '@elastic/eui'; +import { EuiButtonProps, EuiButtonIconProps } from '@elastic/eui'; import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; export type TopNavMenuAction = (anchorElement: HTMLElement) => void; +export type TopNavMenuClickAction = (targetElement: HTMLElement) => void; +export type TopNavMenuSwitchAction = (targetElement: HTMLElement, checked: boolean) => void; -export interface TopNavMenuData { +// @deprecated +export interface TopNavMenuLegacyData { id?: string; label: string; run: TopNavMenuAction; @@ -50,6 +53,60 @@ export interface TopNavMenuData { type?: 'toggle' | 'button'; } -export interface RegisteredTopNavMenuData extends TopNavMenuData { - appName?: string; +type RequireAtLeastOne = Pick> & + { + [K in Keys]-?: Required> & Partial>>; + }[Keys]; + +interface TopNavMenuCommonData { + testId?: string; + className?: string; + disabled?: boolean | (() => boolean); + tooltip?: string | (() => string | undefined); } + +export type TopNavMenuButtonData = TopNavMenuCommonData & + RequireAtLeastOne< + { + label: string; + iconType?: EuiButtonProps['iconType']; + iconSide?: EuiButtonProps['iconSide']; + ariaLabel?: string; + isLoading?: boolean; + run?: TopNavMenuClickAction; + href?: string; + controlType: 'button'; + }, + 'href' | 'run' + >; + +export type TopNavMenuIconData = TopNavMenuCommonData & + RequireAtLeastOne< + { + iconType: EuiButtonIconProps['iconType']; + ariaLabel: string; + run?: TopNavMenuClickAction; + href?: string; + tooltip: string | (() => string | undefined); + controlType: 'icon'; + }, + 'href' | 'run' + >; + +export type TopNavMenuSwitchData = TopNavMenuCommonData & { + label: string; + ariaLabel?: string; + checked: boolean | (() => boolean); + run: TopNavMenuSwitchAction; + controlType: 'switch'; +}; + +export type TopNavMenuData = + | TopNavMenuLegacyData + | TopNavMenuButtonData + | TopNavMenuIconData + | TopNavMenuSwitchData; + +export type RegisteredTopNavMenuData = TopNavMenuData & { + appName?: string; +}; diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx index 7e759d2a6c0..7dba8069889 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx @@ -28,9 +28,15 @@ * under the License. */ +import { EuiButton, EuiButtonIcon, EuiSwitch, EuiToolTip } from '@elastic/eui'; import React from 'react'; import { TopNavMenuItem } from './top_nav_menu_item'; -import { TopNavMenuData } from './top_nav_menu_data'; +import { + TopNavMenuData, + TopNavMenuButtonData, + TopNavMenuIconData, + TopNavMenuSwitchData, +} from './top_nav_menu_data'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; describe('TopNavMenu', () => { @@ -138,4 +144,56 @@ describe('TopNavMenu', () => { run: jest.fn(), }); }); + + const defaultProps = { + label: 'Test Label', + run: jest.fn(), + testId: 'test-id', + }; + + it('Should render a button with tooltip', () => { + const props = { + ...defaultProps, + controlType: 'button', + tooltip: 'Test Tooltip', + } as TopNavMenuButtonData; + const wrapper = shallowWithIntl(); + expect(wrapper.find(EuiToolTip).length).toBe(1); + expect(wrapper.find(EuiButton).length).toBe(1); + }); + + it('Should render an icon button', () => { + const props = { + ...defaultProps, + controlType: 'icon', + iconType: 'alert', + tooltip: 'Test Tooltip', + ariaLabel: 'Test', + } as TopNavMenuIconData; + const wrapper = shallowWithIntl(); + expect(wrapper.find(EuiButtonIcon).length).toBe(1); + }); + + it('Should render a switch', () => { + const props = { ...defaultProps, controlType: 'switch', checked: true } as TopNavMenuSwitchData; + const wrapper = shallowWithIntl(); + expect(wrapper.find(EuiSwitch).length).toBe(1); + }); + + it('Should handles button click', () => { + const props = { ...defaultProps, controlType: 'button' } as TopNavMenuButtonData; + const wrapper = shallowWithIntl(); + wrapper.find(EuiButton).simulate('click', { currentTarget: {} }); + expect(props.run).toHaveBeenCalled(); + }); + + it('Should disable the button when disabled is true', () => { + const props = { + ...defaultProps, + controlType: 'button', + disabled: true, + } as TopNavMenuButtonData; + const wrapper = shallowWithIntl(); + expect(wrapper.find(EuiButton).props().isDisabled).toBe(true); + }); }); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx index 629ae019407..f594a092833 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx @@ -30,10 +30,25 @@ import { upperFirst, isFunction } from 'lodash'; import React, { MouseEvent } from 'react'; -import { EuiToolTip, EuiButton, EuiHeaderLink, EuiCompressedSwitch } from '@elastic/eui'; -import { TopNavMenuData } from './top_nav_menu_data'; +import classNames from 'classnames'; +import { + EuiToolTip, + EuiButton, + EuiHeaderLink, + EuiCompressedSwitch, + EuiButtonIcon, + EuiSwitch, + EuiSwitchEvent, +} from '@elastic/eui'; +import { + TopNavMenuClickAction, + TopNavMenuData, + TopNavMenuLegacyData, + TopNavMenuSwitchAction, + TopNavMenuSwitchData, +} from './top_nav_menu_data'; -export function TopNavMenuItem(props: TopNavMenuData) { +function TopNavMenuLegacyItem(props: TopNavMenuLegacyData) { function isDisabled(): boolean { const val = isFunction(props.disableButton) ? props.disableButton() : props.disableButton; return val!; @@ -91,6 +106,103 @@ export function TopNavMenuItem(props: TopNavMenuData) { return component; } +export function TopNavMenuItem(props: TopNavMenuData) { + if (!('controlType' in props)) return TopNavMenuLegacyItem(props); + + const { disabled, tooltip, run } = props as Exclude; + + const isDisabled = () => Boolean(typeof disabled === 'function' ? disabled() : disabled); + + const handleClick = (e: MouseEvent) => { + if (!isDisabled()) (run as TopNavMenuClickAction)?.(e.currentTarget); + }; + + const getComponent = (addTypeClassName: boolean = false) => { + const className = classNames(props.className, { + [`osdTopNavGroup--${props.controlType}`]: addTypeClassName, + 'osdTopNavGroup-isDisabled': isDisabled(), + }); + switch (props.controlType) { + case 'button': + return ( + <> + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + {props.label} + + + ); + + case 'icon': + return ( + + ); + + case 'switch': + const { checked } = props as TopNavMenuSwitchData; + + const isChecked = () => Boolean(typeof checked === 'function' ? checked() : checked); + + const handleSwitch = (e: EuiSwitchEvent) => { + if (!isDisabled()) (run as TopNavMenuSwitchAction)?.(e.currentTarget, e.target.checked); + }; + + return ( + + ); + } + }; + + const tooltipContent = typeof tooltip === 'function' ? tooltip() : tooltip; + + if (tooltipContent) { + const className = classNames(`osdTopNavGroup--${props.controlType}`, { + 'osdTopNavGroup-isDisabled': isDisabled(), + }); + return ( + + {getComponent()} + + ); + } + + return getComponent(true); +} + TopNavMenuItem.defaultProps = { disableButton: false, tooltip: '', diff --git a/src/plugins/navigation/public/types.ts b/src/plugins/navigation/public/types.ts index 89b0a3ca632..9e1c3ffc812 100644 --- a/src/plugins/navigation/public/types.ts +++ b/src/plugins/navigation/public/types.ts @@ -28,7 +28,11 @@ * under the License. */ -import { TopNavMenuProps, TopNavMenuExtensionsRegistrySetup } from './top_nav_menu'; +import { + TopNavMenuProps, + TopNavMenuExtensionsRegistrySetup, + TopNavControlsProps, +} from './top_nav_menu'; import { DataPublicPluginStart } from '../../data/public'; export interface NavigationPublicPluginSetup { @@ -38,6 +42,7 @@ export interface NavigationPublicPluginSetup { export interface NavigationPublicPluginStart { ui: { TopNavMenu: React.ComponentType; + HeaderControl: React.ComponentType; }; } diff --git a/src/plugins/region_map/public/components/__snapshots__/region_map_options.test.tsx.snap b/src/plugins/region_map/public/components/__snapshots__/region_map_options.test.tsx.snap index 447d5876fa1..8f16f7a650f 100644 --- a/src/plugins/region_map/public/components/__snapshots__/region_map_options.test.tsx.snap +++ b/src/plugins/region_map/public/components/__snapshots__/region_map_options.test.tsx.snap @@ -271,7 +271,7 @@ exports[`region_map_options renders the RegionMapOptions with custom option if c onMouseOver={[Function]} >