diff --git a/changelogs/fragments/6105.yml b/changelogs/fragments/6105.yml new file mode 100644 index 000000000000..30038aad59c3 --- /dev/null +++ b/changelogs/fragments/6105.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace]Import sample data to current workspace ([#6105](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6105)) \ No newline at end of file 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..2bf8eb66f583 --- /dev/null +++ b/src/plugins/home/public/application/application.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useRef } from 'react'; +import { render } from '@testing-library/react'; +import { coreMock } 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); + useEffect(() => { + if (container.current) { + const destroyFn = props.renderFn(container.current, coreStartMocks); + return () => { + destroyFn.then((res) => res()); + }; + } + }, [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..7b383b53ae92 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,16 @@ export const renderApp = async ( unlisten(); }; }; + +export const renderImportSampleDataApp = async (element: HTMLElement, coreStart: CoreStart) => { + render( + + + , + element + ); + + return () => { + unmountComponentAtNode(element); + }; +}; diff --git a/src/plugins/home/public/application/components/home_app.js b/src/plugins/home/public/application/components/home_app.js index 366d162f02eb..4febddb9148d 100644 --- a/src/plugins/home/public/application/components/home_app.js +++ b/src/plugins/home/public/application/components/home_app.js @@ -51,6 +51,35 @@ 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({ + // Pass a fixed tab to avoid TutorialDirectory missing openTab property + match: { + params: { tab: 'sampleData' }, + }, + withoutHomeBreadCrumb: true, + })} + + ); +} + export function HomeApp({ directories, solutions }) { const { savedObjectsClient, @@ -63,16 +92,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 5749b6fa3882..a9e4cb263e88 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, WorkspaceAvailability } 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,42 +96,49 @@ 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, + 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, - 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 }, workspaceAvailability: WorkspaceAvailability.outsideWorkspace, }); + + // 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); + }, + }); urlForwarding.forwardApp('home', 'home'); const featureCatalogue = { ...this.featuresCatalogueRegistry.setup() }; diff --git a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts index 75e9ea50ff87..9fb6e7bedd22 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts @@ -33,7 +33,11 @@ import { i18n } from '@osd/i18n'; import { getSavedObjects } from './saved_objects'; import { fieldMappings } from './field_mappings'; import { SampleDatasetSchema, AppLinkSchema } from '../../lib/sample_dataset_registry_types'; -import { getSavedObjectsWithDataSource, appendDataSourceId } from '../util'; +import { + appendDataSourceId, + getSavedObjectsWithDataSource, + overwriteSavedObjectsWithWorkspaceId, +} from '../util'; const ecommerceName = i18n.translate('home.sampleData.ecommerceSpecTitle', { defaultMessage: 'Sample eCommerce orders', @@ -62,6 +66,8 @@ export const ecommerceSpecProvider = function (): SampleDatasetSchema { savedObjects: getSavedObjects(), getDataSourceIntegratedSavedObjects: (dataSourceId?: string, dataSourceTitle?: string) => getSavedObjectsWithDataSource(getSavedObjects(), dataSourceId, dataSourceTitle), + getWorkspaceIntegratedSavedObjects: (workspaceId) => + overwriteSavedObjectsWithWorkspaceId(getSavedObjects(), workspaceId), dataIndices: [ { id: 'ecommerce', diff --git a/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts b/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts index 415d98027c4f..c4a6d84e752c 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts @@ -33,7 +33,11 @@ import { i18n } from '@osd/i18n'; import { getSavedObjects } from './saved_objects'; import { fieldMappings } from './field_mappings'; import { SampleDatasetSchema, AppLinkSchema } from '../../lib/sample_dataset_registry_types'; -import { getSavedObjectsWithDataSource, appendDataSourceId } from '../util'; +import { + appendDataSourceId, + getSavedObjectsWithDataSource, + overwriteSavedObjectsWithWorkspaceId, +} from '../util'; const flightsName = i18n.translate('home.sampleData.flightsSpecTitle', { defaultMessage: 'Sample flight data', @@ -62,6 +66,8 @@ export const flightsSpecProvider = function (): SampleDatasetSchema { savedObjects: getSavedObjects(), getDataSourceIntegratedSavedObjects: (dataSourceId?: string, dataSourceTitle?: string) => getSavedObjectsWithDataSource(getSavedObjects(), dataSourceId, dataSourceTitle), + getWorkspaceIntegratedSavedObjects: (workspaceId) => + overwriteSavedObjectsWithWorkspaceId(getSavedObjects(), workspaceId), dataIndices: [ { id: 'flights', diff --git a/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts b/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts index 0e8eaf99d411..5466c9f3a22f 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts @@ -33,7 +33,11 @@ import { i18n } from '@osd/i18n'; import { getSavedObjects } from './saved_objects'; import { fieldMappings } from './field_mappings'; import { SampleDatasetSchema, AppLinkSchema } from '../../lib/sample_dataset_registry_types'; -import { appendDataSourceId, getSavedObjectsWithDataSource } from '../util'; +import { + appendDataSourceId, + getSavedObjectsWithDataSource, + overwriteSavedObjectsWithWorkspaceId, +} from '../util'; const logsName = i18n.translate('home.sampleData.logsSpecTitle', { defaultMessage: 'Sample web logs', @@ -62,6 +66,8 @@ export const logsSpecProvider = function (): SampleDatasetSchema { savedObjects: getSavedObjects(), getDataSourceIntegratedSavedObjects: (dataSourceId?: string, dataSourceTitle?: string) => getSavedObjectsWithDataSource(getSavedObjects(), dataSourceId, dataSourceTitle), + getWorkspaceIntegratedSavedObjects: (workspaceId) => + overwriteSavedObjectsWithWorkspaceId(getSavedObjects(), workspaceId), dataIndices: [ { id: 'logs', diff --git a/src/plugins/home/server/services/sample_data/data_sets/util.test.ts b/src/plugins/home/server/services/sample_data/data_sets/util.test.ts index 56f2f15bca64..0cefac395b9c 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/util.test.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/util.test.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { getSavedObjectsWithDataSource } from './util'; +import { getSavedObjectsWithDataSource, getFinalSavedObjects, appendDataSourceId } from './util'; import { SavedObject, updateDataSourceNameInVegaSpec } from '../../../../../../core/server'; import visualizationObjects from './test_utils/visualization_objects.json'; @@ -63,3 +63,100 @@ describe('getSavedObjectsWithDataSource()', () => { expect(updatedVegaVisualizationsFields).toEqual(expect.arrayContaining(expectedUpdatedFields)); }); }); + +describe('getFinalSavedObjects()', () => { + const savedObjects = [ + { id: 'saved-object-1', type: 'test', attributes: { title: 'Saved object 1' }, references: [] }, + ]; + const generateTestDataSet = () => { + return { + id: 'foo', + name: 'Foo', + description: 'A test sample data set', + previewImagePath: '', + darkPreviewImagePath: '', + overviewDashboard: '', + getDataSourceIntegratedDashboard: () => '', + appLinks: [], + defaultIndex: '', + getDataSourceIntegratedDefaultIndex: () => '', + savedObjects, + getDataSourceIntegratedSavedObjects: (dataSourceId?: string, dataSourceTitle?: string) => + savedObjects.map((item) => ({ + ...item, + ...(dataSourceId ? { id: `${dataSourceId}_${item.id}` } : {}), + attributes: { + ...item.attributes, + title: dataSourceTitle + ? `${item.attributes.title}_${dataSourceTitle}` + : item.attributes.title, + }, + })), + getWorkspaceIntegratedSavedObjects: (workspaceId?: string) => + savedObjects.map((item) => ({ + ...item, + ...(workspaceId ? { id: `${workspaceId}_${item.id}` } : {}), + })), + dataIndices: [], + }; + }; + it('should return consistent saved object id and title when workspace id and data source provided', () => { + expect( + getFinalSavedObjects({ + dataset: generateTestDataSet(), + workspaceId: 'workspace-1', + dataSourceId: 'datasource-1', + dataSourceTitle: 'data source 1', + }) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: `workspace-1_datasource-1_saved-object-1`, + attributes: expect.objectContaining({ + title: 'Saved object 1_data source 1', + }), + }), + ]) + ); + }); + it('should return consistent saved object id when workspace id', () => { + expect( + getFinalSavedObjects({ + dataset: generateTestDataSet(), + workspaceId: 'workspace-1', + }) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: `workspace-1_saved-object-1`, + }), + ]) + ); + }); + it('should return consistent saved object id and title when data source id and title', () => { + expect( + getFinalSavedObjects({ + dataset: generateTestDataSet(), + dataSourceId: 'data-source-1', + dataSourceTitle: 'data source 1', + }) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: `data-source-1_saved-object-1`, + attributes: expect.objectContaining({ + title: 'Saved object 1_data source 1', + }), + }), + ]) + ); + }); + it('should return original saved objects when no workspace and data source provided', () => { + const dataset = generateTestDataSet(); + expect( + getFinalSavedObjects({ + dataset, + }) + ).toBe(dataset.savedObjects); + }); +}); diff --git a/src/plugins/home/server/services/sample_data/data_sets/util.ts b/src/plugins/home/server/services/sample_data/data_sets/util.ts index c58cb2302f00..4991b02272cc 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/util.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/util.ts @@ -8,9 +8,57 @@ import { extractVegaSpecFromSavedObject, updateDataSourceNameInVegaSpec, } from '../../../../../../core/server'; +import { SampleDatasetSchema } from '../lib/sample_dataset_registry_types'; export const appendDataSourceId = (id: string) => { - return (dataSourceId?: string) => (dataSourceId ? `${dataSourceId}_` + id : id); + return (dataSourceId?: string, workspaceId?: string) => { + const idWithDataSource = dataSourceId ? `${dataSourceId}_` + id : id; + if (!workspaceId) { + return idWithDataSource; + } + return `${workspaceId}_${idWithDataSource}`; + }; +}; + +const overrideSavedObjectId = (savedObject: SavedObject, idGenerator: (id: string) => string) => { + savedObject.id = idGenerator(savedObject.id); + // update reference + if (savedObject.type === 'dashboard') { + savedObject.references.map((reference) => { + if (reference.id) { + reference.id = idGenerator(reference.id); + } + }); + } + + // update reference + if (savedObject.type === 'visualization' || savedObject.type === 'search') { + const searchSourceString = savedObject.attributes?.kibanaSavedObjectMeta?.searchSourceJSON; + const visStateString = savedObject.attributes?.visState; + + if (searchSourceString) { + const searchSource = JSON.parse(searchSourceString); + if (searchSource.index) { + searchSource.index = idGenerator(searchSource.index); + savedObject.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify( + searchSource + ); + } + } + + if (visStateString) { + const visState = JSON.parse(visStateString); + const controlList = visState.params?.controls; + if (controlList) { + controlList.map((control) => { + if (control.indexPattern) { + control.indexPattern = idGenerator(control.indexPattern); + } + }); + } + savedObject.attributes.visState = JSON.stringify(visState); + } + } }; export const getSavedObjectsWithDataSource = ( @@ -19,56 +67,9 @@ export const getSavedObjectsWithDataSource = ( dataSourceTitle?: string ): SavedObject[] => { if (dataSourceId) { + const idGenerator = (id: string) => `${dataSourceId}_${id}`; return saveObjectList.map((saveObject) => { - saveObject.id = `${dataSourceId}_` + saveObject.id; - // update reference - if (saveObject.type === 'dashboard') { - saveObject.references.map((reference) => { - if (reference.id) { - reference.id = `${dataSourceId}_` + reference.id; - } - }); - } - - // update reference - if (saveObject.type === 'visualization' || saveObject.type === 'search') { - const searchSourceString = saveObject.attributes?.kibanaSavedObjectMeta?.searchSourceJSON; - const visStateString = saveObject.attributes?.visState; - - if (searchSourceString) { - const searchSource = JSON.parse(searchSourceString); - if (searchSource.index) { - searchSource.index = `${dataSourceId}_` + searchSource.index; - saveObject.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify( - searchSource - ); - } - } - - if (visStateString) { - const visState = JSON.parse(visStateString); - const controlList = visState.params?.controls; - if (controlList) { - controlList.map((control) => { - if (control.indexPattern) { - control.indexPattern = `${dataSourceId}_` + control.indexPattern; - } - }); - } - saveObject.attributes.visState = JSON.stringify(visState); - } - } - - // update reference - if (saveObject.type === 'index-pattern') { - saveObject.references = [ - { - id: `${dataSourceId}`, - type: 'data-source', - name: 'dataSource', - }, - ]; - } + overrideSavedObjectId(saveObject, idGenerator); if (dataSourceTitle) { if ( @@ -111,3 +112,41 @@ export const getSavedObjectsWithDataSource = ( return saveObjectList; }; + +export const overwriteSavedObjectsWithWorkspaceId = ( + savedObjectList: SavedObject[], + workspaceId: string +) => { + const idGenerator = (id: string) => `${workspaceId}_${id}`; + savedObjectList.forEach((savedObject) => { + overrideSavedObjectId(savedObject, idGenerator); + }); + return savedObjectList; +}; + +export const getFinalSavedObjects = ({ + dataset, + workspaceId, + dataSourceId, + dataSourceTitle, +}: { + dataset: SampleDatasetSchema; + workspaceId?: string; + dataSourceId?: string; + dataSourceTitle?: string; +}) => { + if (workspaceId && dataSourceId) { + return overwriteSavedObjectsWithWorkspaceId( + dataset.getDataSourceIntegratedSavedObjects(dataSourceId, dataSourceTitle), + workspaceId + ); + } + if (workspaceId) { + return dataset.getWorkspaceIntegratedSavedObjects(workspaceId); + } + if (dataSourceId) { + return dataset.getDataSourceIntegratedSavedObjects(dataSourceId, dataSourceTitle); + } + + return dataset.savedObjects; +}; diff --git a/src/plugins/home/server/services/sample_data/lib/sample_dataset_registry_types.ts b/src/plugins/home/server/services/sample_data/lib/sample_dataset_registry_types.ts index 5f6d036d6b39..625427265858 100644 --- a/src/plugins/home/server/services/sample_data/lib/sample_dataset_registry_types.ts +++ b/src/plugins/home/server/services/sample_data/lib/sample_dataset_registry_types.ts @@ -89,12 +89,12 @@ export interface SampleDatasetSchema { // saved object id of main dashboard for sample data set overviewDashboard: string; - getDataSourceIntegratedDashboard: (dataSourceId?: string) => string; + getDataSourceIntegratedDashboard: (dataSourceId?: string, workspaceId?: string) => string; appLinks: AppLinkSchema[]; // saved object id of default index-pattern for sample data set defaultIndex: string; - getDataSourceIntegratedDefaultIndex: (dataSourceId?: string) => string; + getDataSourceIntegratedDefaultIndex: (dataSourceId?: string, workspaceId?: string) => string; // OpenSearch Dashboards saved objects (index patter, visualizations, dashboard, ...) // Should provide a nice demo of OpenSearch Dashboards's functionality with the sample data set @@ -103,6 +103,7 @@ export interface SampleDatasetSchema { dataSourceId?: string, dataSourceTitle?: string ) => Array>; + getWorkspaceIntegratedSavedObjects: (workspaceId: string) => Array>; dataIndices: DataIndexSchema[]; status?: string | undefined; statusMsg?: unknown; diff --git a/src/plugins/home/server/services/sample_data/routes/install.test.ts b/src/plugins/home/server/services/sample_data/routes/install.test.ts index ad7b421c23d5..9e174b5a53cb 100644 --- a/src/plugins/home/server/services/sample_data/routes/install.test.ts +++ b/src/plugins/home/server/services/sample_data/routes/install.test.ts @@ -5,6 +5,7 @@ import { CoreSetup, RequestHandlerContext } from 'src/core/server'; import { coreMock, httpServerMock } from '../../../../../../core/server/mocks'; +import { updateWorkspaceState } from '../../../../../../core/server/utils'; import { flightsSpecProvider } from '../data_sets'; import { SampleDatasetSchema } from '../lib/sample_dataset_registry_types'; import { createInstallRoute } from './install'; @@ -157,4 +158,68 @@ describe('sample data install route', () => { }, }); }); + + it('handler calls expected api with the given request with workspace', async () => { + const mockWorkspaceId = 'workspace'; + + const mockClient = jest.fn().mockResolvedValue(true); + + const mockSOClientGetResponse = { + saved_objects: [ + { + type: 'dashboard', + id: '12345', + namespaces: ['default'], + attributes: { title: 'dashboard' }, + }, + ], + }; + const mockSOClient = { + bulkCreate: jest.fn().mockResolvedValue(mockSOClientGetResponse), + get: jest.fn().mockResolvedValue(mockSOClientGetResponse), + }; + + const mockContext = { + core: { + opensearch: { + legacy: { + client: { callAsCurrentUser: mockClient }, + }, + }, + savedObjects: { client: mockSOClient }, + }, + }; + const mockBody = { id: 'flights' }; + const mockQuery = {}; + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest({ + params: mockBody, + query: mockQuery, + }); + updateWorkspaceState(mockRequest, { requestWorkspaceId: mockWorkspaceId }); + const mockResponse = httpServerMock.createResponseFactory(); + + createInstallRoute( + mockCoreSetup.http.createRouter(), + sampleDatasets, + mockLogger, + mockUsageTracker + ); + + const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; + const handler = mockRouter.post.mock.calls[0][1]; + + await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + + expect(mockClient.mock.calls[1][1].body.settings).toMatchObject({ + index: { number_of_shards: 1 }, + }); + + expect(mockResponse.ok).toBeCalled(); + expect(mockResponse.ok.mock.calls[0][0]).toMatchObject({ + body: { + opensearchIndicesCreated: { opensearch_dashboards_sample_data_flights: 13059 }, + opensearchDashboardsSavedObjectsLoaded: 20, + }, + }); + }); }); diff --git a/src/plugins/home/server/services/sample_data/routes/install.ts b/src/plugins/home/server/services/sample_data/routes/install.ts index 279357fc1977..8134f8099352 100644 --- a/src/plugins/home/server/services/sample_data/routes/install.ts +++ b/src/plugins/home/server/services/sample_data/routes/install.ts @@ -30,6 +30,7 @@ import { schema } from '@osd/config-schema'; import { IRouter, LegacyCallAPIOptions, Logger } from 'src/core/server'; +import { getWorkspaceState } from '../../../../../../core/server/utils'; import { SampleDatasetSchema } from '../lib/sample_dataset_registry_types'; import { createIndexName } from '../lib/create_index_name'; import { @@ -39,6 +40,7 @@ import { } from '../lib/translate_timestamp'; import { loadData } from '../lib/load_data'; import { SampleDataUsageTracker } from '../usage/usage'; +import { getFinalSavedObjects } from '../data_sets/util'; const insertDataIntoIndex = ( dataIndexConfig: any, @@ -119,6 +121,8 @@ export function createInstallRoute( async (context, req, res) => { const { params, query } = req; const dataSourceId = query.data_source_id; + const workspaceState = getWorkspaceState(req); + const workspaceId = workspaceState?.requestWorkspaceId; const sampleDataset = sampleDatasets.find(({ id }) => id === params.id); if (!sampleDataset) { @@ -198,9 +202,12 @@ export function createInstallRoute( } let createResults; - const savedObjectsList = dataSourceId - ? sampleDataset.getDataSourceIntegratedSavedObjects(dataSourceId, dataSourceTitle) - : sampleDataset.savedObjects; + const savedObjectsList = getFinalSavedObjects({ + dataset: sampleDataset, + workspaceId, + dataSourceId, + dataSourceTitle, + }); try { createResults = await context.core.savedObjects.client.bulkCreate( diff --git a/src/plugins/home/server/services/sample_data/routes/list.test.ts b/src/plugins/home/server/services/sample_data/routes/list.test.ts index 70201fafd06b..445cc1e18a05 100644 --- a/src/plugins/home/server/services/sample_data/routes/list.test.ts +++ b/src/plugins/home/server/services/sample_data/routes/list.test.ts @@ -4,6 +4,7 @@ */ import { CoreSetup, RequestHandlerContext } from 'src/core/server'; +import { updateWorkspaceState } from '../../../../../../core/server/utils'; import { coreMock, httpServerMock } from '../../../../../../core/server/mocks'; import { createListRoute } from './list'; import { flightsSpecProvider } from '../data_sets'; @@ -119,4 +120,111 @@ describe('sample data list route', () => { `${mockDataSourceId}_7adfa750-4c81-11e8-b3d7-01146121b73d` ); }); + + it('handler calls expected api with the given request with workspace', async () => { + const mockWorkspaceId = 'workspace'; + const mockClient = jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce({ count: 1 }); + + const mockSOClientGetResponse = { + saved_objects: [ + { + type: 'dashboard', + id: `${mockWorkspaceId}_7adfa750-4c81-11e8-b3d7-01146121b73d`, + namespaces: ['default'], + attributes: { title: 'dashboard' }, + }, + ], + }; + const mockSOClient = { get: jest.fn().mockResolvedValue(mockSOClientGetResponse) }; + + const mockContext = { + core: { + opensearch: { + legacy: { + client: { callAsCurrentUser: mockClient }, + }, + }, + savedObjects: { client: mockSOClient }, + }, + }; + + const mockBody = {}; + const mockQuery = {}; + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest({ + body: mockBody, + query: mockQuery, + }); + updateWorkspaceState(mockRequest, { requestWorkspaceId: mockWorkspaceId }); + const mockResponse = httpServerMock.createResponseFactory(); + + createListRoute(mockCoreSetup.http.createRouter(), sampleDatasets); + + const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; + const handler = mockRouter.get.mock.calls[0][1]; + + await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + + expect(mockClient).toBeCalledTimes(2); + expect(mockResponse.ok).toBeCalled(); + expect(mockSOClient.get.mock.calls[0][1]).toMatch( + `${mockWorkspaceId}_7adfa750-4c81-11e8-b3d7-01146121b73d` + ); + }); + + it('handler calls expected api with the given request with workspace and data source', async () => { + const mockWorkspaceId = 'workspace'; + const mockDataSourceId = 'dataSource'; + const mockClient = jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce({ count: 1 }); + + const mockSOClientGetResponse = { + saved_objects: [ + { + type: 'dashboard', + id: `${mockWorkspaceId}_${mockDataSourceId}_7adfa750-4c81-11e8-b3d7-01146121b73d`, + namespaces: ['default'], + attributes: { title: 'dashboard' }, + }, + ], + }; + const mockSOClient = { get: jest.fn().mockResolvedValue(mockSOClientGetResponse) }; + + const mockContext = { + dataSource: { + opensearch: { + legacy: { + getClient: (id) => { + return { + callAPI: mockClient, + }; + }, + }, + }, + }, + core: { + savedObjects: { client: mockSOClient }, + }, + }; + + const mockBody = {}; + const mockQuery = { data_source_id: mockDataSourceId }; + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest({ + body: mockBody, + query: mockQuery, + }); + updateWorkspaceState(mockRequest, { requestWorkspaceId: mockWorkspaceId }); + const mockResponse = httpServerMock.createResponseFactory(); + + createListRoute(mockCoreSetup.http.createRouter(), sampleDatasets); + + const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; + const handler = mockRouter.get.mock.calls[0][1]; + + await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + + expect(mockClient).toBeCalledTimes(2); + expect(mockResponse.ok).toBeCalled(); + expect(mockSOClient.get.mock.calls[0][1]).toMatch( + `${mockWorkspaceId}_${mockDataSourceId}_7adfa750-4c81-11e8-b3d7-01146121b73d` + ); + }); }); diff --git a/src/plugins/home/server/services/sample_data/routes/list.ts b/src/plugins/home/server/services/sample_data/routes/list.ts index 5d4b036a9ead..9a3f1acbead2 100644 --- a/src/plugins/home/server/services/sample_data/routes/list.ts +++ b/src/plugins/home/server/services/sample_data/routes/list.ts @@ -30,6 +30,7 @@ import { IRouter } from 'src/core/server'; import { schema } from '@osd/config-schema'; +import { getWorkspaceState } from '../../../../../../core/server/utils'; import { SampleDatasetSchema } from '../lib/sample_dataset_registry_types'; import { createIndexName } from '../lib/create_index_name'; @@ -42,11 +43,15 @@ export const createListRoute = (router: IRouter, sampleDatasets: SampleDatasetSc { path: '/api/sample_data', validate: { - query: schema.object({ data_source_id: schema.maybe(schema.string()) }), + query: schema.object({ + data_source_id: schema.maybe(schema.string()), + }), }, }, async (context, req, res) => { const dataSourceId = req.query.data_source_id; + const workspaceState = getWorkspaceState(req); + const workspaceId = workspaceState?.requestWorkspaceId; const registeredSampleDatasets = sampleDatasets.map((sampleDataset) => { return { @@ -56,9 +61,15 @@ export const createListRoute = (router: IRouter, sampleDatasets: SampleDatasetSc previewImagePath: sampleDataset.previewImagePath, darkPreviewImagePath: sampleDataset.darkPreviewImagePath, hasNewThemeImages: sampleDataset.hasNewThemeImages, - overviewDashboard: sampleDataset.getDataSourceIntegratedDashboard(dataSourceId), + overviewDashboard: sampleDataset.getDataSourceIntegratedDashboard( + dataSourceId, + workspaceId + ), appLinks: sampleDataset.appLinks, - defaultIndex: sampleDataset.getDataSourceIntegratedDefaultIndex(dataSourceId), + defaultIndex: sampleDataset.getDataSourceIntegratedDefaultIndex( + dataSourceId, + workspaceId + ), dataIndices: sampleDataset.dataIndices.map(({ id }) => ({ id })), status: sampleDataset.status, statusMsg: sampleDataset.statusMsg, diff --git a/src/plugins/home/server/services/sample_data/routes/uninstall.test.ts b/src/plugins/home/server/services/sample_data/routes/uninstall.test.ts index 7d9797d752cb..b3fb029d1f04 100644 --- a/src/plugins/home/server/services/sample_data/routes/uninstall.test.ts +++ b/src/plugins/home/server/services/sample_data/routes/uninstall.test.ts @@ -5,6 +5,7 @@ import { CoreSetup, RequestHandlerContext } from 'src/core/server'; import { coreMock, httpServerMock } from '../../../../../../core/server/mocks'; +import { updateWorkspaceState } from '../../../../../../core/server/utils'; import { flightsSpecProvider } from '../data_sets'; import { SampleDatasetSchema } from '../lib/sample_dataset_registry_types'; import { createUninstallRoute } from './uninstall'; @@ -98,4 +99,36 @@ describe('sample data uninstall route', () => { expect(mockClient).toBeCalled(); expect(mockSOClient.delete).toBeCalled(); }); + + it('handler calls expected api with the given request with workspace', async () => { + const mockWorkspaceId = 'workspace'; + const mockContext = { + core: { + opensearch: { + legacy: { + client: { callAsCurrentUser: mockClient }, + }, + }, + savedObjects: { client: mockSOClient }, + }, + }; + const mockBody = { id: 'flights' }; + const mockQuery = {}; + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest({ + params: mockBody, + query: mockQuery, + }); + updateWorkspaceState(mockRequest, { requestWorkspaceId: mockWorkspaceId }); + const mockResponse = httpServerMock.createResponseFactory(); + + createUninstallRoute(mockCoreSetup.http.createRouter(), sampleDatasets, mockUsageTracker); + + const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; + const handler = mockRouter.delete.mock.calls[0][1]; + + await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + + expect(mockClient).toBeCalled(); + expect(mockSOClient.delete).toBeCalled(); + }); }); diff --git a/src/plugins/home/server/services/sample_data/routes/uninstall.ts b/src/plugins/home/server/services/sample_data/routes/uninstall.ts index d5a09ce56070..472bc6bc4aad 100644 --- a/src/plugins/home/server/services/sample_data/routes/uninstall.ts +++ b/src/plugins/home/server/services/sample_data/routes/uninstall.ts @@ -31,9 +31,11 @@ import { schema } from '@osd/config-schema'; import _ from 'lodash'; import { IRouter } from 'src/core/server'; +import { getWorkspaceState } from '../../../../../../core/server/utils'; import { SampleDatasetSchema } from '../lib/sample_dataset_registry_types'; import { createIndexName } from '../lib/create_index_name'; import { SampleDataUsageTracker } from '../usage/usage'; +import { getFinalSavedObjects } from '../data_sets/util'; export function createUninstallRoute( router: IRouter, @@ -53,6 +55,8 @@ export function createUninstallRoute( async (context, request, response) => { const sampleDataset = sampleDatasets.find(({ id }) => id === request.params.id); const dataSourceId = request.query.data_source_id; + const workspaceState = getWorkspaceState(request); + const workspaceId = workspaceState?.requestWorkspaceId; if (!sampleDataset) { return response.notFound(); @@ -78,9 +82,11 @@ export function createUninstallRoute( } } - const savedObjectsList = dataSourceId - ? sampleDataset.getDataSourceIntegratedSavedObjects(dataSourceId) - : sampleDataset.savedObjects; + const savedObjectsList = getFinalSavedObjects({ + dataset: sampleDataset, + workspaceId, + dataSourceId, + }); const deletePromises = savedObjectsList.map(({ type, id }) => context.core.savedObjects.client.delete(type, id) diff --git a/src/plugins/workspace/public/components/workspace_overview/__snapshots__/workspace_overview.test.tsx.snap b/src/plugins/workspace/public/components/workspace_overview/__snapshots__/workspace_overview.test.tsx.snap index 357f190534a3..650f0775b8e4 100644 --- a/src/plugins/workspace/public/components/workspace_overview/__snapshots__/workspace_overview.test.tsx.snap +++ b/src/plugins/workspace/public/components/workspace_overview/__snapshots__/workspace_overview.test.tsx.snap @@ -50,6 +50,60 @@ exports[`WorkspaceOverview render workspace overview page normally 1`] = ` class="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--directionRow euiFlexGroup--responsive" data-test-subj="workspaceGetStartCards" > +
+
+
+ + + + +
+ +
+
diff --git a/src/plugins/workspace/public/components/workspace_overview/all_get_started_cards.ts b/src/plugins/workspace/public/components/workspace_overview/all_get_started_cards.ts index c9986b00f5c1..ad7dcad86bb8 100644 --- a/src/plugins/workspace/public/components/workspace_overview/all_get_started_cards.ts +++ b/src/plugins/workspace/public/components/workspace_overview/all_get_started_cards.ts @@ -12,10 +12,10 @@ import { WORKSPACE_APP_CATEGORIES } from '../../../common/constants'; export const getStartCards: GetStartCard[] = [ // getStarted { - id: 'home', + id: '', // set id as empty so that it will always show up featureDescription: 'Discover pre-loaded datasets before adding your own.', featureName: 'Sample Datasets', - link: '/app/home#/tutorial_directory', + link: '/app/import_sample_data', category: WORKSPACE_APP_CATEGORIES.getStarted, }, {