Force last breadcrumb to be inactive (#166785)

**Related to:** https://github.com/elastic/kibana/issues/161540, https://github.com/elastic/kibana/issues/161539

## Summary

Always force the last breadcrumb to be inactive.

## Details

Usual UX expects the last breadcrumb to be inactive as it represents the current page. The same can be seen from EUI [examples](https://eui.elastic.co/#/navigation/breadcrumbs). It turns out Serverless Security Solution plugin does't remove `href` and `onClick` fields from the last breadcrumb and passes it to `chrome.setBreadcrumbs()` or `serverless.setBreadcrumbs()` which renders the last breadcrumb as active but clicking on it only refreshes the page. ESS Security Solution on the other hand processes breadcrumbs currently. The same behavior may be the case for the other plungs as well.

As it's much simpler to strip off undesired fields at one place instead of processing them in plugins it's done in `packages/core/chrome/core-chrome-browser-internal/src/ui/header/header_breadcrumbs.tsx`. Security Solution codebase has been updated accordingly.

A side effect of this PR is consistent ESS and Serverless breadcrumbs behavior and it will help to reuse ESS tests for Serverless.
This commit is contained in:
Maxim Palenov 2023-09-21 07:44:49 -07:00 committed by GitHub
parent 22a9f4afb2
commit 522f577e4c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 43 additions and 193 deletions

View file

@ -1,127 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable 1`] = `
<li
class="euiBreadcrumb emotion-euiBreadcrumb-application-isTruncated"
data-test-subj="euiBreadcrumb"
>
<span
aria-current="page"
class="euiBreadcrumb__content emotion-euiBreadcrumb__content-application-isTruncatedLast-onlyChild-euiTextColor-default"
data-test-subj="breadcrumb first last"
title="First"
>
First
</span>
</li>
`;
exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable 2`] = `
<li
class="euiBreadcrumb emotion-euiBreadcrumb-application-isTruncated"
data-test-subj="euiBreadcrumb"
>
<span
aria-current="page"
class="euiBreadcrumb__content emotion-euiBreadcrumb__content-application-isTruncatedLast-onlyChild-euiTextColor-default"
data-test-subj="breadcrumb first last"
title="First"
>
First
</span>
</li>
`;
exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable 3`] = `
<li
class="euiBreadcrumb emotion-euiBreadcrumb-application-isTruncated"
data-test-subj="euiBreadcrumb"
>
<span
class="euiBreadcrumb__content emotion-euiBreadcrumb__content-application-isTruncated-firstChild-euiTextColor-subdued"
data-test-subj="breadcrumb first"
title="First"
>
First
</span>
</li>
`;
exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable 4`] = `
<li
class="euiBreadcrumb emotion-euiBreadcrumb-application-isTruncated"
data-test-subj="euiBreadcrumb"
>
<span
class="euiBreadcrumb__content emotion-euiBreadcrumb__content-application-isTruncated-firstChild-euiTextColor-subdued"
data-test-subj="breadcrumb first"
title="First"
>
First
</span>
</li>
`;
exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable 5`] = `
<li
class="euiBreadcrumb emotion-euiBreadcrumb-application-isTruncated"
data-test-subj="euiBreadcrumb"
>
<span
aria-current="page"
class="euiBreadcrumb__content emotion-euiBreadcrumb__content-application-isTruncatedLast-lastChild-euiTextColor-default"
data-test-subj="breadcrumb last"
title="Second"
>
Second
</span>
</li>
`;
exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable 6`] = `
<li
class="euiBreadcrumb emotion-euiBreadcrumb-application-isTruncated"
data-test-subj="euiBreadcrumb"
>
<span
aria-current="page"
class="euiBreadcrumb__content emotion-euiBreadcrumb__content-application-isTruncatedLast-lastChild-euiTextColor-default"
data-test-subj="breadcrumb last"
title="Second"
>
Second
</span>
</li>
`;
exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable 7`] = `
<li
class="euiBreadcrumb emotion-euiBreadcrumb-application-isTruncated"
data-test-subj="euiBreadcrumb"
>
<span
aria-current="page"
class="euiBreadcrumb__content emotion-euiBreadcrumb__content-application-isTruncatedLast-onlyChild-euiTextColor-default"
data-test-subj="breadcrumb first"
title="First"
>
Kibana
</span>
</li>
`;
exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable 8`] = `
<li
class="euiBreadcrumb emotion-euiBreadcrumb-application-isTruncated"
data-test-subj="euiBreadcrumb"
>
<span
aria-current="page"
class="euiBreadcrumb__content emotion-euiBreadcrumb__content-application-isTruncatedLast-onlyChild-euiTextColor-default"
data-test-subj="breadcrumb first"
title="First"
>
Kibana
</span>
</li>
`;

View file

@ -6,24 +6,40 @@
* Side Public License, v 1.
*/
import { mount } from 'enzyme';
import '@testing-library/jest-dom';
import { act, render, screen } from '@testing-library/react';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { BehaviorSubject } from 'rxjs';
import { BehaviorSubject, of } from 'rxjs';
import { HeaderBreadcrumbs } from './header_breadcrumbs';
describe('HeaderBreadcrumbs', () => {
it('renders updates to the breadcrumbs$ observable', () => {
it('renders updates to the breadcrumbs$ observable', async () => {
const breadcrumbs$ = new BehaviorSubject([{ text: 'First' }]);
const wrapper = mount(<HeaderBreadcrumbs breadcrumbs$={breadcrumbs$} />);
wrapper.find('.euiBreadcrumb').forEach((el) => expect(el.render()).toMatchSnapshot());
render(<HeaderBreadcrumbs breadcrumbs$={breadcrumbs$} />);
expect(await screen.findByLabelText('Breadcrumbs')).toHaveTextContent('First');
act(() => breadcrumbs$.next([{ text: 'First' }, { text: 'Second' }]));
wrapper.update();
wrapper.find('.euiBreadcrumb').forEach((el) => expect(el.render()).toMatchSnapshot());
expect(await screen.findByLabelText('Breadcrumbs')).toHaveTextContent('FirstSecond');
act(() => breadcrumbs$.next([]));
wrapper.update();
wrapper.find('.euiBreadcrumb').forEach((el) => expect(el.render()).toMatchSnapshot());
expect(await screen.findByLabelText('Breadcrumbs')).toHaveTextContent('Kibana');
});
it('forces the last breadcrumb inactivity', async () => {
const breadcrumbs$ = of([
{ text: 'First' },
{ text: 'Last', href: '/something', onClick: jest.fn() },
]);
render(<HeaderBreadcrumbs breadcrumbs$={breadcrumbs$} />);
const lastBreadcrumb = await screen.findByTitle('Last');
expect(lastBreadcrumb).not.toHaveAttribute('href');
expect(lastBreadcrumb.tagName).not.toBe('a');
});
});

View file

@ -25,15 +25,21 @@ export function HeaderBreadcrumbs({ breadcrumbs$ }: Props) {
crumbs = [{ text: 'Kibana' }];
}
crumbs = crumbs.map((breadcrumb, i) => ({
...breadcrumb,
'data-test-subj': classNames(
'breadcrumb',
breadcrumb['data-test-subj'],
i === 0 && 'first',
i === breadcrumbs.length - 1 && 'last'
),
}));
crumbs = crumbs.map((breadcrumb, i) => {
const isLast = i === breadcrumbs.length - 1;
return {
...breadcrumb,
href: isLast ? undefined : breadcrumb.href,
onClick: isLast ? undefined : breadcrumb.onClick,
'data-test-subj': classNames(
'breadcrumb',
breadcrumb['data-test-subj'],
i === 0 && 'first',
isLast && 'last'
),
};
});
return <EuiHeaderBreadcrumbs breadcrumbs={crumbs} max={10} data-test-subj="breadcrumbs" />;
}

View file

@ -1,33 +0,0 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ChromeBreadcrumb } from '@kbn/core/public';
import { emptyLastBreadcrumbUrl } from './breadcrumbs';
describe('emptyLastBreadcrumbUrl', () => {
it('should empty the URL and onClick function of the last breadcrumb', () => {
const breadcrumbs: ChromeBreadcrumb[] = [
{ text: 'Home', href: '/home', onClick: () => {} },
{ text: 'Breadcrumb 1', href: '/bc1', onClick: () => {} },
{ text: 'Last Breadcrumbs', href: '/last_bc', onClick: () => {} },
];
const expectedBreadcrumbs = [
{ text: 'Home', href: '/home', onClick: breadcrumbs[0].onClick },
{ text: 'Breadcrumb 1', href: '/bc1', onClick: breadcrumbs[1].onClick },
{ text: 'Last Breadcrumbs', href: '', onClick: undefined },
];
expect(emptyLastBreadcrumbUrl(breadcrumbs)).toEqual(expectedBreadcrumbs);
});
it('should return the original breadcrumbs if the input is empty', () => {
const emptyBreadcrumbs: ChromeBreadcrumb[] = [];
expect(emptyLastBreadcrumbUrl(emptyBreadcrumbs)).toEqual(emptyBreadcrumbs);
});
});

View file

@ -5,23 +5,11 @@
* 2.0.
*/
import type { ChromeBreadcrumb } from '@kbn/core/public';
import type { Services } from '../common/services';
export const subscribeBreadcrumbs = (services: Services) => {
const { chrome, securitySolution } = services;
const { securitySolution, chrome } = services;
securitySolution.getBreadcrumbsNav$().subscribe((breadcrumbsNav) => {
const breadcrumbs = [...breadcrumbsNav.leading, ...breadcrumbsNav.trailing];
if (breadcrumbs.length > 0) {
chrome.setBreadcrumbs(emptyLastBreadcrumbUrl(breadcrumbs));
}
chrome.setBreadcrumbs([...breadcrumbsNav.leading, ...breadcrumbsNav.trailing]);
});
};
export const emptyLastBreadcrumbUrl = (breadcrumbs: ChromeBreadcrumb[]) => {
const lastBreadcrumb = breadcrumbs[breadcrumbs.length - 1];
if (lastBreadcrumb) {
return [...breadcrumbs.slice(0, -1), { ...lastBreadcrumb, href: '', onClick: undefined }];
}
return breadcrumbs;
};