From 5d2338e3262f4aa9d94ae2504d5fdf702dbbb0a7 Mon Sep 17 00:00:00 2001 From: Ashwin P Chandran Date: Tue, 14 Mar 2023 07:24:13 +0000 Subject: [PATCH 01/10] adds uiActions to visBuilder Signed-off-by: Ashwin P Chandran --- .../vis_builder/opensearch_dashboards.json | 5 +- .../vis_builder/public/application/app.tsx | 3 +- .../application/components/right_nav.tsx | 4 +- .../application/components/workspace.tsx | 11 +- .../utils/get_saved_vis_builder_vis.ts | 19 ---- .../application/utils/get_top_nav_config.tsx | 4 +- .../utils/handle_vis_event.test.ts | 102 +++++++++++++++++ .../application/utils/handle_vis_event.ts | 34 ++++++ .../utils/use/use_saved_vis_builder_vis.ts | 60 ++++------ .../embeddable/vis_builder_embeddable.tsx | 106 +++++++++--------- .../vis_builder_embeddable_factory.tsx | 5 +- src/plugins/vis_builder/public/plugin.ts | 5 +- .../vis_builder/public/plugin_services.ts | 2 + .../saved_visualization_references.ts | 4 +- .../saved_visualizations/transforms.test.ts | 57 +++++++++- .../public/saved_visualizations/transforms.ts | 59 +++++++++- src/plugins/vis_builder/public/types.ts | 5 +- 17 files changed, 351 insertions(+), 134 deletions(-) delete mode 100644 src/plugins/vis_builder/public/application/utils/get_saved_vis_builder_vis.ts create mode 100644 src/plugins/vis_builder/public/application/utils/handle_vis_event.test.ts create mode 100644 src/plugins/vis_builder/public/application/utils/handle_vis_event.ts 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 c98638da28f1..d2dc10d44870 100644 --- a/src/plugins/vis_builder/public/application/components/right_nav.tsx +++ b/src/plugins/vis_builder/public/application/components/right_nav.tsx @@ -24,7 +24,7 @@ import { } from '../utils/state_management'; import { usePersistedAggParams } from '../utils/use/use_persisted_agg_params'; -export const RightNav = () => { +const RightNavUI = () => { const [newVisType, setNewVisType] = useState(); const { services: { types }, @@ -109,3 +109,5 @@ const OptionItem = ({ icon, title }: { icon: IconType; title: string }) => ( {title} ); + +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..bcfa866cafeb 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(), @@ -91,6 +93,7 @@ export const Workspace: FC = ({ children }) => { expression={expression} searchContext={searchContext} uiState={uiState} + onEvent={(event) => handleVisEvent(event, uiActions, indexPattern?.timeFieldName)} /> ) : ( @@ -127,3 +130,5 @@ export const Workspace: FC = ({ children }) => { ); }; + +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..06e3299ce348 --- /dev/null +++ b/src/plugins/vis_builder/public/application/utils/handle_vis_event.ts @@ -0,0 +1,34 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { get } from 'lodash'; +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 = get(VIS_EVENT_TO_TRIGGER, event.name, VIS_EVENT_TO_TRIGGER.filter); + let context; + + if (triggerId === VIS_EVENT_TO_TRIGGER.applyFilter) { + context = { + timeFieldName, + ...event.data, + }; + } else { + context = { + data: { + timeFieldName, + ...event.data, + }, + }; + } + + 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..cd01471b4f25 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,41 +46,21 @@ export const useSavedVisBuilderVis = (visualizationIdFromUrl: string | undefined const loadSavedVisBuilderVis = async () => { try { - const savedVisBuilderVis = await getSavedVisBuilderVis(services, visualizationIdFromUrl); + const savedVisBuilderVis = await getSavedVisBuilderVis( + savedVisBuilderLoader, + visualizationIdFromUrl + ); + const { id, title, state } = getStateFromSavedObject(savedVisBuilderVis); - if (savedVisBuilderVis.id) { - chrome.setBreadcrumbs(getEditBreadcrumbs(savedVisBuilderVis.title, navigateToApp)); - chrome.docTitle.change(savedVisBuilderVis.title); + if (id && title) { + chrome.setBreadcrumbs(getEditBreadcrumbs(title, navigateToApp)); + chrome.docTitle.change(title); } 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)); - } + dispatch(setStyleState(state.style)); + dispatch(setVisualizationState(state.visualization)); setSavedVisState(savedVisBuilderVis); dispatch(setEditorState({ state: 'clean' })); @@ -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..8697ff393478 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; @@ -49,7 +55,7 @@ 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 +71,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 +153,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 +269,7 @@ export class VisBuilderEmbeddable extends Embeddable { 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 +92,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..b308f7d2248f 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/vis_builder_saved_object_attributes'; 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, + }; + mockVBSaveObj.visualizationState = '{}'; + + expect(() => getStateFromSavedObject(mockVBSaveObj)).toThrowErrorMatchingInlineSnapshot( + `"/visualizationState must have required property 'searchField'"` + ); + }); + + 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..8395b44177c4 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,50 @@ 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 = { + ...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 {} From aab03490b019e8a71492684c4bf4e1f06db160d3 Mon Sep 17 00:00:00 2001 From: Ashwin P Chandran Date: Tue, 14 Mar 2023 08:07:10 +0000 Subject: [PATCH 02/10] prevents multiple errors on load Signed-off-by: Ashwin P Chandran --- .../public/application/components/workspace.tsx | 8 +++++--- .../public/saved_visualizations/transforms.test.ts | 4 ++-- .../vis_builder/public/saved_visualizations/transforms.ts | 1 + 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/plugins/vis_builder/public/application/components/workspace.tsx b/src/plugins/vis_builder/public/application/components/workspace.tsx index bcfa866cafeb..107bf4479034 100644 --- a/src/plugins/vis_builder/public/application/components/workspace.tsx +++ b/src/plugins/vis_builder/public/application/components/workspace.tsx @@ -46,15 +46,17 @@ export const WorkspaceUI = () => { 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; } 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 b308f7d2248f..de7963313de1 100644 --- a/src/plugins/vis_builder/public/saved_visualizations/transforms.test.ts +++ b/src/plugins/vis_builder/public/saved_visualizations/transforms.test.ts @@ -92,10 +92,10 @@ describe('transforms', () => { const mockVBSaveObj = { ...defaultVBSaveObj, }; - mockVBSaveObj.visualizationState = '{}'; + delete mockVBSaveObj.visualizationState; expect(() => getStateFromSavedObject(mockVBSaveObj)).toThrowErrorMatchingInlineSnapshot( - `"/visualizationState must have required property 'searchField'"` + `"Unexpected end of JSON input"` ); }); diff --git a/src/plugins/vis_builder/public/saved_visualizations/transforms.ts b/src/plugins/vis_builder/public/saved_visualizations/transforms.ts index 8395b44177c4..0a7a6e529a6b 100644 --- a/src/plugins/vis_builder/public/saved_visualizations/transforms.ts +++ b/src/plugins/vis_builder/public/saved_visualizations/transforms.ts @@ -43,6 +43,7 @@ export const getStateFromSavedObject = ( const styleState = JSON.parse(obj.styleState || ''); const vizStateWithoutIndex = JSON.parse(obj.visualizationState || ''); const visualizationState: VisualizationState = { + searchField: '', ...vizStateWithoutIndex, indexPattern: obj.searchSourceFields?.index, }; From 217286fed9e84877127f4aeceb553523ae98f3ef Mon Sep 17 00:00:00 2001 From: Ashwin P Chandran Date: Tue, 14 Mar 2023 08:11:53 +0000 Subject: [PATCH 03/10] fixes visbuilder type errors Signed-off-by: Ashwin P Chandran --- .../vis_builder/public/embeddable/vis_builder_embeddable.tsx | 2 ++ .../public/embeddable/vis_builder_embeddable_factory.tsx | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) 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 8697ff393478..7142c9c3c0cf 100644 --- a/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable.tsx +++ b/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable.tsx @@ -50,6 +50,8 @@ 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 diff --git a/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable_factory.tsx b/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable_factory.tsx index 610e71dbba78..f80ad18ee363 100644 --- a/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable_factory.tsx +++ b/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable_factory.tsx @@ -23,6 +23,7 @@ import { import { DisabledEmbeddable } from './disabled_embeddable'; import { VisBuilderEmbeddable, + VisBuilderInput, VisBuilderOutput, VISBUILDER_EMBEDDABLE, } from './vis_builder_embeddable'; @@ -76,7 +77,7 @@ export class VisBuilderEmbeddableFactoryDefinition public async createFromSavedObject( savedObjectId: string, - input: Partial & { id: string }, + input: VisBuilderInput, parent?: IContainer ): Promise { try { From 1472ee1ae26f19b4d8c49e37f65625af2f0076df Mon Sep 17 00:00:00 2001 From: Ashwin P Chandran Date: Thu, 23 Mar 2023 22:40:47 +0000 Subject: [PATCH 04/10] fixes save Signed-off-by: Ashwin P Chandran --- .../application/utils/use/use_saved_vis_builder_vis.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 cd01471b4f25..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 @@ -50,18 +50,18 @@ export const useSavedVisBuilderVis = (visualizationIdFromUrl: string | undefined savedVisBuilderLoader, visualizationIdFromUrl ); - const { id, title, state } = getStateFromSavedObject(savedVisBuilderVis); - if (id && title) { + if (savedVisBuilderVis.id) { + 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)); } - dispatch(setStyleState(state.style)); - dispatch(setVisualizationState(state.visualization)); - setSavedVisState(savedVisBuilderVis); dispatch(setEditorState({ state: 'clean' })); } catch (error) { From 9fe1a3276c500962b1c850273993ab84b3e7939d Mon Sep 17 00:00:00 2001 From: Ashwin P Chandran Date: Fri, 24 Mar 2023 08:27:01 +0000 Subject: [PATCH 05/10] adds ui state to vis builder Signed-off-by: Ashwin P Chandran --- .../vis_builder_saved_object_attributes.ts | 1 + .../application/components/workspace.tsx | 24 ++++++- .../public/application/utils/schema.json | 63 ++++++++++++------- .../utils/state_management/metadata_slice.ts | 10 ++- .../utils/state_management/store.ts | 1 + .../utils/use/use_saved_vis_builder_vis.ts | 6 +- .../embeddable/vis_builder_embeddable.tsx | 26 ++++++-- .../public/saved_visualizations/_saved_vis.ts | 4 +- .../saved_visualizations/transforms.test.ts | 10 ++- .../public/saved_visualizations/transforms.ts | 8 ++- src/plugins/vis_builder/public/types.ts | 1 + .../server/saved_objects/vis_builder_app.ts | 4 ++ 12 files changed, 119 insertions(+), 39 deletions(-) diff --git a/src/plugins/vis_builder/common/vis_builder_saved_object_attributes.ts b/src/plugins/vis_builder/common/vis_builder_saved_object_attributes.ts index 243e455d7157..3e157f9bf6df 100644 --- a/src/plugins/vis_builder/common/vis_builder_saved_object_attributes.ts +++ b/src/plugins/vis_builder/common/vis_builder_saved_object_attributes.ts @@ -13,6 +13,7 @@ export interface VisBuilderSavedObjectAttributes extends SavedObjectAttributes { visualizationState?: string; updated_at?: string; styleState?: string; + uiState?: string; version: number; searchSourceFields?: { index?: string; diff --git a/src/plugins/vis_builder/public/application/components/workspace.tsx b/src/plugins/vis_builder/public/application/components/workspace.tsx index 107bf4479034..16563fa9b587 100644 --- a/src/plugins/vis_builder/public/application/components/workspace.tsx +++ b/src/plugins/vis_builder/public/application/components/workspace.tsx @@ -10,7 +10,7 @@ import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react import { IExpressionLoaderParams } from '../../../../expressions/public'; import { VisBuilderServices } from '../../types'; import { validateSchemaState, validateAggregations } from '../utils/validations'; -import { useTypedSelector } from '../utils/state_management'; +import { useTypedDispatch, useTypedSelector } from '../utils/state_management'; import { useAggs, useVisualizationType } from '../utils/use'; import { PersistedState } from '../../../../visualizations/public'; @@ -20,6 +20,7 @@ import fields_bg from '../../assets/fields_bg.svg'; import './workspace.scss'; import { ExperimentalInfo } from './experimental_info'; import { handleVisEvent } from '../utils/handle_vis_event'; +import { setUiState } from '../utils/state_management/metadata_slice'; export const WorkspaceUI = () => { const { @@ -39,8 +40,25 @@ export const WorkspaceUI = () => { timeRange: data.query.timefilter.timefilter.getTime(), }); const rootState = useTypedSelector((state) => state); - // Visualizations require the uiState to persist even when the expression changes - const uiState = useMemo(() => new PersistedState(), []); + const dispatch = useTypedDispatch(); + // Visualizations require the uiState object to persist even when the expression changes + // eslint-disable-next-line react-hooks/exhaustive-deps + const uiState = useMemo(() => new PersistedState(rootState.metadata.uiState), []); + + useEffect(() => { + if (rootState.metadata.editor.state === 'loaded') { + uiState.setSilent(rootState.metadata.uiState); + } + // To update uiState once saved object data is loaded + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rootState.metadata.editor.state, uiState]); + + useEffect(() => { + uiState.on('change', (args) => { + // Store changes to UI state + dispatch(setUiState({ state: uiState.toJSON() })); + }); + }, [dispatch, uiState]); useEffect(() => { async function loadExpression() { diff --git a/src/plugins/vis_builder/public/application/utils/schema.json b/src/plugins/vis_builder/public/application/utils/schema.json index 9effed97b2be..7cf8bbc2534f 100644 --- a/src/plugins/vis_builder/public/application/utils/schema.json +++ b/src/plugins/vis_builder/public/application/utils/schema.json @@ -1,28 +1,47 @@ { - "type": "object", - "properties": { - "styleState": { - "type": "object" - }, - "visualizationState": { - "type": "object", - "properties": { - "activeVisualization": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "aggConfigParams": { "type": "array" } + "type": "object", + "properties": { + "styleState": { + "type": "object" + }, + "visualizationState": { + "type": "object", + "properties": { + "activeVisualization": { + "type": "object", + "properties": { + "name": { + "type": "string" }, - "required": ["name", "aggConfigParams"], - "additionalProperties": false + "aggConfigParams": { + "type": "array" + } }, - "indexPattern": { "type": "string" }, - "searchField": { "type": "string" } + "required": [ + "name", + "aggConfigParams" + ], + "additionalProperties": false }, - "required": ["searchField"], - "additionalProperties": false - } + "indexPattern": { + "type": "string" + }, + "searchField": { + "type": "string" + } + }, + "required": [ + "searchField" + ], + "additionalProperties": false }, - "required": ["styleState", "visualizationState"], - "additionalProperties": false + "uiState": { + "type": "object" + } + }, + "required": [ + "styleState", + "visualizationState" + ], + "additionalProperties": false } \ No newline at end of file diff --git a/src/plugins/vis_builder/public/application/utils/state_management/metadata_slice.ts b/src/plugins/vis_builder/public/application/utils/state_management/metadata_slice.ts index 05ceb324aaa1..75000c07ac94 100644 --- a/src/plugins/vis_builder/public/application/utils/state_management/metadata_slice.ts +++ b/src/plugins/vis_builder/public/application/utils/state_management/metadata_slice.ts @@ -11,7 +11,7 @@ import { VisBuilderServices } from '../../../types'; * Clean state: when viz finished loading and ready to be edited * Dirty state: when there are changes applied to the viz after it finished loading */ -type EditorState = 'loading' | 'clean' | 'dirty'; +type EditorState = 'loading' | 'loaded' | 'clean' | 'dirty'; export interface MetadataState { editor: { @@ -22,6 +22,7 @@ export interface MetadataState { state: EditorState; }; originatingApp?: string; + uiState: any; } const initialState: MetadataState = { @@ -30,6 +31,7 @@ const initialState: MetadataState = { state: 'loading', }, originatingApp: undefined, + uiState: {}, }; export const getPreloadedState = async ({ @@ -61,6 +63,10 @@ export const slice = createSlice({ setOriginatingApp: (state, action: PayloadAction<{ state?: string }>) => { state.originatingApp = action.payload.state; }, + setUiState: (state, action: PayloadAction<{ state: any; editorState?: EditorState }>) => { + state.uiState = action.payload.state; + state.editor.state = action.payload.editorState || 'dirty'; + }, setState: (_state, action: PayloadAction) => { return action.payload; }, @@ -68,4 +74,4 @@ export const slice = createSlice({ }); export const { reducer } = slice; -export const { setError, setEditorState, setOriginatingApp, setState } = slice.actions; +export const { setError, setEditorState, setOriginatingApp, setState, setUiState } = slice.actions; diff --git a/src/plugins/vis_builder/public/application/utils/state_management/store.ts b/src/plugins/vis_builder/public/application/utils/state_management/store.ts index f1b1c0eeae2a..c2600d8df247 100644 --- a/src/plugins/vis_builder/public/application/utils/state_management/store.ts +++ b/src/plugins/vis_builder/public/application/utils/state_management/store.ts @@ -60,3 +60,4 @@ export type AppDispatch = Store['dispatch']; export { setState as setStyleState, StyleState } from './style_slice'; export { setState as setVisualizationState, VisualizationState } from './visualization_slice'; +export { MetadataState } from './metadata_slice'; 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 604c90a25ccd..22650363ddad 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 @@ -16,7 +16,7 @@ import { VisBuilderServices } from '../../../types'; import { getCreateBreadcrumbs, getEditBreadcrumbs } from '../breadcrumbs'; import { useTypedDispatch, setStyleState, setVisualizationState } from '../state_management'; import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; -import { setEditorState } from '../state_management/metadata_slice'; +import { setEditorState, setUiState } from '../state_management/metadata_slice'; import { getStateFromSavedObject } from '../../../saved_visualizations/transforms'; // This function can be used when instantiating a saved vis or creating a new one @@ -46,16 +46,18 @@ export const useSavedVisBuilderVis = (visualizationIdFromUrl: string | undefined const loadSavedVisBuilderVis = async () => { try { + dispatch(setEditorState({ state: 'loading' })); const savedVisBuilderVis = await getSavedVisBuilderVis( savedVisBuilderLoader, visualizationIdFromUrl ); if (savedVisBuilderVis.id) { - const { title, state } = getStateFromSavedObject(savedVisBuilderVis); + const { title, state, uiState } = getStateFromSavedObject(savedVisBuilderVis); chrome.setBreadcrumbs(getEditBreadcrumbs(title, navigateToApp)); chrome.docTitle.change(title); + dispatch(setUiState({ state: uiState, editorState: 'loaded' })); dispatch(setStyleState(state.style)); dispatch(setVisualizationState(state.visualization)); } else { 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 7142c9c3c0cf..51097bea1ac1 100644 --- a/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable.tsx +++ b/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable.tsx @@ -50,7 +50,9 @@ export interface VisBuilderEmbeddableConfiguration { editable: boolean; } -export type VisBuilderInput = SavedObjectEmbeddableInput; +export interface VisBuilderInput extends SavedObjectEmbeddableInput { + uiState?: any; +} export interface VisBuilderOutput extends EmbeddableOutput { /** @@ -62,7 +64,7 @@ export interface VisBuilderOutput extends EmbeddableOutput { type ExpressionLoader = InstanceType; -export class VisBuilderEmbeddable extends Embeddable { +export class VisBuilderEmbeddable extends Embeddable { public readonly type = VISBUILDER_EMBEDDABLE; private handler?: ExpressionLoader; private timeRange?: TimeRange; @@ -75,7 +77,7 @@ export class VisBuilderEmbeddable extends Embeddable s.unsubscribe()); + this.uiState.off('change', this.uiStateChangeHandler); + this.uiState.off('reload', this.reload); if (this.node) { ReactDOM.unmountComponentAtNode(this.node); } @@ -249,6 +255,7 @@ export class VisBuilderEmbeddable extends Embeddable { + this.updateInput({ + uiState: this.uiState.toJSON(), + }); + }; + + private transferInputToUiState = () => { + if (JSON.stringify(this.input.uiState) !== this.uiState.toString()) + this.uiState.set(this.input.uiState); + }; + // TODO: we may eventually need to add support for visualizations that use triggers like filter or brush, but current VisBuilder vis types don't support triggers // public supportedTriggers(): TriggerId[] { // return this.visType.getSupportedTriggers?.() ?? []; diff --git a/src/plugins/vis_builder/public/saved_visualizations/_saved_vis.ts b/src/plugins/vis_builder/public/saved_visualizations/_saved_vis.ts index 53ccc7a9d7d0..021af777df17 100644 --- a/src/plugins/vis_builder/public/saved_visualizations/_saved_vis.ts +++ b/src/plugins/vis_builder/public/saved_visualizations/_saved_vis.ts @@ -22,6 +22,7 @@ export function createSavedVisBuilderVisClass(services: SavedObjectOpenSearchDas description: 'text', visualizationState: 'text', styleState: 'text', + uiState: 'text', version: 'integer', }; @@ -44,7 +45,8 @@ export function createSavedVisBuilderVisClass(services: SavedObjectOpenSearchDas description: '', visualizationState: '{}', styleState: '{}', - version: 2, + uiState: '{}', + version: 3, }, }); this.showInRecentlyAccessed = true; 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 de7963313de1..d7d557b7c38c 100644 --- a/src/plugins/vis_builder/public/saved_visualizations/transforms.test.ts +++ b/src/plugins/vis_builder/public/saved_visualizations/transforms.test.ts @@ -24,8 +24,8 @@ describe('transforms', () => { TEST_INDEX_PATTERN_ID = 'test-pattern'; savedObject = {} as VisBuilderSavedObject; rootState = { - metadata: { editor: { state: 'loading', errors: {} } }, - style: '', + metadata: { editor: { state: 'loading', errors: {} }, uiState: {} }, + style: {}, visualization: { searchField: '', indexPattern: TEST_INDEX_PATTERN_ID, @@ -49,6 +49,7 @@ describe('transforms', () => { expect(savedObject.visualizationState).not.toContain(TEST_INDEX_PATTERN_ID); expect(savedObject.styleState).toEqual(JSON.stringify(rootState.style)); + expect(savedObject.uiState).toEqual(JSON.stringify(rootState.metadata.uiState)); expect(savedObject.searchSourceFields?.index?.id).toEqual(TEST_INDEX_PATTERN_ID); }); @@ -69,13 +70,14 @@ describe('transforms', () => { visualizationState: JSON.stringify({ searchField: '', }), + uiState: JSON.stringify('{}'), searchSourceFields: { index: 'test-index', }, } as VisBuilderSavedObjectAttributes; test('should return saved object with state', () => { - const { state } = getStateFromSavedObject(defaultVBSaveObj); + const { state, uiState } = getStateFromSavedObject(defaultVBSaveObj); expect(state).toMatchInlineSnapshot(` Object { @@ -86,6 +88,8 @@ describe('transforms', () => { }, } `); + + expect(uiState).toMatchInlineSnapshot(); }); test('should throw error if state is invalid', () => { diff --git a/src/plugins/vis_builder/public/saved_visualizations/transforms.ts b/src/plugins/vis_builder/public/saved_visualizations/transforms.ts index 0a7a6e529a6b..2120edc60799 100644 --- a/src/plugins/vis_builder/public/saved_visualizations/transforms.ts +++ b/src/plugins/vis_builder/public/saved_visualizations/transforms.ts @@ -7,7 +7,7 @@ import { i18n } from '@osd/i18n'; import produce from 'immer'; import { IndexPattern } from '../../../data/public'; import { InvalidJSONProperty } from '../../../opensearch_dashboards_utils/public'; -import { RenderState, RootState, VisualizationState } from '../application/utils/state_management'; +import { RootState, VisualizationState, RenderState } from '../application/utils/state_management'; import { validateVisBuilderState } from '../application/utils/validations'; import { VisBuilderSavedObject } from '../types'; import { VisBuilderSavedObjectAttributes } from '../../common'; @@ -27,6 +27,7 @@ export const saveStateToSavedObject = ( ); obj.styleState = JSON.stringify(state.style); obj.searchSourceFields = { index: indexPattern }; + obj.uiState = JSON.stringify(state.metadata.uiState); return obj; }; @@ -34,13 +35,15 @@ export const saveStateToSavedObject = ( export interface VisBuilderSavedVis extends Pick { state: RenderState; + uiState: any; } export const getStateFromSavedObject = ( obj: VisBuilderSavedObjectAttributes ): VisBuilderSavedVis => { const { id, title, description } = obj; - const styleState = JSON.parse(obj.styleState || ''); + const styleState = JSON.parse(obj.styleState || '{}'); + const uiState = JSON.parse(obj.uiState || '{}'); const vizStateWithoutIndex = JSON.parse(obj.visualizationState || ''); const visualizationState: VisualizationState = { searchField: '', @@ -76,5 +79,6 @@ export const getStateFromSavedObject = ( visualization: visualizationState, style: styleState, }, + uiState, }; }; diff --git a/src/plugins/vis_builder/public/types.ts b/src/plugins/vis_builder/public/types.ts index 5221a1c513ec..1ba8843e016a 100644 --- a/src/plugins/vis_builder/public/types.ts +++ b/src/plugins/vis_builder/public/types.ts @@ -62,6 +62,7 @@ export interface ISavedVis { description?: string; visualizationState?: string; styleState?: string; + uiState?: string; version?: number; } diff --git a/src/plugins/vis_builder/server/saved_objects/vis_builder_app.ts b/src/plugins/vis_builder/server/saved_objects/vis_builder_app.ts index 15d785b3b451..029557010bee 100644 --- a/src/plugins/vis_builder/server/saved_objects/vis_builder_app.ts +++ b/src/plugins/vis_builder/server/saved_objects/vis_builder_app.ts @@ -46,6 +46,10 @@ export const visBuilderSavedObjectType: SavedObjectsType = { type: 'text', index: false, }, + uiState: { + type: 'text', + index: false, + }, version: { type: 'integer' }, // Need to add a kibanaSavedObjectMeta attribute here to follow the current saved object flow // When we save a saved object, the saved object plugin will extract the search source into two parts From d2b1b0ee0bb3cba3c7824032c3926dacd5f67b4f Mon Sep 17 00:00:00 2001 From: Ashwin P Chandran Date: Fri, 24 Mar 2023 08:31:44 +0000 Subject: [PATCH 06/10] fixes tests Signed-off-by: Ashwin P Chandran --- .../utils/state_management/redux_persistence.test.tsx | 1 + .../vis_builder/public/saved_visualizations/transforms.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plugins/vis_builder/public/application/utils/state_management/redux_persistence.test.tsx b/src/plugins/vis_builder/public/application/utils/state_management/redux_persistence.test.tsx index 91f760bbf231..333d5d65d369 100644 --- a/src/plugins/vis_builder/public/application/utils/state_management/redux_persistence.test.tsx +++ b/src/plugins/vis_builder/public/application/utils/state_management/redux_persistence.test.tsx @@ -32,6 +32,7 @@ describe('test redux state persistence', () => { metadata: { editor: { errors: {}, state: 'loading' }, originatingApp: undefined, + uiState: {}, }, }; 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 d7d557b7c38c..d635e9eefd6e 100644 --- a/src/plugins/vis_builder/public/saved_visualizations/transforms.test.ts +++ b/src/plugins/vis_builder/public/saved_visualizations/transforms.test.ts @@ -89,7 +89,7 @@ describe('transforms', () => { } `); - expect(uiState).toMatchInlineSnapshot(); + expect(uiState).toMatchInlineSnapshot(`"{}"`); }); test('should throw error if state is invalid', () => { From 6fcace5b6038ae0a5dc41024369410df0c27f02a Mon Sep 17 00:00:00 2001 From: Ashwin P Chandran Date: Tue, 28 Mar 2023 07:06:13 +0000 Subject: [PATCH 07/10] Adds support in embeddables for multiple indices Signed-off-by: Ashwin P Chandran --- .../embeddable/vis_builder_embeddable.tsx | 19 +++++++++++-- .../vis_builder_embeddable_factory.tsx | 28 +++++++++++-------- src/plugins/vis_builder/public/plugin.ts | 9 +++--- 3 files changed, 37 insertions(+), 19 deletions(-) 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 51097bea1ac1..f1fb76175b61 100644 --- a/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable.tsx +++ b/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable.tsx @@ -22,6 +22,7 @@ import { } from '../../../expressions/public'; import { Filter, + IIndexPattern, opensearchFilters, Query, TimefilterContract, @@ -37,17 +38,18 @@ import { import { PersistedState } from '../../../visualizations/public'; import { VisBuilderSavedVis } from '../saved_visualizations/transforms'; import { handleVisEvent } from '../application/utils/handle_vis_event'; +import { VisBuilderEmbeddableFactoryDeps } from './vis_builder_embeddable_factory'; // 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 { savedVis: VisBuilderSavedVis; - // TODO: add indexPatterns as part of configuration - // indexPatterns?: IIndexPattern[]; + indexPatterns?: IIndexPattern[]; editPath: string; editUrl: string; editable: boolean; + deps: VisBuilderEmbeddableFactoryDeps; } export interface VisBuilderInput extends SavedObjectEmbeddableInput { @@ -60,6 +62,7 @@ export interface VisBuilderOutput extends EmbeddableOutput { * `input.savedObjectId`. If the id is invalid, this may be undefined. */ savedVis?: VisBuilderSavedVis; + indexPatterns?: IIndexPattern[]; } type ExpressionLoader = InstanceType; @@ -78,10 +81,18 @@ export class VisBuilderEmbeddable extends Embeddable; +export interface VisBuilderEmbeddableFactoryDeps { + start: StartServicesGetter; +} -export class VisBuilderEmbeddableFactoryDefinition +export class VisBuilderEmbeddableFactory implements EmbeddableFactoryDefinition< SavedObjectEmbeddableInput, @@ -62,7 +59,7 @@ export class VisBuilderEmbeddableFactoryDefinition }; // TODO: Would it be better to explicitly declare start service dependencies? - constructor() {} + constructor(private readonly deps: VisBuilderEmbeddableFactoryDeps) {} public canCreateNew() { // Because VisBuilder creation starts with the visualization modal, no need to have a separate entry for VisBuilder until it's separate @@ -90,13 +87,22 @@ export class VisBuilderEmbeddableFactoryDefinition return new DisabledEmbeddable(PLUGIN_NAME, input); } + const savedVis = getStateFromSavedObject(savedObject); + const indexPatternService = this.deps.start().plugins.data.indexPatterns; + const indexPattern = await indexPatternService.get( + savedVis.state.visualization.indexPattern || '' + ); + const indexPatterns = indexPattern ? [indexPattern] : []; + return new VisBuilderEmbeddable( getTimeFilter(), { - savedVis: getStateFromSavedObject(savedObject), + savedVis, editUrl, editPath, editable: true, + deps: this.deps, + indexPatterns, }, { ...input, diff --git a/src/plugins/vis_builder/public/plugin.ts b/src/plugins/vis_builder/public/plugin.ts index 0c1d569f6bed..7ed9e68ff7d7 100644 --- a/src/plugins/vis_builder/public/plugin.ts +++ b/src/plugins/vis_builder/public/plugin.ts @@ -23,7 +23,7 @@ import { VisBuilderSetup, VisBuilderStart, } from './types'; -import { VisBuilderEmbeddableFactoryDefinition, VISBUILDER_EMBEDDABLE } from './embeddable'; +import { VisBuilderEmbeddableFactory, VISBUILDER_EMBEDDABLE } from './embeddable'; import visBuilderIconSecondaryFill from './assets/vis_builder_icon_secondary_fill.svg'; import visBuilderIcon from './assets/vis_builder_icon.svg'; import { @@ -54,6 +54,7 @@ import { ConfigSchema } from '../config'; import { createOsdUrlStateStorage, createOsdUrlTracker, + createStartServicesGetter, withNotifyOnErrors, } from '../../opensearch_dashboards_utils/public'; import { opensearchFilters } from '../../data/public'; @@ -176,10 +177,8 @@ export class VisBuilderPlugin }); // Register embeddable - // TODO: investigate simplification via getter a la visualizations: - // const start = createStartServicesGetter(core.getStartServices)); - // const embeddableFactory = new VisBuilderEmbeddableFactoryDefinition({ start }); - const embeddableFactory = new VisBuilderEmbeddableFactoryDefinition(); + const start = createStartServicesGetter(core.getStartServices); + const embeddableFactory = new VisBuilderEmbeddableFactory({ start }); embeddable.registerEmbeddableFactory(VISBUILDER_EMBEDDABLE, embeddableFactory); // Register the plugin as an alias to create visualization From 97ccbe98419a041c5d8ab2e1f8662bdb19d0c2e4 Mon Sep 17 00:00:00 2001 From: Ashwin P Chandran Date: Mon, 17 Apr 2023 20:26:11 +0000 Subject: [PATCH 08/10] Moves ui state to separate slice Signed-off-by: Ashwin P Chandran --- .../application/components/right_nav.tsx | 2 +- .../application/components/workspace.tsx | 11 +++--- .../utils/state_management/metadata_slice.ts | 8 +---- .../redux_persistence.test.tsx | 3 +- .../state_management/redux_persistence.ts | 4 +-- .../utils/state_management/store.ts | 3 ++ .../utils/state_management/ui_state_slice.ts | 34 +++++++++++++++++++ .../utils/use/use_saved_vis_builder_vis.ts | 14 +++++--- .../embeddable/vis_builder_embeddable.tsx | 2 +- .../saved_visualizations/transforms.test.ts | 10 +++--- .../public/saved_visualizations/transforms.ts | 7 ++-- 11 files changed, 67 insertions(+), 31 deletions(-) create mode 100644 src/plugins/vis_builder/public/application/utils/state_management/ui_state_slice.ts 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 5d3f298c6bf3..fde4f3110d1c 100644 --- a/src/plugins/vis_builder/public/application/components/right_nav.tsx +++ b/src/plugins/vis_builder/public/application/components/right_nav.tsx @@ -122,6 +122,6 @@ const OptionItem = ({ icon, title }: { icon: IconType; title: string }) => ( ); -// The app uses EuiResizableContainer that triggers a rerender for ever mouseover action. +// The app uses EuiResizableContainer that triggers a rerender for every 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 93936cc90a57..43f3ad04fe0f 100644 --- a/src/plugins/vis_builder/public/application/components/workspace.tsx +++ b/src/plugins/vis_builder/public/application/components/workspace.tsx @@ -10,7 +10,7 @@ import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react import { IExpressionLoaderParams } from '../../../../expressions/public'; import { VisBuilderServices } from '../../types'; import { validateSchemaState, validateAggregations } from '../utils/validations'; -import { useTypedDispatch, useTypedSelector } from '../utils/state_management'; +import { useTypedDispatch, useTypedSelector, setUIStateState } from '../utils/state_management'; import { useAggs, useVisualizationType } from '../utils/use'; import { PersistedState } from '../../../../visualizations/public'; @@ -20,7 +20,6 @@ import fields_bg from '../../assets/fields_bg.svg'; import './workspace.scss'; import { ExperimentalInfo } from './experimental_info'; import { handleVisEvent } from '../utils/handle_vis_event'; -import { setUiState } from '../utils/state_management/metadata_slice'; export const WorkspaceUI = () => { const { @@ -43,11 +42,11 @@ export const WorkspaceUI = () => { const dispatch = useTypedDispatch(); // Visualizations require the uiState object to persist even when the expression changes // eslint-disable-next-line react-hooks/exhaustive-deps - const uiState = useMemo(() => new PersistedState(rootState.metadata.uiState), []); + const uiState = useMemo(() => new PersistedState(rootState.ui), []); useEffect(() => { if (rootState.metadata.editor.state === 'loaded') { - uiState.setSilent(rootState.metadata.uiState); + uiState.setSilent(rootState.ui); } // To update uiState once saved object data is loaded // eslint-disable-next-line react-hooks/exhaustive-deps @@ -56,7 +55,7 @@ export const WorkspaceUI = () => { useEffect(() => { uiState.on('change', (args) => { // Store changes to UI state - dispatch(setUiState({ state: uiState.toJSON() })); + dispatch(setUIStateState(uiState.toJSON())); }); }, [dispatch, uiState]); @@ -151,6 +150,6 @@ export const WorkspaceUI = () => { ); }; -// The app uses EuiResizableContainer that triggers a rerender for ever mouseover action. +// The app uses EuiResizableContainer that triggers a rerender for every 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/state_management/metadata_slice.ts b/src/plugins/vis_builder/public/application/utils/state_management/metadata_slice.ts index 75000c07ac94..880c15f3e44a 100644 --- a/src/plugins/vis_builder/public/application/utils/state_management/metadata_slice.ts +++ b/src/plugins/vis_builder/public/application/utils/state_management/metadata_slice.ts @@ -22,7 +22,6 @@ export interface MetadataState { state: EditorState; }; originatingApp?: string; - uiState: any; } const initialState: MetadataState = { @@ -31,7 +30,6 @@ const initialState: MetadataState = { state: 'loading', }, originatingApp: undefined, - uiState: {}, }; export const getPreloadedState = async ({ @@ -63,10 +61,6 @@ export const slice = createSlice({ setOriginatingApp: (state, action: PayloadAction<{ state?: string }>) => { state.originatingApp = action.payload.state; }, - setUiState: (state, action: PayloadAction<{ state: any; editorState?: EditorState }>) => { - state.uiState = action.payload.state; - state.editor.state = action.payload.editorState || 'dirty'; - }, setState: (_state, action: PayloadAction) => { return action.payload; }, @@ -74,4 +68,4 @@ export const slice = createSlice({ }); export const { reducer } = slice; -export const { setError, setEditorState, setOriginatingApp, setState, setUiState } = slice.actions; +export const { setError, setEditorState, setOriginatingApp, setState } = slice.actions; diff --git a/src/plugins/vis_builder/public/application/utils/state_management/redux_persistence.test.tsx b/src/plugins/vis_builder/public/application/utils/state_management/redux_persistence.test.tsx index 333d5d65d369..a46d5c027656 100644 --- a/src/plugins/vis_builder/public/application/utils/state_management/redux_persistence.test.tsx +++ b/src/plugins/vis_builder/public/application/utils/state_management/redux_persistence.test.tsx @@ -18,6 +18,7 @@ describe('test redux state persistence', () => { style: 'style', visualization: 'visualization', metadata: 'metadata', + ui: 'ui', }; }); @@ -32,8 +33,8 @@ describe('test redux state persistence', () => { metadata: { editor: { errors: {}, state: 'loading' }, originatingApp: undefined, - uiState: {}, }, + ui: {}, }; const returnStates = await loadReduxState(mockServices); diff --git a/src/plugins/vis_builder/public/application/utils/state_management/redux_persistence.ts b/src/plugins/vis_builder/public/application/utils/state_management/redux_persistence.ts index a531986a9ac9..b58b14e6d4be 100644 --- a/src/plugins/vis_builder/public/application/utils/state_management/redux_persistence.ts +++ b/src/plugins/vis_builder/public/application/utils/state_management/redux_persistence.ts @@ -21,13 +21,13 @@ export const loadReduxState = async (services: VisBuilderServices) => { }; export const persistReduxState = ( - { style, visualization, metadata }, + { style, visualization, metadata, ui }: RootState, services: VisBuilderServices ) => { try { services.osdUrlStateStorage.set( '_a', - { style, visualization, metadata }, + { style, visualization, metadata, ui }, { replace: true, } diff --git a/src/plugins/vis_builder/public/application/utils/state_management/store.ts b/src/plugins/vis_builder/public/application/utils/state_management/store.ts index c2600d8df247..820631f7568e 100644 --- a/src/plugins/vis_builder/public/application/utils/state_management/store.ts +++ b/src/plugins/vis_builder/public/application/utils/state_management/store.ts @@ -8,12 +8,14 @@ import { isEqual } from 'lodash'; import { reducer as styleReducer } from './style_slice'; import { reducer as visualizationReducer } from './visualization_slice'; import { reducer as metadataReducer } from './metadata_slice'; +import { reducer as uiStateReducer } from './ui_state_slice'; import { VisBuilderServices } from '../../..'; import { loadReduxState, persistReduxState } from './redux_persistence'; import { handlerEditorState } from './handlers/editor_state'; import { handlerParentAggs } from './handlers/parent_aggs'; const rootReducer = combineReducers({ + ui: uiStateReducer, style: styleReducer, visualization: visualizationReducer, metadata: metadataReducer, @@ -61,3 +63,4 @@ export type AppDispatch = Store['dispatch']; export { setState as setStyleState, StyleState } from './style_slice'; export { setState as setVisualizationState, VisualizationState } from './visualization_slice'; export { MetadataState } from './metadata_slice'; +export { setState as setUIStateState, UIStateState } from './ui_state_slice'; diff --git a/src/plugins/vis_builder/public/application/utils/state_management/ui_state_slice.ts b/src/plugins/vis_builder/public/application/utils/state_management/ui_state_slice.ts new file mode 100644 index 000000000000..26ad24b28a50 --- /dev/null +++ b/src/plugins/vis_builder/public/application/utils/state_management/ui_state_slice.ts @@ -0,0 +1,34 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +export type UIStateState = T; + +const initialState = {} as UIStateState; + +export const uiStateSlice = createSlice({ + name: 'ui', + initialState, + reducers: { + setState(state: T, action: PayloadAction>) { + return action.payload; + }, + updateState(state: T, action: PayloadAction>>) { + state = { + ...state, + ...action.payload, + }; + }, + }, +}); + +// Exposing the state functions as generics +export const setState = uiStateSlice.actions.setState as (payload: T) => PayloadAction; +export const updateState = uiStateSlice.actions.updateState as ( + payload: Partial +) => PayloadAction>; + +export const { reducer } = uiStateSlice; 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 22650363ddad..44ffbaf75953 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,9 +14,14 @@ import { import { EDIT_PATH, PLUGIN_ID } from '../../../../common'; import { VisBuilderServices } from '../../../types'; import { getCreateBreadcrumbs, getEditBreadcrumbs } from '../breadcrumbs'; -import { useTypedDispatch, setStyleState, setVisualizationState } from '../state_management'; +import { + useTypedDispatch, + setStyleState, + setVisualizationState, + setUIStateState, +} from '../state_management'; import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; -import { setEditorState, setUiState } from '../state_management/metadata_slice'; +import { setEditorState } from '../state_management/metadata_slice'; import { getStateFromSavedObject } from '../../../saved_visualizations/transforms'; // This function can be used when instantiating a saved vis or creating a new one @@ -53,13 +58,14 @@ export const useSavedVisBuilderVis = (visualizationIdFromUrl: string | undefined ); if (savedVisBuilderVis.id) { - const { title, state, uiState } = getStateFromSavedObject(savedVisBuilderVis); + const { title, state } = getStateFromSavedObject(savedVisBuilderVis); chrome.setBreadcrumbs(getEditBreadcrumbs(title, navigateToApp)); chrome.docTitle.change(title); - dispatch(setUiState({ state: uiState, editorState: 'loaded' })); + dispatch(setUIStateState(state.ui)); dispatch(setStyleState(state.style)); dispatch(setVisualizationState(state.visualization)); + dispatch(setEditorState({ state: 'loaded' })); } else { chrome.setBreadcrumbs(getCreateBreadcrumbs(navigateToApp)); } 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 f1fb76175b61..a931877ffe6d 100644 --- a/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable.tsx +++ b/src/plugins/vis_builder/public/embeddable/vis_builder_embeddable.tsx @@ -116,7 +116,7 @@ export class VisBuilderEmbeddable extends Embeddable { TEST_INDEX_PATTERN_ID = 'test-pattern'; savedObject = {} as VisBuilderSavedObject; rootState = { - metadata: { editor: { state: 'loading', errors: {} }, uiState: {} }, + metadata: { editor: { state: 'loading', errors: {} } }, style: {}, visualization: { searchField: '', @@ -34,6 +34,7 @@ describe('transforms', () => { aggConfigParams: [], }, }, + ui: {}, }; indexPattern = getStubIndexPattern( TEST_INDEX_PATTERN_ID, @@ -49,7 +50,7 @@ describe('transforms', () => { expect(savedObject.visualizationState).not.toContain(TEST_INDEX_PATTERN_ID); expect(savedObject.styleState).toEqual(JSON.stringify(rootState.style)); - expect(savedObject.uiState).toEqual(JSON.stringify(rootState.metadata.uiState)); + expect(savedObject.uiState).toEqual(JSON.stringify(rootState.ui)); expect(savedObject.searchSourceFields?.index?.id).toEqual(TEST_INDEX_PATTERN_ID); }); @@ -77,7 +78,7 @@ describe('transforms', () => { } as VisBuilderSavedObjectAttributes; test('should return saved object with state', () => { - const { state, uiState } = getStateFromSavedObject(defaultVBSaveObj); + const { state } = getStateFromSavedObject(defaultVBSaveObj); expect(state).toMatchInlineSnapshot(` Object { @@ -86,10 +87,9 @@ describe('transforms', () => { "indexPattern": "test-index", "searchField": "", }, + "ui": Object {}, } `); - - expect(uiState).toMatchInlineSnapshot(`"{}"`); }); test('should throw error if state is invalid', () => { diff --git a/src/plugins/vis_builder/public/saved_visualizations/transforms.ts b/src/plugins/vis_builder/public/saved_visualizations/transforms.ts index 694bf75ecbb6..9f8dd705e3e4 100644 --- a/src/plugins/vis_builder/public/saved_visualizations/transforms.ts +++ b/src/plugins/vis_builder/public/saved_visualizations/transforms.ts @@ -27,7 +27,7 @@ export const saveStateToSavedObject = ( ); obj.styleState = JSON.stringify(state.style); obj.searchSourceFields = { index: indexPattern }; - obj.uiState = JSON.stringify(state.metadata.uiState); + obj.uiState = JSON.stringify(state.ui); return obj; }; @@ -35,7 +35,6 @@ export const saveStateToSavedObject = ( export interface VisBuilderSavedVis extends Pick { state: RenderState; - uiState: any; } export const getStateFromSavedObject = ( @@ -51,7 +50,7 @@ export const getStateFromSavedObject = ( indexPattern: obj.searchSourceFields?.index, }; - const validateResult = validateVisBuilderState({ styleState, visualizationState }); + const validateResult = validateVisBuilderState({ styleState, visualizationState, uiState }); if (!validateResult.valid) { throw new InvalidJSONProperty( @@ -78,7 +77,7 @@ export const getStateFromSavedObject = ( state: { visualization: visualizationState, style: styleState, + ui: uiState, }, - uiState, }; }; From 71ee8fcb130f20f434e53c0a65078e4587bc4677 Mon Sep 17 00:00:00 2001 From: Ashwin P Chandran Date: Mon, 17 Apr 2023 21:46:46 +0000 Subject: [PATCH 09/10] Fixes tests Signed-off-by: Ashwin P Chandran --- .../public/application/utils/state_management/preload.ts | 3 +++ .../utils/state_management/redux_persistence.ts | 3 +-- .../application/utils/state_management/ui_state_slice.ts | 8 ++++++++ .../public/saved_visualizations/transforms.test.ts | 4 ++-- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/plugins/vis_builder/public/application/utils/state_management/preload.ts b/src/plugins/vis_builder/public/application/utils/state_management/preload.ts index 43aa2e7b8ede..f7a0f6bd7ad3 100644 --- a/src/plugins/vis_builder/public/application/utils/state_management/preload.ts +++ b/src/plugins/vis_builder/public/application/utils/state_management/preload.ts @@ -8,6 +8,7 @@ import { VisBuilderServices } from '../../..'; import { getPreloadedState as getPreloadedStyleState } from './style_slice'; import { getPreloadedState as getPreloadedVisualizationState } from './visualization_slice'; import { getPreloadedState as getPreloadedMetadataState } from './metadata_slice'; +import { getPreloadedState as getPreloadedUIState } from './ui_state_slice'; import { RootState } from './store'; export const getPreloadedState = async ( @@ -16,10 +17,12 @@ export const getPreloadedState = async ( const styleState = await getPreloadedStyleState(services); const visualizationState = await getPreloadedVisualizationState(services); const metadataState = await getPreloadedMetadataState(services); + const uiState = await getPreloadedUIState(services); return { style: styleState, visualization: visualizationState, metadata: metadataState, + ui: uiState, }; }; diff --git a/src/plugins/vis_builder/public/application/utils/state_management/redux_persistence.ts b/src/plugins/vis_builder/public/application/utils/state_management/redux_persistence.ts index b58b14e6d4be..3ebfa47268ec 100644 --- a/src/plugins/vis_builder/public/application/utils/state_management/redux_persistence.ts +++ b/src/plugins/vis_builder/public/application/utils/state_management/redux_persistence.ts @@ -12,9 +12,8 @@ export const loadReduxState = async (services: VisBuilderServices) => { const serializedState = services.osdUrlStateStorage.get('_a'); if (serializedState !== null) return serializedState; } catch (err) { - /* eslint-disable no-console */ + // eslint-disable-next-line no-console console.error(err); - /* eslint-enable no-console */ } return await getPreloadedState(services); diff --git a/src/plugins/vis_builder/public/application/utils/state_management/ui_state_slice.ts b/src/plugins/vis_builder/public/application/utils/state_management/ui_state_slice.ts index 26ad24b28a50..826fe9d9873d 100644 --- a/src/plugins/vis_builder/public/application/utils/state_management/ui_state_slice.ts +++ b/src/plugins/vis_builder/public/application/utils/state_management/ui_state_slice.ts @@ -4,11 +4,19 @@ */ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { VisBuilderServices } from '../../../types'; export type UIStateState = T; const initialState = {} as UIStateState; +export const getPreloadedState = async ({ + types, + data, +}: VisBuilderServices): Promise => { + return initialState; +}; + export const uiStateSlice = createSlice({ name: 'ui', initialState, 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 8f3b59762853..68c24dfe4af1 100644 --- a/src/plugins/vis_builder/public/saved_visualizations/transforms.test.ts +++ b/src/plugins/vis_builder/public/saved_visualizations/transforms.test.ts @@ -71,7 +71,7 @@ describe('transforms', () => { visualizationState: JSON.stringify({ searchField: '', }), - uiState: JSON.stringify('{}'), + uiState: '{}', searchSourceFields: { index: 'test-index', }, @@ -83,11 +83,11 @@ describe('transforms', () => { expect(state).toMatchInlineSnapshot(` Object { "style": Object {}, + "ui": Object {}, "visualization": Object { "indexPattern": "test-index", "searchField": "", }, - "ui": Object {}, } `); }); From ceb58605377e816cedb96209f93f438a0c313a21 Mon Sep 17 00:00:00 2001 From: Ashwin P Chandran Date: Mon, 17 Apr 2023 22:50:00 +0000 Subject: [PATCH 10/10] Adds changelog Signed-off-by: Ashwin P Chandran --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 062c3205f947..8f2202b91a44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [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)) - [Multiple DataSource] Integrate multiple datasource with dev tool console ([#3754](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3754)) - Add satisfaction survey link to help menu ([#3676] (https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3676)) +- [Vis Builder] Add persistence to visualizations inner state ([#3751](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3751)) ### 🐛 Bug Fixes @@ -122,6 +123,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [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)) +- [VisBuilder] Fix indexpattern selection in filter bar ([#3751](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3751)) - [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))