Skip to content

Commit

Permalink
[Search][Notebooks] allow pre-selecting a specific notebook (elastic#…
Browse files Browse the repository at this point in the history
…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 <[email protected]>
  • Loading branch information
TattdCodeMonkey and elasticmachine authored Aug 5, 2024
1 parent 507f1d2 commit 40d1a91
Show file tree
Hide file tree
Showing 7 changed files with 295 additions and 6 deletions.
2 changes: 2 additions & 0 deletions x-pack/plugins/search_notebooks/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,5 @@ export const INTRODUCTION_NOTEBOOK: Notebook = {
],
},
};

export const DEFAULT_NOTEBOOK_ID = 'introduction';
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
* 2.0.
*/
import React from 'react';
import {} from '@elastic/eui';

import { NotebookInformation } from '../../common/types';
import { LoadingPanel } from './loading_panel';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,21 +27,34 @@ 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>) => ({
...prevSizes,
...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;
}, [data]);
const onNotebookSelectionClick = useCallback((id: string) => {
setSelectedNotebookId(id);
}, []);

return (
<EuiResizableContainer
style={{ height: '100%', width: '100%' }}
Expand Down
9 changes: 7 additions & 2 deletions x-pack/plugins/search_notebooks/public/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -66,7 +67,7 @@ export class SearchNotebooksPlugin
core,
this.queryClient!,
this.usageTracker,
this.clearNotebookList.bind(this),
this.clearNotebooksState.bind(this),
this.getNotebookList.bind(this)
)
);
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/search_notebooks/public/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface SearchNotebooksPluginSetup {}

export interface SearchNotebooksPluginStart {
setNotebookList: (value: NotebookListValue) => void;
setSelectedNotebook: (notebookId: string) => void;
}

export interface SearchNotebooksPluginStartDependencies {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Original file line number Diff line number Diff line change
@@ -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;
};

0 comments on commit 40d1a91

Please sign in to comment.