mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
8941058f68
commit
6185b4033c
11 changed files with 147 additions and 28 deletions
|
@ -117,7 +117,7 @@ pageLoadAssetSize:
|
|||
securitySolution: 66738
|
||||
serverless: 16573
|
||||
serverlessObservability: 16582
|
||||
serverlessSearch: 20555
|
||||
serverlessSearch: 22555
|
||||
serverlessSecurity: 41807
|
||||
sessionView: 77750
|
||||
share: 71239
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -21,7 +21,6 @@ export interface NavigationModelDeps {
|
|||
* @public
|
||||
*/
|
||||
export enum Platform {
|
||||
Recents = 'recents',
|
||||
Analytics = 'analytics',
|
||||
MachineLearning = 'ml',
|
||||
DevTools = 'devTools',
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -27,4 +27,10 @@ export const getI18nStrings = () => ({
|
|||
defaultMessage: 'My deployments',
|
||||
}
|
||||
),
|
||||
recentlyAccessed: i18n.translate(
|
||||
'sharedUXPackages.chrome.sideNavigation.recentlyAccessed.title',
|
||||
{
|
||||
defaultMessage: 'Recent',
|
||||
}
|
||||
),
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}`} />;
|
||||
})}
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue