From c1c70c73aa02931f39045fdf2bc7938aea53c58d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Fri, 11 Oct 2024 15:29:39 +0100 Subject: [PATCH] [Stateful sidenav] Feedback button (#195751) --- .../src/chrome_service.test.tsx | 35 ++++++++++ .../src/chrome_service.tsx | 6 ++ .../src/ui/project/navigation.tsx | 6 ++ .../src/chrome_service.mock.ts | 2 + .../core-chrome-browser/src/contracts.ts | 11 ++++ .../chrome/navigation/__jest__/utils.tsx | 1 + .../chrome/navigation/mocks/storybook.ts | 3 +- .../chrome/navigation/src/services.tsx | 3 +- .../shared-ux/chrome/navigation/src/types.ts | 2 + .../src/ui/components/feedback_btn.tsx | 64 +++++++++++++++++++ .../navigation/src/ui/components/index.ts | 2 + .../chrome/navigation/src/ui/navigation.tsx | 16 ++++- src/plugins/navigation/public/plugin.test.ts | 54 ++++++++++++++++ src/plugins/navigation/public/plugin.tsx | 12 +++- .../page_objects/solution_navigation.ts | 11 ++++ .../tests/observability_sidenav.ts | 8 +++ .../tests/search_sidenav.ts | 8 +++ .../tests/security_sidenav.ts | 8 +++ 18 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 packages/shared-ux/chrome/navigation/src/ui/components/feedback_btn.tsx diff --git a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.tsx b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.tsx index dc1ad36f01c5e..7d7122e7387ce 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.test.tsx @@ -588,6 +588,41 @@ describe('start', () => { expect(updatedIsCollapsed).toBe(!isCollapsed); }); }); + + describe('getIsFeedbackBtnVisible$', () => { + it('should return false by default', async () => { + const { chrome, service } = await start(); + const isCollapsed = await firstValueFrom(chrome.sideNav.getIsFeedbackBtnVisible$()); + service.stop(); + expect(isCollapsed).toBe(false); + }); + + it('should return "false" when the sidenav is collapsed', async () => { + const { chrome, service } = await start(); + + const isFeedbackBtnVisible$ = chrome.sideNav.getIsFeedbackBtnVisible$(); + chrome.sideNav.setIsFeedbackBtnVisible(true); // Mark it as visible + chrome.sideNav.setIsCollapsed(true); // But the sidenav is collapsed + + const isFeedbackBtnVisible = await firstValueFrom(isFeedbackBtnVisible$); + service.stop(); + expect(isFeedbackBtnVisible).toBe(false); + }); + }); + + describe('setIsFeedbackBtnVisible', () => { + it('should update the isFeedbackBtnVisible$ observable', async () => { + const { chrome, service } = await start(); + const isFeedbackBtnVisible$ = chrome.sideNav.getIsFeedbackBtnVisible$(); + const isFeedbackBtnVisible = await firstValueFrom(isFeedbackBtnVisible$); + + chrome.sideNav.setIsFeedbackBtnVisible(!isFeedbackBtnVisible); + + const updatedIsFeedbackBtnVisible = await firstValueFrom(isFeedbackBtnVisible$); + service.stop(); + expect(updatedIsFeedbackBtnVisible).toBe(!isFeedbackBtnVisible); + }); + }); }); }); diff --git a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx index 3444b3edd8078..8ae1b7fb61cc5 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/chrome_service.tsx @@ -90,6 +90,7 @@ export class ChromeService { private readonly isSideNavCollapsed$ = new BehaviorSubject( localStorage.getItem(IS_SIDENAV_COLLAPSED_KEY) === 'true' ); + private readonly isFeedbackBtnVisible$ = new BehaviorSubject(false); private logger: Logger; private isServerless = false; @@ -570,6 +571,11 @@ export class ChromeService { setIsCollapsed: setIsSideNavCollapsed, getPanelSelectedNode$: projectNavigation.getPanelSelectedNode$.bind(projectNavigation), setPanelSelectedNode: projectNavigation.setPanelSelectedNode.bind(projectNavigation), + getIsFeedbackBtnVisible$: () => + combineLatest([this.isFeedbackBtnVisible$, this.isSideNavCollapsed$]).pipe( + map(([isVisible, isCollapsed]) => isVisible && !isCollapsed) + ), + setIsFeedbackBtnVisible: (isVisible: boolean) => this.isFeedbackBtnVisible$.next(isVisible), }, getActiveSolutionNavId$: () => projectNavigation.getActiveSolutionNavId$(), project: { diff --git a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/navigation.tsx b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/navigation.tsx index a574d8061d3e2..fb1051947137d 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/ui/project/navigation.tsx +++ b/packages/core/chrome/core-chrome-browser-internal/src/ui/project/navigation.tsx @@ -11,6 +11,7 @@ import React, { FC, PropsWithChildren } from 'react'; import { EuiCollapsibleNavBeta } from '@elastic/eui'; import useObservable from 'react-use/lib/useObservable'; import type { Observable } from 'rxjs'; +import { css } from '@emotion/css'; interface Props { toggleSideNav: (isVisible: boolean) => void; @@ -35,6 +36,11 @@ export const ProjectNavigation: FC> = ({ overflow: 'visible', clipPath: `polygon(0 0, calc(var(--euiCollapsibleNavOffset) + ${PANEL_WIDTH}px) 0, calc(var(--euiCollapsibleNavOffset) + ${PANEL_WIDTH}px) 100%, 0 100%)`, }} + className={css` + .euiFlyoutBody__overflowContent { + height: 100%; + } + `} > {children} diff --git a/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts b/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts index ff39293738ff0..144002ee94547 100644 --- a/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts +++ b/packages/core/chrome/core-chrome-browser-mocks/src/chrome_service.mock.ts @@ -56,6 +56,8 @@ const createStartContractMock = () => { setIsCollapsed: jest.fn(), getPanelSelectedNode$: jest.fn(), setPanelSelectedNode: jest.fn(), + getIsFeedbackBtnVisible$: jest.fn(), + setIsFeedbackBtnVisible: jest.fn(), }, getBreadcrumbsAppendExtension$: jest.fn(), setBreadcrumbsAppendExtension: jest.fn(), diff --git a/packages/core/chrome/core-chrome-browser/src/contracts.ts b/packages/core/chrome/core-chrome-browser/src/contracts.ts index 9fe54c971bbc7..aa2e4cf23ebbb 100644 --- a/packages/core/chrome/core-chrome-browser/src/contracts.ts +++ b/packages/core/chrome/core-chrome-browser/src/contracts.ts @@ -199,6 +199,17 @@ export interface ChromeStart { * will be closed. */ setPanelSelectedNode(node: string | PanelSelectedNode | null): void; + + /** + * Get an observable of the visibility state of the feedback button in the side nav. + */ + getIsFeedbackBtnVisible$: () => Observable; + + /** + * Set the visibility state of the feedback button in the side nav. + * @param isVisible The visibility state of the feedback button in the side nav. + */ + setIsFeedbackBtnVisible: (isVisible: boolean) => void; }; /** diff --git a/packages/shared-ux/chrome/navigation/__jest__/utils.tsx b/packages/shared-ux/chrome/navigation/__jest__/utils.tsx index ae212ee716214..21ac68ae06c0f 100644 --- a/packages/shared-ux/chrome/navigation/__jest__/utils.tsx +++ b/packages/shared-ux/chrome/navigation/__jest__/utils.tsx @@ -39,6 +39,7 @@ export const getServicesMock = (): NavigationServices => { activeNodes$: of(activeNodes), isSideNavCollapsed: false, eventTracker, + isFeedbackBtnVisible$: of(false), }; }; diff --git a/packages/shared-ux/chrome/navigation/mocks/storybook.ts b/packages/shared-ux/chrome/navigation/mocks/storybook.ts index 93255474d1ecc..6ae9fd24ef8f6 100644 --- a/packages/shared-ux/chrome/navigation/mocks/storybook.ts +++ b/packages/shared-ux/chrome/navigation/mocks/storybook.ts @@ -9,7 +9,7 @@ import { AbstractStorybookMock } from '@kbn/shared-ux-storybook-mock'; import { action } from '@storybook/addon-actions'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import { EventTracker } from '../src/analytics'; import { NavigationServices } from '../src/types'; @@ -43,6 +43,7 @@ export class StorybookMock extends AbstractStorybookMock<{}, NavigationServices> activeNodes$: params.activeNodes$ ?? new BehaviorSubject([]), isSideNavCollapsed: true, eventTracker: new EventTracker({ reportEvent: action('Report event') }), + isFeedbackBtnVisible$: of(false), }; } diff --git a/packages/shared-ux/chrome/navigation/src/services.tsx b/packages/shared-ux/chrome/navigation/src/services.tsx index 26a8b61478ecb..8f9a1915e3c4d 100644 --- a/packages/shared-ux/chrome/navigation/src/services.tsx +++ b/packages/shared-ux/chrome/navigation/src/services.tsx @@ -50,6 +50,7 @@ export const NavigationKibanaProvider: FC void; + isFeedbackBtnVisible$: Observable; } /** @@ -60,6 +61,7 @@ export interface NavigationKibanaDependencies { getIsCollapsed$: () => Observable; getPanelSelectedNode$: () => Observable; setPanelSelectedNode(node: string | PanelSelectedNode | null): void; + getIsFeedbackBtnVisible$: () => Observable; }; }; http: { diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/feedback_btn.tsx b/packages/shared-ux/chrome/navigation/src/ui/components/feedback_btn.tsx new file mode 100644 index 0000000000000..3cc2fca2d8f81 --- /dev/null +++ b/packages/shared-ux/chrome/navigation/src/ui/components/feedback_btn.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { EuiButton, EuiCallOut, useEuiTheme, EuiText, EuiSpacer } from '@elastic/eui'; +import React, { FC, useState } from 'react'; +import { i18n } from '@kbn/i18n'; + +const feedbackUrl = 'https://ela.st/nav-feedback'; +const FEEDBACK_BTN_KEY = 'core.chrome.sideNav.feedbackBtn'; + +export const FeedbackBtn: FC = () => { + const { euiTheme } = useEuiTheme(); + const [showCallOut, setShowCallOut] = useState( + sessionStorage.getItem(FEEDBACK_BTN_KEY) !== 'hidden' + ); + + const onDismiss = () => { + setShowCallOut(false); + sessionStorage.setItem(FEEDBACK_BTN_KEY, 'hidden'); + }; + + const onClick = () => { + window.open(feedbackUrl, '_blank'); + onDismiss(); + }; + + if (!showCallOut) return null; + + return ( + + + {i18n.translate('sharedUXPackages.chrome.sideNavigation.feedbackCallout.title', { + defaultMessage: `How's the navigation working for you? Missing anything?`, + })} + + + + {i18n.translate('sharedUXPackages.chrome.sideNavigation.feedbackCallout.btn', { + defaultMessage: 'Let us know', + })} + + + ); +}; diff --git a/packages/shared-ux/chrome/navigation/src/ui/components/index.ts b/packages/shared-ux/chrome/navigation/src/ui/components/index.ts index 0751435094fea..d53b812e9d7a2 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/components/index.ts +++ b/packages/shared-ux/chrome/navigation/src/ui/components/index.ts @@ -13,6 +13,8 @@ export { NavigationPanel, PanelProvider } from './panel'; export type { Props as RecentlyAccessedProps } from './recently_accessed'; +export { FeedbackBtn } from './feedback_btn'; + export type { PanelContent, PanelComponentProps, diff --git a/packages/shared-ux/chrome/navigation/src/ui/navigation.tsx b/packages/shared-ux/chrome/navigation/src/ui/navigation.tsx index 3dacd01f8465f..688ee1e709e15 100644 --- a/packages/shared-ux/chrome/navigation/src/ui/navigation.tsx +++ b/packages/shared-ux/chrome/navigation/src/ui/navigation.tsx @@ -16,12 +16,13 @@ import type { NavigationTreeDefinitionUI, } from '@kbn/core-chrome-browser'; import type { Observable } from 'rxjs'; -import { EuiCollapsibleNavBeta } from '@elastic/eui'; +import { EuiCollapsibleNavBeta, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { RecentlyAccessed, NavigationPanel, PanelProvider, type PanelContentProvider, + FeedbackBtn, } from './components'; import { useNavigation as useNavigationService } from '../services'; import { NavigationSectionUI } from './components/navigation_section_ui'; @@ -47,10 +48,12 @@ export interface Props { } const NavigationComp: FC = ({ navigationTree$, dataTestSubj, panelContentProvider }) => { - const { activeNodes$, selectedPanelNode, setSelectedPanelNode } = useNavigationService(); + const { activeNodes$, selectedPanelNode, setSelectedPanelNode, isFeedbackBtnVisible$ } = + useNavigationService(); const activeNodes = useObservable(activeNodes$, []); const navigationTree = useObservable(navigationTree$, { body: [] }); + const isFeedbackBtnVisible = useObservable(isFeedbackBtnVisible$, false); const contextValue = useMemo( () => ({ @@ -88,7 +91,14 @@ const NavigationComp: FC = ({ navigationTree$, dataTestSubj, panelContent {/* Main navigation content */} - {renderNodes(navigationTree.body)} + + {renderNodes(navigationTree.body)} + {isFeedbackBtnVisible && ( + + + + )} + {/* Footer */} diff --git a/src/plugins/navigation/public/plugin.test.ts b/src/plugins/navigation/public/plugin.test.ts index a88b51abba665..d05cf756f7178 100644 --- a/src/plugins/navigation/public/plugin.test.ts +++ b/src/plugins/navigation/public/plugin.test.ts @@ -179,6 +179,60 @@ describe('Navigation Plugin', () => { }); }); + describe('set feedback button visibility', () => { + it('should set the feedback button visibility to "true" when space solution is a known solution', async () => { + const { plugin, coreStart, unifiedSearch, cloud, spaces } = setup(); + + for (const solution of ['es', 'oblt', 'security']) { + spaces.getActiveSpace$ = jest + .fn() + .mockReturnValue(of({ solution } as Pick)); + plugin.start(coreStart, { unifiedSearch, cloud, spaces }); + await new Promise((resolve) => setTimeout(resolve)); + expect(coreStart.chrome.sideNav.setIsFeedbackBtnVisible).toHaveBeenCalledWith(true); + coreStart.chrome.sideNav.setIsFeedbackBtnVisible.mockReset(); + } + }); + + it('should set the feedback button visibility to "false" for deployment in trial', async () => { + const { plugin, coreStart, unifiedSearch, cloud: cloudStart, spaces } = setup(); + const coreSetup = coreMock.createSetup(); + const cloudSetup = cloudMock.createSetup(); + cloudSetup.trialEndDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); // 30 days from now + plugin.setup(coreSetup, { cloud: cloudSetup }); + + for (const solution of ['es', 'oblt', 'security']) { + spaces.getActiveSpace$ = jest + .fn() + .mockReturnValue(of({ solution } as Pick)); + plugin.start(coreStart, { unifiedSearch, cloud: cloudStart, spaces }); + await new Promise((resolve) => setTimeout(resolve)); + expect(coreStart.chrome.sideNav.setIsFeedbackBtnVisible).toHaveBeenCalledWith(false); + coreStart.chrome.sideNav.setIsFeedbackBtnVisible.mockReset(); + } + }); + + it('should not set the feedback button visibility for classic or unknown solution', async () => { + const { plugin, coreStart, unifiedSearch, cloud, spaces } = setup(); + + for (const solution of ['classic', 'unknown', undefined]) { + spaces.getActiveSpace$ = jest.fn().mockReturnValue(of({ solution })); + plugin.start(coreStart, { unifiedSearch, cloud, spaces }); + await new Promise((resolve) => setTimeout(resolve)); + expect(coreStart.chrome.sideNav.setIsFeedbackBtnVisible).not.toHaveBeenCalled(); + coreStart.chrome.sideNav.setIsFeedbackBtnVisible.mockReset(); + } + }); + + it('should not set the feedback button visibility when on serverless', async () => { + const { plugin, coreStart, unifiedSearch, cloud } = setup({ buildFlavor: 'serverless' }); + + plugin.start(coreStart, { unifiedSearch, cloud }); + await new Promise((resolve) => setTimeout(resolve)); + expect(coreStart.chrome.sideNav.setIsFeedbackBtnVisible).not.toHaveBeenCalled(); + }); + }); + describe('isSolutionNavEnabled$', () => { it('should be off if spaces plugin not available', async () => { const { plugin, coreStart, unifiedSearch } = setup(); diff --git a/src/plugins/navigation/public/plugin.tsx b/src/plugins/navigation/public/plugin.tsx index f382b80221642..9f41405a8438b 100644 --- a/src/plugins/navigation/public/plugin.tsx +++ b/src/plugins/navigation/public/plugin.tsx @@ -48,12 +48,18 @@ export class NavigationPublicPlugin private coreStart?: CoreStart; private depsStart?: NavigationPublicStartDependencies; private isSolutionNavEnabled = false; + private isCloudTrialUser = false; constructor(private initializerContext: PluginInitializerContext) {} - public setup(core: CoreSetup): NavigationPublicSetup { + public setup(core: CoreSetup, deps: NavigationPublicSetupDependencies): NavigationPublicSetup { registerNavigationEventTypes(core); + const cloudTrialEndDate = deps.cloud?.trialEndDate; + if (cloudTrialEndDate) { + this.isCloudTrialUser = cloudTrialEndDate.getTime() > Date.now(); + } + return { registerMenuItem: this.topNavMenuExtensionsRegistry.register.bind( this.topNavMenuExtensionsRegistry @@ -183,6 +189,10 @@ export class NavigationPublicPlugin // On serverless the chrome style is already set by the serverless plugin if (!isServerless) { chrome.setChromeStyle(isProjectNav ? 'project' : 'classic'); + + if (isProjectNav) { + chrome.sideNav.setIsFeedbackBtnVisible(!this.isCloudTrialUser); + } } if (isProjectNav) { diff --git a/test/functional/page_objects/solution_navigation.ts b/test/functional/page_objects/solution_navigation.ts index 79e13b0f24943..c4db6cd76e985 100644 --- a/test/functional/page_objects/solution_navigation.ts +++ b/test/functional/page_objects/solution_navigation.ts @@ -265,6 +265,17 @@ export function SolutionNavigationProvider(ctx: Pick euiDismissCalloutButton'); + }, + }, }, breadcrumbs: { async expectExists() { diff --git a/x-pack/test/functional_solution_sidenav/tests/observability_sidenav.ts b/x-pack/test/functional_solution_sidenav/tests/observability_sidenav.ts index 87daa58fc2681..f2712fd6cf5e7 100644 --- a/x-pack/test/functional_solution_sidenav/tests/observability_sidenav.ts +++ b/x-pack/test/functional_solution_sidenav/tests/observability_sidenav.ts @@ -95,6 +95,14 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await expectNoPageReload(); }); + + it('renders a feedback callout', async () => { + await solutionNavigation.sidenav.feedbackCallout.expectExists(); + await solutionNavigation.sidenav.feedbackCallout.dismiss(); + await solutionNavigation.sidenav.feedbackCallout.expectMissing(); + await browser.refresh(); + await solutionNavigation.sidenav.feedbackCallout.expectMissing(); + }); }); }); } diff --git a/x-pack/test/functional_solution_sidenav/tests/search_sidenav.ts b/x-pack/test/functional_solution_sidenav/tests/search_sidenav.ts index bf1dfe993e1ae..eb69631b09b0e 100644 --- a/x-pack/test/functional_solution_sidenav/tests/search_sidenav.ts +++ b/x-pack/test/functional_solution_sidenav/tests/search_sidenav.ts @@ -77,6 +77,14 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await expectNoPageReload(); }); + + it('renders a feedback callout', async () => { + await solutionNavigation.sidenav.feedbackCallout.expectExists(); + await solutionNavigation.sidenav.feedbackCallout.dismiss(); + await solutionNavigation.sidenav.feedbackCallout.expectMissing(); + await browser.refresh(); + await solutionNavigation.sidenav.feedbackCallout.expectMissing(); + }); }); }); } diff --git a/x-pack/test/functional_solution_sidenav/tests/security_sidenav.ts b/x-pack/test/functional_solution_sidenav/tests/security_sidenav.ts index 153c809ff715b..12ad88677b4ff 100644 --- a/x-pack/test/functional_solution_sidenav/tests/security_sidenav.ts +++ b/x-pack/test/functional_solution_sidenav/tests/security_sidenav.ts @@ -69,6 +69,14 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await expectNoPageReload(); }); + + it('renders a feedback callout', async () => { + await solutionNavigation.sidenav.feedbackCallout.expectExists(); + await solutionNavigation.sidenav.feedbackCallout.dismiss(); + await solutionNavigation.sidenav.feedbackCallout.expectMissing(); + await browser.refresh(); + await solutionNavigation.sidenav.feedbackCallout.expectMissing(); + }); }); }); }