diff --git a/src/plugins/home/common/constants.ts b/src/plugins/home/common/constants.ts index a1fcfe265f0b..25c78c59c4ac 100644 --- a/src/plugins/home/common/constants.ts +++ b/src/plugins/home/common/constants.ts @@ -31,3 +31,4 @@ export const PLUGIN_ID = 'home'; export const HOME_APP_BASE_PATH = `/app/${PLUGIN_ID}`; export const USE_NEW_HOME_PAGE = 'home:useNewHomePage'; +export const IMPORT_SAMPLE_DATA_APP_ID = 'import_sample_data'; diff --git a/src/plugins/home/public/application/application.test.tsx b/src/plugins/home/public/application/application.test.tsx new file mode 100644 index 000000000000..95149d52cf8f --- /dev/null +++ b/src/plugins/home/public/application/application.test.tsx @@ -0,0 +1,45 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useRef } from 'react'; +import { render } from '@testing-library/react'; +import { coreMock, scopedHistoryMock } from '../../../../core/public/mocks'; +import { renderImportSampleDataApp } from './application'; + +jest.mock('./components/home_app', () => ({ + HomeApp: () => 'HomeApp', + ImportSampleDataApp: () => 'ImportSampleDataApp', +})); + +const coreStartMocks = coreMock.createStart(); + +const ComponentForRender = (props: { renderFn: typeof renderImportSampleDataApp }) => { + const container = useRef(null); + const historyMock = scopedHistoryMock.create(); + historyMock.listen.mockReturnValueOnce(() => () => null); + useEffect(() => { + if (container.current) { + const destroyFn = props.renderFn(container.current, coreStartMocks, historyMock); + return () => { + destroyFn.then((res) => res()); + }; + } + }, [historyMock, props]); + + return
; +}; + +describe('renderImportSampleDataApp', () => { + it('should render ImportSampleDataApp when calling renderImportSampleDataApp', async () => { + const { container } = render(); + expect(container).toMatchInlineSnapshot(` +
+
+ ImportSampleDataApp +
+
+ `); + }); +}); diff --git a/src/plugins/home/public/application/application.tsx b/src/plugins/home/public/application/application.tsx index 80b628c56b3b..bd5b3c161ff8 100644 --- a/src/plugins/home/public/application/application.tsx +++ b/src/plugins/home/public/application/application.tsx @@ -34,7 +34,7 @@ import { i18n } from '@osd/i18n'; import { ScopedHistory, CoreStart } from 'opensearch-dashboards/public'; import { OpenSearchDashboardsContextProvider } from '../../../opensearch_dashboards_react/public'; // @ts-ignore -import { HomeApp } from './components/home_app'; +import { HomeApp, ImportSampleDataApp } from './components/home_app'; import { getServices } from './opensearch_dashboards_services'; import './index.scss'; @@ -77,3 +77,28 @@ export const renderApp = async ( unlisten(); }; }; + +export const renderImportSampleDataApp = async ( + element: HTMLElement, + coreStart: CoreStart, + history: ScopedHistory +) => { + // dispatch synthetic hash change event to update hash history objects + // this is necessary because hash updates triggered by using popState won't trigger this event naturally. + // This must be called before the app is mounted to avoid call this after the redirect to default app logic kicks in + const unlisten = history.listen((location) => { + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + + render( + + + , + element + ); + + return () => { + unmountComponentAtNode(element); + unlisten(); + }; +}; diff --git a/src/plugins/home/public/application/components/home_app.js b/src/plugins/home/public/application/components/home_app.js index 366d162f02eb..05687e09d883 100644 --- a/src/plugins/home/public/application/components/home_app.js +++ b/src/plugins/home/public/application/components/home_app.js @@ -51,6 +51,44 @@ const RedirectToDefaultApp = () => { return null; }; +const renderTutorialDirectory = (props) => { + const { addBasePath, environmentService } = getServices(); + const environment = environmentService.getEnvironment(); + const isCloudEnabled = environment.cloud; + + return ( + + ); +}; + +export function ImportSampleDataApp() { + return ( + + + + + renderTutorialDirectory({ + ...props, + // For standalone import sample data application + // home breadcrumb should not be appended as it is not a sub app of home + withoutHomeBreadCrumb: true, + }) + } + /> + + + + ); +} + export function HomeApp({ directories, solutions }) { const { savedObjectsClient, @@ -63,16 +101,6 @@ export function HomeApp({ directories, solutions }) { const environment = environmentService.getEnvironment(); const isCloudEnabled = environment.cloud; - const renderTutorialDirectory = (props) => { - return ( - - ); - }; - const renderTutorial = (props) => { return ( ({ + Home: () =>
Home
, +})); + +jest.mock('../load_tutorials', () => ({ + getTutorial: () => {}, +})); + +jest.mock('./tutorial_directory', () => ({ + TutorialDirectory: (props: { withoutHomeBreadCrumb?: boolean }) => ( +
+ ), +})); + +describe('', () => { + let currentService: ReturnType; + beforeEach(() => { + currentService = getMockedServices(); + setServices(currentService); + }); + + it('should not pass withoutHomeBreadCrumb to TutorialDirectory component', async () => { + const originalHash = window.location.hash; + const { findByTestId } = render(); + window.location.hash = '/tutorial_directory'; + const tutorialRenderResult = await findByTestId('tutorial_directory'); + expect(tutorialRenderResult.dataset.withoutHomeBreadCrumb).toEqual('false'); + + // revert to original hash + window.location.hash = originalHash; + }); +}); + +describe('', () => { + let currentService: ReturnType; + beforeEach(() => { + currentService = getMockedServices(); + setServices(currentService); + }); + + it('should pass withoutHomeBreadCrumb to TutorialDirectory component', async () => { + const { findByTestId } = render(); + const tutorialRenderResult = await findByTestId('tutorial_directory'); + expect(tutorialRenderResult.dataset.withoutHomeBreadCrumb).toEqual('true'); + }); +}); diff --git a/src/plugins/home/public/application/components/tutorial_directory.js b/src/plugins/home/public/application/components/tutorial_directory.js index a36f231b38ca..fac078abaab0 100644 --- a/src/plugins/home/public/application/components/tutorial_directory.js +++ b/src/plugins/home/public/application/components/tutorial_directory.js @@ -93,14 +93,17 @@ class TutorialDirectoryUi extends React.Component { async componentDidMount() { this._isMounted = true; - - getServices().chrome.setBreadcrumbs([ - { + const { chrome } = getServices(); + const { withoutHomeBreadCrumb } = this.props; + const breadcrumbs = [{ text: addDataTitle }]; + if (!withoutHomeBreadCrumb) { + breadcrumbs.splice(0, 0, { text: homeTitle, href: '#/', - }, - { text: addDataTitle }, - ]); + }); + } + + chrome.setBreadcrumbs(breadcrumbs); const tutorialConfigs = await getTutorials(); @@ -322,6 +325,7 @@ TutorialDirectoryUi.propTypes = { addBasePath: PropTypes.func.isRequired, openTab: PropTypes.string, isCloudEnabled: PropTypes.bool.isRequired, + withoutHomeBreadCrumb: PropTypes.bool, }; export const TutorialDirectory = injectI18n(TutorialDirectoryUi); diff --git a/src/plugins/home/public/application/components/tutorial_directory.test.tsx b/src/plugins/home/public/application/components/tutorial_directory.test.tsx new file mode 100644 index 000000000000..eacd50fc43d0 --- /dev/null +++ b/src/plugins/home/public/application/components/tutorial_directory.test.tsx @@ -0,0 +1,65 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; +import { coreMock } from '../../../../../core/public/mocks'; +import { setServices } from '../opensearch_dashboards_services'; +import { getMockedServices } from '../opensearch_dashboards_services.mock'; + +const makeProps = () => { + const coreMocks = coreMock.createStart(); + return { + addBasePath: coreMocks.http.basePath.prepend, + openTab: 'foo', + isCloudEnabled: false, + }; +}; + +describe('', () => { + let currentService: ReturnType; + beforeEach(() => { + currentService = getMockedServices(); + setServices(currentService); + }); + it('should render home breadcrumbs when withoutHomeBreadCrumb is undefined', async () => { + const finalProps = makeProps(); + currentService.http.get.mockResolvedValueOnce([]); + // @ts-ignore + const { TutorialDirectory } = await import('./tutorial_directory'); + render( + + + + ); + expect(currentService.chrome.setBreadcrumbs).toBeCalledWith([ + { + href: '#/', + text: 'Home', + }, + { + text: 'Add data', + }, + ]); + }); + + it('should not render home breadcrumbs when withoutHomeBreadCrumb is true', async () => { + const finalProps = makeProps(); + currentService.http.get.mockResolvedValueOnce([]); + // @ts-ignore + const { TutorialDirectory } = await import('./tutorial_directory'); + render( + + + + ); + expect(currentService.chrome.setBreadcrumbs).toBeCalledWith([ + { + text: 'Add data', + }, + ]); + }); +}); diff --git a/src/plugins/home/public/application/index.ts b/src/plugins/home/public/application/index.ts index ba5ccc3e62fa..5bb49c2993d9 100644 --- a/src/plugins/home/public/application/index.ts +++ b/src/plugins/home/public/application/index.ts @@ -28,4 +28,4 @@ * under the License. */ -export { renderApp } from './application'; +export { renderApp, renderImportSampleDataApp } from './application'; diff --git a/src/plugins/home/public/application/opensearch_dashboards_services.mock.ts b/src/plugins/home/public/application/opensearch_dashboards_services.mock.ts new file mode 100644 index 000000000000..b06d03cbc105 --- /dev/null +++ b/src/plugins/home/public/application/opensearch_dashboards_services.mock.ts @@ -0,0 +1,47 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { coreMock } from '../../../../core/public/mocks'; +import { urlForwardingPluginMock } from '../../../url_forwarding/public/mocks'; +import { homePluginMock } from '../mocks'; +import { + EnvironmentService, + FeatureCatalogueRegistry, + SectionTypeService, + TutorialService, +} from '../services'; +import { telemetryPluginMock } from '../../../telemetry/public/mocks'; + +export const getMockedServices = () => { + const coreMocks = coreMock.createStart(); + const urlForwarding = urlForwardingPluginMock.createStartContract(); + const homePlugin = homePluginMock.createSetupContract(); + return { + ...coreMocks, + ...homePlugin, + telemetry: telemetryPluginMock.createStartContract(), + indexPatternService: jest.fn(), + dataSource: { + dataSourceEnabled: false, + hideLocalCluster: false, + noAuthenticationTypeEnabled: false, + usernamePasswordAuthEnabled: false, + awsSigV4AuthEnabled: false, + }, + opensearchDashboardsVersion: '', + urlForwarding, + savedObjectsClient: coreMocks.savedObjects.client, + toastNotifications: coreMocks.notifications.toasts, + banners: coreMocks.overlays.banners, + trackUiMetric: jest.fn(), + getBasePath: jest.fn(), + addBasePath: jest.fn(), + environmentService: new EnvironmentService(), + tutorialService: new TutorialService(), + homeConfig: homePlugin.config, + featureCatalogue: new FeatureCatalogueRegistry(), + sectionTypes: new SectionTypeService(), + }; +}; diff --git a/src/plugins/home/public/plugin.test.ts b/src/plugins/home/public/plugin.test.ts index c883ff0ab771..62ab89dce847 100644 --- a/src/plugins/home/public/plugin.test.ts +++ b/src/plugins/home/public/plugin.test.ts @@ -96,5 +96,13 @@ describe('HomePublicPlugin', () => { expect(setup).toHaveProperty('tutorials'); expect(setup.tutorials).toHaveProperty('setVariable'); }); + + test('wires up and register applications', async () => { + const coreMocks = coreMock.createSetup(); + await new HomePublicPlugin(mockInitializerContext).setup(coreMocks, { + urlForwarding: urlForwardingPluginMock.createSetupContract(), + }); + expect(coreMocks.application.register).toBeCalledTimes(2); + }); }); }); diff --git a/src/plugins/home/public/plugin.ts b/src/plugins/home/public/plugin.ts index 6fe459570fd1..d7867959d019 100644 --- a/src/plugins/home/public/plugin.ts +++ b/src/plugins/home/public/plugin.ts @@ -51,13 +51,16 @@ import { SectionTypeServiceSetup, } from './services'; import { ConfigSchema } from '../config'; -import { setServices } from './application/opensearch_dashboards_services'; +import { + HomeOpenSearchDashboardsServices, + setServices, +} from './application/opensearch_dashboards_services'; import { DataPublicPluginStart } from '../../data/public'; import { TelemetryPluginStart } from '../../telemetry/public'; import { UsageCollectionSetup } from '../../usage_collection/public'; import { UrlForwardingSetup, UrlForwardingStart } from '../../url_forwarding/public'; import { AppNavLinkStatus } from '../../../core/public'; -import { PLUGIN_ID, HOME_APP_BASE_PATH } from '../common/constants'; +import { PLUGIN_ID, HOME_APP_BASE_PATH, IMPORT_SAMPLE_DATA_APP_ID } from '../common/constants'; import { DataSourcePluginStart } from '../../data_source/public'; import { workWithDataSection } from './application/components/homepage/sections/work_with_data'; import { learnBasicsSection } from './application/components/homepage/sections/learn_basics'; @@ -93,43 +96,50 @@ export class HomePublicPlugin core: CoreSetup, { urlForwarding, usageCollection }: HomePluginSetupDependencies ): HomePublicPluginSetup { + const setCommonService = async ( + homeOpenSearchDashboardsServices?: Partial + ) => { + const trackUiMetric = usageCollection + ? usageCollection.reportUiStats.bind(usageCollection, 'OpenSearch_Dashboards_home') + : () => {}; + const [ + coreStart, + { telemetry, data, urlForwarding: urlForwardingStart, dataSource }, + ] = await core.getStartServices(); + setServices({ + trackUiMetric, + opensearchDashboardsVersion: this.initializerContext.env.packageInfo.version, + http: coreStart.http, + toastNotifications: core.notifications.toasts, + banners: coreStart.overlays.banners, + docLinks: coreStart.docLinks, + savedObjectsClient: coreStart.savedObjects.client, + chrome: coreStart.chrome, + application: coreStart.application, + telemetry, + uiSettings: core.uiSettings, + addBasePath: core.http.basePath.prepend, + getBasePath: core.http.basePath.get, + indexPatternService: data.indexPatterns, + environmentService: this.environmentService, + urlForwarding: urlForwardingStart, + homeConfig: this.initializerContext.config.get(), + tutorialService: this.tutorialService, + featureCatalogue: this.featuresCatalogueRegistry, + injectedMetadata: coreStart.injectedMetadata, + dataSource, + workspaces: coreStart.workspaces, + sectionTypes: this.sectionTypeService, + ...homeOpenSearchDashboardsServices, + }); + }; core.application.register({ id: PLUGIN_ID, title: 'Home', navLinkStatus: AppNavLinkStatus.hidden, mount: async (params: AppMountParameters) => { - const trackUiMetric = usageCollection - ? usageCollection.reportUiStats.bind(usageCollection, 'OpenSearch_Dashboards_home') - : () => {}; - const [ - coreStart, - { telemetry, data, urlForwarding: urlForwardingStart, dataSource }, - ] = await core.getStartServices(); - setServices({ - trackUiMetric, - opensearchDashboardsVersion: this.initializerContext.env.packageInfo.version, - http: coreStart.http, - toastNotifications: core.notifications.toasts, - banners: coreStart.overlays.banners, - docLinks: coreStart.docLinks, - savedObjectsClient: coreStart.savedObjects.client, - chrome: coreStart.chrome, - application: coreStart.application, - telemetry, - uiSettings: core.uiSettings, - addBasePath: core.http.basePath.prepend, - getBasePath: core.http.basePath.get, - indexPatternService: data.indexPatterns, - environmentService: this.environmentService, - urlForwarding: urlForwardingStart, - homeConfig: this.initializerContext.config.get(), - tutorialService: this.tutorialService, - featureCatalogue: this.featuresCatalogueRegistry, - injectedMetadata: coreStart.injectedMetadata, - dataSource, - workspaces: coreStart.workspaces, - sectionTypes: this.sectionTypeService, - }); + const [coreStart] = await core.getStartServices(); + setCommonService(); coreStart.chrome.docTitle.change( i18n.translate('home.pageTitle', { defaultMessage: 'Home' }) ); @@ -137,6 +147,26 @@ export class HomePublicPlugin return await renderApp(params.element, coreStart, params.history); }, }); + + // Register import sample data as a standalone app so that it is available inside workspace. + core.application.register({ + id: IMPORT_SAMPLE_DATA_APP_ID, + title: i18n.translate('home.tutorialDirectory.featureCatalogueTitle', { + defaultMessage: 'Add sample data', + }), + navLinkStatus: AppNavLinkStatus.hidden, + mount: async (params: AppMountParameters) => { + const [coreStart] = await core.getStartServices(); + setCommonService(); + coreStart.chrome.docTitle.change( + i18n.translate('home.tutorialDirectory.featureCatalogueTitle', { + defaultMessage: 'Add sample data', + }) + ); + const { renderImportSampleDataApp } = await import('./application'); + return await renderImportSampleDataApp(params.element, coreStart, params.history); + }, + }); urlForwarding.forwardApp('home', 'home'); const featureCatalogue = { ...this.featuresCatalogueRegistry.setup() };