/* * SPDX-License-Identifier: Apache-2.0 * * The OpenSearch Contributors require contributions made to * this file be licensed under the Apache-2.0 license or a * compatible open source license. * * Any modifications Copyright OpenSearch Contributors. See * GitHub history for details. */ /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright * ownership. Elasticsearch B.V. licenses this file to you under * the Apache License, Version 2.0 (the "License"); you may * not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import React from 'react'; import { BehaviorSubject } from 'rxjs'; import { createMemoryHistory, History, createHashHistory } from 'history'; import { AppRouter, AppNotFound } from '../ui'; import { MockedMounterMap, MockedMounterTuple } from '../test_types'; import { createRenderer, createAppMounter, getUnmounter } from './utils'; import { AppStatus } from '../types'; describe('AppRouter', () => { let mounters: MockedMounterMap; let globalHistory: History; let update: ReturnType; let scopedAppHistory: History; const navigate = (path: string) => { globalHistory.push(path); return update(); }; const mockMountersToMounters = () => new Map([...mounters].map(([appId, { mounter }]) => [appId, mounter])); const noop = () => undefined; const mountersToAppStatus$ = () => { return new BehaviorSubject( new Map( [...mounters.keys()].map((id) => [ id, id.startsWith('disabled') ? AppStatus.inaccessible : AppStatus.accessible, ]) ) ); }; const createMountersRenderer = () => createRenderer( ); beforeEach(() => { mounters = new Map([ createAppMounter({ appId: 'app1', html: 'App 1' }), createAppMounter({ appId: 'app2', html: '
App 2
' }), createAppMounter({ appId: 'app3', html: '
Chromeless A
', appRoute: '/chromeless-a/path', }), createAppMounter({ appId: 'app4', html: '
Chromeless B
', appRoute: '/chromeless-b/path', }), createAppMounter({ appId: 'disabledApp', html: '
Disabled app
' }), createAppMounter({ appId: 'scopedApp', extraMountHook: ({ history }) => { scopedAppHistory = history; history.push('/subpath'); }, }), createAppMounter({ appId: 'app5', html: '
App 5
', appRoute: '/app/my-app/app5', }), createAppMounter({ appId: 'app6', html: '
App 6
', appRoute: '/app/my-app/app6', }), ] as MockedMounterTuple[]); globalHistory = createMemoryHistory(); update = createMountersRenderer(); }); it('calls mount handler and returned unmount function when navigating between apps', async () => { const app1 = mounters.get('app1')!; const app2 = mounters.get('app2')!; let dom = await navigate('/app/app1'); expect(app1.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` "
basename: /app/app1 html: App 1
" `); const app1Unmount = await getUnmounter(app1); dom = await navigate('/app/app2'); expect(app1Unmount).toHaveBeenCalled(); expect(app2.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` "
basename: /app/app2 html:
App 2
" `); }); it('can navigate between standard application and one with custom appRoute', async () => { const standardApp = mounters.get('app1')!; const chromelessApp = mounters.get('app3')!; let dom = await navigate('/app/app1'); expect(standardApp.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` "
basename: /app/app1 html: App 1
" `); const standardAppUnmount = await getUnmounter(standardApp); dom = await navigate('/chromeless-a/path'); expect(standardAppUnmount).toHaveBeenCalled(); expect(chromelessApp.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` "
basename: /chromeless-a/path html:
Chromeless A
" `); const chromelessAppUnmount = await getUnmounter(standardApp); dom = await navigate('/app/app1'); expect(chromelessAppUnmount).toHaveBeenCalled(); expect(standardApp.mounter.mount).toHaveBeenCalledTimes(2); expect(dom?.html()).toMatchInlineSnapshot(` "
basename: /app/app1 html: App 1
" `); }); it('can navigate between two applications with custom appRoutes', async () => { const chromelessAppA = mounters.get('app3')!; const chromelessAppB = mounters.get('app4')!; let dom = await navigate('/chromeless-a/path'); expect(chromelessAppA.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` "
basename: /chromeless-a/path html:
Chromeless A
" `); const chromelessAppAUnmount = await getUnmounter(chromelessAppA); dom = await navigate('/chromeless-b/path'); expect(chromelessAppAUnmount).toHaveBeenCalled(); expect(chromelessAppB.mounter.mount).toHaveBeenCalled(); expect(dom?.html()).toMatchInlineSnapshot(` "
basename: /chromeless-b/path html:
Chromeless B
" `); const chromelessAppBUnmount = await getUnmounter(chromelessAppB); dom = await navigate('/chromeless-a/path'); expect(chromelessAppBUnmount).toHaveBeenCalled(); expect(chromelessAppA.mounter.mount).toHaveBeenCalledTimes(2); expect(dom?.html()).toMatchInlineSnapshot(` "
basename: /chromeless-a/path html:
Chromeless A
" `); }); it('should not mount when partial route path matches', async () => { mounters.set( ...createAppMounter({ appId: 'spaces', html: '
Custom Space
', appRoute: '/spaces/fake-login', }) ); mounters.set( ...createAppMounter({ appId: 'login', html: '
Login Page
', appRoute: '/fake-login', }) ); globalHistory = createMemoryHistory(); update = createMountersRenderer(); await navigate('/fake-login'); expect(mounters.get('spaces')!.mounter.mount).not.toHaveBeenCalled(); expect(mounters.get('login')!.mounter.mount).toHaveBeenCalled(); }); it('should not mount when partial route path has higher specificity', async () => { mounters.set( ...createAppMounter({ appId: 'login', html: '
Login Page
', appRoute: '/fake-login', }) ); mounters.set( ...createAppMounter({ appId: 'spaces', html: '
Custom Space
', appRoute: '/spaces/fake-login', }) ); globalHistory = createMemoryHistory(); update = createMountersRenderer(); await navigate('/spaces/fake-login'); expect(mounters.get('spaces')!.mounter.mount).toHaveBeenCalled(); expect(mounters.get('login')!.mounter.mount).not.toHaveBeenCalled(); }); it('should mount an exact route app only when the path is an exact match', async () => { mounters.set( ...createAppMounter({ appId: 'exactApp', html: '
exact app
', exactRoute: true, appRoute: '/app/exact-app', }) ); globalHistory = createMemoryHistory(); update = createMountersRenderer(); await navigate('/app/exact-app/some-path'); expect(mounters.get('exactApp')!.mounter.mount).not.toHaveBeenCalled(); await navigate('/app/exact-app'); expect(mounters.get('exactApp')!.mounter.mount).toHaveBeenCalledTimes(1); }); it('should mount an an app with a route nested in an exact route app', async () => { mounters.set( ...createAppMounter({ appId: 'exactApp', html: '
exact app
', exactRoute: true, appRoute: '/app/exact-app', }) ); mounters.set( ...createAppMounter({ appId: 'nestedApp', html: '
nested app
', appRoute: '/app/exact-app/another-app', }) ); globalHistory = createMemoryHistory(); update = createMountersRenderer(); await navigate('/app/exact-app/another-app'); expect(mounters.get('exactApp')!.mounter.mount).not.toHaveBeenCalled(); expect(mounters.get('nestedApp')!.mounter.mount).toHaveBeenCalledTimes(1); }); it('should not remount when changing pages within app', async () => { const { mounter, unmount } = mounters.get('app1')!; await navigate('/app/app1/page1'); expect(mounter.mount).toHaveBeenCalledTimes(1); // Navigating to page within app does not trigger re-render await navigate('/app/app1/page2'); expect(mounter.mount).toHaveBeenCalledTimes(1); expect(unmount).not.toHaveBeenCalled(); }); it('should not remount when going back within app', async () => { const { mounter, unmount } = mounters.get('app1')!; await navigate('/app/app1/page1'); expect(mounter.mount).toHaveBeenCalledTimes(1); // Hitting back button within app does not trigger re-render await navigate('/app/app1/page2'); globalHistory.goBack(); await update(); expect(mounter.mount).toHaveBeenCalledTimes(1); expect(unmount).not.toHaveBeenCalled(); }); it('allows multiple apps with the same `/app/appXXX` appRoute prefix', async () => { await navigate('/app/my-app/app5/path'); expect(mounters.get('app5')!.mounter.mount).toHaveBeenCalledTimes(1); expect(mounters.get('app6')!.mounter.mount).toHaveBeenCalledTimes(0); await navigate('/app/my-app/app6/another-path'); expect(mounters.get('app5')!.mounter.mount).toHaveBeenCalledTimes(1); expect(mounters.get('app6')!.mounter.mount).toHaveBeenCalledTimes(1); }); it('should not remount when when changing pages within app using hash history', async () => { globalHistory = createHashHistory(); update = createMountersRenderer(); const { mounter, unmount } = mounters.get('app1')!; await navigate('/app/app1/page1'); expect(mounter.mount).toHaveBeenCalledTimes(1); // Changing hash history does not trigger re-render await navigate('/app/app1/page2'); expect(mounter.mount).toHaveBeenCalledTimes(1); expect(unmount).not.toHaveBeenCalled(); }); it('should unmount when changing between apps', async () => { const { mounter, unmount } = mounters.get('app1')!; await navigate('/app/app1/page1'); expect(mounter.mount).toHaveBeenCalledTimes(1); // Navigating to other app triggers unmount await navigate('/app/app2/page1'); expect(unmount).toHaveBeenCalledTimes(1); }); it('pushes global history changes to inner scoped history', async () => { const scopedApp = mounters.get('scopedApp'); await navigate('/app/scopedApp'); // Verify that internal app's redirect propagated expect(scopedApp?.mounter.mount).toHaveBeenCalledTimes(1); expect(scopedAppHistory.location.pathname).toEqual('/subpath'); expect(globalHistory.location.pathname).toEqual('/app/scopedApp/subpath'); // Simulate user clicking on navlink again to return to app root globalHistory.push('/app/scopedApp'); // Should not call mount again expect(scopedApp?.mounter.mount).toHaveBeenCalledTimes(1); expect(scopedApp?.unmount).not.toHaveBeenCalled(); // Inner scoped history should be synced expect(scopedAppHistory.location.pathname).toEqual(''); // Make sure going back to subpath works globalHistory.goBack(); expect(scopedApp?.mounter.mount).toHaveBeenCalledTimes(1); expect(scopedApp?.unmount).not.toHaveBeenCalled(); expect(scopedAppHistory.location.pathname).toEqual('/subpath'); expect(globalHistory.location.pathname).toEqual('/app/scopedApp/subpath'); }); it('displays error page if no app is found', async () => { const dom = await navigate('/app/unknown'); expect(dom?.exists(AppNotFound)).toBe(true); }); it('displays error page if app is inaccessible', async () => { const dom = await navigate('/app/disabledApp'); expect(dom?.exists(AppNotFound)).toBe(true); }); });