[7.x] Display global loading bar while applications are mounting (#64556) (#65117)

This commit is contained in:
Josh Dover 2020-05-04 13:07:18 -06:00 committed by GitHub
parent af89ad78bf
commit 8a1d4d0b32
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 242 additions and 31 deletions

View file

@ -80,5 +80,6 @@ exports[`#start() getComponent returns renderable JSX tree 1`] = `
}
mounters={Map {}}
setAppLeaveHandler={[Function]}
setIsMounting={[Function]}
/>
`;

View file

@ -20,7 +20,7 @@
import { createElement } from 'react';
import { BehaviorSubject, Subject } from 'rxjs';
import { bufferCount, take, takeUntil } from 'rxjs/operators';
import { shallow } from 'enzyme';
import { shallow, mount } from 'enzyme';
import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock';
import { contextServiceMock } from '../context/context_service.mock';
@ -30,6 +30,7 @@ import { MockCapabilitiesService, MockHistory } from './application_service.test
import { MockLifecycle } from './test_types';
import { ApplicationService } from './application_service';
import { App, AppNavLinkStatus, AppStatus, AppUpdater, LegacyApp } from './types';
import { act } from 'react-dom/test-utils';
const createApp = (props: Partial<App>): App => {
return {
@ -452,9 +453,9 @@ describe('#setup()', () => {
const container = setupDeps.context.createContextContainer.mock.results[0].value;
const pluginId = Symbol();
const mount = () => () => undefined;
registerMountContext(pluginId, 'test' as any, mount);
expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', mount);
const appMount = () => () => undefined;
registerMountContext(pluginId, 'test' as any, appMount);
expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', appMount);
});
});
@ -809,6 +810,74 @@ describe('#start()', () => {
`);
});
it('updates httpLoadingCount$ while mounting', async () => {
// Use a memory history so that mounting the component will work
const { createMemoryHistory } = jest.requireActual('history');
const history = createMemoryHistory();
setupDeps.history = history;
const flushPromises = () => new Promise(resolve => setImmediate(resolve));
// Create an app and a promise that allows us to control when the app completes mounting
const createWaitingApp = (props: Partial<App>): [App, () => void] => {
let finishMount: () => void;
const mountPromise = new Promise(resolve => (finishMount = resolve));
const app = {
id: 'some-id',
title: 'some-title',
mount: async () => {
await mountPromise;
return () => undefined;
},
...props,
};
return [app, finishMount!];
};
// Create some dummy applications
const { register } = service.setup(setupDeps);
const [alphaApp, finishAlphaMount] = createWaitingApp({ id: 'alpha' });
const [betaApp, finishBetaMount] = createWaitingApp({ id: 'beta' });
register(Symbol(), alphaApp);
register(Symbol(), betaApp);
const { navigateToApp, getComponent } = await service.start(startDeps);
const httpLoadingCount$ = startDeps.http.addLoadingCountSource.mock.calls[0][0];
const stop$ = new Subject();
const currentLoadingCount$ = new BehaviorSubject(0);
httpLoadingCount$.pipe(takeUntil(stop$)).subscribe(currentLoadingCount$);
const loadingPromise = httpLoadingCount$.pipe(bufferCount(5), takeUntil(stop$)).toPromise();
mount(getComponent()!);
await act(() => navigateToApp('alpha'));
expect(currentLoadingCount$.value).toEqual(1);
await act(async () => {
finishAlphaMount();
await flushPromises();
});
expect(currentLoadingCount$.value).toEqual(0);
await act(() => navigateToApp('beta'));
expect(currentLoadingCount$.value).toEqual(1);
await act(async () => {
finishBetaMount();
await flushPromises();
});
expect(currentLoadingCount$.value).toEqual(0);
stop$.next();
const loadingCounts = await loadingPromise;
expect(loadingCounts).toMatchInlineSnapshot(`
Array [
0,
1,
0,
1,
0,
]
`);
});
it('sets window.location.href when navigating to legacy apps', async () => {
setupDeps.http = httpServiceMock.createSetupContract({ basePath: '/test' });
setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true);

View file

@ -238,6 +238,9 @@ export class ApplicationService {
throw new Error('ApplicationService#setup() must be invoked before start.');
}
const httpLoadingCount$ = new BehaviorSubject(0);
http.addLoadingCountSource(httpLoadingCount$);
this.registrationClosed = true;
window.addEventListener('beforeunload', this.onBeforeUnload);
@ -303,6 +306,7 @@ export class ApplicationService {
mounters={availableMounters}
appStatuses$={applicationStatuses$}
setAppLeaveHandler={this.setAppLeaveHandler}
setIsMounting={isMounting => httpLoadingCount$.next(isMounting ? 1 : 0)}
/>
);
},

View file

@ -40,7 +40,7 @@ describe('AppContainer', () => {
};
const mockMountersToMounters = () =>
new Map([...mounters].map(([appId, { mounter }]) => [appId, mounter]));
const setAppLeaveHandlerMock = () => undefined;
const noop = () => undefined;
const mountersToAppStatus$ = () => {
return new BehaviorSubject(
@ -86,7 +86,8 @@ describe('AppContainer', () => {
history={globalHistory}
mounters={mockMountersToMounters()}
appStatuses$={appStatuses$}
setAppLeaveHandler={setAppLeaveHandlerMock}
setAppLeaveHandler={noop}
setIsMounting={noop}
/>
);
});
@ -98,7 +99,7 @@ describe('AppContainer', () => {
expect(app1.mounter.mount).toHaveBeenCalled();
expect(dom?.html()).toMatchInlineSnapshot(`
"<div><div>
"<div class=\\"appContainer__loading\\"><span class=\\"euiLoadingSpinner euiLoadingSpinner--large\\"></span></div><div><div>
basename: /app/app1
html: <span>App 1</span>
</div></div>"
@ -110,7 +111,7 @@ describe('AppContainer', () => {
expect(app1Unmount).toHaveBeenCalled();
expect(app2.mounter.mount).toHaveBeenCalled();
expect(dom?.html()).toMatchInlineSnapshot(`
"<div><div>
"<div class=\\"appContainer__loading\\"><span class=\\"euiLoadingSpinner euiLoadingSpinner--large\\"></span></div><div><div>
basename: /app/app2
html: <div>App 2</div>
</div></div>"
@ -124,7 +125,7 @@ describe('AppContainer', () => {
expect(standardApp.mounter.mount).toHaveBeenCalled();
expect(dom?.html()).toMatchInlineSnapshot(`
"<div><div>
"<div class=\\"appContainer__loading\\"><span class=\\"euiLoadingSpinner euiLoadingSpinner--large\\"></span></div><div><div>
basename: /app/app1
html: <span>App 1</span>
</div></div>"
@ -136,7 +137,7 @@ describe('AppContainer', () => {
expect(standardAppUnmount).toHaveBeenCalled();
expect(chromelessApp.mounter.mount).toHaveBeenCalled();
expect(dom?.html()).toMatchInlineSnapshot(`
"<div><div>
"<div class=\\"appContainer__loading\\"><span class=\\"euiLoadingSpinner euiLoadingSpinner--large\\"></span></div><div><div>
basename: /chromeless-a/path
html: <div>Chromeless A</div>
</div></div>"
@ -148,7 +149,7 @@ describe('AppContainer', () => {
expect(chromelessAppUnmount).toHaveBeenCalled();
expect(standardApp.mounter.mount).toHaveBeenCalledTimes(2);
expect(dom?.html()).toMatchInlineSnapshot(`
"<div><div>
"<div class=\\"appContainer__loading\\"><span class=\\"euiLoadingSpinner euiLoadingSpinner--large\\"></span></div><div><div>
basename: /app/app1
html: <span>App 1</span>
</div></div>"
@ -162,7 +163,7 @@ describe('AppContainer', () => {
expect(chromelessAppA.mounter.mount).toHaveBeenCalled();
expect(dom?.html()).toMatchInlineSnapshot(`
"<div><div>
"<div class=\\"appContainer__loading\\"><span class=\\"euiLoadingSpinner euiLoadingSpinner--large\\"></span></div><div><div>
basename: /chromeless-a/path
html: <div>Chromeless A</div>
</div></div>"
@ -174,7 +175,7 @@ describe('AppContainer', () => {
expect(chromelessAppAUnmount).toHaveBeenCalled();
expect(chromelessAppB.mounter.mount).toHaveBeenCalled();
expect(dom?.html()).toMatchInlineSnapshot(`
"<div><div>
"<div class=\\"appContainer__loading\\"><span class=\\"euiLoadingSpinner euiLoadingSpinner--large\\"></span></div><div><div>
basename: /chromeless-b/path
html: <div>Chromeless B</div>
</div></div>"
@ -186,7 +187,7 @@ describe('AppContainer', () => {
expect(chromelessAppBUnmount).toHaveBeenCalled();
expect(chromelessAppA.mounter.mount).toHaveBeenCalledTimes(2);
expect(dom?.html()).toMatchInlineSnapshot(`
"<div><div>
"<div class=\\"appContainer__loading\\"><span class=\\"euiLoadingSpinner euiLoadingSpinner--large\\"></span></div><div><div>
basename: /chromeless-a/path
html: <div>Chromeless A</div>
</div></div>"
@ -214,7 +215,8 @@ describe('AppContainer', () => {
history={globalHistory}
mounters={mockMountersToMounters()}
appStatuses$={mountersToAppStatus$()}
setAppLeaveHandler={setAppLeaveHandlerMock}
setAppLeaveHandler={noop}
setIsMounting={noop}
/>
);
@ -245,7 +247,8 @@ describe('AppContainer', () => {
history={globalHistory}
mounters={mockMountersToMounters()}
appStatuses$={mountersToAppStatus$()}
setAppLeaveHandler={setAppLeaveHandlerMock}
setAppLeaveHandler={noop}
setIsMounting={noop}
/>
);
@ -286,7 +289,8 @@ describe('AppContainer', () => {
history={globalHistory}
mounters={mockMountersToMounters()}
appStatuses$={mountersToAppStatus$()}
setAppLeaveHandler={setAppLeaveHandlerMock}
setAppLeaveHandler={noop}
setIsMounting={noop}
/>
);

View file

@ -18,6 +18,7 @@
*/
import React, { ReactElement } from 'react';
import { act } from 'react-dom/test-utils';
import { mount } from 'enzyme';
import { I18nProvider } from '@kbn/i18n/react';
@ -34,7 +35,9 @@ export const createRenderer = (element: ReactElement | null): Renderer => {
return () =>
new Promise(async resolve => {
if (dom) {
dom.update();
await act(async () => {
dom.update();
});
}
setImmediate(() => resolve(dom)); // flushes any pending promises
});

View file

@ -0,0 +1,25 @@
.appContainer__loading {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: $euiZLevel1;
animation-name: appContainerFadeIn;
animation-iteration-count: 1;
animation-timing-function: ease-in;
animation-duration: 2s;
}
@keyframes appContainerFadeIn {
0% {
opacity: 0;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}

View file

@ -18,6 +18,7 @@
*/
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mount } from 'enzyme';
import { AppContainer } from './app_container';
@ -28,6 +29,12 @@ import { ScopedHistory } from '../scoped_history';
describe('AppContainer', () => {
const appId = 'someApp';
const setAppLeaveHandler = jest.fn();
const setIsMounting = jest.fn();
beforeEach(() => {
setAppLeaveHandler.mockClear();
setIsMounting.mockClear();
});
const flushPromises = async () => {
await new Promise(async resolve => {
@ -67,6 +74,7 @@ describe('AppContainer', () => {
appStatus={AppStatus.inaccessible}
mounter={mounter}
setAppLeaveHandler={setAppLeaveHandler}
setIsMounting={setIsMounting}
createScopedHistory={(appPath: string) =>
// Create a history using the appPath as the current location
new ScopedHistory(createMemoryHistory({ initialEntries: [appPath] }), appPath)
@ -86,10 +94,86 @@ describe('AppContainer', () => {
expect(wrapper.text()).toEqual('');
resolvePromise();
await flushPromises();
wrapper.update();
await act(async () => {
resolvePromise();
await flushPromises();
wrapper.update();
});
expect(wrapper.text()).toContain('some-content');
});
it('should call setIsMounting while mounting', async () => {
const [waitPromise, resolvePromise] = createResolver();
const mounter = createMounter(waitPromise);
const wrapper = mount(
<AppContainer
appPath={`/app/${appId}`}
appId={appId}
appStatus={AppStatus.accessible}
mounter={mounter}
setAppLeaveHandler={setAppLeaveHandler}
setIsMounting={setIsMounting}
createScopedHistory={(appPath: string) =>
// Create a history using the appPath as the current location
new ScopedHistory(createMemoryHistory({ initialEntries: [appPath] }), appPath)
}
/>
);
expect(setIsMounting).toHaveBeenCalledTimes(1);
expect(setIsMounting).toHaveBeenLastCalledWith(true);
await act(async () => {
resolvePromise();
await flushPromises();
wrapper.update();
});
expect(setIsMounting).toHaveBeenCalledTimes(2);
expect(setIsMounting).toHaveBeenLastCalledWith(false);
});
it('should call setIsMounting(false) if mounting throws', async () => {
const [waitPromise, resolvePromise] = createResolver();
const mounter = {
appBasePath: '/base-path',
appRoute: '/some-route',
unmountBeforeMounting: false,
mount: async ({ element }: AppMountParameters) => {
await waitPromise;
throw new Error(`Mounting failed!`);
},
};
const wrapper = mount(
<AppContainer
appPath={`/app/${appId}`}
appId={appId}
appStatus={AppStatus.accessible}
mounter={mounter}
setAppLeaveHandler={setAppLeaveHandler}
setIsMounting={setIsMounting}
createScopedHistory={(appPath: string) =>
// Create a history using the appPath as the current location
new ScopedHistory(createMemoryHistory({ initialEntries: [appPath] }), appPath)
}
/>
);
expect(setIsMounting).toHaveBeenCalledTimes(1);
expect(setIsMounting).toHaveBeenLastCalledWith(true);
// await expect(
await act(async () => {
resolvePromise();
await flushPromises();
wrapper.update();
});
// ).rejects.toThrow();
expect(setIsMounting).toHaveBeenCalledTimes(2);
expect(setIsMounting).toHaveBeenLastCalledWith(false);
});
});

View file

@ -26,9 +26,11 @@ import React, {
MutableRefObject,
} from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
import { AppLeaveHandler, AppStatus, AppUnmount, Mounter } from '../types';
import { AppNotFound } from './app_not_found_screen';
import { ScopedHistory } from '../scoped_history';
import './app_container.scss';
interface Props {
/** Path application is mounted on without the global basePath */
@ -38,6 +40,7 @@ interface Props {
appStatus: AppStatus;
setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void;
createScopedHistory: (appUrl: string) => ScopedHistory;
setIsMounting: (isMounting: boolean) => void;
}
export const AppContainer: FunctionComponent<Props> = ({
@ -47,7 +50,9 @@ export const AppContainer: FunctionComponent<Props> = ({
setAppLeaveHandler,
createScopedHistory,
appStatus,
setIsMounting,
}: Props) => {
const [showSpinner, setShowSpinner] = useState(true);
const [appNotFound, setAppNotFound] = useState(false);
const elementRef = useRef<HTMLDivElement>(null);
const unmountRef: MutableRefObject<AppUnmount | null> = useRef<AppUnmount>(null);
@ -65,28 +70,42 @@ export const AppContainer: FunctionComponent<Props> = ({
}
setAppNotFound(false);
setIsMounting(true);
if (mounter.unmountBeforeMounting) {
unmount();
}
const mount = async () => {
unmountRef.current =
(await mounter.mount({
appBasePath: mounter.appBasePath,
history: createScopedHistory(appPath),
element: elementRef.current!,
onAppLeave: handler => setAppLeaveHandler(appId, handler),
})) || null;
setShowSpinner(true);
try {
unmountRef.current =
(await mounter.mount({
appBasePath: mounter.appBasePath,
history: createScopedHistory(appPath),
element: elementRef.current!,
onAppLeave: handler => setAppLeaveHandler(appId, handler),
})) || null;
} catch (e) {
// TODO: add error UI
} finally {
setShowSpinner(false);
setIsMounting(false);
}
};
mount();
return unmount;
}, [appId, appStatus, mounter, createScopedHistory, setAppLeaveHandler, appPath]);
}, [appId, appStatus, mounter, createScopedHistory, setAppLeaveHandler, appPath, setIsMounting]);
return (
<Fragment>
{appNotFound && <AppNotFound />}
{showSpinner && (
<div className="appContainer__loading">
<EuiLoadingSpinner size="l" />
</div>
)}
<div key={appId} ref={elementRef} />
</Fragment>
);

View file

@ -32,6 +32,7 @@ interface Props {
history: History;
appStatuses$: Observable<Map<string, AppStatus>>;
setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void;
setIsMounting: (isMounting: boolean) => void;
}
interface Params {
@ -43,6 +44,7 @@ export const AppRouter: FunctionComponent<Props> = ({
mounters,
setAppLeaveHandler,
appStatuses$,
setIsMounting,
}) => {
const appStatuses = useObservable(appStatuses$, new Map());
const createScopedHistory = useMemo(
@ -67,7 +69,7 @@ export const AppRouter: FunctionComponent<Props> = ({
appPath={url}
appStatus={appStatuses.get(appId) ?? AppStatus.inaccessible}
createScopedHistory={createScopedHistory}
{...{ appId, mounter, setAppLeaveHandler }}
{...{ appId, mounter, setAppLeaveHandler, setIsMounting }}
/>
)}
/>,
@ -92,7 +94,7 @@ export const AppRouter: FunctionComponent<Props> = ({
appId={id}
appStatus={appStatuses.get(id) ?? AppStatus.inaccessible}
createScopedHistory={createScopedHistory}
{...{ mounter, setAppLeaveHandler }}
{...{ mounter, setAppLeaveHandler, setIsMounting }}
/>
);
}}