diff --git a/CHANGELOG.md b/CHANGELOG.md index c24e5ec31654..f6cadcac29ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Use mirrors to download Node.js binaries to escape sporadic 404 errors ([#3619](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3619)) - [Multiple DataSource] Refactor dev tool console to use opensearch-js client to send requests ([#3544](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3544)) - [Data] Add geo shape filter field ([#3605](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3605)) +- [VisBuilder] Add UI actions handler ([#3732](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3732)) - [Dashboard] Indicate that IE is no longer supported ([#3641](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3641)) - [UI] Add support for comma delimiters in the global filter bar ([#3686](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3686)) - [Multiple DataSource] Allow create and distinguish index pattern with same name but from different datasources ([#3571](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3571)) @@ -117,6 +118,9 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Clean up and rebuild `@osd/pm` ([#3570](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3570)) - [Vega] Add Filter custom label for opensearchDashboardsAddFilter ([#3640](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3640)) - [Timeline] Fix y-axis label color in dark mode ([#3698](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3698)) +- [VisBuilder] Fix multiple warnings thrown on page load ([#3732](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3732)) +- [VisBuilder] Fix Firefox legend selection issue ([#3732](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3732)) +- [VisBuilder] Fix type errors ([#3732](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3732)) - [Table Visualization] Fix table rendering empty unused space ([#3797](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3797)) - [Table Visualization] Fix data table not adjusting height on the initial load ([#3816](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3816)) - Cleanup unused url ([#3847](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3847)) diff --git a/src/plugins/vis_builder/opensearch_dashboards.json b/src/plugins/vis_builder/opensearch_dashboards.json index 98ef5153a9b0..477deb4db841 100644 --- a/src/plugins/vis_builder/opensearch_dashboards.json +++ b/src/plugins/vis_builder/opensearch_dashboards.json @@ -11,7 +11,8 @@ "expressions", "navigation", "savedObjects", - "visualizations" + "visualizations", + "uiActions" ], "requiredBundles": [ "charts", @@ -20,4 +21,4 @@ "visDefaultEditor", "visTypeVislib" ] -} +} \ No newline at end of file diff --git a/src/plugins/vis_builder/public/application/app.tsx b/src/plugins/vis_builder/public/application/app.tsx index 2bdc2b1c631b..9a3367651fc2 100644 --- a/src/plugins/vis_builder/public/application/app.tsx +++ b/src/plugins/vis_builder/public/application/app.tsx @@ -11,12 +11,13 @@ import { DragDropProvider } from './utils/drag_drop/drag_drop_context'; import { LeftNav } from './components/left_nav'; import { TopNav } from './components/top_nav'; import { Workspace } from './components/workspace'; -import './app.scss'; import { RightNav } from './components/right_nav'; import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; import { VisBuilderServices } from '../types'; import { syncQueryStateWithUrl } from '../../../data/public'; +import './app.scss'; + export const VisBuilderApp = () => { const { services: { diff --git a/src/plugins/vis_builder/public/application/components/right_nav.tsx b/src/plugins/vis_builder/public/application/components/right_nav.tsx index 70b22d600ae2..5d3f298c6bf3 100644 --- a/src/plugins/vis_builder/public/application/components/right_nav.tsx +++ b/src/plugins/vis_builder/public/application/components/right_nav.tsx @@ -25,7 +25,7 @@ import { } from '../utils/state_management'; import { getPersistedAggParams } from '../utils/get_persisted_agg_params'; -export const RightNav = () => { +export const RightNavUI = () => { const { ui, name: activeVisName } = useVisualizationType(); const [confirmAggs, setConfirmAggs] = useState(); const { @@ -121,3 +121,7 @@ const OptionItem = ({ icon, title }: { icon: IconType; title: string }) => ( {title} ); + +// The app uses EuiResizableContainer that triggers a rerender for ever mouseover action. +// To prevent this child component from unnecessarily rerendering in that instance, it needs to be memoized +export const RightNav = React.memo(RightNavUI); diff --git a/src/plugins/vis_builder/public/application/components/workspace.tsx b/src/plugins/vis_builder/public/application/components/workspace.tsx index 6e3371404355..214a735f6ba8 100644 --- a/src/plugins/vis_builder/public/application/components/workspace.tsx +++ b/src/plugins/vis_builder/public/application/components/workspace.tsx @@ -5,7 +5,7 @@ import { i18n } from '@osd/i18n'; import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiPanel } from '@elastic/eui'; -import React, { FC, useState, useMemo, useEffect, useLayoutEffect } from 'react'; +import React, { useState, useMemo, useEffect, useLayoutEffect } from 'react'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { IExpressionLoaderParams } from '../../../../expressions/public'; import { VisBuilderServices } from '../../types'; @@ -19,17 +19,19 @@ import fields_bg from '../../assets/fields_bg.svg'; import './workspace.scss'; import { ExperimentalInfo } from './experimental_info'; +import { handleVisEvent } from '../utils/handle_vis_event'; -export const Workspace: FC = ({ children }) => { +export const WorkspaceUI = () => { const { services: { expressions: { ReactExpressionRenderer }, notifications: { toasts }, data, + uiActions, }, } = useOpenSearchDashboards(); const { toExpression, ui } = useVisualizationType(); - const { aggConfigs } = useAggs(); + const { aggConfigs, indexPattern } = useAggs(); const [expression, setExpression] = useState(); const [searchContext, setSearchContext] = useState({ query: data.query.queryString.getQuery(), @@ -44,15 +46,17 @@ export const Workspace: FC = ({ children }) => { async function loadExpression() { const schemas = ui.containerConfig.data.schemas; - const noAggs = aggConfigs?.aggs?.length === 0; + const noAggs = (aggConfigs?.aggs?.length ?? 0) === 0; const schemaValidation = validateSchemaState(schemas, rootState.visualization); const aggValidation = validateAggregations(aggConfigs?.aggs || []); - if (noAggs || !aggValidation.valid || !schemaValidation.valid) { + if (!aggValidation.valid || !schemaValidation.valid) { + setExpression(undefined); + if (noAggs) return; // don't show error when there are no active aggregations + const err = schemaValidation.errorMsg || aggValidation.errorMsg; if (err) toasts.addWarning(err); - setExpression(undefined); return; } @@ -91,6 +95,7 @@ export const Workspace: FC = ({ children }) => { expression={expression} searchContext={searchContext} uiState={uiState} + onEvent={(event) => handleVisEvent(event, uiActions, indexPattern?.timeFieldName)} /> ) : ( @@ -127,3 +132,7 @@ export const Workspace: FC = ({ children }) => { ); }; + +// The app uses EuiResizableContainer that triggers a rerender for ever mouseover action. +// To prevent this child component from unnecessarily rerendering in that instance, it needs to be memoized +export const Workspace = React.memo(WorkspaceUI); diff --git a/src/plugins/vis_builder/public/application/utils/get_saved_vis_builder_vis.ts b/src/plugins/vis_builder/public/application/utils/get_saved_vis_builder_vis.ts deleted file mode 100644 index 6e4551e67e5d..000000000000 --- a/src/plugins/vis_builder/public/application/utils/get_saved_vis_builder_vis.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { VisBuilderServices } from '../..'; - -export const getSavedVisBuilderVis = async ( - services: VisBuilderServices, - visBuilderVisId?: string -) => { - const { savedVisBuilderLoader } = services; - if (!savedVisBuilderLoader) { - return {}; - } - const savedVisBuilderVis = await savedVisBuilderLoader.get(visBuilderVisId); - - return savedVisBuilderVis; -}; diff --git a/src/plugins/vis_builder/public/application/utils/get_top_nav_config.tsx b/src/plugins/vis_builder/public/application/utils/get_top_nav_config.tsx index 9df321822852..2a30e1700b43 100644 --- a/src/plugins/vis_builder/public/application/utils/get_top_nav_config.tsx +++ b/src/plugins/vis_builder/public/application/utils/get_top_nav_config.tsx @@ -37,13 +37,13 @@ import { showSaveModal, } from '../../../../saved_objects/public'; import { VisBuilderServices } from '../..'; -import { VisBuilderVisSavedObject } from '../../types'; +import { VisBuilderSavedObject } from '../../types'; import { AppDispatch } from './state_management'; import { EDIT_PATH, VISBUILDER_SAVED_OBJECT } from '../../../common'; import { setEditorState } from './state_management/metadata_slice'; export interface TopNavConfigParams { visualizationIdFromUrl: string; - savedVisBuilderVis: VisBuilderVisSavedObject; + savedVisBuilderVis: VisBuilderSavedObject; saveDisabledReason?: string; dispatch: AppDispatch; originatingApp?: string; diff --git a/src/plugins/vis_builder/public/application/utils/handle_vis_event.test.ts b/src/plugins/vis_builder/public/application/utils/handle_vis_event.test.ts new file mode 100644 index 000000000000..d92f77a7f51a --- /dev/null +++ b/src/plugins/vis_builder/public/application/utils/handle_vis_event.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ExpressionRendererEvent } from '../../../../expressions/public'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../visualizations/public'; +import { handleVisEvent } from './handle_vis_event'; +import { uiActionsPluginMock } from '../../../../ui_actions/public/mocks'; +import { Action, ActionType, createAction } from '../../../../ui_actions/public'; + +const executeFn = jest.fn(); + +function createTestAction( + type: string, + checkCompatibility: (context: C) => boolean, + autoExecutable = true +): Action { + return createAction({ + type: type as ActionType, + id: type, + isCompatible: (context: C) => Promise.resolve(checkCompatibility(context)), + execute: (context) => { + return executeFn(context); + }, + shouldAutoExecute: () => Promise.resolve(autoExecutable), + }); +} + +let uiActions: ReturnType; + +describe('handleVisEvent', () => { + beforeEach(() => { + uiActions = uiActionsPluginMock.createPlugin(); + + executeFn.mockClear(); + jest.useFakeTimers(); + }); + + test('should trigger the correct event', async () => { + const event: ExpressionRendererEvent = { + name: 'filter', + data: {}, + }; + const action = createTestAction('test1', () => true); + const timeFieldName = 'test-timefeild-name'; + uiActions.setup.addTriggerAction(VIS_EVENT_TO_TRIGGER.filter, action); + + await handleVisEvent(event, uiActions.doStart(), timeFieldName); + + jest.runAllTimers(); + + expect(executeFn).toBeCalledTimes(1); + expect(executeFn).toBeCalledWith( + expect.objectContaining({ + data: { timeFieldName }, + }) + ); + }); + + test('should trigger the default trigger when not found', async () => { + const event: ExpressionRendererEvent = { + name: 'test', + data: {}, + }; + const action = createTestAction('test2', () => true); + const timeFieldName = 'test-timefeild-name'; + uiActions.setup.addTriggerAction(VIS_EVENT_TO_TRIGGER.filter, action); + + await handleVisEvent(event, uiActions.doStart(), timeFieldName); + + jest.runAllTimers(); + + expect(executeFn).toBeCalledTimes(1); + expect(executeFn).toBeCalledWith( + expect.objectContaining({ + data: { timeFieldName }, + }) + ); + }); + + test('should have the correct context for `applyfilter`', async () => { + const event: ExpressionRendererEvent = { + name: 'applyFilter', + data: {}, + }; + const action = createTestAction('test3', () => true); + const timeFieldName = 'test-timefeild-name'; + uiActions.setup.addTriggerAction(VIS_EVENT_TO_TRIGGER.applyFilter, action); + + await handleVisEvent(event, uiActions.doStart(), timeFieldName); + + jest.runAllTimers(); + + expect(executeFn).toBeCalledTimes(1); + expect(executeFn).toBeCalledWith( + expect.objectContaining({ + timeFieldName, + }) + ); + }); +}); diff --git a/src/plugins/vis_builder/public/application/utils/handle_vis_event.ts b/src/plugins/vis_builder/public/application/utils/handle_vis_event.ts new file mode 100644 index 000000000000..55404a00a052 --- /dev/null +++ b/src/plugins/vis_builder/public/application/utils/handle_vis_event.ts @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ExpressionRendererEvent } from '../../../../expressions/public'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../visualizations/public'; +import { UiActionsStart } from '../../../../ui_actions/public'; + +export const handleVisEvent = async ( + event: ExpressionRendererEvent, + uiActions: UiActionsStart, + timeFieldName?: string +) => { + const triggerId = VIS_EVENT_TO_TRIGGER[event.name] ?? VIS_EVENT_TO_TRIGGER.filter; + const isApplyFilter = triggerId === VIS_EVENT_TO_TRIGGER.applyFilter; + const dataContext = { + timeFieldName, + ...event.data, + }; + const context = isApplyFilter ? dataContext : { data: dataContext }; + + await uiActions.getTrigger(triggerId).exec(context); +}; diff --git a/src/plugins/vis_builder/public/application/utils/use/use_saved_vis_builder_vis.ts b/src/plugins/vis_builder/public/application/utils/use/use_saved_vis_builder_vis.ts index 29c14dc07b08..604c90a25ccd 100644 --- a/src/plugins/vis_builder/public/application/utils/use/use_saved_vis_builder_vis.ts +++ b/src/plugins/vis_builder/public/application/utils/use/use_saved_vis_builder_vis.ts @@ -14,16 +14,10 @@ import { import { EDIT_PATH, PLUGIN_ID } from '../../../../common'; import { VisBuilderServices } from '../../../types'; import { getCreateBreadcrumbs, getEditBreadcrumbs } from '../breadcrumbs'; -import { getSavedVisBuilderVis } from '../get_saved_vis_builder_vis'; -import { - useTypedDispatch, - setStyleState, - setVisualizationState, - VisualizationState, -} from '../state_management'; +import { useTypedDispatch, setStyleState, setVisualizationState } from '../state_management'; import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; import { setEditorState } from '../state_management/metadata_slice'; -import { validateVisBuilderState } from '../validations/vis_builder_state_validation'; +import { getStateFromSavedObject } from '../../../saved_visualizations/transforms'; // This function can be used when instantiating a saved vis or creating a new one // using url parameters, embedding and destroying it in DOM @@ -39,6 +33,7 @@ export const useSavedVisBuilderVis = (visualizationIdFromUrl: string | undefined history, http: { basePath }, toastNotifications, + savedVisBuilderLoader, } = services; const toastNotification = (message: string) => { toastNotifications.addDanger({ @@ -51,42 +46,22 @@ export const useSavedVisBuilderVis = (visualizationIdFromUrl: string | undefined const loadSavedVisBuilderVis = async () => { try { - const savedVisBuilderVis = await getSavedVisBuilderVis(services, visualizationIdFromUrl); + const savedVisBuilderVis = await getSavedVisBuilderVis( + savedVisBuilderLoader, + visualizationIdFromUrl + ); if (savedVisBuilderVis.id) { - chrome.setBreadcrumbs(getEditBreadcrumbs(savedVisBuilderVis.title, navigateToApp)); - chrome.docTitle.change(savedVisBuilderVis.title); + const { title, state } = getStateFromSavedObject(savedVisBuilderVis); + chrome.setBreadcrumbs(getEditBreadcrumbs(title, navigateToApp)); + chrome.docTitle.change(title); + + dispatch(setStyleState(state.style)); + dispatch(setVisualizationState(state.visualization)); } else { chrome.setBreadcrumbs(getCreateBreadcrumbs(navigateToApp)); } - if ( - savedVisBuilderVis.styleState !== '{}' && - savedVisBuilderVis.visualizationState !== '{}' - ) { - const styleState = JSON.parse(savedVisBuilderVis.styleState); - const vizStateWithoutIndex = JSON.parse(savedVisBuilderVis.visualizationState); - const visualizationState: VisualizationState = { - searchField: vizStateWithoutIndex.searchField, - activeVisualization: vizStateWithoutIndex.activeVisualization, - indexPattern: savedVisBuilderVis.searchSourceFields.index, - }; - - const validateResult = validateVisBuilderState({ styleState, visualizationState }); - if (!validateResult.valid) { - throw new InvalidJSONProperty( - validateResult.errorMsg || - i18n.translate('visBuilder.useSavedVisBuilderVis.genericJSONError', { - defaultMessage: - 'Something went wrong while loading your saved object. The object may be corrupted or does not match the latest schema', - }) - ); - } - - dispatch(setStyleState(styleState)); - dispatch(setVisualizationState(visualizationState)); - } - setSavedVisState(savedVisBuilderVis); dispatch(setEditorState({ state: 'clean' })); } catch (error) { @@ -123,3 +98,12 @@ export const useSavedVisBuilderVis = (visualizationIdFromUrl: string | undefined return savedVisState; }; + +async function getSavedVisBuilderVis( + savedVisBuilderLoader: VisBuilderServices['savedVisBuilderLoader'], + visBuilderVisId?: string +) { + const savedVisBuilderVis = await savedVisBuilderLoader.get(visBuilderVisId); + + return savedVisBuilderVis; +} diff --git a/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable.tsx b/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable.tsx index 6282845372ac..7142c9c3c0cf 100644 --- a/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable.tsx +++ b/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable.tsx @@ -7,7 +7,7 @@ import { cloneDeep, isEqual } from 'lodash'; import ReactDOM from 'react-dom'; import { merge, Subscription } from 'rxjs'; -import { PLUGIN_ID, VisBuilderSavedObjectAttributes, VISBUILDER_SAVED_OBJECT } from '../../common'; +import { PLUGIN_ID, VISBUILDER_SAVED_OBJECT } from '../../common'; import { Embeddable, EmbeddableOutput, @@ -28,15 +28,21 @@ import { TimeRange, } from '../../../data/public'; import { validateSchemaState } from '../application/utils/validations/validate_schema_state'; -import { getExpressionLoader, getTypeService } from '../plugin_services'; +import { + getExpressionLoader, + getIndexPatterns, + getTypeService, + getUIActions, +} from '../plugin_services'; import { PersistedState } from '../../../visualizations/public'; -import { RenderState, VisualizationState } from '../application/utils/state_management'; +import { VisBuilderSavedVis } from '../saved_visualizations/transforms'; +import { handleVisEvent } from '../application/utils/handle_vis_event'; // Apparently this needs to match the saved object type for the clone and replace panel actions to work export const VISBUILDER_EMBEDDABLE = VISBUILDER_SAVED_OBJECT; export interface VisBuilderEmbeddableConfiguration { - savedVisBuilder: VisBuilderSavedObjectAttributes; + savedVis: VisBuilderSavedVis; // TODO: add indexPatterns as part of configuration // indexPatterns?: IIndexPattern[]; editPath: string; @@ -44,12 +50,14 @@ export interface VisBuilderEmbeddableConfiguration { editable: boolean; } +export type VisBuilderInput = SavedObjectEmbeddableInput; + export interface VisBuilderOutput extends EmbeddableOutput { /** * Will contain the saved object attributes of the VisBuilder Saved Object that matches * `input.savedObjectId`. If the id is invalid, this may be undefined. */ - savedVisBuilder?: VisBuilderSavedObjectAttributes; + savedVis?: VisBuilderSavedVis; } type ExpressionLoader = InstanceType; @@ -65,13 +73,13 @@ export class VisBuilderEmbeddable extends Embeddable { - const { visualizationState: visualization = '{}', styleState: style = '{}' } = - this.savedVisBuilder || {}; - return { - visualization, - style, - }; - }; + private getSerializedState = () => JSON.stringify(this.savedVis?.state); private getExpression = async () => { - if (!this.serializedState) { - return; - } - const { visualization, style } = this.serializedState; + try { + // Check if saved visualization exists + const renderState = this.savedVis?.state; + if (!renderState) throw new Error('No saved visualization'); + + const visTypeString = renderState.visualization?.activeVisualization?.name || ''; + const visualizationType = getTypeService().get(visTypeString); + + if (!visualizationType) throw new Error(`Invalid visualization type ${visTypeString}`); + + const { toExpression, ui } = visualizationType; + const schemas = ui.containerConfig.data.schemas; + const { valid, errorMsg } = validateSchemaState(schemas, renderState.visualization); + + if (!valid && errorMsg) throw new Error(errorMsg); - const vizStateWithoutIndex = JSON.parse(visualization); - const visualizationState: VisualizationState = { - searchField: vizStateWithoutIndex.searchField, - activeVisualization: vizStateWithoutIndex.activeVisualization, - indexPattern: this.savedVisBuilder?.searchSourceFields?.index, - }; - const renderState: RenderState = { - visualization: visualizationState, - style: JSON.parse(style), - }; - const visualizationName = renderState.visualization?.activeVisualization?.name ?? ''; - const visualizationType = getTypeService().get(visualizationName); - if (!visualizationType) { - this.onContainerError(new Error(`Invalid visualization type ${visualizationName}`)); - return; - } - const { toExpression, ui } = visualizationType; - const schemas = ui.containerConfig.data.schemas; - const { valid, errorMsg } = validateSchemaState(schemas, visualizationState); - - if (!valid) { - if (errorMsg) { - this.onContainerError(new Error(errorMsg)); - return; - } - } else { - // TODO: handle error in Expression creation const exp = await toExpression(renderState, { filters: this.filters, query: this.query, timeRange: this.timeRange, }); return exp; + } catch (error) { + this.onContainerError(error as Error); + return; } }; @@ -167,7 +155,7 @@ export class VisBuilderEmbeddable extends Embeddable { + if (!this.input.disableTriggers) { + const indexPattern = await getIndexPatterns().get( + this.savedVis?.state.visualization.indexPattern ?? '' + ); + + handleVisEvent(event, getUIActions(), indexPattern.timeFieldName); + } + }) + ); + + if (this.savedVis?.description) { + div.setAttribute('data-description', this.savedVis.description); } div.setAttribute('data-test-subj', 'visBuilderLoader'); @@ -271,7 +271,7 @@ export class VisBuilderEmbeddable extends Embeddable & { id: string }, + input: VisBuilderInput, parent?: IContainer ): Promise { try { - const savedVisBuilder = await getSavedVisBuilderLoader().get(savedObjectId); + const savedObject = await getSavedVisBuilderLoader().get(savedObjectId); const editPath = `${EDIT_PATH}/${savedObjectId}`; const editUrl = getHttp().basePath.prepend(`/app/${PLUGIN_ID}${editPath}`); const isLabsEnabled = getUISettings().get(VISUALIZE_ENABLE_LABS_SETTING); @@ -91,7 +93,7 @@ export class VisBuilderEmbeddableFactoryDefinition return new VisBuilderEmbeddable( getTimeFilter(), { - savedVisBuilder, + savedVis: getStateFromSavedObject(savedObject), editUrl, editPath, editable: true, diff --git a/src/plugins/vis_builder/public/plugin.ts b/src/plugins/vis_builder/public/plugin.ts index 3995c1246de5..0c1d569f6bed 100644 --- a/src/plugins/vis_builder/public/plugin.ts +++ b/src/plugins/vis_builder/public/plugin.ts @@ -46,6 +46,7 @@ import { setTypeService, setReactExpressionRenderer, setQueryService, + setUIActions, } from './plugin_services'; import { createSavedVisBuilderLoader } from './saved_visualizations'; import { registerDefaultTypes } from './visualizations'; @@ -158,6 +159,7 @@ export class VisBuilderPlugin savedVisBuilderLoader: selfStart.savedVisBuilderLoader, embeddable: pluginsStart.embeddable, dashboard: pluginsStart.dashboard, + uiActions: pluginsStart.uiActions, }; // Instantiate the store @@ -217,7 +219,7 @@ export class VisBuilderPlugin public start( core: CoreStart, - { expressions, data }: VisBuilderPluginStartDependencies + { expressions, data, uiActions }: VisBuilderPluginStartDependencies ): VisBuilderStart { const typeService = this.typeService.start(); @@ -239,6 +241,7 @@ export class VisBuilderPlugin setTimeFilter(data.query.timefilter.timefilter); setTypeService(typeService); setUISettings(core.uiSettings); + setUIActions(uiActions); setQueryService(data.query); return { diff --git a/src/plugins/vis_builder/public/plugin_services.ts b/src/plugins/vis_builder/public/plugin_services.ts index c5583e3c5e43..844a56566d0e 100644 --- a/src/plugins/vis_builder/public/plugin_services.ts +++ b/src/plugins/vis_builder/public/plugin_services.ts @@ -9,6 +9,7 @@ import { SavedVisBuilderLoader } from './saved_visualizations'; import { HttpStart, IUiSettingsClient } from '../../../core/public'; import { ExpressionsStart } from '../../expressions/public'; import { TypeServiceStart } from './services/type_service'; +import { UiActionsStart } from '../../ui_actions/public'; export const [getSearchService, setSearchService] = createGetterSetter< DataPublicPluginStart['search'] @@ -37,6 +38,7 @@ export const [getTimeFilter, setTimeFilter] = createGetterSetter('TypeService'); export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); +export const [getUIActions, setUIActions] = createGetterSetter('UIActions'); export const [getQueryService, setQueryService] = createGetterSetter< DataPublicPluginStart['query'] diff --git a/src/plugins/vis_builder/public/saved_visualizations/saved_visualization_references.ts b/src/plugins/vis_builder/public/saved_visualizations/saved_visualization_references.ts index 8a897b35ccda..06710c4d0780 100644 --- a/src/plugins/vis_builder/public/saved_visualizations/saved_visualization_references.ts +++ b/src/plugins/vis_builder/public/saved_visualizations/saved_visualization_references.ts @@ -4,11 +4,11 @@ */ import { SavedObjectReference } from '../../../../core/public'; -import { VisBuilderVisSavedObject } from '../types'; +import { VisBuilderSavedObject } from '../types'; import { injectSearchSourceReferences } from '../../../data/public'; export function injectReferences( - savedObject: VisBuilderVisSavedObject, + savedObject: VisBuilderSavedObject, references: SavedObjectReference[] ) { if (savedObject.searchSourceFields) { diff --git a/src/plugins/vis_builder/public/saved_visualizations/transforms.test.ts b/src/plugins/vis_builder/public/saved_visualizations/transforms.test.ts index 3fb5b7ff7bda..efbcfd23f799 100644 --- a/src/plugins/vis_builder/public/saved_visualizations/transforms.test.ts +++ b/src/plugins/vis_builder/public/saved_visualizations/transforms.test.ts @@ -7,8 +7,9 @@ import { coreMock } from '../../../../core/public/mocks'; import { getStubIndexPattern } from '../../../data/public/test_utils'; import { IndexPattern } from '../../../data/public'; import { RootState } from '../application/utils/state_management'; -import { VisBuilderVisSavedObject } from '../types'; -import { saveStateToSavedObject } from './transforms'; +import { VisBuilderSavedObject } from '../types'; +import { getStateFromSavedObject, saveStateToSavedObject } from './transforms'; +import { VisBuilderSavedObjectAttributes } from '../../common'; const getConfig = (cfg: any) => cfg; @@ -21,9 +22,9 @@ describe('transforms', () => { beforeEach(() => { TEST_INDEX_PATTERN_ID = 'test-pattern'; - savedObject = {} as VisBuilderVisSavedObject; + savedObject = {} as VisBuilderSavedObject; rootState = { - metadata: { editor: { state: 'loading', validity: {} } }, + metadata: { editor: { state: 'loading', errors: {} } }, style: '', visualization: { searchField: '', @@ -61,4 +62,52 @@ describe('transforms', () => { ); }); }); + + describe('getStateFromSavedObject', () => { + const defaultVBSaveObj = { + styleState: '{}', + visualizationState: JSON.stringify({ + searchField: '', + }), + searchSourceFields: { + index: 'test-index', + }, + } as VisBuilderSavedObjectAttributes; + + test('should return saved object with state', () => { + const { state } = getStateFromSavedObject(defaultVBSaveObj); + + expect(state).toMatchInlineSnapshot(` + Object { + "style": Object {}, + "visualization": Object { + "indexPattern": "test-index", + "searchField": "", + }, + } + `); + }); + + test('should throw error if state is invalid', () => { + const mockVBSaveObj = { + ...defaultVBSaveObj, + }; + delete mockVBSaveObj.visualizationState; + + expect(() => getStateFromSavedObject(mockVBSaveObj)).toThrowErrorMatchingInlineSnapshot( + `"Unexpected end of JSON input"` + ); + }); + + test('should throw error if index pattern is missing', () => { + const mockVBSaveObj = { + ...defaultVBSaveObj, + }; + delete mockVBSaveObj.searchSourceFields; + + expect(() => getStateFromSavedObject(mockVBSaveObj)).toThrowErrorMatchingInlineSnapshot( + `"The saved object is missing an index pattern"` + ); + }); + }); }); diff --git a/src/plugins/vis_builder/public/saved_visualizations/transforms.ts b/src/plugins/vis_builder/public/saved_visualizations/transforms.ts index 672f80111076..0a7a6e529a6b 100644 --- a/src/plugins/vis_builder/public/saved_visualizations/transforms.ts +++ b/src/plugins/vis_builder/public/saved_visualizations/transforms.ts @@ -3,16 +3,20 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { i18n } from '@osd/i18n'; import produce from 'immer'; import { IndexPattern } from '../../../data/public'; -import { RootState, VisualizationState } from '../application/utils/state_management'; -import { VisBuilderVisSavedObject } from '../types'; +import { InvalidJSONProperty } from '../../../opensearch_dashboards_utils/public'; +import { RenderState, RootState, VisualizationState } from '../application/utils/state_management'; +import { validateVisBuilderState } from '../application/utils/validations'; +import { VisBuilderSavedObject } from '../types'; +import { VisBuilderSavedObjectAttributes } from '../../common'; export const saveStateToSavedObject = ( - obj: VisBuilderVisSavedObject, + obj: VisBuilderSavedObject, state: RootState, indexPattern: IndexPattern -): VisBuilderVisSavedObject => { +): VisBuilderSavedObject => { if (state.visualization.indexPattern !== indexPattern.id) throw new Error('indexPattern id should match the value in redux state'); @@ -26,3 +30,51 @@ export const saveStateToSavedObject = ( return obj; }; + +export interface VisBuilderSavedVis + extends Pick { + state: RenderState; +} + +export const getStateFromSavedObject = ( + obj: VisBuilderSavedObjectAttributes +): VisBuilderSavedVis => { + const { id, title, description } = obj; + const styleState = JSON.parse(obj.styleState || ''); + const vizStateWithoutIndex = JSON.parse(obj.visualizationState || ''); + const visualizationState: VisualizationState = { + searchField: '', + ...vizStateWithoutIndex, + indexPattern: obj.searchSourceFields?.index, + }; + + const validateResult = validateVisBuilderState({ styleState, visualizationState }); + + if (!validateResult.valid) { + throw new InvalidJSONProperty( + validateResult.errorMsg || + i18n.translate('visBuilder.getStateFromSavedObject.genericJSONError', { + defaultMessage: + 'Something went wrong while loading your saved object. The object may be corrupted or does not match the latest schema', + }) + ); + } + + if (!visualizationState.indexPattern) { + throw new Error( + i18n.translate('visBuilder.getStateFromSavedObject.missingIndexPattern', { + defaultMessage: 'The saved object is missing an index pattern', + }) + ); + } + + return { + id, + title, + description, + state: { + visualization: visualizationState, + style: styleState, + }, + }; +}; diff --git a/src/plugins/vis_builder/public/types.ts b/src/plugins/vis_builder/public/types.ts index e79762bedc1f..5221a1c513ec 100644 --- a/src/plugins/vis_builder/public/types.ts +++ b/src/plugins/vis_builder/public/types.ts @@ -16,6 +16,7 @@ import { SavedObjectLoader } from '../../saved_objects/public'; import { AppMountParameters, CoreStart, ToastsStart, ScopedHistory } from '../../../core/public'; import { IOsdUrlStateStorage } from '../../opensearch_dashboards_utils/public'; import { DataPublicPluginSetup } from '../../data/public'; +import { UiActionsStart } from '../../ui_actions/public'; export type VisBuilderSetup = TypeServiceSetup; export interface VisBuilderStart extends TypeServiceStart { @@ -34,6 +35,7 @@ export interface VisBuilderPluginStartDependencies { savedObjects: SavedObjectsStart; dashboard: DashboardStart; expressions: ExpressionsStart; + uiActions: UiActionsStart; } export interface VisBuilderServices extends CoreStart { @@ -51,6 +53,7 @@ export interface VisBuilderServices extends CoreStart { scopedHistory: ScopedHistory; osdUrlStateStorage: IOsdUrlStateStorage; dashboard: DashboardStart; + uiActions: UiActionsStart; } export interface ISavedVis { @@ -62,4 +65,4 @@ export interface ISavedVis { version?: number; } -export interface VisBuilderVisSavedObject extends SavedObject, ISavedVis {} +export interface VisBuilderSavedObject extends SavedObject, ISavedVis {}