[Serverless Projects] add recent items to the side navigation (#156729)

## Summary

Closes https://github.com/elastic/kibana/issues/154488

Technical document:
https://docs.google.com/document/d/1dK-VhH4xZA_EQXRzx9d5tv6xzEh3UhEK8F0NFpLh_sI/edit#

The purpose of this is to allow teams to test the UX of recent items.
See [comment from
below](https://github.com/elastic/kibana/pull/156729#issuecomment-1538729174):
> This can be seen as a temporary step that puts the recently accessed
feature in front of people, and it's beneficial as it could help us make
new decisions about the UX.

### Other changes
1. Use observable for loading count within the header component
2. Unwrap observables at the point where they are used, rather than in
NavigationService
 
### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Tim Sullivan 2023-05-08 10:01:56 -07:00 committed by GitHub
parent 8941058f68
commit 6185b4033c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 147 additions and 28 deletions

View file

@ -117,7 +117,7 @@ pageLoadAssetSize:
securitySolution: 66738
serverless: 16573
serverlessObservability: 16582
serverlessSearch: 20555
serverlessSearch: 22555
serverlessSecurity: 41807
sessionView: 77750
share: 71239

View file

@ -6,16 +6,19 @@
* Side Public License, v 1.
*/
import { BehaviorSubject } from 'rxjs';
import { NavigationServices, ChromeNavigationNodeViewModel } from '../../types';
export const getServicesMock = (): NavigationServices => {
const navigateToUrl = jest.fn().mockResolvedValue(undefined);
const basePath = { prepend: jest.fn((path: string) => `/base${path}`) };
const loadingCount = 0;
const loadingCount$ = new BehaviorSubject(0);
const recentlyAccessed$ = new BehaviorSubject([]);
return {
basePath,
loadingCount,
loadingCount$,
recentlyAccessed$,
navIsOpen: true,
navigateToUrl,
};

View file

@ -8,12 +8,19 @@
import { AbstractStorybookMock } from '@kbn/shared-ux-storybook-mock';
import { action } from '@storybook/addon-actions';
import { BehaviorSubject } from 'rxjs';
import { ChromeNavigationViewModel, NavigationServices } from '../../types';
type Arguments = ChromeNavigationViewModel & NavigationServices;
export type Params = Pick<
Arguments,
'activeNavItemId' | 'loadingCount' | 'navIsOpen' | 'platformConfig' | 'navigationTree'
| 'activeNavItemId'
| 'loadingCount$'
| 'navIsOpen'
| 'navigationTree'
| 'platformConfig'
| 'recentlyAccessed$'
| 'recentlyAccessedFilter'
>;
export class StorybookMock extends AbstractStorybookMock<
@ -27,17 +34,11 @@ export class StorybookMock extends AbstractStorybookMock<
control: 'boolean',
defaultValue: true,
},
loadingCount: {
control: 'number',
defaultValue: 0,
},
};
dependencies = [];
getServices(params: Params): NavigationServices {
const { navIsOpen } = params;
const navAction = action('Navigate to');
const navigateToUrl = (url: string) => {
navAction(url);
@ -48,7 +49,8 @@ export class StorybookMock extends AbstractStorybookMock<
...params,
basePath: { prepend: (suffix: string) => `/basepath${suffix}` },
navigateToUrl,
navIsOpen,
loadingCount$: params.loadingCount$ ?? new BehaviorSubject(0),
recentlyAccessed$: params.recentlyAccessed$ ?? new BehaviorSubject([]),
};
}
@ -57,6 +59,7 @@ export class StorybookMock extends AbstractStorybookMock<
...params,
homeHref: '#',
linkToCloud: 'projects',
recentlyAccessedFilter: params.recentlyAccessedFilter,
};
}
}

View file

@ -12,8 +12,8 @@ import type { ChromeNavigationNodeViewModel, PlatformSectionConfig } from '../..
* Navigation node parser. It filers out the nodes disabled through config and
* sets the `path` of each of the nodes.
*
* @param items Navigation nodes
* @param platformConfig Configuration with flags to disable nodes in the navigation tree
* @param navItems Navigation nodes
* @param platformSectionConfig Configuration with flags to disable nodes in the navigation tree
*
* @returns The navigation tree filtered
*/

View file

@ -21,7 +21,6 @@ export interface NavigationModelDeps {
* @public
*/
export enum Platform {
Recents = 'recents',
Analytics = 'analytics',
MachineLearning = 'ml',
DevTools = 'devTools',

View file

@ -7,7 +7,6 @@
*/
import React, { FC, useContext } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { NavigationKibanaDependencies, NavigationServices } from '../types';
const Context = React.createContext<NavigationServices | null>(null);
@ -27,15 +26,14 @@ export const NavigationKibanaProvider: FC<NavigationKibanaDependencies> = ({
...dependencies
}) => {
const { core } = dependencies;
const { http } = core;
const { chrome, http } = core;
const { basePath } = http;
const { navigateToUrl } = core.application;
const loadingCount = useObservable(http.getLoadingCount$(), 0);
const value: NavigationServices = {
basePath,
loadingCount,
loadingCount$: http.getLoadingCount$(),
recentlyAccessed$: chrome.recentlyAccessed.get$(),
navigateToUrl,
navIsOpen: true,
};

View file

@ -27,4 +27,10 @@ export const getI18nStrings = () => ({
defaultMessage: 'My deployments',
}
),
recentlyAccessed: i18n.translate(
'sharedUXPackages.chrome.sideNavigation.recentlyAccessed.title',
{
defaultMessage: 'Recent',
}
),
});

View file

@ -13,9 +13,10 @@ import {
EuiPopover,
EuiThemeProvider,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import React, { useCallback, useState } from 'react';
import { css } from '@emotion/react';
import { BehaviorSubject } from 'rxjs';
import { getSolutionPropertiesMock, NavigationStorybookMock } from '../../mocks';
import mdx from '../../README.mdx';
import { ChromeNavigationViewModel, NavigationServices } from '../../types';
@ -132,11 +133,25 @@ ReducedPlatformLinks.argTypes = storybookMock.getArgumentTypes();
export const WithRequestsLoading: ComponentStory<typeof Template> = Template.bind({});
WithRequestsLoading.args = {
activeNavItemId: 'example_project.root.get_started',
loadingCount: 1,
loadingCount$: new BehaviorSubject(1),
navigationTree: [getSolutionPropertiesMock()],
};
WithRequestsLoading.argTypes = storybookMock.getArgumentTypes();
export const WithRecentlyAccessed: ComponentStory<typeof Template> = Template.bind({});
WithRecentlyAccessed.args = {
activeNavItemId: 'example_project.root.get_started',
loadingCount$: new BehaviorSubject(0),
recentlyAccessed$: new BehaviorSubject([
{ label: 'This is an example', link: '/app/example/39859', id: '39850' },
{ label: 'This is not an example', link: '/app/non-example/39458', id: '39458' }, // NOTE: this will be filtered out
]),
recentlyAccessedFilter: (items) =>
items.filter((item) => item.link.indexOf('/app/example') === 0),
navigationTree: [getSolutionPropertiesMock()],
};
WithRecentlyAccessed.argTypes = storybookMock.getArgumentTypes();
export const CustomElements: ComponentStory<typeof Template> = Template.bind({});
CustomElements.args = {
activeNavItemId: 'example_project.custom',

View file

@ -8,8 +8,9 @@
import { render } from '@testing-library/react';
import React from 'react';
import { BehaviorSubject } from 'rxjs';
import { getServicesMock } from '../../mocks/src/jest';
import { PlatformConfigSet, ChromeNavigationNodeViewModel } from '../../types';
import { ChromeNavigationNodeViewModel, PlatformConfigSet } from '../../types';
import { Platform } from '../model';
import { NavigationProvider } from '../services';
import { Navigation } from './navigation';
@ -27,7 +28,7 @@ describe('<Navigation />', () => {
});
test('renders the header logo and top-level navigation buckets', async () => {
const { findByTestId, findByText } = render(
const { findByTestId, findByText, queryByTestId } = render(
<NavigationProvider {...services} navIsOpen={true}>
<Navigation
platformConfig={platformSections}
@ -45,6 +46,8 @@ describe('<Navigation />', () => {
expect(await findByTestId('nav-bucket-ml')).toBeVisible();
expect(await findByTestId('nav-bucket-devTools')).toBeVisible();
expect(await findByTestId('nav-bucket-management')).toBeVisible();
expect(queryByTestId('nav-bucket-recentlyAccessed')).not.toBeInTheDocument();
});
test('includes link to deployments', async () => {
@ -122,7 +125,7 @@ describe('<Navigation />', () => {
});
test('shows loading state', async () => {
services.loadingCount = 5;
services.loadingCount$ = new BehaviorSubject(5);
const { findByTestId } = render(
<NavigationProvider {...services} navIsOpen={true}>
@ -136,4 +139,43 @@ describe('<Navigation />', () => {
expect(await findByTestId('nav-header-loading-spinner')).toBeVisible();
});
describe('recent items', () => {
const recentlyAccessed = [
{ id: 'dashboard:234', label: 'Recently Accessed Test Item', link: '/app/dashboard/234' },
];
test('shows recent items', async () => {
services.recentlyAccessed$ = new BehaviorSubject(recentlyAccessed);
const { findByTestId } = render(
<NavigationProvider {...services} navIsOpen={true}>
<Navigation
platformConfig={platformSections}
navigationTree={solutions}
homeHref={homeHref}
/>
</NavigationProvider>
);
expect(await findByTestId('nav-bucket-recentlyAccessed')).toBeVisible();
});
test('shows no recent items container when items are filtered', async () => {
services.recentlyAccessed$ = new BehaviorSubject(recentlyAccessed);
const { queryByTestId } = render(
<NavigationProvider {...services} navIsOpen={true}>
<Navigation
platformConfig={platformSections}
navigationTree={solutions}
homeHref={homeHref}
recentlyAccessedFilter={() => []}
/>
</NavigationProvider>
);
expect(queryByTestId('nav-bucket-recentlyAccessed')).not.toBeInTheDocument();
});
});
});

View file

@ -13,16 +13,20 @@ import {
EuiHeaderLogo,
EuiLink,
EuiLoadingSpinner,
EuiSideNav,
EuiSideNavItemType,
EuiSpacer,
useEuiTheme,
} from '@elastic/eui';
import React from 'react';
import { getI18nStrings } from './i18n_strings';
import useObservable from 'react-use/lib/useObservable';
import type { ChromeNavigationViewModel } from '../../types';
import { NavigationModel } from '../model';
import { useNavigation } from '../services';
import { navigationStyles as styles } from '../styles';
import { ElasticMark } from './elastic_mark';
import './header_logo.scss';
import { getI18nStrings } from './i18n_strings';
import { NavigationBucket, type Props as NavigationBucketProps } from './navigation_bucket';
interface Props extends ChromeNavigationViewModel {
@ -38,8 +42,9 @@ export const Navigation = ({
homeHref,
linkToCloud,
activeNavItemId: activeNavItemIdProps,
...props
}: Props) => {
const { loadingCount, activeNavItemId, basePath, navIsOpen, navigateToUrl } = useNavigation();
const { activeNavItemId } = useNavigation();
const { euiTheme } = useEuiTheme();
const activeNav = activeNavItemId ?? activeNavItemIdProps;
@ -52,6 +57,8 @@ export const Navigation = ({
const strings = getI18nStrings();
const NavHeader = () => {
const { basePath, navIsOpen, navigateToUrl, loadingCount$ } = useNavigation();
const loadingCount = useObservable(loadingCount$, 0);
const homeUrl = basePath.prepend(homeHref);
const navigateHome = (event: React.MouseEvent) => {
event.preventDefault();
@ -111,6 +118,45 @@ export const Navigation = ({
}
};
const RecentlyAccessed = () => {
const { recentlyAccessed$ } = useNavigation();
const recentlyAccessed = useObservable(recentlyAccessed$, []);
// consumer may filter objects from recent that are not applicable to the project
let filteredRecent = recentlyAccessed;
if (props.recentlyAccessedFilter) {
filteredRecent = props.recentlyAccessedFilter(recentlyAccessed);
}
if (filteredRecent.length > 0) {
const navItems: Array<EuiSideNavItemType<unknown>> = [
{
name: '', // no list header title
id: 'recents_root',
items: filteredRecent.map(({ id, label, link }) => ({
id,
name: label,
href: link,
})),
},
];
return (
<EuiCollapsibleNavGroup
title={strings.recentlyAccessed}
iconType="clock"
isCollapsible={true}
initialIsOpen={true}
data-test-subj={`nav-bucket-recentlyAccessed`}
>
<EuiSideNav items={navItems} css={styles.euiSideNavItems} />
</EuiCollapsibleNavGroup>
);
}
return null;
};
// higher-order-component to keep the common props DRY
const NavigationBucketHoc = (outerProps: Omit<NavigationBucketProps, 'activeNavItemId'>) => (
<NavigationBucket {...outerProps} activeNavItemId={activeNav} />
@ -125,6 +171,8 @@ export const Navigation = ({
<LinkToCloud />
<RecentlyAccessed />
{solutions.map((navTree, idx) => {
return <NavigationBucketHoc navigationTree={navTree} key={`solution${idx}`} />;
})}

View file

@ -17,7 +17,8 @@ import type { BasePathService, NavigateToUrlFn, RecentItem } from './internal';
export interface NavigationServices {
activeNavItemId?: string;
basePath: BasePathService;
loadingCount: number;
loadingCount$: Observable<number>;
recentlyAccessed$: Observable<RecentItem[]>;
navIsOpen: boolean;
navigateToUrl: NavigateToUrlFn;
}
@ -111,6 +112,10 @@ export interface ChromeNavigation {
* above.
*/
platformConfig?: Partial<PlatformConfigSet>;
/**
* Filter function to allow consumer to remove items from the recently accessed section
*/
recentlyAccessedFilter?: (items: RecentItem[]) => RecentItem[];
}
/**
@ -119,7 +124,7 @@ export interface ChromeNavigation {
* @internal
*/
export interface ChromeNavigationViewModel
extends Pick<ChromeNavigation, 'linkToCloud' | 'platformConfig'> {
extends Pick<ChromeNavigation, 'linkToCloud' | 'platformConfig' | 'recentlyAccessedFilter'> {
/**
* Target for the logo icon
*/