diff --git a/changelogs/fragments/7529.yml b/changelogs/fragments/7529.yml new file mode 100644 index 000000000000..b2031779e48c --- /dev/null +++ b/changelogs/fragments/7529.yml @@ -0,0 +1,2 @@ +feat: +- [VisBuilder-Next] Migration of legacy visualizations to VisBuilder by constructing the URL. ([#7529](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7529)) \ No newline at end of file diff --git a/src/plugins/opensearch_dashboards_react/public/table_list_view/edit_action_dropdown.test.tsx b/src/plugins/opensearch_dashboards_react/public/table_list_view/edit_action_dropdown.test.tsx new file mode 100644 index 000000000000..f6a7cfab2d62 --- /dev/null +++ b/src/plugins/opensearch_dashboards_react/public/table_list_view/edit_action_dropdown.test.tsx @@ -0,0 +1,184 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mount } from 'enzyme'; +import { EditActionDropdown, VisualizationItem } from './edit_action_dropdown'; +import { + EuiContextMenuPanel, + EuiIcon, + EuiPopover, + EuiContextMenuItem, + EuiConfirmModal, +} from '@elastic/eui'; +import { useOpenSearchDashboards } from '../context'; + +// Mock the useOpenSearchDashboards hook +jest.mock('../context', () => ({ + useOpenSearchDashboards: jest.fn(), +})); + +describe('EditActionDropdown', () => { + let component: any; + const mockEditItem = jest.fn(); + const mockVisbuilderEditItem = jest.fn(); + const mockOpenModal = jest.fn(); + const mockCloseModal = jest.fn(); + + const defaultItem: VisualizationItem = { + typeTitle: 'Area', + id: '1', + version: 1, + }; + + const mockOverlays = { + openModal: mockOpenModal.mockReturnValue({ close: mockCloseModal }), + }; + + beforeEach(() => { + // Cast the mocked function to any to avoid TypeScript errors + (useOpenSearchDashboards as jest.Mock).mockReturnValue({ overlays: mockOverlays }); + + component = mount( + + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the edit icon', () => { + expect(component.find(EuiIcon).first().prop('type')).toBe('pencil'); + }); + + it('opens the popover when icon is clicked', () => { + act(() => { + component.find(EuiIcon).first().simulate('click'); + }); + component.update(); + expect(component.find(EuiPopover).prop('isOpen')).toBe(true); + }); + + it('renders context menu panel with correct options for VisBuilder compatible item', () => { + act(() => { + component.find(EuiIcon).first().simulate('click'); + }); + component.update(); + const contextMenuPanel = component.find(EuiContextMenuPanel); + expect(contextMenuPanel.exists()).toBe(true); + expect(contextMenuPanel.prop('items')).toHaveLength(2); + expect(contextMenuPanel.find(EuiContextMenuItem).at(0).text()).toBe('Edit'); + expect(contextMenuPanel.find(EuiContextMenuItem).at(1).text()).toBe('Import to VisBuilder'); + }); + + it('does not render VisBuilder option for incompatible item', () => { + const incompatibleItem: VisualizationItem = { + typeTitle: 'Pie', + id: '2', + version: 2, + }; + component.setProps({ item: incompatibleItem }); + act(() => { + component.find(EuiIcon).first().simulate('click'); + }); + component.update(); + const contextMenuPanel = component.find(EuiContextMenuPanel); + expect(contextMenuPanel.prop('items')).toHaveLength(1); + expect(contextMenuPanel.find(EuiContextMenuItem).at(0).text()).toBe('Edit'); + }); + + it('calls editItem when Edit option is clicked', () => { + act(() => { + component.find(EuiIcon).first().simulate('click'); + }); + component.update(); + act(() => { + component.find(EuiContextMenuItem).at(0).simulate('click'); + }); + expect(mockEditItem).toHaveBeenCalledWith(defaultItem); + }); + + it('opens a confirmation modal when Import to VisBuilder option is clicked', () => { + act(() => { + component.find(EuiIcon).first().simulate('click'); + }); + component.update(); + act(() => { + component.find(EuiContextMenuItem).at(1).simulate('click'); + }); + expect(mockOpenModal).toHaveBeenCalled(); + expect(mockOpenModal.mock.calls[0][0].type).toBe(EuiConfirmModal); + }); + + it('calls visbuilderEditItem when confirmation modal is confirmed', () => { + act(() => { + component.find(EuiIcon).first().simulate('click'); + }); + component.update(); + act(() => { + component.find(EuiContextMenuItem).at(1).simulate('click'); + }); + + const modalProps = mockOpenModal.mock.calls[0][0].props; + act(() => { + modalProps.onConfirm(); + }); + + expect(mockVisbuilderEditItem).toHaveBeenCalledWith(defaultItem); + expect(mockCloseModal).toHaveBeenCalled(); + }); + + it('does not call visbuilderEditItem when confirmation modal is cancelled', () => { + act(() => { + component.find(EuiIcon).first().simulate('click'); + }); + component.update(); + act(() => { + component.find(EuiContextMenuItem).at(1).simulate('click'); + }); + + const modalProps = mockOpenModal.mock.calls[0][0].props; + act(() => { + modalProps.onCancel(); + }); + + expect(mockVisbuilderEditItem).not.toHaveBeenCalled(); + expect(mockCloseModal).toHaveBeenCalled(); + }); + + it('closes the popover after an action is selected', () => { + act(() => { + component.find(EuiIcon).first().simulate('click'); + }); + component.update(); + act(() => { + component.find(EuiContextMenuItem).at(0).simulate('click'); + }); + component.update(); + expect(component.find(EuiPopover).prop('isOpen')).toBe(false); + }); + + it('sets correct props on EuiPopover', () => { + const popover = component.find(EuiPopover); + expect(popover.prop('panelPaddingSize')).toBe('none'); + expect(popover.prop('anchorPosition')).toBe('downLeft'); + expect(popover.prop('initialFocus')).toBe('none'); + }); + + it('sets correct props on EuiContextMenuPanel', () => { + act(() => { + component.find(EuiIcon).first().simulate('click'); + }); + component.update(); + const panel = component.find(EuiContextMenuPanel); + expect(panel.prop('size')).toBe('s'); + }); +}); diff --git a/src/plugins/opensearch_dashboards_react/public/table_list_view/edit_action_dropdown.tsx b/src/plugins/opensearch_dashboards_react/public/table_list_view/edit_action_dropdown.tsx new file mode 100644 index 000000000000..2b155baa0328 --- /dev/null +++ b/src/plugins/opensearch_dashboards_react/public/table_list_view/edit_action_dropdown.tsx @@ -0,0 +1,123 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { + EuiIcon, + EuiPopover, + EuiContextMenuPanel, + EuiContextMenuItem, + EuiText, + EuiConfirmModal, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { useOpenSearchDashboards } from '../context'; + +// TODO: include more types once VisBuilder supports more visualization types +const types = ['Area', 'Vertical Bar', 'Line', 'Metric', 'Table']; + +export interface VisualizationItem { + typeTitle: string; + id?: string; + version?: number; + overlays?: any; +} + +interface EditActionDropdownProps { + item: VisualizationItem; + editItem?(item: VisualizationItem): void; + visbuilderEditItem?(item: VisualizationItem): void; +} + +export const EditActionDropdown: React.FC = ({ + item, + editItem, + visbuilderEditItem, +}) => { + const { overlays } = useOpenSearchDashboards(); + const [isPopoverOpen, setPopoverOpen] = useState(false); + const onButtonClick = () => { + setPopoverOpen(!isPopoverOpen); + }; + + const closePopover = () => { + setPopoverOpen(false); + }; + // A saved object will only have the 'Import to VisBuilder' option + // if it is a VisBuilder-compatible type and its version is <= 1. + const typeName = item.typeTitle; + const itemVersion = item.version; + const isVisBuilderCompatible = + types.includes(typeName) && itemVersion !== undefined && itemVersion <= 1; + + const handleImportToVisBuilder = () => { + closePopover(); // Close the popover first + + const modal = overlays.openModal( + modal.close()} + onConfirm={async () => { + modal.close(); + // Call visbuilderEditItem with the item + if (visbuilderEditItem) { + await visbuilderEditItem(item); + } + }} + cancelButtonText="Cancel" + confirmButtonText="Import" + > + +

+ {' '} + Note that not all settings have been migrated from the original visualization. More will + be included as VisBuilder supports additional settings.{' '} +

+
+
+ ); + }; + + const items = [ + } + onClick={() => { + closePopover(); + editItem?.(item); + }} + data-test-subj="dashboardEditDashboard" + > + {i18n.translate('editActionDropdown.edit', { defaultMessage: 'Edit' })} + , + ]; + if (isVisBuilderCompatible) { + items.push( + } + onClick={handleImportToVisBuilder} + data-test-subj="dashboardImportToVisBuilder" + > + {i18n.translate('editActionDropdown.importToVisBuilder', { + defaultMessage: 'Import to VisBuilder', + })} + + ); + } + + return ( + } + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + anchorPosition="downLeft" + initialFocus="none" + > + + + ); +}; diff --git a/src/plugins/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx b/src/plugins/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx index ed44f5867064..04f17a253ab4 100644 --- a/src/plugins/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx +++ b/src/plugins/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx @@ -50,6 +50,7 @@ import { } from '@elastic/eui'; import { HttpFetchError, ToastsStart } from 'opensearch-dashboards/public'; import { toMountPoint } from '../util'; +import { EditActionDropdown, VisualizationItem } from './edit_action_dropdown'; interface Column { name: string; @@ -57,18 +58,15 @@ interface Column { actions?: object[]; } -interface Item { - id?: string; -} - export interface TableListViewProps { createButton?: JSX.Element; createItem?(): void; deleteItems?(items: object[]): Promise; editItem?(item: object): void; + visbuilderEditItem?(item: object): void; entityName: string; entityNamePlural: string; - findItems(query: string): Promise<{ total: number; hits: object[] }>; + findItems(query: string): Promise<{ total: number; hits: VisualizationItem[] }>; listingLimit: number; initialFilter: string; initialPageSize: number; @@ -88,7 +86,7 @@ export interface TableListViewProps { } export interface TableListViewState { - items: object[]; + items: VisualizationItem[]; hasInitialFetchReturned: boolean; isFetchingItems: boolean; isDeletingItems: boolean; @@ -119,7 +117,7 @@ class TableListView extends React.Component { + onSelectionChange: (obj: VisualizationItem[]) => { this.setState({ selectedIds: obj .map((item) => item.id) @@ -444,11 +442,16 @@ class TableListView extends React.Component !error, - onClick: this.props.editItem, 'data-test-subj': 'edit-dashboard-action', + render: (item: VisualizationItem) => ( + + ), }, ]; - const search = { onChange: this.setFilter.bind(this), toolsLeft: this.renderToolsLeft(), @@ -459,7 +462,7 @@ class TableListView extends React.Component ); return ( - itemId="id" items={this.state.items} - columns={(columns as unknown) as Array>} // EuiBasicTableColumn is stricter than Column + columns={(columns as unknown) as Array>} // EuiBasicTableColumn is stricter than Column pagination={this.pagination} loading={this.state.isFetchingItems} message={noItemsMessage} diff --git a/src/plugins/vis_builder/public/application/components/data_tab/secondary_panel.tsx b/src/plugins/vis_builder/public/application/components/data_tab/secondary_panel.tsx index 18a1991f6d80..4857647420e5 100644 --- a/src/plugins/vis_builder/public/application/components/data_tab/secondary_panel.tsx +++ b/src/plugins/vis_builder/public/application/components/data_tab/secondary_panel.tsx @@ -28,7 +28,7 @@ export function SecondaryPanel() { const { draftAgg, aggConfigParams } = useTypedSelector( (state) => state.visualization.activeVisualization! ); - const isEditorValid = useTypedSelector((state) => !state.metadata.editor.errors[PANEL_KEY]); + const isEditorValid = useTypedSelector((state) => !state.metadata.editor.errors?.[PANEL_KEY]); const [touched, setTouched] = useState(false); const dispatch = useTypedDispatch(); const vizType = useVisualizationType(); diff --git a/src/plugins/vis_builder/public/application/utils/breadcrumbs.ts b/src/plugins/vis_builder/public/application/utils/breadcrumbs.ts index 1f5d15a93382..eeafa40fe015 100644 --- a/src/plugins/vis_builder/public/application/utils/breadcrumbs.ts +++ b/src/plugins/vis_builder/public/application/utils/breadcrumbs.ts @@ -21,13 +21,17 @@ export function getVisualizeLandingBreadcrumbs(navigateToApp) { ]; } -export function getCreateBreadcrumbs(navigateToApp) { +export function getCreateBreadcrumbs(navigateToApp, isMigrated: boolean) { return [ ...getVisualizeLandingBreadcrumbs(navigateToApp), { - text: i18n.translate('visBuilder.editor.createBreadcrumb', { - defaultMessage: 'Create', - }), + text: isMigrated + ? i18n.translate('visBuilder.editor.newVisualizationBreadcrumb', { + defaultMessage: 'New visualization', + }) + : i18n.translate('visBuilder.editor.createBreadcrumb', { + defaultMessage: 'Create', + }), }, ]; } 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 42f3e68c3898..6dccaa283bed 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 @@ -38,9 +38,9 @@ import { } from '../../../../saved_objects/public'; import { VisBuilderServices } from '../..'; import { VisBuilderSavedObject } from '../../types'; -import { AppDispatch } from './state_management'; +import { AppDispatch, setMetadataState } from './state_management'; import { EDIT_PATH, VISBUILDER_SAVED_OBJECT } from '../../../common'; -import { setEditorState } from './state_management/metadata_slice'; + export interface TopNavConfigParams { visualizationIdFromUrl: string; savedVisBuilderVis: VisBuilderSavedObject; @@ -243,7 +243,14 @@ export const getOnSave = ( pathname: `${EDIT_PATH}/${id}`, }); } - dispatch(setEditorState({ state: 'clean' })); + dispatch( + setMetadataState({ + editor: { + state: 'clean', + }, + isMigrated: false, + }) + ); } else { // reset title if save not successful savedVisBuilderVis.title = currentTitle; 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 880c15f3e44a..bc46a603ffa8 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 @@ -15,13 +15,14 @@ type EditorState = 'loading' | 'loaded' | 'clean' | 'dirty'; export interface MetadataState { editor: { - errors: { + errors?: { // Errors for each section in the editor [key: string]: boolean; }; state: EditorState; }; originatingApp?: string; + isMigrated?: boolean; } const initialState: MetadataState = { @@ -30,6 +31,7 @@ const initialState: MetadataState = { state: 'loading', }, originatingApp: undefined, + isMigrated: false, }; export const getPreloadedState = async ({ @@ -53,7 +55,7 @@ export const slice = createSlice({ reducers: { setError: (state, action: PayloadAction<{ key: string; error: boolean }>) => { const { key, error } = action.payload; - state.editor.errors[key] = error; + (state.editor.errors ??= {})[key] = error; }, setEditorState: (state, action: PayloadAction<{ state: EditorState }>) => { state.editor.state = action.payload.state; 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 a46d5c027656..9c4965541d0e 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 @@ -33,6 +33,7 @@ describe('test redux state persistence', () => { metadata: { editor: { errors: {}, state: 'loading' }, originatingApp: undefined, + isMigrated: false, }, ui: {}, }; 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 8fe5c23fd657..b55e6ce988e2 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 @@ -62,5 +62,5 @@ 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 setMetadataState, MetadataState } from './metadata_slice'; export { setState as setUIStateState, UIStateState } from './ui_state_slice'; diff --git a/src/plugins/vis_builder/public/application/utils/use/use_can_save.ts b/src/plugins/vis_builder/public/application/utils/use/use_can_save.ts index 7da320d266f3..46125bc82ba9 100644 --- a/src/plugins/vis_builder/public/application/utils/use/use_can_save.ts +++ b/src/plugins/vis_builder/public/application/utils/use/use_can_save.ts @@ -10,7 +10,9 @@ export const useCanSave = () => { const isEmpty = useTypedSelector( (state) => state.visualization.activeVisualization?.aggConfigParams?.length === 0 ); - const hasNoChange = useTypedSelector((state) => state.metadata.editor.state !== 'dirty'); + const hasNoChange = useTypedSelector((state) => { + return state.metadata.editor.state !== 'dirty' && state.metadata.isMigrated === false; + }); const hasDraftAgg = useTypedSelector( (state) => !!state.visualization.activeVisualization?.draftAgg ); 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 8f67b8d2358f..1dd963495c71 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 @@ -5,6 +5,7 @@ import { i18n } from '@osd/i18n'; import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; import { SavedObject } from '../../../../../saved_objects/public'; import { InvalidJSONProperty, @@ -30,6 +31,7 @@ export const useSavedVisBuilderVis = (visualizationIdFromUrl: string | undefined const { services } = useOpenSearchDashboards(); const [savedVisState, setSavedVisState] = useState(undefined); const dispatch = useTypedDispatch(); + const isMigrated = useSelector((state: any) => state.metadata?.isMigrated); useEffect(() => { const { @@ -60,8 +62,17 @@ export const useSavedVisBuilderVis = (visualizationIdFromUrl: string | undefined if (savedVisBuilderVis.id) { const { title, state } = getStateFromSavedObject(savedVisBuilderVis); - chrome.setBreadcrumbs(getEditBreadcrumbs(title, navigateToApp)); - chrome.docTitle.change(title); + + // Use isMigrated to determine which breadcrumb function to use + const breadcrumbs = isMigrated + ? getCreateBreadcrumbs(navigateToApp, isMigrated) + : getEditBreadcrumbs(title, navigateToApp); + + chrome.setBreadcrumbs(breadcrumbs); + + // Change the title based on isMigrated + const newTitle = isMigrated ? 'New Visualization' : title; + chrome.docTitle.change(newTitle); // sync initial app filters from savedObject to filterManager const filters = savedVisBuilderVis.searchSourceFields.filter; const query = @@ -86,7 +97,7 @@ export const useSavedVisBuilderVis = (visualizationIdFromUrl: string | undefined dispatch(setVisualizationState(state.visualization)); dispatch(setEditorState({ state: 'loaded' })); } else { - chrome.setBreadcrumbs(getCreateBreadcrumbs(navigateToApp)); + chrome.setBreadcrumbs(getCreateBreadcrumbs(navigateToApp, isMigrated)); } setSavedVisState(savedVisBuilderVis); @@ -121,7 +132,7 @@ export const useSavedVisBuilderVis = (visualizationIdFromUrl: string | undefined }; loadSavedVisBuilderVis(); - }, [dispatch, services, visualizationIdFromUrl]); + }, [dispatch, services, visualizationIdFromUrl, isMigrated]); return savedVisState; }; diff --git a/src/plugins/visualize/public/application/components/visualize_listing.tsx b/src/plugins/visualize/public/application/components/visualize_listing.tsx index ead43a7b08ca..c1b7c211bba0 100644 --- a/src/plugins/visualize/public/application/components/visualize_listing.tsx +++ b/src/plugins/visualize/public/application/components/visualize_listing.tsx @@ -45,6 +45,7 @@ import { getTableColumns, getNoItemsMessage } from '../utils'; import { getUiActions } from '../../services'; import { SAVED_OBJECT_DELETE_TRIGGER } from '../../../../saved_objects_management/public'; import { HeaderVariant } from '../../../../../core/public/index'; +import { constructVisBuilderPath } from '../utils/construct_vis_builder_path'; export const VisualizeListing = () => { const { @@ -125,6 +126,18 @@ export const VisualizeListing = () => { [application, history] ); + const { services: visualizeServices } = useOpenSearchDashboards(); + + // This function takes a legacy visualization item as input and constructs the appropriate path. + // It then navigates to the VisBuilder app with the constructed path to migrate the legacy visualization. + const visbuilderEditItem = useCallback( + async (item) => { + const path = await constructVisBuilderPath(item, visualizeServices); + application.navigateToApp('vis-builder', { path }); + }, + [visualizeServices, application] + ); + const noItemsFragment = useMemo(() => getNoItemsMessage(createNewVis), [createNewVis]); const tableColumns = useMemo(() => getTableColumns(application, history, uiSettings), [ application, @@ -204,6 +217,7 @@ export const VisualizeListing = () => { findItems={fetchItems} deleteItems={visualizeCapabilities.delete ? deleteItems : undefined} editItem={visualizeCapabilities.save ? editItem : undefined} + visbuilderEditItem={visbuilderEditItem} tableColumns={tableColumns} listingLimit={listingLimit} initialPageSize={savedObjectsPublic.settings.getPerPage()} diff --git a/src/plugins/visualize/public/application/utils/construct_vis_builder_path.ts b/src/plugins/visualize/public/application/utils/construct_vis_builder_path.ts new file mode 100644 index 000000000000..4f132b86f664 --- /dev/null +++ b/src/plugins/visualize/public/application/utils/construct_vis_builder_path.ts @@ -0,0 +1,65 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getVisualizationInstance } from './get_visualization_instance'; +import { setStateToOsdUrl } from '../../../../opensearch_dashboards_utils/public'; +import { VisualizeServices } from '../types'; + +export const constructVisBuilderPath = async ( + item: { id: string | Record | undefined }, + visualizeServices: VisualizeServices +) => { + const { savedVis } = await getVisualizationInstance(visualizeServices, item.id); + + const indexPattern = savedVis.searchSourceFields?.index; + const name = savedVis.visState.type; + const legend = savedVis.visState.params.addLegend; + const tooltip = savedVis.visState.params.addTooltip; + const config = savedVis.visState.aggs; + const position = savedVis.visState.params.legendPosition; + const type = savedVis.visState.type; + const uiState = savedVis.uiStateJSON; + const filter = savedVis.searchSourceFields?.filter; + const query = savedVis.searchSourceFields?.query; + const metric = savedVis.visState.params.metric; + + const _q = { + filters: filter, + query, + }; + + const _a = { + metadata: { + editor: { + errors: {}, + state: 'clean', + }, + isMigrated: true, + }, + style: { + addLegend: legend, + addTooltip: tooltip, + legendPosition: position, + type, + metric, + }, + ui: { uiState }, + visualization: { + activeVisualization: { + aggConfigParams: config, + name, + }, + indexPattern, + searchField: '', + }, + }; + + // Construct the path for VisBuilder and set the states to URL + let visBuilderPath = '/app/vis-builder#/'; + visBuilderPath = setStateToOsdUrl('_q', _q, { useHash: false }, visBuilderPath); + visBuilderPath = setStateToOsdUrl('_a', _a, { useHash: false }, visBuilderPath); + + return visBuilderPath; +}; diff --git a/src/plugins/visualize/public/application/utils/contruct_vis_builder_path.test.ts b/src/plugins/visualize/public/application/utils/contruct_vis_builder_path.test.ts new file mode 100644 index 000000000000..4090fefa2f48 --- /dev/null +++ b/src/plugins/visualize/public/application/utils/contruct_vis_builder_path.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +// Mock the entire module +jest.mock('./construct_vis_builder_path'); + +// Import the mocked module +import { constructVisBuilderPath } from './construct_vis_builder_path'; + +describe('constructVisBuilderPath', () => { + const mockVisualizeServices = {} as any; + const mockItem = { id: 'test-id' }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return a URL with _q and _a parameters', async () => { + const mockUrl = '/app/vis-builder#/_q={"filters":[],"query":""}&_a={"param":"value"}'; + (constructVisBuilderPath as jest.Mock).mockResolvedValue(mockUrl); + + const result = await constructVisBuilderPath(mockItem, mockVisualizeServices); + + expect(result).toContain('/app/vis-builder#/'); + expect(result).toContain('_q='); + expect(result).toContain('_a='); + }); + + it('should include filters and query in _q parameter', async () => { + const mockUrl = '/app/vis-builder#/_q={"filters":["test"],"query":"test query"}&_a={}'; + (constructVisBuilderPath as jest.Mock).mockResolvedValue(mockUrl); + + const result = await constructVisBuilderPath(mockItem, mockVisualizeServices); + + expect(result).toContain('"filters":["test"]'); + expect(result).toContain('"query":"test query"'); + }); + + it('should handle empty _q parameter', async () => { + const mockUrl = '/app/vis-builder#/_q={}&_a={}'; + (constructVisBuilderPath as jest.Mock).mockResolvedValue(mockUrl); + + const result = await constructVisBuilderPath(mockItem, mockVisualizeServices); + + expect(result).toContain('_q={}'); + }); + + it('should include _a parameter with some content', async () => { + const mockUrl = '/app/vis-builder#/_q={}&_a={"someKey":"someValue"}'; + (constructVisBuilderPath as jest.Mock).mockResolvedValue(mockUrl); + + const result = await constructVisBuilderPath(mockItem, mockVisualizeServices); + + expect(result).toContain('_a={"someKey":"someValue"}'); + }); +}); diff --git a/test/plugin_functional/test_suites/dashboard_listing_plugin/dashboard_listing_plugin.ts b/test/plugin_functional/test_suites/dashboard_listing_plugin/dashboard_listing_plugin.ts index 2a385d1282f9..3ffa225779d1 100644 --- a/test/plugin_functional/test_suites/dashboard_listing_plugin/dashboard_listing_plugin.ts +++ b/test/plugin_functional/test_suites/dashboard_listing_plugin/dashboard_listing_plugin.ts @@ -54,8 +54,13 @@ export default function ({ getService, getPageObjects }) { it('should be able to navigate to edit dashboard', async () => { await listingTable.searchForItemWithName(dashboardName); - const editBttn = await testSubjects.find('edit-dashboard-action'); - await editBttn.click(); + // Find and click the edit button + const editBtn = await testSubjects.find('dashboardEditBtn'); + await editBtn.click(); + + // Find and click the edit option in the dropdown + const editOption = await testSubjects.find('dashboardEditDashboard'); + await editOption.click(); await PageObjects.dashboard.clickCancelOutOfEditMode(); await PageObjects.dashboard.gotoDashboardLandingPage(); });