[Search][Notebooks] allow pre-selecting a specific notebook (#189825)

## Summary

Updated the search notebooks to allow loading the selected notebooks
from a query parameter. exposes a function on the search notebooks start
contract to select notebook. Tested to ensure this allows the user to
change the selected notebooks once the notebook view is open.

caveats:
- The way this currently works is you have to set the selected notebook
before opening the notebooks view.
- If you set the query parameter when the notebook view is open the
selection DOES NOT update, I tried to implement this but ran into issues
with not being able to change the selection or unrelated re-renders
reverting the selection back to the query parameter value :/

### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Rodney Norris 2024-08-05 15:00:35 -05:00 committed by GitHub
parent 507f1d2863
commit 40d1a91bac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 295 additions and 6 deletions

View file

@ -65,3 +65,5 @@ export const INTRODUCTION_NOTEBOOK: Notebook = {
],
},
};
export const DEFAULT_NOTEBOOK_ID = 'introduction';

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import React from 'react';
import {} from '@elastic/eui';
import { NotebookInformation } from '../../common/types';
import { LoadingPanel } from './loading_panel';

View file

@ -4,17 +4,19 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { EuiResizableContainer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { INTRODUCTION_NOTEBOOK } from '../../common/constants';
import { INTRODUCTION_NOTEBOOK, DEFAULT_NOTEBOOK_ID } from '../../common/constants';
import { useNotebooksCatalog } from '../hooks/use_notebook_catalog';
import { NotebooksList } from './notebooks_list';
import { SelectionPanel } from './selection_panel';
import { TitlePanel } from './title_panel';
import { SearchNotebook } from './search_notebook';
import { SearchLabsButtonPanel } from './search_labs_button_panel';
import { readNotebookParameter } from '../utils/notebook_query_param';
const LIST_PANEL_ID = 'notebooksList';
const OUTPUT_PANEL_ID = 'notebooksOutput';
@ -25,7 +27,9 @@ const defaultSizes: Record<string, number> = {
export const SearchNotebooks = () => {
const [sizes, setSizes] = useState(defaultSizes);
const [selectedNotebookId, setSelectedNotebookId] = useState<string>('introduction');
const [selectedNotebookId, setSelectedNotebookId] = useState<string>(
readNotebookParameter() ?? DEFAULT_NOTEBOOK_ID
);
const { data } = useNotebooksCatalog();
const onPanelWidthChange = useCallback((newSizes: Record<string, number>) => {
setSizes((prevSizes: Record<string, number>) => ({
@ -33,6 +37,16 @@ export const SearchNotebooks = () => {
...newSizes,
}));
}, []);
useEffect(() => {
if (!data) return;
const selectedNotebookFound =
data.notebooks.find((nb) => nb.id === selectedNotebookId) !== undefined;
if (!selectedNotebookFound) {
// If the currently selected notebook is not in the list of notebooks revert
// to the default notebook selection.
setSelectedNotebookId(DEFAULT_NOTEBOOK_ID);
}
}, [data, selectedNotebookId]);
const notebooks = useMemo(() => {
if (data) return data.notebooks;
return null;
@ -40,6 +54,7 @@ export const SearchNotebooks = () => {
const onNotebookSelectionClick = useCallback((id: string) => {
setSelectedNotebookId(id);
}, []);
return (
<EuiResizableContainer
style={{ height: '100%', width: '100%' }}

View file

@ -18,6 +18,7 @@ import {
} from './types';
import { getErrorCode, getErrorMessage, isKibanaServerError } from './utils/get_error_message';
import { createUsageTracker } from './utils/usage_tracker';
import { removeNotebookParameter, setNotebookParameter } from './utils/notebook_query_param';
export class SearchNotebooksPlugin
implements Plugin<SearchNotebooksPluginSetup, SearchNotebooksPluginStart>
@ -66,7 +67,7 @@ export class SearchNotebooksPlugin
core,
this.queryClient!,
this.usageTracker,
this.clearNotebookList.bind(this),
this.clearNotebooksState.bind(this),
this.getNotebookList.bind(this)
)
);
@ -75,12 +76,16 @@ export class SearchNotebooksPlugin
setNotebookList: (value: NotebookListValue) => {
this.setNotebookList(value);
},
setSelectedNotebook: (value: string) => {
setNotebookParameter(value);
},
};
}
public stop() {}
private clearNotebookList() {
private clearNotebooksState() {
this.setNotebookList(null);
removeNotebookParameter();
}
private setNotebookList(value: NotebookListValue) {

View file

@ -13,6 +13,7 @@ export interface SearchNotebooksPluginSetup {}
export interface SearchNotebooksPluginStart {
setNotebookList: (value: NotebookListValue) => void;
setSelectedNotebook: (notebookId: string) => void;
}
export interface SearchNotebooksPluginStartDependencies {

View file

@ -0,0 +1,207 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { setNotebookParameter, removeNotebookParameter } from './notebook_query_param';
const baseMockWindow = () => {
return {
history: {
pushState: jest.fn(),
},
location: {
host: 'my-kibana.elastic.co',
pathname: '',
protocol: 'https:',
search: '',
hash: '',
},
};
};
let windowSpy: jest.SpyInstance;
let mockWindow = baseMockWindow();
describe('notebook query parameter utility', () => {
beforeEach(() => {
mockWindow = baseMockWindow();
windowSpy = jest.spyOn(globalThis, 'window', 'get');
windowSpy.mockImplementation(() => mockWindow);
});
afterEach(() => {
windowSpy.mockRestore();
});
describe('setNotebookParameter', () => {
it('adds notebookId query param', () => {
mockWindow.location = {
...mockWindow.location,
pathname: '/foo/app/elasticsearch',
};
const notebook = '00_quick_start';
const expectedUrl =
'https://my-kibana.elastic.co/foo/app/elasticsearch?notebookId=AzD6EcFcEsGMGtQGcAuBDATioA';
setNotebookParameter(notebook);
expect(mockWindow.history.pushState).toHaveBeenCalledTimes(1);
expect(mockWindow.history.pushState).toHaveBeenCalledWith(
{
path: expectedUrl,
},
'',
expectedUrl
);
});
it('can replace an existing value', () => {
mockWindow.location = {
...mockWindow.location,
pathname: '/foo/app/elasticsearch',
search: '?notebookId=AwRg+g1gpgng7gewE4BMwEcCuUkwJYB2A5mAGZ4A2ALjoUUA',
};
const notebook = '00_quick_start';
const expectedUrl =
'https://my-kibana.elastic.co/foo/app/elasticsearch?notebookId=AzD6EcFcEsGMGtQGcAuBDATioA';
setNotebookParameter(notebook);
expect(mockWindow.history.pushState).toHaveBeenCalledTimes(1);
expect(mockWindow.history.pushState).toHaveBeenCalledWith(
{
path: expectedUrl,
},
'',
expectedUrl
);
});
it('leaves other query parameters in place', () => {
mockWindow.location = {
...mockWindow.location,
pathname: '/foo/app/elasticsearch',
search: '?foo=bar',
};
const notebook = '00_quick_start';
const expectedUrl =
'https://my-kibana.elastic.co/foo/app/elasticsearch?foo=bar&notebookId=AzD6EcFcEsGMGtQGcAuBDATioA';
setNotebookParameter(notebook);
expect(mockWindow.history.pushState).toHaveBeenCalledTimes(1);
expect(mockWindow.history.pushState).toHaveBeenCalledWith(
{
path: expectedUrl,
},
'',
expectedUrl
);
});
it('works with hash routes', () => {
mockWindow.location = {
...mockWindow.location,
pathname: '/foo/app/elasticsearch',
hash: '#/home',
};
const notebook = '00_quick_start';
const expectedUrl =
'https://my-kibana.elastic.co/foo/app/elasticsearch#/home?notebookId=AzD6EcFcEsGMGtQGcAuBDATioA';
setNotebookParameter(notebook);
expect(mockWindow.history.pushState).toHaveBeenCalledTimes(1);
expect(mockWindow.history.pushState).toHaveBeenCalledWith(
{
path: expectedUrl,
},
'',
expectedUrl
);
});
});
describe('removeNotebookParameter', () => {
it('leaves other params in place', () => {
mockWindow.location = {
...mockWindow.location,
pathname: '/foo/app/elasticsearch',
search: `?foo=bar&notebookId=AzD6EcFcEsGMGtQGcAuBDATioA`,
};
const expectedUrl = 'https://my-kibana.elastic.co/foo/app/elasticsearch?foo=bar';
removeNotebookParameter();
expect(mockWindow.history.pushState).toHaveBeenCalledTimes(1);
expect(mockWindow.history.pushState).toHaveBeenCalledWith(
{
path: expectedUrl,
},
'',
expectedUrl
);
});
it('leaves other params with a hashroute', () => {
mockWindow.location = {
...mockWindow.location,
pathname: '/foo/app/elasticsearch',
hash: `#/home?foo=bar&notebookId=AzD6EcFcEsGMGtQGcAuBDATioA`,
};
const expectedUrl = 'https://my-kibana.elastic.co/foo/app/elasticsearch#/home?foo=bar';
removeNotebookParameter();
expect(mockWindow.history.pushState).toHaveBeenCalledTimes(1);
expect(mockWindow.history.pushState).toHaveBeenCalledWith(
{
path: expectedUrl,
},
'',
expectedUrl
);
});
it('removes ? if load_from was the only param', () => {
mockWindow.location = {
...mockWindow.location,
pathname: '/foo/app/elasticsearch',
search: `?notebookId=AzD6EcFcEsGMGtQGcAuBDATioA`,
};
const expectedUrl = 'https://my-kibana.elastic.co/foo/app/elasticsearch';
removeNotebookParameter();
expect(mockWindow.history.pushState).toHaveBeenCalledTimes(1);
expect(mockWindow.history.pushState).toHaveBeenCalledWith(
{
path: expectedUrl,
},
'',
expectedUrl
);
});
it('removes ? if load_from was the only param in a hashroute', () => {
mockWindow.location = {
...mockWindow.location,
pathname: '/foo/app/elasticsearch',
hash: '#/home?notebookId=AzD6EcFcEsGMGtQGcAuBDATioA',
};
const expectedUrl = 'https://my-kibana.elastic.co/foo/app/elasticsearch#/home';
removeNotebookParameter();
expect(mockWindow.history.pushState).toHaveBeenCalledTimes(1);
expect(mockWindow.history.pushState).toHaveBeenCalledWith(
{
path: expectedUrl,
},
'',
expectedUrl
);
});
it('noop if load_from not currently defined on QS', () => {
mockWindow.location = {
...mockWindow.location,
pathname: '/foo/app/elasticsearch',
hash: `#/home?foo=bar`,
};
removeNotebookParameter();
expect(mockWindow.history.pushState).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import qs from 'query-string';
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string';
function getBaseUrl() {
return `${window.location.protocol}//${window.location.host}${window.location.pathname}`;
}
function parseQueryString() {
const [hashRoute, queryString] = (window.location.hash || window.location.search || '').split(
'?'
);
const parsedQueryString = qs.parse(queryString || '', { sort: false });
return {
hasHash: !!window.location.hash,
hashRoute,
queryString: parsedQueryString,
};
}
export const setNotebookParameter = (value: string) => {
const baseUrl = getBaseUrl();
const { hasHash, hashRoute, queryString } = parseQueryString();
const notebookId = compressToEncodedURIComponent(value);
queryString.notebookId = notebookId;
const params = `?${qs.stringify(queryString)}`;
const newUrl = hasHash ? `${baseUrl}${hashRoute}${params}` : `${baseUrl}${params}`;
window.history.pushState({ path: newUrl }, '', newUrl);
};
export const removeNotebookParameter = () => {
const baseUrl = getBaseUrl();
const { hasHash, hashRoute, queryString } = parseQueryString();
if (queryString.notebookId) {
delete queryString.notebookId;
const params = Object.keys(queryString).length ? `?${qs.stringify(queryString)}` : '';
const newUrl = hasHash ? `${baseUrl}${hashRoute}${params}` : `${baseUrl}${params}`;
window.history.pushState({ path: newUrl }, '', newUrl);
}
};
export const readNotebookParameter = (): string | undefined => {
const { queryString } = parseQueryString();
if (queryString.notebookId && typeof queryString.notebookId === 'string') {
try {
const notebookId = decompressFromEncodedURIComponent(queryString.notebookId);
if (notebookId.length > 0) return notebookId;
} catch {
return undefined;
}
}
return undefined;
};