diff --git a/CHANGELOG.md b/CHANGELOG.md index 6539fbcdc3d8..977ae5cc86da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,8 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Workspace] Add workspace id in basePath ([#6060](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6060)) ### 🐛 Bug Fixes +- [BUG][Discover] Allow saved sort from search embeddable to load in Dashboard ([#5934](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5934)) +- [BUG][Discover] Allow save query to load correctly in Discover ([#5951](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5951)) - [BUG][Discover] Allow saved sort from search embeddable to load in Dashboard ([#5934](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5934)) - [BUG][Discover] Add key to index pattern options for support deplicate index pattern names([#5946](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5946)) diff --git a/src/plugins/discover/public/application/utils/state_management/discover_slice.test.tsx b/src/plugins/discover/public/application/utils/state_management/discover_slice.test.tsx index ba94236e1492..cb5503a13459 100644 --- a/src/plugins/discover/public/application/utils/state_management/discover_slice.test.tsx +++ b/src/plugins/discover/public/application/utils/state_management/discover_slice.test.tsx @@ -4,6 +4,7 @@ */ import { discoverSlice, DiscoverState } from './discover_slice'; +import { SortOrder } from '../../../saved_searches/types'; describe('discoverSlice', () => { let initialState: DiscoverState; @@ -134,4 +135,40 @@ describe('discoverSlice', () => { const result = discoverSlice.reducer(initialState, action); expect(result.columns).toEqual(['column1', 'column2', 'column3']); }); + + it('should set the savedQuery when a valid string is provided', () => { + const savedQueryId = 'some-query-id'; + const action = { type: 'discover/setSavedQuery', payload: savedQueryId }; + const result = discoverSlice.reducer(initialState, action); + expect(result.savedQuery).toEqual(savedQueryId); + }); + + it('should remove the savedQuery from state when payload is undefined', () => { + // pre-set the savedQuery in the initialState + const initialStateWithSavedQuery = { + ...initialState, + savedQuery: 'existing-query-id', + }; + + const action = { type: 'discover/setSavedQuery', payload: undefined }; + const result = discoverSlice.reducer(initialStateWithSavedQuery, action); + + // Check that savedQuery is not in the resulting state + expect(result.savedQuery).toBeUndefined(); + }); + + it('should not affect other state properties when setting savedQuery', () => { + const initialStateWithOtherProperties = { + ...initialState, + columns: ['column1', 'column2'], + sort: [['field1', 'asc']] as SortOrder[], + }; + const savedQueryId = 'new-query-id'; + const action = { type: 'discover/setSavedQuery', payload: savedQueryId }; + const result = discoverSlice.reducer(initialStateWithOtherProperties, action); + // check that other properties remain unchanged + expect(result.columns).toEqual(['column1', 'column2']); + expect(result.sort).toEqual([['field1', 'asc']] as SortOrder[]); + expect(result.savedQuery).toEqual(savedQueryId); + }); }); diff --git a/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx b/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx index 90fb417c2b0e..cb9454a746b3 100644 --- a/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx +++ b/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx @@ -46,6 +46,7 @@ export interface DiscoverState { /** * Metadata for the view */ + savedQuery?: string; metadata?: { /** * Number of lines to display per row @@ -110,6 +111,9 @@ export const discoverSlice = createSlice({ setState(state, action: PayloadAction) { return action.payload; }, + getState(state, action: PayloadAction) { + return state; + }, addColumn(state, action: PayloadAction<{ column: string; index?: number }>) { const columns = utils.addColumn(state.columns || [], action.payload); return { ...state, columns: buildColumns(columns) }; @@ -188,6 +192,18 @@ export const discoverSlice = createSlice({ }, }; }, + setSavedQuery(state, action: PayloadAction) { + if (action.payload === undefined) { + // if the payload is undefined, remove the savedQuery property + const { savedQuery, ...restState } = state; + return restState; + } else { + return { + ...state, + savedQuery: action.payload, + }; + } + }, }, }); @@ -201,8 +217,10 @@ export const { setSort, setInterval, setState, + getState, updateState, setSavedSearchId, setMetadata, + setSavedQuery, } = discoverSlice.actions; export const { reducer } = discoverSlice; diff --git a/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx b/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx index ccf82e4ccba0..8a18353081d2 100644 --- a/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx @@ -23,6 +23,7 @@ import { SortOrder } from '../../../saved_searches/types'; import { DOC_HIDE_TIME_COLUMN_SETTING } from '../../../../common'; import { OpenSearchSearchHit } from '../../doc_views/doc_views_types'; import { popularizeField } from '../../helpers/popularize_field'; +import { buildColumns } from '../../utils/columns'; interface Props { rows?: OpenSearchSearchHit[]; @@ -41,7 +42,20 @@ export const DiscoverTable = ({ rows, scrollToTop }: Props) => { } = services; const { refetch$, indexPattern, savedSearch } = useDiscoverContext(); - const { columns, sort } = useSelector((state) => state.discover); + const { columns } = useSelector((state) => { + const stateColumns = state.discover.columns; + // check if state columns is not undefined, otherwise use buildColumns + return { + columns: stateColumns !== undefined ? stateColumns : buildColumns([]), + }; + }); + const { sort } = useSelector((state) => { + const stateSort = state.discover.sort; + // check if state sort is not undefined, otherwise assign an empty array + return { + sort: stateSort !== undefined ? stateSort : [], + }; + }); const dispatch = useDispatch(); const onAddColumn = (col: string) => { if (indexPattern && capabilities.discover?.save) { diff --git a/src/plugins/discover/public/application/view_components/canvas/index.tsx b/src/plugins/discover/public/application/view_components/canvas/index.tsx index 1c2681995f98..f1dddd26b26d 100644 --- a/src/plugins/discover/public/application/view_components/canvas/index.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/index.tsx @@ -20,6 +20,7 @@ import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_re import { filterColumns } from '../utils/filter_columns'; import { DEFAULT_COLUMNS_SETTING, MODIFY_COLUMNS_ON_SWITCH } from '../../../../common'; import { OpenSearchSearchHit } from '../../../application/doc_views/doc_views_types'; +import { buildColumns } from '../../utils/columns'; import './discover_canvas.scss'; // eslint-disable-next-line import/no-default-export @@ -27,9 +28,16 @@ export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewPro const panelRef = useRef(null); const { data$, refetch$, indexPattern } = useDiscoverContext(); const { - services: { uiSettings }, + services: { uiSettings, capabilities }, } = useOpenSearchDashboards(); - const { columns } = useSelector((state) => state.discover); + const { columns } = useSelector((state) => { + const stateColumns = state.discover.columns; + + // check if stateColumns is not undefined, otherwise use buildColumns + return { + columns: stateColumns !== undefined ? stateColumns : buildColumns([]), + }; + }); const filteredColumns = filterColumns( columns, indexPattern, @@ -95,6 +103,7 @@ export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewPro panelRef.current.scrollTop = 0; } }; + const showSaveQuery = !!capabilities.discover?.saveQuery; return ( {fetchState.status === ResultStatus.NO_RESULTS && ( diff --git a/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx index feb7b91e7c5e..3d850033295e 100644 --- a/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx @@ -14,18 +14,22 @@ import { getTopNavLinks } from '../../components/top_nav/get_top_nav_links'; import { useDiscoverContext } from '../context'; import { getRootBreadcrumbs } from '../../helpers/breadcrumbs'; import { opensearchFilters, connectStorageToQueryState } from '../../../../../data/public'; +import { useDispatch, setSavedQuery, useSelector } from '../../utils/state_management'; export interface TopNavProps { opts: { setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; onQuerySubmit: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void; }; + showSaveQuery: boolean; } -export const TopNav = ({ opts }: TopNavProps) => { +export const TopNav = ({ opts, showSaveQuery }: TopNavProps) => { const { services } = useOpenSearchDashboards(); const { inspectorAdapters, savedSearch, indexPattern } = useDiscoverContext(); const [indexPatterns, setIndexPatterns] = useState(undefined); + const state = useSelector((s) => s.discover); + const dispatch = useDispatch(); const { navigation: { @@ -36,16 +40,10 @@ export const TopNav = ({ opts }: TopNavProps) => { }, data, chrome, - osdUrlStateStorage, } = services; const topNavLinks = savedSearch ? getTopNavLinks(services, inspectorAdapters, savedSearch) : []; - connectStorageToQueryState(services.data.query, osdUrlStateStorage, { - filters: opensearchFilters.FilterStateStore.APP_STATE, - query: true, - }); - useEffect(() => { let isMounted = true; const getDefaultIndexPattern = async () => { @@ -79,17 +77,23 @@ export const TopNav = ({ opts }: TopNavProps) => { indexPattern, ]); + const updateSavedQueryId = (newSavedQueryId: string | undefined) => { + dispatch(setSavedQuery(newSavedQueryId)); + }; + return ( ); }; diff --git a/src/plugins/discover/public/application/view_components/context/index.tsx b/src/plugins/discover/public/application/view_components/context/index.tsx index f4180d16b196..3a1af081226a 100644 --- a/src/plugins/discover/public/application/view_components/context/index.tsx +++ b/src/plugins/discover/public/application/view_components/context/index.tsx @@ -10,7 +10,7 @@ import { useOpenSearchDashboards, } from '../../../../../opensearch_dashboards_react/public'; import { getServices } from '../../../opensearch_dashboards_services'; -import { useSearch, SearchContextValue } from '../utils/use_search'; +import { useSearch, SearchContextValue, ResultStatus } from '../utils/use_search'; const SearchContext = React.createContext({} as SearchContextValue); @@ -22,6 +22,9 @@ export default function DiscoverContext({ children }: React.PropsWithChildren diff --git a/src/plugins/discover/public/application/view_components/panel/index.tsx b/src/plugins/discover/public/application/view_components/panel/index.tsx index 6b4cd2a87c91..3dd217d744ee 100644 --- a/src/plugins/discover/public/application/view_components/panel/index.tsx +++ b/src/plugins/discover/public/application/view_components/panel/index.tsx @@ -35,9 +35,13 @@ export default function DiscoverPanel(props: ViewProps) { const { data$, indexPattern } = useDiscoverContext(); const [fetchState, setFetchState] = useState(data$.getValue()); - const { columns } = useSelector((state) => ({ - columns: state.discover.columns, - })); + const { columns } = useSelector((state) => { + const stateColumns = state.discover.columns; + // check if state columns is not undefined, otherwise use buildColumns + return { + columns: stateColumns !== undefined ? stateColumns : buildColumns([]), + }; + }); const prevColumns = useRef(columns); const dispatch = useDispatch(); @@ -47,6 +51,7 @@ export default function DiscoverPanel(props: ViewProps) { if (columns !== prevColumns.current) { let updatedColumns = buildColumns(columns); if ( + columns && timeFieldname && !prevColumns.current.includes(timeFieldname) && columns.includes(timeFieldname) diff --git a/src/plugins/discover/public/url_generator.ts b/src/plugins/discover/public/url_generator.ts index 25e8517c8c9d..f184cba1ecb5 100644 --- a/src/plugins/discover/public/url_generator.ts +++ b/src/plugins/discover/public/url_generator.ts @@ -78,6 +78,10 @@ export interface DiscoverUrlGeneratorState { * whether to hash the data in the url to avoid url length issues. */ useHash?: boolean; + /** + * Saved query Id + */ + savedQuery?: string; } interface Params { @@ -99,12 +103,14 @@ export class DiscoverUrlGenerator savedSearchId, timeRange, useHash = this.params.useHash, + savedQuery, }: DiscoverUrlGeneratorState): Promise => { const savedSearchPath = savedSearchId ? encodeURIComponent(savedSearchId) : ''; const appState: { query?: Query; filters?: Filter[]; index?: string; + savedQuery?: string; } = {}; const queryState: QueryState = {}; @@ -117,6 +123,7 @@ export class DiscoverUrlGenerator if (filters && filters.length) queryState.filters = filters?.filter((f) => opensearchFilters.isFilterPinned(f)); if (refreshInterval) queryState.refreshInterval = refreshInterval; + if (savedQuery) appState.savedQuery = savedQuery; let url = `${this.params.appBasePath}#/${savedSearchPath}`; url = setStateToOsdUrl('_g', queryState, { useHash }, url); diff --git a/src/plugins/vis_builder/public/application/components/top_nav.tsx b/src/plugins/vis_builder/public/application/components/top_nav.tsx index 768f2db35465..d817f4a60d6a 100644 --- a/src/plugins/vis_builder/public/application/components/top_nav.tsx +++ b/src/plugins/vis_builder/public/application/components/top_nav.tsx @@ -13,11 +13,13 @@ import { VisBuilderServices } from '../../types'; import './top_nav.scss'; import { useIndexPatterns, useSavedVisBuilderVis } from '../utils/use'; import { useTypedSelector, useTypedDispatch } from '../utils/state_management'; +import { setSavedQuery } from '../utils/state_management/visualization_slice'; import { setEditorState } from '../utils/state_management/metadata_slice'; import { useCanSave } from '../utils/use/use_can_save'; import { saveStateToSavedObject } from '../../saved_visualizations/transforms'; import { TopNavMenuData } from '../../../../navigation/public'; import { opensearchFilters, connectStorageToQueryState } from '../../../../data/public'; +import { RootState } from '../../../../data_explorer/public'; export const TopNav = () => { // id will only be set for the edit route @@ -29,8 +31,9 @@ export const TopNav = () => { ui: { TopNavMenu }, }, appName, + capabilities, } = services; - const rootState = useTypedSelector((state) => state); + const rootState = useTypedSelector((state: RootState) => state); const dispatch = useTypedDispatch(); const saveDisabledReason = useCanSave(); @@ -78,6 +81,11 @@ export const TopNav = () => { dispatch(setEditorState({ state: 'loading' })); }); + const updateSavedQueryId = (newSavedQueryId: string | undefined) => { + dispatch(setSavedQuery(newSavedQueryId)); + }; + const showSaveQuery = !!capabilities['visualization-visbuilder']?.saveQuery; + return (
{ indexPatterns={indexPattern ? [indexPattern] : []} showDatePicker={!!indexPattern?.timeFieldName ?? true} showSearchBar - showSaveQuery + showSaveQuery={showSaveQuery} useDefaultBehaviors + savedQueryId={rootState.visualization.savedQuery} + onSavedQueryIdChange={updateSavedQueryId} />
); diff --git a/src/plugins/vis_builder/public/application/utils/state_management/visualization_slice.test.ts b/src/plugins/vis_builder/public/application/utils/state_management/visualization_slice.test.ts new file mode 100644 index 000000000000..5c1aa621a0ab --- /dev/null +++ b/src/plugins/vis_builder/public/application/utils/state_management/visualization_slice.test.ts @@ -0,0 +1,148 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + slice as visualizationSlice, + VisualizationState, + setIndexPattern, + setSearchField, + editDraftAgg, + saveDraftAgg, + updateAggConfigParams, + setAggParamValue, + reorderAgg, + setSavedQuery, + setState, +} from './visualization_slice'; +import { CreateAggConfigParams } from '../../../../../data/common'; + +describe('visualizationSlice', () => { + let initialState: VisualizationState; + + beforeEach(() => { + initialState = { + searchField: '', + activeVisualization: { + name: 'some-vis', + aggConfigParams: [], + }, + savedQuery: undefined, + } as VisualizationState; + }); + + it('should handle setIndexPattern', () => { + const indexPattern = 'new-index-pattern'; + const action = setIndexPattern(indexPattern); + const result = visualizationSlice.reducer(initialState, action); + expect(result.indexPattern).toEqual(indexPattern); + }); + + it('should handle setSearchField', () => { + const searchField = 'new-search-field'; + const action = setSearchField(searchField); + const result = visualizationSlice.reducer(initialState, action); + expect(result.searchField).toEqual(searchField); + }); + + it('should handle editDraftAgg', () => { + const draftAgg: CreateAggConfigParams = { id: '1', enabled: true, type: 'count', params: {} }; + const action = editDraftAgg(draftAgg); + const result = visualizationSlice.reducer(initialState, action); + expect(result.activeVisualization?.draftAgg).toEqual(draftAgg); + }); + + it('should handle saveDraftAgg', () => { + const draftAgg: CreateAggConfigParams = { id: '1', enabled: true, type: 'count', params: {} }; + const stateWithDraft = { + ...initialState, + activeVisualization: { + name: 'some-name', + draftAgg, + aggConfigParams: [], + }, + }; + const action = saveDraftAgg(undefined); + const result = visualizationSlice.reducer(stateWithDraft, action); + expect(result.activeVisualization?.aggConfigParams).toContain(draftAgg); + }); + + it('should handle updateAggConfigParams', () => { + const newAggConfigParams: CreateAggConfigParams[] = [ + { id: '2', enabled: true, type: 'avg', params: {} }, + ]; + const action = updateAggConfigParams(newAggConfigParams); + const result = visualizationSlice.reducer(initialState, action); + expect(result.activeVisualization?.aggConfigParams).toEqual(newAggConfigParams); + }); + + it('should handle setAggParamValue', () => { + const aggParamValue = { aggId: '1', paramName: 'field', value: 'newField' }; + const action = setAggParamValue(aggParamValue); + + const initialStateWithAgg = { + ...initialState, + activeVisualization: { + ...initialState.activeVisualization, + name: 'defaultName', + aggConfigParams: [{ id: '1', enabled: true, type: 'count', params: {} }], + }, + }; + + const result = visualizationSlice.reducer(initialStateWithAgg, action); + expect( + result.activeVisualization?.aggConfigParams?.[0]?.params?.[aggParamValue.paramName] + ).toEqual(aggParamValue.value); + }); + + it('should handle reorderAgg', () => { + const reorderAction = { sourceId: '1', destinationId: '2' }; + const action = reorderAgg(reorderAction); + + // Initial state with multiple aggregations + const initialStateWithMultipleAggs = { + ...initialState, + activeVisualization: { + ...initialState.activeVisualization, + name: 'defaultName', + aggConfigParams: [ + { id: '1', enabled: true, type: 'count', params: {} }, + { id: '2', enabled: true, type: 'avg', params: {} }, + ], + }, + }; + + const result = visualizationSlice.reducer(initialStateWithMultipleAggs, action); + // verify the order of aggConfigParams + expect(result.activeVisualization?.aggConfigParams[0].id).toEqual('2'); + expect(result.activeVisualization?.aggConfigParams[1].id).toEqual('1'); + }); + + it('should handle savedQueryId by setSavedQuery', () => { + const savedQueryId = 'some-query-id'; + const action = setSavedQuery(savedQueryId); + const result = visualizationSlice.reducer(initialState, action) as VisualizationState; + expect(result.savedQuery).toEqual(savedQueryId); + }); + + it('should handle undefined savedQueryId by setSavedQuery', () => { + const savedQueryId = undefined; + const action = setSavedQuery(savedQueryId); + const result = visualizationSlice.reducer(initialState, action) as VisualizationState; + expect(result.savedQuery).toBeUndefined(); + }); + + it('should handle setState', () => { + const newState = { + searchField: 'new-search-field', + activeVisualization: { + name: 'new-vis', + aggConfigParams: [], + }, + }; + const action = setState(newState); + const result = visualizationSlice.reducer(initialState, action); + expect(result).toEqual(newState); + }); +}); diff --git a/src/plugins/vis_builder/public/application/utils/state_management/visualization_slice.ts b/src/plugins/vis_builder/public/application/utils/state_management/visualization_slice.ts index 6662f9f43d71..a5fbda896829 100644 --- a/src/plugins/vis_builder/public/application/utils/state_management/visualization_slice.ts +++ b/src/plugins/vis_builder/public/application/utils/state_management/visualization_slice.ts @@ -16,6 +16,7 @@ export interface VisualizationState { aggConfigParams: CreateAggConfigParams[]; draftAgg?: CreateAggConfigParams; }; + savedQuery?: string; } const initialState: VisualizationState = { @@ -115,6 +116,18 @@ export const slice = createSlice({ [action.payload.paramName]: action.payload.value, }; }, + setSavedQuery(state, action: PayloadAction) { + if (action.payload === undefined) { + // if the payload is undefined, remove the savedQuery property + const { savedQuery, ...restState } = state; + return restState; + } else { + return { + ...state, + savedQuery: action.payload, + }; + } + }, setState: (_state, action: PayloadAction) => { return action.payload; }, @@ -138,5 +151,6 @@ export const { updateAggConfigParams, setAggParamValue, reorderAgg, + setSavedQuery, setState, } = slice.actions; diff --git a/src/plugins/vis_builder/public/plugin.ts b/src/plugins/vis_builder/public/plugin.ts index 4e8f020d1fe8..89366b618431 100644 --- a/src/plugins/vis_builder/public/plugin.ts +++ b/src/plugins/vis_builder/public/plugin.ts @@ -159,6 +159,7 @@ export class VisBuilderPlugin embeddable: pluginsStart.embeddable, dashboard: pluginsStart.dashboard, uiActions: pluginsStart.uiActions, + capabilities: coreStart.application.capabilities, }; // Instantiate the store diff --git a/src/plugins/vis_builder/public/types.ts b/src/plugins/vis_builder/public/types.ts index 1ba8843e016a..61088400d92d 100644 --- a/src/plugins/vis_builder/public/types.ts +++ b/src/plugins/vis_builder/public/types.ts @@ -17,6 +17,7 @@ import { AppMountParameters, CoreStart, ToastsStart, ScopedHistory } from '../.. import { IOsdUrlStateStorage } from '../../opensearch_dashboards_utils/public'; import { DataPublicPluginSetup } from '../../data/public'; import { UiActionsStart } from '../../ui_actions/public'; +import { Capabilities } from '../../../core/public'; export type VisBuilderSetup = TypeServiceSetup; export interface VisBuilderStart extends TypeServiceStart { @@ -54,6 +55,7 @@ export interface VisBuilderServices extends CoreStart { osdUrlStateStorage: IOsdUrlStateStorage; dashboard: DashboardStart; uiActions: UiActionsStart; + capabilities: Capabilities; } export interface ISavedVis { diff --git a/src/plugins/vis_builder/server/capabilities_provider.ts b/src/plugins/vis_builder/server/capabilities_provider.ts index c810efabdfe5..54493073e708 100644 --- a/src/plugins/vis_builder/server/capabilities_provider.ts +++ b/src/plugins/vis_builder/server/capabilities_provider.ts @@ -12,6 +12,6 @@ export const capabilitiesProvider = () => ({ show: true, // showWriteControls: true, // save: true, - // saveQuery: true, + saveQuery: true, }, });