Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Stateful sidenav] Feedback button #195751

Merged
merged 10 commits into from
Oct 11, 2024
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