diff --git a/CHANGELOG.md b/CHANGELOG.md index d41a5049059f..69bcd6404eb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) * [Multi DataSource] UX enhacement on index pattern management stack ([#2505](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2505)) * [Multi DataSource] UX enhancement on Data source management stack ([#2521](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2521)) * [Multi DataSource] UX enhancement on Update stored password modal for Data source management stack ([#2532](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2532)) +* [Viz Builder] Create a new wizard directly on a dashboard ([#2384](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2384)) ### 🐛 Bug Fixes diff --git a/src/plugins/wizard/public/application/components/top_nav.tsx b/src/plugins/wizard/public/application/components/top_nav.tsx index 807fb90ecad4..ab23aa563e91 100644 --- a/src/plugins/wizard/public/application/components/top_nav.tsx +++ b/src/plugins/wizard/public/application/components/top_nav.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useMemo } from 'react'; +import React, { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { useUnmount } from 'react-use'; import { PLUGIN_ID } from '../../../common'; @@ -17,6 +17,7 @@ import { useTypedSelector, useTypedDispatch } from '../utils/state_management'; import { setEditorState } from '../utils/state_management/metadata_slice'; import { useCanSave } from '../utils/use/use_can_save'; import { saveStateToSavedObject } from '../../saved_visualizations/transforms'; +import { TopNavMenuData } from '../../../../navigation/public'; export const TopNav = () => { // id will only be set for the edit route @@ -34,27 +35,32 @@ export const TopNav = () => { const saveDisabledReason = useCanSave(); const savedWizardVis = useSavedWizardVis(visualizationIdFromUrl); const { selected: indexPattern } = useIndexPatterns(); + const [config, setConfig] = useState(); - const config = useMemo(() => { - if (!savedWizardVis || !indexPattern) return; + useEffect(() => { + const getConfig = () => { + if (!savedWizardVis || !indexPattern) return; - return getTopNavConfig( - { - visualizationIdFromUrl, - savedWizardVis: saveStateToSavedObject(savedWizardVis, rootState, indexPattern), - saveDisabledReason, - dispatch, - }, - services - ); + return getTopNavConfig( + { + visualizationIdFromUrl, + savedWizardVis: saveStateToSavedObject(savedWizardVis, rootState, indexPattern), + saveDisabledReason, + dispatch, + }, + services + ); + }; + + setConfig(getConfig()); }, [ + rootState, savedWizardVis, + services, visualizationIdFromUrl, - rootState, - indexPattern, saveDisabledReason, dispatch, - services, + indexPattern, ]); // reset validity before component destroyed diff --git a/src/plugins/wizard/public/application/utils/get_top_nav_config.test.tsx b/src/plugins/wizard/public/application/utils/get_top_nav_config.test.tsx new file mode 100644 index 000000000000..3928858d08e8 --- /dev/null +++ b/src/plugins/wizard/public/application/utils/get_top_nav_config.test.tsx @@ -0,0 +1,157 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WizardServices } from '../../types'; +import { getOnSave } from './get_top_nav_config'; +import { createWizardServicesMock } from './mocks'; + +describe('getOnSave', () => { + let savedWizardVis: any; + let originatingApp: string | undefined; + let visualizationIdFromUrl: string; + let dispatch: any; + let mockServices: jest.Mocked; + let onSaveProps: { + newTitle: string; + newCopyOnSave: boolean; + isTitleDuplicateConfirmed: boolean; + onTitleDuplicate: any; + newDescription: string; + returnToOrigin: boolean; + }; + + beforeEach(() => { + savedWizardVis = { + id: '1', + title: 'save wizard wiz title', + description: '', + visualizationState: '', + styleState: '', + version: 0, + copyOnSave: true, + searchSourceFields: {}, + save: jest.fn().mockReturnValue('1'), + }; + originatingApp = ''; + visualizationIdFromUrl = ''; + dispatch = jest.fn(); + mockServices = createWizardServicesMock(); + + onSaveProps = { + newTitle: 'new title', + newCopyOnSave: false, + isTitleDuplicateConfirmed: false, + onTitleDuplicate: jest.fn(), + newDescription: 'new description', + returnToOrigin: true, + }; + }); + + test('return undefined when savedWizardVis is null', async () => { + savedWizardVis = null; + const onSave = getOnSave( + savedWizardVis, + originatingApp, + visualizationIdFromUrl, + dispatch, + mockServices + ); + const onSaveResult = await onSave(onSaveProps); + + expect(onSaveResult).toBeUndefined(); + }); + + test('savedWizardVis get saved correctly', async () => { + const onSave = getOnSave( + savedWizardVis, + originatingApp, + visualizationIdFromUrl, + dispatch, + mockServices + ); + const onSaveReturn = await onSave(onSaveProps); + expect(savedWizardVis).toMatchInlineSnapshot(` + Object { + "copyOnSave": false, + "description": "new description", + "id": "1", + "save": [MockFunction] { + "calls": Array [ + Array [ + Object { + "confirmOverwrite": false, + "isTitleDuplicateConfirmed": false, + "onTitleDuplicate": [MockFunction], + "returnToOrigin": true, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": "1", + }, + ], + }, + "searchSourceFields": Object {}, + "styleState": "", + "title": "new title", + "version": 0, + "visualizationState": "", + } + `); + expect(onSaveReturn?.id).toBe('1'); + }); + + test('savedWizardVis does not change title with a null id', async () => { + savedWizardVis.save = jest.fn().mockReturnValue(null); + const onSave = getOnSave( + savedWizardVis, + originatingApp, + visualizationIdFromUrl, + dispatch, + mockServices + ); + const onSaveResult = await onSave(onSaveProps); + expect(savedWizardVis.title).toBe('save wizard wiz title'); + expect(onSaveResult?.id).toBeNull(); + }); + + test('create a new wizard from dashboard', async () => { + savedWizardVis.id = undefined; + savedWizardVis.save = jest.fn().mockReturnValue('2'); + originatingApp = 'dashboard'; + onSaveProps.returnToOrigin = true; + + const onSave = getOnSave( + savedWizardVis, + originatingApp, + visualizationIdFromUrl, + dispatch, + mockServices + ); + const onSaveResult = await onSave(onSaveProps); + expect(onSaveResult?.id).toBe('2'); + expect(dispatch).toBeCalledTimes(0); + }); + + test('edit an exising wizard from dashboard', async () => { + savedWizardVis.copyOnSave = false; + onSaveProps.newDescription = 'new description after editing'; + originatingApp = 'dashboard'; + onSaveProps.returnToOrigin = true; + const onSave = getOnSave( + savedWizardVis, + originatingApp, + visualizationIdFromUrl, + dispatch, + mockServices + ); + const onSaveResult = await onSave(onSaveProps); + expect(onSaveResult?.id).toBe('1'); + expect(mockServices.application.navigateToApp).toBeCalledTimes(1); + expect(savedWizardVis.description).toBe('new description after editing'); + }); +}); diff --git a/src/plugins/wizard/public/application/utils/get_top_nav_config.tsx b/src/plugins/wizard/public/application/utils/get_top_nav_config.tsx index cad63d62c9a7..1afd9358d50d 100644 --- a/src/plugins/wizard/public/application/utils/get_top_nav_config.tsx +++ b/src/plugins/wizard/public/application/utils/get_top_nav_config.tsx @@ -41,7 +41,7 @@ import { WizardVisSavedObject } from '../../types'; import { AppDispatch } from './state_management'; import { EDIT_PATH } from '../../../common'; import { setEditorState } from './state_management/metadata_slice'; -interface TopNavConfigParams { +export interface TopNavConfigParams { visualizationIdFromUrl: string; savedWizardVis: WizardVisSavedObject; saveDisabledReason?: string; @@ -50,8 +50,20 @@ interface TopNavConfigParams { export const getTopNavConfig = ( { visualizationIdFromUrl, savedWizardVis, saveDisabledReason, dispatch }: TopNavConfigParams, - { history, toastNotifications, i18n: { Context: I18nContext } }: WizardServices + services: WizardServices ) => { + const { + i18n: { Context: I18nContext }, + embeddable, + scopedHistory, + } = services; + + const { originatingApp } = + embeddable + .getStateTransfer(scopedHistory) + .getIncomingEditorState({ keysToRemoveAfterFetch: ['id', 'input'] }) || {}; + const stateTransfer = embeddable.getStateTransfer(); + const topNavConfig: TopNavMenuData[] = [ { id: 'save', @@ -68,86 +80,20 @@ export const getTopNavConfig = ( disableButton: !!saveDisabledReason, tooltip: saveDisabledReason, run: (_anchorElement) => { - const onSave = async ({ - newTitle, - newCopyOnSave, - isTitleDuplicateConfirmed, - onTitleDuplicate, - newDescription, - returnToOrigin, - }: OnSaveProps & { returnToOrigin: boolean }) => { - if (!savedWizardVis) { - return; - } - const currentTitle = savedWizardVis.title; - savedWizardVis.title = newTitle; - savedWizardVis.description = newDescription; - savedWizardVis.copyOnSave = newCopyOnSave; - - try { - const id = await savedWizardVis.save({ - confirmOverwrite: false, - isTitleDuplicateConfirmed, - onTitleDuplicate, - returnToOrigin, - }); - - if (id) { - toastNotifications.addSuccess({ - title: i18n.translate( - 'wizard.topNavMenu.saveVisualization.successNotificationText', - { - defaultMessage: `Saved '{visTitle}'`, - values: { - visTitle: savedWizardVis.title, - }, - } - ), - 'data-test-subj': 'saveVisualizationSuccess', - }); - - // Update URL - if (id !== visualizationIdFromUrl) { - history.push({ - ...history.location, - pathname: `${EDIT_PATH}/${id}`, - }); - } - dispatch(setEditorState({ state: 'clean' })); - } else { - // reset title if save not successful - savedWizardVis.title = currentTitle; - } - - // Even if id='', which it will be for a duplicate title warning, we still want to return it, to avoid closing the modal - return { id }; - } catch (error: any) { - // eslint-disable-next-line no-console - console.error(error); - - toastNotifications.addDanger({ - title: i18n.translate('wizard.topNavMenu.saveVisualization.failureNotificationText', { - defaultMessage: `Error on saving '{visTitle}'`, - values: { - visTitle: newTitle, - }, - }), - text: error.message, - 'data-test-subj': 'saveVisualizationError', - }); - - // reset title if save not successful - savedWizardVis.title = currentTitle; - return { error }; - } - }; - const saveModal = ( {}} + originatingApp={originatingApp} + getAppNameFromId={stateTransfer.getAppNameFromId} /> ); @@ -158,3 +104,101 @@ export const getTopNavConfig = ( return topNavConfig; }; + +export const getOnSave = ( + savedWizardVis, + originatingApp, + visualizationIdFromUrl, + dispatch, + services +) => { + const onSave = async ({ + newTitle, + newCopyOnSave, + isTitleDuplicateConfirmed, + onTitleDuplicate, + newDescription, + returnToOrigin, + }: OnSaveProps & { returnToOrigin: boolean }) => { + const { embeddable, toastNotifications, application, history } = services; + const stateTransfer = embeddable.getStateTransfer(); + + if (!savedWizardVis) { + return; + } + const newlyCreated = !savedWizardVis.id || savedWizardVis.copyOnSave; + const currentTitle = savedWizardVis.title; + savedWizardVis.title = newTitle; + savedWizardVis.description = newDescription; + savedWizardVis.copyOnSave = newCopyOnSave; + + try { + const id = await savedWizardVis.save({ + confirmOverwrite: false, + isTitleDuplicateConfirmed, + onTitleDuplicate, + returnToOrigin, + }); + + if (id) { + toastNotifications.addSuccess({ + title: i18n.translate('wizard.topNavMenu.saveVisualization.successNotificationText', { + defaultMessage: `Saved '{visTitle}'`, + values: { + visTitle: savedWizardVis.title, + }, + }), + 'data-test-subj': 'saveVisualizationSuccess', + }); + + if (originatingApp && returnToOrigin) { + // create or edit wizard directly from another app, such as `dashboard` + if (newlyCreated && stateTransfer) { + // create new embeddable to transfer to originatingApp + stateTransfer.navigateToWithEmbeddablePackage(originatingApp, { + state: { type: 'wizard', input: { savedObjectId: id } }, + }); + return { id }; + } else { + // update an existing wizard from another app + application.navigateToApp(originatingApp); + } + } + + // Update URL + if (id !== visualizationIdFromUrl) { + history.push({ + ...history.location, + pathname: `${EDIT_PATH}/${id}`, + }); + } + dispatch(setEditorState({ state: 'clean' })); + } else { + // reset title if save not successful + savedWizardVis.title = currentTitle; + } + + // Even if id='', which it will be for a duplicate title warning, we still want to return it, to avoid closing the modal + return { id }; + } catch (error: any) { + // eslint-disable-next-line no-console + console.error(error); + + toastNotifications.addDanger({ + title: i18n.translate('wizard.topNavMenu.saveVisualization.failureNotificationText', { + defaultMessage: `Error on saving '{visTitle}'`, + values: { + visTitle: newTitle, + }, + }), + text: error.message, + 'data-test-subj': 'saveVisualizationError', + }); + + // reset title if save not successful + savedWizardVis.title = currentTitle; + return { error }; + } + }; + return onSave; +}; diff --git a/src/plugins/wizard/public/application/utils/mocks.ts b/src/plugins/wizard/public/application/utils/mocks.ts new file mode 100644 index 000000000000..3898b3121164 --- /dev/null +++ b/src/plugins/wizard/public/application/utils/mocks.ts @@ -0,0 +1,45 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ScopedHistory } from '../../../../../core/public'; +import { coreMock, scopedHistoryMock } from '../../../../../core/public/mocks'; +import { dataPluginMock } from '../../../../data/public/mocks'; +import { embeddablePluginMock } from '../../../../embeddable/public/mocks'; +import { expressionsPluginMock } from '../../../../expressions/public/mocks'; +import { navigationPluginMock } from '../../../../navigation/public/mocks'; +import { WizardServices } from '../../types'; + +export const createWizardServicesMock = () => { + const coreStartMock = coreMock.createStart(); + const toastNotifications = coreStartMock.notifications.toasts; + const applicationMock = coreStartMock.application; + const i18nContextMock = coreStartMock.i18n.Context; + const indexPatternMock = dataPluginMock.createStartContract().indexPatterns; + const embeddableMock = embeddablePluginMock.createStartContract(); + const navigationMock = navigationPluginMock.createStartContract(); + const expressionMock = expressionsPluginMock.createStartContract(); + + const wizardServicesMock = { + ...coreStartMock, + navigation: navigationMock, + expression: expressionMock, + savedWizardLoader: { + get: jest.fn(), + } as any, + setHeaderActionMenu: () => {}, + applicationMock, + history: { + push: jest.fn(), + location: { pathname: '' }, + }, + toastNotifications, + i18n: i18nContextMock, + data: indexPatternMock, + embeddable: embeddableMock, + scopedHistory: (scopedHistoryMock.create() as unknown) as ScopedHistory, + }; + + return (wizardServicesMock as unknown) as jest.Mocked; +}; diff --git a/src/plugins/wizard/public/plugin.ts b/src/plugins/wizard/public/plugin.ts index 5746913bd4ad..7b0e983c4a83 100644 --- a/src/plugins/wizard/public/plugin.ts +++ b/src/plugins/wizard/public/plugin.ts @@ -87,6 +87,8 @@ export class WizardPlugin setHeaderActionMenu: params.setHeaderActionMenu, types: typeService.start(), savedWizardLoader: selfStart.savedWizardLoader, + embeddable: pluginsStart.embeddable, + scopedHistory: params.history, }; // Instantiate the store diff --git a/src/plugins/wizard/public/types.ts b/src/plugins/wizard/public/types.ts index f8371b832bdc..8745a6bfbe18 100644 --- a/src/plugins/wizard/public/types.ts +++ b/src/plugins/wizard/public/types.ts @@ -13,7 +13,7 @@ import { NavigationPublicPluginStart } from '../../navigation/public'; import { DataPublicPluginStart } from '../../data/public'; import { TypeServiceSetup, TypeServiceStart } from './services/type_service'; import { SavedObjectLoader } from '../../saved_objects/public'; -import { AppMountParameters, CoreStart, ToastsStart } from '../../../core/public'; +import { AppMountParameters, CoreStart, ToastsStart, ScopedHistory } from '../../../core/public'; export type WizardSetup = TypeServiceSetup; export interface WizardStart extends TypeServiceStart { @@ -43,6 +43,8 @@ export interface WizardServices extends CoreStart { types: TypeServiceStart; expressions: ExpressionsStart; history: History; + embeddable: EmbeddableStart; + scopedHistory: ScopedHistory; } export interface ISavedVis {