[Stateful sidenav] Feedback button (#195751)

This commit is contained in:
Sébastien Loix 2024-10-11 15:29:39 +01:00 committed by GitHub
parent 5b43c1f0f0
commit c1c70c73aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 246 additions and 6 deletions

View file

@ -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);
});
});
});
});

View file

@ -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: {

View file

@ -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<PropsWithChildren<Props>> = ({
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}
</EuiCollapsibleNavBeta>

View file

@ -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(),

View file

@ -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<boolean>;
/**
* 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;
};
/**

View file

@ -39,6 +39,7 @@ export const getServicesMock = (): NavigationServices => {
activeNodes$: of(activeNodes),
isSideNavCollapsed: false,
eventTracker,
isFeedbackBtnVisible$: of(false),
};
};

View file

@ -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),
};
}

View file

@ -50,6 +50,7 @@ export const NavigationKibanaProvider: FC<PropsWithChildren<NavigationKibanaDepe
eventTracker: new EventTracker({ reportEvent: analytics.reportEvent }),
selectedPanelNode,
setSelectedPanelNode: chrome.sideNav.setPanelSelectedNode,
isFeedbackBtnVisible$: chrome.sideNav.getIsFeedbackBtnVisible$(),
}),
[
activeNodes$,
@ -59,7 +60,7 @@ export const NavigationKibanaProvider: FC<PropsWithChildren<NavigationKibanaDepe
isSideNavCollapsed,
navigateToUrl,
selectedPanelNode,
chrome.sideNav.setPanelSelectedNode,
chrome.sideNav,
]
);

View file

@ -41,6 +41,7 @@ export interface NavigationServices {
eventTracker: EventTracker;
selectedPanelNode?: PanelSelectedNode | null;
setSelectedPanelNode?: (node: PanelSelectedNode | null) => void;
isFeedbackBtnVisible$: Observable<boolean>;
}
/**
@ -60,6 +61,7 @@ export interface NavigationKibanaDependencies {
getIsCollapsed$: () => Observable<boolean>;
getPanelSelectedNode$: () => Observable<PanelSelectedNode | null>;
setPanelSelectedNode(node: string | PanelSelectedNode | null): void;
getIsFeedbackBtnVisible$: () => Observable<boolean>;
};
};
http: {

View file

@ -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 (
<EuiCallOut
color="warning"
css={{
margin: `0 ${euiTheme.size.m} ${euiTheme.size.m} ${euiTheme.size.m}`,
}}
onDismiss={onDismiss}
data-test-subj="sideNavfeedbackCallout"
>
<EuiText size="s" color="dimgrey">
{i18n.translate('sharedUXPackages.chrome.sideNavigation.feedbackCallout.title', {
defaultMessage: `How's the navigation working for you? Missing anything?`,
})}
</EuiText>
<EuiSpacer />
<EuiButton
onClick={onClick}
color="warning"
iconType="popout"
iconSide="right"
size="s"
fullWidth
>
{i18n.translate('sharedUXPackages.chrome.sideNavigation.feedbackCallout.btn', {
defaultMessage: 'Let us know',
})}
</EuiButton>
</EuiCallOut>
);
};

View file

@ -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,

View file

@ -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<Props> = ({ 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<Context>(
() => ({
@ -88,7 +91,14 @@ const NavigationComp: FC<Props> = ({ navigationTree$, dataTestSubj, panelContent
<NavigationContext.Provider value={contextValue}>
{/* Main navigation content */}
<EuiCollapsibleNavBeta.Body data-test-subj={dataTestSubj}>
{renderNodes(navigationTree.body)}
<EuiFlexGroup direction="column" justifyContent="spaceBetween" css={{ height: '100%' }}>
<EuiFlexItem>{renderNodes(navigationTree.body)}</EuiFlexItem>
{isFeedbackBtnVisible && (
<EuiFlexItem grow={false}>
<FeedbackBtn />
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiCollapsibleNavBeta.Body>
{/* Footer */}

View file

@ -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<Space, 'solution'>));
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<Space, 'solution'>));
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();

View file

@ -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) {

View file

@ -265,6 +265,17 @@ export function SolutionNavigationProvider(ctx: Pick<FtrProviderContext, 'getSer
await collapseNavBtn.click();
}
},
feedbackCallout: {
async expectExists() {
await testSubjects.existOrFail('sideNavfeedbackCallout', { timeout: TIMEOUT_CHECK });
},
async expectMissing() {
await testSubjects.missingOrFail('sideNavfeedbackCallout', { timeout: TIMEOUT_CHECK });
},
async dismiss() {
await testSubjects.click('sideNavfeedbackCallout > euiDismissCalloutButton');
},
},
},
breadcrumbs: {
async expectExists() {

View file

@ -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();
});
});
});
}

View file

@ -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();
});
});
});
}

View file

@ -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();
});
});
});
}