kibana/examples/unified_tabs_examples/public/example_app.tsx
Julia Rechkunova c348586e58
[Discover] Persist tabs in local storage and sync selected tab ID with URL (#217706)
- Closes https://github.com/elastic/kibana/issues/216549
- Closes https://github.com/elastic/kibana/issues/216071

## Summary

This PR allows to restore the following state for the previously opened
tabs:
- the selected data view
- classic or ES|QL mode
- query and filters
- time range and refresh interval
- and other properties of the app state
bcba741abc/src/platform/plugins/shared/discover/public/application/main/state_management/discover_app_state_container.ts (L92)

## Changes
- [x] Sync selected tab id to URL => after refresh the initial tab would
be the last selected one
- [x] Restore tabs after refresh 
- [x] Restore appState and globalState after reopening closed tabs
- [x] Clear tabs if Discover was opened from another Kibana app  
- [x] Store tabs in LocalStorage
- [x] Fix "New" action and clear all tabs
- [x] Populate "Recently closed tabs" with data from LocalStorage
- [x] If selected tab id changes in URL externally => update the state  
- [x] Reset the stored state when userId or space Id changes
- [x] Fix all tests

### Testing
- Test that the existing functionality is not affected
- Enable tabs feature in
bcba741abc/src/platform/plugins/shared/discover/public/constants.ts (L15)
and test that tabs are being persisted and can be restored manually too.


### 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
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: Davis McPhee <davismcphee@hotmail.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
2025-05-27 23:32:56 +03:00

265 lines
9.4 KiB
TypeScript

/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiPage,
EuiPageBody,
EuiEmptyPrompt,
EuiLoadingLogo,
useEuiTheme,
EuiPanel,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import type { AppMountParameters } from '@kbn/core-application-browser';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { DataViewField } from '@kbn/data-views-plugin/public';
import type { DataViewPickerProps } from '@kbn/unified-search-plugin/public';
import { UnifiedTabs, useNewTabProps, type UnifiedTabsProps } from '@kbn/unified-tabs';
import { type TabPreviewData, TabStatus } from '@kbn/unified-tabs';
import { PLUGIN_ID, PLUGIN_NAME } from '../common';
import { FieldListSidebar, FieldListSidebarProps } from './field_list_sidebar';
// TODO: replace with real data when ready
const TAB_CONTENT_MOCK: TabPreviewData[] = [
{
query: {
esql: 'FROM logs-* | FIND ?findText | WHERE host.name == ?hostName AND log.level == ?logLevel',
},
status: TabStatus.SUCCESS,
},
{
query: {
esql: 'FROM logs-* | FIND ?findText | WHERE host.name == ?hostName AND log.level == ?logLevel',
},
status: TabStatus.RUNNING,
},
{
query: {
language: 'kql',
query: 'agent.name : "activemq-integrations-5f6677988-hjp58"',
},
status: TabStatus.ERROR,
},
];
interface UnifiedTabsExampleAppProps {
services: FieldListSidebarProps['services'];
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
}
export const UnifiedTabsExampleApp: React.FC<UnifiedTabsExampleAppProps> = ({
services,
setHeaderActionMenu,
}) => {
const { euiTheme } = useEuiTheme();
const { navigation, data, unifiedSearch } = services;
const { IndexPatternSelect } = unifiedSearch.ui;
const [dataView, setDataView] = useState<DataView | null>();
const [selectedFieldNames, setSelectedFieldNames] = useState<string[]>([]);
const { getNewTabDefaultProps } = useNewTabProps({ numberOfInitialItems: 0 });
const [{ managedItems, managedSelectedItemId }, setState] = useState<{
managedItems: UnifiedTabsProps['items'];
managedSelectedItemId: UnifiedTabsProps['selectedItemId'];
}>(() => ({
managedItems: Array.from({ length: 7 }, () => getNewTabDefaultProps()),
managedSelectedItemId: undefined,
}));
const onAddFieldToWorkspace = useCallback(
(field: DataViewField) => {
setSelectedFieldNames((names) => [...names, field.name]);
},
[setSelectedFieldNames]
);
const onRemoveFieldFromWorkspace = useCallback(
(field: DataViewField) => {
setSelectedFieldNames((names) => names.filter((name) => name !== field.name));
},
[setSelectedFieldNames]
);
useEffect(() => {
const setDefaultDataView = async () => {
try {
const defaultDataView = await data.dataViews.getDefault();
setDataView(defaultDataView);
} catch (e) {
setDataView(null);
}
};
setDefaultDataView();
}, [data]);
if (typeof dataView === 'undefined') {
return (
<EuiEmptyPrompt
icon={<EuiLoadingLogo logo="logoKibana" size="xl" />}
title={<h2>{PLUGIN_NAME}</h2>}
body={<p>Loading...</p>}
/>
);
}
const SearchBar = navigation.ui.AggregateQueryTopNavMenu;
return (
<EuiPage
grow={true}
css={css`
background-color: ${euiTheme.colors.backgroundBasePlain};
`}
>
<EuiPageBody paddingSize="none">
{dataView ? (
<div className="eui-fullHeight">
<UnifiedTabs
items={managedItems}
selectedItemId={managedSelectedItemId}
recentlyClosedItems={[]}
maxItemsCount={25}
services={services}
onChanged={(updatedState) =>
setState({
managedItems: updatedState.items,
managedSelectedItemId: updatedState.selectedItem?.id,
})
}
createItem={getNewTabDefaultProps}
getPreviewData={() =>
TAB_CONTENT_MOCK[Math.floor(Math.random() * TAB_CONTENT_MOCK.length)]
}
renderContent={({ label }) => {
return (
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem grow={false}>
<SearchBar
appName={PLUGIN_ID}
indexPatterns={[dataView]}
onQuerySubmit={() => {}}
isLoading={false}
showDatePicker
allowSavingQueries
showSearchBar
dataViewPickerComponentProps={
{
trigger: {
label: dataView?.getName() || '',
'data-test-subj': 'discover-dataView-switch-link',
title: dataView?.getIndexPattern() || '',
},
currentDataViewId: dataView?.id,
} as DataViewPickerProps
}
useDefaultBehaviors
displayStyle="detached"
config={[
{
id: 'inspect',
label: 'Inspect',
run: () => {},
},
{
id: 'alerts',
label: 'Alerts',
run: () => {},
},
{
id: 'open',
label: 'Open',
iconType: 'folderOpen',
iconOnly: true,
run: () => {},
},
{
id: 'share',
label: 'Share',
iconType: 'share',
iconOnly: true,
run: () => {},
},
{
id: 'save',
label: 'Save',
emphasize: true,
run: () => {},
},
]}
setMenuMountPoint={setHeaderActionMenu}
/>
</EuiFlexItem>
<EuiFlexItem grow={true}>
<EuiFlexGroup direction="row" alignItems="stretch">
<EuiFlexItem
grow={false}
css={css`
background-color: ${euiTheme.colors.body};
border-right: ${euiTheme.border.thin};
`}
>
<FieldListSidebar
services={services}
dataView={dataView}
selectedFieldNames={selectedFieldNames}
onAddFieldToWorkspace={onAddFieldToWorkspace}
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiPanel hasShadow={false} paddingSize="l" className="eui-fullHeight">
<EuiEmptyPrompt
iconType="beaker"
title={<h3>{PLUGIN_NAME}</h3>}
body={<p>Tab: {label}</p>}
/>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
}}
/>
</div>
) : (
<EuiEmptyPrompt
iconType="warning"
color="warning"
title={<h2>Make sure to have at least one data view</h2>}
body={
<p>
<IndexPatternSelect
placeholder={i18n.translate('searchSessionExample.selectDataViewPlaceholder', {
defaultMessage: 'Select data view',
})}
indexPatternId=""
onChange={async (dataViewId?: string) => {
if (dataViewId) {
const newDataView = await data.dataViews.get(dataViewId);
setDataView(newDataView);
} else {
setDataView(undefined);
}
}}
isClearable={false}
data-test-subj="dataViewSelector"
/>
</p>
}
/>
)}
</EuiPageBody>
</EuiPage>
);
};