[Serverless] Make project header component handle chrome visibility changes (#162746)

## Summary
Closes https://github.com/elastic/kibana/issues/160834

This PR fixes a bug with the Dashboard app in serverless projects. The
Dashboard has a "Full screen" button that is intended to cause the
content area of the dashboard take up the entire viewport. To do this,
the dashboard app uses a chrome service to update an observable used in
the rendering of the header, which sets the layout to a "chromeless"
state. The bug is: in serverless, the project header must respect the
chromeless state.

### Testing

1. Run `yarn es snapshot` in one terminal and then `yarn serverless` in
another terminal.
2. Load sample data through the "Integrations" app, which can be found
in Global Search.
3. View a sample data dashboard, and use the `Full screen` button in the
app menu toolbar.

### Checklist

- [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

---------

Co-authored-by: Christiane (Tina) Heiligers <christiane.heiligers@elastic.co>
This commit is contained in:
Tim Sullivan 2023-08-02 14:08:30 -07:00 committed by GitHub
parent d8078b625d
commit 2c6fd26f5e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 119 additions and 105 deletions

View file

@ -73,8 +73,12 @@ const createHistoryMock = (): jest.Mocked<History> => {
};
};
const createInternalStartContractMock = (): jest.Mocked<InternalApplicationStart> => {
const currentAppId$ = new Subject<string | undefined>();
const createInternalStartContractMock = (
currentAppId?: string
): jest.Mocked<InternalApplicationStart> => {
const currentAppId$ = currentAppId
? new BehaviorSubject<string | undefined>(currentAppId)
: new Subject<string | undefined>();
return {
applications$: new BehaviorSubject<Map<string, PublicAppInfo>>(new Map()),

View file

@ -45,9 +45,9 @@ Object.defineProperty(window, 'localStorage', {
writable: true,
});
function defaultStartDeps(availableApps?: App[]) {
function defaultStartDeps(availableApps?: App[], currentAppId?: string) {
const deps = {
application: applicationServiceMock.createInternalStartContract(),
application: applicationServiceMock.createInternalStartContract(currentAppId),
docLinks: docLinksServiceMock.createStartContract(),
http: httpServiceMock.createStartContract(),
injectedMetadata: injectedMetadataServiceMock.createStartContract(),
@ -192,7 +192,7 @@ describe('start', () => {
expect(startDeps.notifications.toasts.addWarning).not.toBeCalled();
});
describe('getComponent', () => {
describe('getHeaderComponent', () => {
it('returns a renderable React component', async () => {
const { chrome } = await start();
@ -202,7 +202,9 @@ describe('start', () => {
});
it('renders the default project side navigation', async () => {
const { chrome } = await start();
const { chrome } = await start({
startDeps: defaultStartDeps([{ id: 'foo', title: 'Foo' } as App], 'foo'),
});
chrome.setChromeStyle('project');
@ -216,7 +218,9 @@ describe('start', () => {
});
it('renders the custom project side navigation', async () => {
const { chrome } = await start();
const { chrome } = await start({
startDeps: defaultStartDeps([{ id: 'foo', title: 'Foo' } as App], 'foo'),
});
const MyNav = function MyNav() {
return <div data-test-subj="customProjectSideNav">HELLO</div>;
@ -235,6 +239,17 @@ describe('start', () => {
const customProjectSideNav = findTestSubject(component, 'customProjectSideNav');
expect(customProjectSideNav.text()).toBe('HELLO');
});
it('renders chromeless header', async () => {
const { chrome } = await start({ startDeps: defaultStartDeps() });
chrome.setIsVisible(false);
const component = mount(chrome.getHeaderComponent());
const chromeless = findTestSubject(component, 'kibanaHeaderChromeless');
expect(chromeless.length).toBe(1);
});
});
describe('visibility', () => {

View file

@ -43,9 +43,10 @@ import { NavControlsService } from './nav_controls';
import { NavLinksService } from './nav_links';
import { ProjectNavigationService } from './project_navigation';
import { RecentlyAccessedService } from './recently_accessed';
import { Header, ProjectHeader, ProjectSideNavigation } from './ui';
import { Header, LoadingIndicator, ProjectHeader, ProjectSideNavigation } from './ui';
import { registerAnalyticsContextProvider } from './register_analytics_context_provider';
import type { InternalChromeStart } from './types';
import { HeaderTopBanner } from './ui/header/header_top_banner';
const IS_LOCKED_KEY = 'core.chrome.isLocked';
const SNAPSHOT_REGEX = /-snapshot/i;
@ -265,89 +266,102 @@ export class ChromeService {
}
const getHeaderComponent = () => {
if (chromeStyle$.getValue() === 'project') {
const projectNavigationComponent$ = projectNavigation.getProjectSideNavComponent$();
const projectBreadcrumbs$ = projectNavigation
.getProjectBreadcrumbs$()
.pipe(takeUntil(this.stop$));
const activeNodes$ = projectNavigation.getActiveNodes$();
const ProjectHeaderWithNavigation = () => {
const CustomSideNavComponent = useObservable(projectNavigationComponent$, undefined);
const activeNodes = useObservable(activeNodes$, []);
const currentProjectBreadcrumbs$ = projectBreadcrumbs$;
let SideNavComponent: ISideNavComponent = () => null;
if (CustomSideNavComponent !== undefined) {
// We have the state from the Observable
SideNavComponent =
CustomSideNavComponent.current !== null
? CustomSideNavComponent.current
: ProjectSideNavigation;
}
const HeaderComponent = () => {
const isVisible = useObservable(this.isVisible$);
if (!isVisible) {
return (
<ProjectHeader
{...{
application,
globalHelpExtensionMenuLinks$,
}}
actionMenu$={application.currentActionMenu$}
breadcrumbs$={currentProjectBreadcrumbs$}
helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))}
helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))}
helpMenuLinks$={helpMenuLinks$}
navControlsLeft$={navControls.getLeft$()}
navControlsCenter$={navControls.getCenter$()}
navControlsRight$={navControls.getRight$()}
loadingCount$={http.getLoadingCount$()}
headerBanner$={headerBanner$.pipe(takeUntil(this.stop$))}
homeHref$={projectNavigation.getProjectHome$()}
docLinks={docLinks}
kibanaVersion={injectedMetadata.getKibanaVersion()}
prependBasePath={http.basePath.prepend}
>
<SideNavComponent activeNodes={activeNodes} />
</ProjectHeader>
<div data-test-subj="kibanaHeaderChromeless">
<LoadingIndicator loadingCount$={http.getLoadingCount$()} showAsBar />
<HeaderTopBanner headerBanner$={headerBanner$.pipe(takeUntil(this.stop$))} />
</div>
);
};
}
return <ProjectHeaderWithNavigation />;
}
// render header
if (chromeStyle$.getValue() === 'project') {
const projectNavigationComponent$ = projectNavigation.getProjectSideNavComponent$();
const projectBreadcrumbs$ = projectNavigation
.getProjectBreadcrumbs$()
.pipe(takeUntil(this.stop$));
const activeNodes$ = projectNavigation.getActiveNodes$();
return (
<Header
loadingCount$={http.getLoadingCount$()}
application={application}
headerBanner$={headerBanner$.pipe(takeUntil(this.stop$))}
badge$={badge$.pipe(takeUntil(this.stop$))}
basePath={http.basePath}
breadcrumbs$={breadcrumbs$.pipe(takeUntil(this.stop$))}
breadcrumbsAppendExtension$={breadcrumbsAppendExtension$.pipe(takeUntil(this.stop$))}
customNavLink$={customNavLink$.pipe(takeUntil(this.stop$))}
kibanaDocLink={docLinks.links.kibana.guide}
docLinks={docLinks}
forceAppSwitcherNavigation$={navLinks.getForceAppSwitcherNavigation$()}
globalHelpExtensionMenuLinks$={globalHelpExtensionMenuLinks$}
helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))}
helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))}
helpMenuLinks$={helpMenuLinks$}
homeHref={http.basePath.prepend('/app/home')}
isVisible$={this.isVisible$}
kibanaVersion={injectedMetadata.getKibanaVersion()}
navLinks$={navLinks.getNavLinks$()}
recentlyAccessed$={recentlyAccessed.get$()}
navControlsLeft$={navControls.getLeft$()}
navControlsCenter$={navControls.getCenter$()}
navControlsRight$={navControls.getRight$()}
navControlsExtension$={navControls.getExtension$()}
onIsLockedUpdate={setIsNavDrawerLocked}
isLocked$={getIsNavDrawerLocked$}
customBranding$={customBranding$}
/>
);
const ProjectHeaderWithNavigationComponent = () => {
const CustomSideNavComponent = useObservable(projectNavigationComponent$, undefined);
const activeNodes = useObservable(activeNodes$, []);
const currentProjectBreadcrumbs$ = projectBreadcrumbs$;
let SideNavComponent: ISideNavComponent = () => null;
if (CustomSideNavComponent !== undefined) {
// We have the state from the Observable
SideNavComponent =
CustomSideNavComponent.current !== null
? CustomSideNavComponent.current
: ProjectSideNavigation;
}
return (
<ProjectHeader
application={application}
globalHelpExtensionMenuLinks$={globalHelpExtensionMenuLinks$}
actionMenu$={application.currentActionMenu$}
breadcrumbs$={currentProjectBreadcrumbs$}
helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))}
helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))}
helpMenuLinks$={helpMenuLinks$}
navControlsLeft$={navControls.getLeft$()}
navControlsCenter$={navControls.getCenter$()}
navControlsRight$={navControls.getRight$()}
loadingCount$={http.getLoadingCount$()}
headerBanner$={headerBanner$.pipe(takeUntil(this.stop$))}
homeHref$={projectNavigation.getProjectHome$()}
docLinks={docLinks}
kibanaVersion={injectedMetadata.getKibanaVersion()}
prependBasePath={http.basePath.prepend}
>
<SideNavComponent activeNodes={activeNodes} />
</ProjectHeader>
);
};
return <ProjectHeaderWithNavigationComponent />;
}
return (
<Header
loadingCount$={http.getLoadingCount$()}
application={application}
headerBanner$={headerBanner$.pipe(takeUntil(this.stop$))}
badge$={badge$.pipe(takeUntil(this.stop$))}
basePath={http.basePath}
breadcrumbs$={breadcrumbs$.pipe(takeUntil(this.stop$))}
breadcrumbsAppendExtension$={breadcrumbsAppendExtension$.pipe(takeUntil(this.stop$))}
customNavLink$={customNavLink$.pipe(takeUntil(this.stop$))}
kibanaDocLink={docLinks.links.kibana.guide}
docLinks={docLinks}
forceAppSwitcherNavigation$={navLinks.getForceAppSwitcherNavigation$()}
globalHelpExtensionMenuLinks$={globalHelpExtensionMenuLinks$}
helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))}
helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))}
helpMenuLinks$={helpMenuLinks$}
homeHref={http.basePath.prepend('/app/home')}
kibanaVersion={injectedMetadata.getKibanaVersion()}
navLinks$={navLinks.getNavLinks$()}
recentlyAccessed$={recentlyAccessed.get$()}
navControlsLeft$={navControls.getLeft$()}
navControlsCenter$={navControls.getCenter$()}
navControlsRight$={navControls.getRight$()}
navControlsExtension$={navControls.getExtension$()}
onIsLockedUpdate={setIsNavDrawerLocked}
isLocked$={getIsNavDrawerLocked$}
customBranding$={customBranding$}
/>
);
};
return <HeaderComponent />;
};
return {

View file

@ -28,7 +28,6 @@ function mockProps() {
breadcrumbs$: new BehaviorSubject([]),
breadcrumbsAppendExtension$: new BehaviorSubject(undefined),
homeHref: '/',
isVisible$: new BehaviorSubject(true),
customBranding$: new BehaviorSubject({}),
kibanaDocLink: '/docs',
docLinks: docLinksServiceMock.createStartContract(),
@ -58,7 +57,6 @@ describe('Header', () => {
});
it('renders', () => {
const isVisible$ = new BehaviorSubject(false);
const breadcrumbs$ = new BehaviorSubject([{ text: 'test' }]);
const isLocked$ = new BehaviorSubject(false);
const navLinks$ = new BehaviorSubject([
@ -81,7 +79,6 @@ describe('Header', () => {
const component = mountWithIntl(
<Header
{...mockProps()}
isVisible$={isVisible$}
breadcrumbs$={breadcrumbs$}
navLinks$={navLinks$}
recentlyAccessed$={recentlyAccessed$}
@ -92,10 +89,6 @@ describe('Header', () => {
helpMenuLinks$={of([])}
/>
);
expect(component.find('EuiHeader').exists()).toBeFalsy();
act(() => isVisible$.next(true));
component.update();
expect(component.find('EuiHeader').exists()).toBeTruthy();
expect(component.find('nav[aria-label="Primary"]').exists()).toBeFalsy();

View file

@ -36,7 +36,6 @@ import type {
} from '@kbn/core-chrome-browser';
import { CustomBranding } from '@kbn/core-custom-branding-common';
import type { DocLinksStart } from '@kbn/core-doc-links-browser';
import { LoadingIndicator } from '../loading_indicator';
import type { OnIsLockedUpdate } from './types';
import { CollapsibleNav } from './collapsible_nav';
import { HeaderBadge } from './header_badge';
@ -59,7 +58,6 @@ export interface HeaderProps {
breadcrumbsAppendExtension$: Observable<ChromeBreadcrumbsAppendExtension | undefined>;
customNavLink$: Observable<ChromeNavLink | undefined>;
homeHref: string;
isVisible$: Observable<boolean>;
kibanaDocLink: string;
docLinks: DocLinksStart;
navLinks$: Observable<ChromeNavLink[]>;
@ -93,21 +91,11 @@ export function Header({
customBranding$,
...observables
}: HeaderProps) {
const isVisible = useObservable(observables.isVisible$, false);
const [isNavOpen, setIsNavOpen] = useState(false);
const [navId] = useState(htmlIdGenerator()());
const breadcrumbsAppendExtension = useObservable(breadcrumbsAppendExtension$);
const headerActionMenuMounter = useHeaderActionMenuMounter(application.currentActionMenu$);
if (!isVisible) {
return (
<>
<LoadingIndicator loadingCount$={observables.loadingCount$} showAsBar />
<HeaderTopBanner headerBanner$={observables.headerBanner$} />
</>
);
}
const toggleCollapsibleNavRef = createRef<HTMLButtonElement & { euiAnimate: () => void }>();
const className = classnames('hide-for-sharing', 'headerGlobalNav');