mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[Stateful sidenav] Feedback button (#195751)
This commit is contained in:
parent
5b43c1f0f0
commit
c1c70c73aa
18 changed files with 246 additions and 6 deletions
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -39,6 +39,7 @@ export const getServicesMock = (): NavigationServices => {
|
|||
activeNodes$: of(activeNodes),
|
||||
isSideNavCollapsed: false,
|
||||
eventTracker,
|
||||
isFeedbackBtnVisible$: of(false),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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 */}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue