Skip to content

Commit

Permalink
[Stateful sidenav] Feedback button (elastic#195751)
Browse files Browse the repository at this point in the history
  • Loading branch information
sebelga authored Oct 11, 2024
1 parent 5b43c1f commit c1c70c7
Show file tree
Hide file tree
Showing 18 changed files with 246 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
11 changes: 11 additions & 0 deletions packages/core/chrome/core-chrome-browser/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down
1 change: 1 addition & 0 deletions packages/shared-ux/chrome/navigation/__jest__/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const getServicesMock = (): NavigationServices => {
activeNodes$: of(activeNodes),
isSideNavCollapsed: false,
eventTracker,
isFeedbackBtnVisible$: of(false),
};
};

Expand Down
3 changes: 2 additions & 1 deletion packages/shared-ux/chrome/navigation/mocks/storybook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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

Expand Down
3 changes: 2 additions & 1 deletion packages/shared-ux/chrome/navigation/src/services.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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$,
Expand All @@ -59,7 +60,7 @@ export const NavigationKibanaProvider: FC<PropsWithChildren<NavigationKibanaDepe
isSideNavCollapsed,
navigateToUrl,
selectedPanelNode,
chrome.sideNav.setPanelSelectedNode,
chrome.sideNav,
]
);

Expand Down
2 changes: 2 additions & 0 deletions packages/shared-ux/chrome/navigation/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface NavigationServices {
eventTracker: EventTracker;
selectedPanelNode?: PanelSelectedNode | null;
setSelectedPanelNode?: (node: PanelSelectedNode | null) => void;
isFeedbackBtnVisible$: Observable<boolean>;
}

/**
Expand All @@ -60,6 +61,7 @@ export interface NavigationKibanaDependencies {
getIsCollapsed$: () => Observable<boolean>;
getPanelSelectedNode$: () => Observable<PanelSelectedNode | null>;
setPanelSelectedNode(node: string | PanelSelectedNode | null): void;
getIsFeedbackBtnVisible$: () => Observable<boolean>;
};
};
http: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 13 additions & 3 deletions packages/shared-ux/chrome/navigation/src/ui/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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>(
() => ({
Expand Down Expand Up @@ -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 */}
Expand Down
54 changes: 54 additions & 0 deletions src/plugins/navigation/public/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
12 changes: 11 additions & 1 deletion src/plugins/navigation/public/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 11 additions & 0 deletions test/functional/page_objects/solution_navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading

0 comments on commit c1c70c7

Please sign in to comment.