Remember tab choice between logs explorer and discover (#194930)

Closes #193321 

## Summary

The PR adds the redirection point when "Discover" menu item is clicked
on the sidenav in serverless (or solution nav on stateful). Based on
what tab between "Discover" or "Logs Explorer" the user clicked
recently, "Discover" will point to that app/tab. Previously, "Discover"
would always point to "Logs Explorer" on serverless and to "Discover" on
stateful.

In order to implement this, a temporary app `last-used-logs-viewer` is
registered in `observability-logs-explorer` plugin whose only job is to
read the last stored value in local storage and perform the redirection.

Doing the redirection from a temporary app should help prevent
triggering unnecessary telemetry and history entries. And it should be
fairly easy to undo once context aware redirection is in place.

~With this implementation, only the behavior of user clicking "Discover"
on the sidenav and clicking the tabs is affected and any deeplinks from
other apps or direct links should work as is.~ The tab choice will be
updated even if the apps are visited via url.



https://github.com/user-attachments/assets/8a0308db-9ddb-47b6-b1a5-8ed70662040d

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Abdul Wahab Zahid 2024-10-14 11:26:24 +02:00 committed by GitHub
parent c53b2a8bb0
commit fed9a19386
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 287 additions and 10 deletions

View file

@ -11,6 +11,9 @@ export const LOGS_APP_ID = 'logs';
export const OBSERVABILITY_LOGS_EXPLORER_APP_ID = 'observability-logs-explorer';
// TODO: Remove the app once context-aware switching between discover and observability logs explorer is implemented
export const LAST_USED_LOGS_VIEWER_APP_ID = 'last-used-logs-viewer';
export const OBSERVABILITY_OVERVIEW_APP_ID = 'observability-overview';
export const METRICS_APP_ID = 'metrics';

View file

@ -12,6 +12,7 @@ import {
LOGS_APP_ID,
METRICS_APP_ID,
OBSERVABILITY_LOGS_EXPLORER_APP_ID,
LAST_USED_LOGS_VIEWER_APP_ID,
OBSERVABILITY_ONBOARDING_APP_ID,
OBSERVABILITY_OVERVIEW_APP_ID,
SYNTHETICS_APP_ID,
@ -24,6 +25,7 @@ import {
type LogsApp = typeof LOGS_APP_ID;
type ObservabilityLogsExplorerApp = typeof OBSERVABILITY_LOGS_EXPLORER_APP_ID;
type LastUsedLogsViewerApp = typeof LAST_USED_LOGS_VIEWER_APP_ID;
type ObservabilityOverviewApp = typeof OBSERVABILITY_OVERVIEW_APP_ID;
type MetricsApp = typeof METRICS_APP_ID;
type ApmApp = typeof APM_APP_ID;
@ -38,6 +40,7 @@ type InventoryApp = typeof INVENTORY_APP_ID;
export type AppId =
| LogsApp
| ObservabilityLogsExplorerApp
| LastUsedLogsViewerApp
| ObservabilityOverviewApp
| ObservabilityOnboardingApp
| ApmApp

View file

@ -10,6 +10,7 @@
export {
LOGS_APP_ID,
OBSERVABILITY_LOGS_EXPLORER_APP_ID,
LAST_USED_LOGS_VIEWER_APP_ID,
OBSERVABILITY_ONBOARDING_APP_ID,
OBSERVABILITY_OVERVIEW_APP_ID,
AI_ASSISTANT_APP_ID,

View file

@ -49,3 +49,6 @@ export interface ObsLogsExplorerDataViewLocatorParams extends DatasetLocatorPara
*/
id: string;
}
// To store the last used logs viewer (either of discover or observability-logs-explorer)
export const OBS_LOGS_EXPLORER_LOGS_VIEWER_KEY = 'obs-logs-explorer:lastUsedViewer';

View file

@ -10,10 +10,21 @@
import userEvent from '@testing-library/user-event';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { DISCOVER_APP_ID } from '@kbn/deeplinks-analytics';
import {
ALL_DATASETS_LOCATOR_ID,
OBSERVABILITY_LOGS_EXPLORER_APP_ID,
} from '@kbn/deeplinks-observability';
import { discoverServiceMock } from '../../__mocks__/services';
import { LogsExplorerTabs, LogsExplorerTabsProps } from './logs_explorer_tabs';
import { DISCOVER_APP_LOCATOR } from '../../../common';
import { ALL_DATASETS_LOCATOR_ID } from '@kbn/deeplinks-observability';
const mockSetLastUsedViewer = jest.fn();
jest.mock('react-use/lib/useLocalStorage', () => {
return jest.fn((key: string, _initialValue: string) => {
return [undefined, mockSetLastUsedViewer]; // Always use undefined as the initial value
});
});
const createMockLocator = (id: string) => ({
navigate: jest.fn(),
@ -46,11 +57,12 @@ describe('LogsExplorerTabs', () => {
},
} as unknown as typeof discoverServiceMock;
render(<LogsExplorerTabs services={services} selectedTab={selectedTab} />);
const { unmount } = render(<LogsExplorerTabs services={services} selectedTab={selectedTab} />);
return {
mockDiscoverLocator,
mockLogsExplorerLocator,
unmount,
};
};
@ -86,4 +98,14 @@ describe('LogsExplorerTabs', () => {
await userEvent.click(getDiscoverTab());
expect(mockDiscoverLocator.navigate).toHaveBeenCalledWith({});
});
it('should update the last used viewer in local storage for selectedTab', async () => {
const { unmount } = renderTabs('discover');
expect(mockSetLastUsedViewer).toHaveBeenCalledWith(DISCOVER_APP_ID);
unmount();
mockSetLastUsedViewer.mockClear();
renderTabs('logs-explorer');
expect(mockSetLastUsedViewer).toHaveBeenCalledWith(OBSERVABILITY_LOGS_EXPLORER_APP_ID);
});
});

View file

@ -8,9 +8,16 @@
*/
import { EuiTab, EuiTabs, useEuiTheme } from '@elastic/eui';
import { AllDatasetsLocatorParams, ALL_DATASETS_LOCATOR_ID } from '@kbn/deeplinks-observability';
import { DISCOVER_APP_ID } from '@kbn/deeplinks-analytics';
import {
AllDatasetsLocatorParams,
ALL_DATASETS_LOCATOR_ID,
OBS_LOGS_EXPLORER_LOGS_VIEWER_KEY,
OBSERVABILITY_LOGS_EXPLORER_APP_ID,
} from '@kbn/deeplinks-observability';
import { i18n } from '@kbn/i18n';
import React, { MouseEvent } from 'react';
import React, { MouseEvent, useEffect } from 'react';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { DiscoverAppLocatorParams, DISCOVER_APP_LOCATOR } from '../../../common';
import type { DiscoverServices } from '../../build_services';
@ -29,6 +36,10 @@ export const LogsExplorerTabs = ({ services, selectedTab }: LogsExplorerTabsProp
const discoverUrl = discoverLocator?.getRedirectUrl(emptyParams);
const logsExplorerUrl = logsExplorerLocator?.getRedirectUrl(emptyParams);
const [lastUsedViewer, setLastUsedViewer] = useLocalStorage<
typeof DISCOVER_APP_ID | typeof OBSERVABILITY_LOGS_EXPLORER_APP_ID
>(OBS_LOGS_EXPLORER_LOGS_VIEWER_KEY, OBSERVABILITY_LOGS_EXPLORER_APP_ID);
const navigateToDiscover = createNavigateHandler(() => {
if (selectedTab !== 'discover') {
discoverLocator?.navigate(emptyParams);
@ -41,6 +52,16 @@ export const LogsExplorerTabs = ({ services, selectedTab }: LogsExplorerTabsProp
}
});
useEffect(() => {
if (selectedTab === 'discover' && lastUsedViewer !== DISCOVER_APP_ID) {
setLastUsedViewer(DISCOVER_APP_ID);
}
if (selectedTab === 'logs-explorer' && lastUsedViewer !== OBSERVABILITY_LOGS_EXPLORER_APP_ID) {
setLastUsedViewer(OBSERVABILITY_LOGS_EXPLORER_APP_ID);
}
}, [setLastUsedViewer, lastUsedViewer, selectedTab]);
return (
<EuiTabs bottomBorder={false} data-test-subj="logsExplorerTabs">
<EuiTab

View file

@ -159,6 +159,7 @@ export const applicationUsageSchema = {
monitoring: commonSchema,
'observability-log-explorer': commonSchema,
'observability-logs-explorer': commonSchema,
'last-used-logs-viewer': commonSchema,
'observability-overview': commonSchema,
observabilityOnboarding: commonSchema,
observabilityAIAssistant: commonSchema,

View file

@ -5111,6 +5111,137 @@
}
}
},
"last-used-logs-viewer": {
"properties": {
"appId": {
"type": "keyword",
"_meta": {
"description": "The application being tracked"
}
},
"viewId": {
"type": "keyword",
"_meta": {
"description": "Always `main`"
}
},
"clicks_total": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application since we started counting them"
}
},
"clicks_7_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 7 days"
}
},
"clicks_30_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 30 days"
}
},
"clicks_90_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application over the last 90 days"
}
},
"minutes_on_screen_total": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen since we started counting them."
}
},
"minutes_on_screen_7_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 7 days"
}
},
"minutes_on_screen_30_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 30 days"
}
},
"minutes_on_screen_90_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen over the last 90 days"
}
},
"views": {
"type": "array",
"items": {
"properties": {
"appId": {
"type": "keyword",
"_meta": {
"description": "The application being tracked"
}
},
"viewId": {
"type": "keyword",
"_meta": {
"description": "The application view being tracked"
}
},
"clicks_total": {
"type": "long",
"_meta": {
"description": "General number of clicks in the application sub view since we started counting them"
}
},
"clicks_7_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 7 days"
}
},
"clicks_30_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 30 days"
}
},
"clicks_90_days": {
"type": "long",
"_meta": {
"description": "General number of clicks in the active application sub view over the last 90 days"
}
},
"minutes_on_screen_total": {
"type": "float",
"_meta": {
"description": "Minutes the application sub view is active and on-screen since we started counting them."
}
},
"minutes_on_screen_7_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 7 days"
}
},
"minutes_on_screen_30_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 30 days"
}
},
"minutes_on_screen_90_days": {
"type": "float",
"_meta": {
"description": "Minutes the application is active and on-screen active application sub view over the last 90 days"
}
}
}
}
}
}
},
"observability-overview": {
"properties": {
"appId": {

View file

@ -34,12 +34,22 @@ export function createNavTree(pluginsStart: ObservabilityPublicPluginsStart) {
link: 'observability-overview',
},
{
link: 'discover',
title: i18n.translate('xpack.observability.obltNav.discover', {
defaultMessage: 'Discover',
}),
// 'last-used-logs-viewer' is wrapper app to handle the navigation between observability-log-explorer and discover
link: 'last-used-logs-viewer',
breadcrumbStatus: 'hidden', // avoid duplicate "Discover" breadcrumbs
renderAs: 'item',
children: [
{
// This is to show "observability-log-explorer" breadcrumbs when navigating from "discover" to "log explorer"
link: 'observability-logs-explorer',
link: 'discover',
children: [
{
// This is to show "observability-log-explorer" breadcrumbs when navigating from "discover" to "log explorer"
link: 'observability-logs-explorer',
},
],
},
],
},

View file

@ -0,0 +1,64 @@
/*
* 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 React, { useEffect } from 'react';
import ReactDOM from 'react-dom';
import { useLocation } from 'react-router-dom';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { Router } from '@kbn/shared-ux-router';
import {
OBSERVABILITY_LOGS_EXPLORER_APP_ID,
OBS_LOGS_EXPLORER_LOGS_VIEWER_KEY,
} from '@kbn/deeplinks-observability';
import { DISCOVER_APP_ID } from '@kbn/deeplinks-analytics';
import { AppMountParameters, CoreStart } from '@kbn/core/public';
export const renderLastUsedLogsViewerRedirect = (
core: CoreStart,
appParams: AppMountParameters
) => {
ReactDOM.render(
<Router history={appParams.history}>
<LastUsedLogsViewerRedirect core={core} />
</Router>,
appParams.element
);
return () => {
ReactDOM.unmountComponentAtNode(appParams.element);
};
};
export const LastUsedLogsViewerRedirect = ({ core }: { core: CoreStart }) => {
const location = useLocation();
const path = `${location.pathname}${location.search}`;
const [lastUsedLogsViewApp] = useLocalStorage<
typeof DISCOVER_APP_ID | typeof OBSERVABILITY_LOGS_EXPLORER_APP_ID
>(OBS_LOGS_EXPLORER_LOGS_VIEWER_KEY, OBSERVABILITY_LOGS_EXPLORER_APP_ID);
if (
lastUsedLogsViewApp &&
lastUsedLogsViewApp !== DISCOVER_APP_ID &&
lastUsedLogsViewApp !== OBSERVABILITY_LOGS_EXPLORER_APP_ID
) {
throw new Error(
`Invalid last used logs viewer app: "${lastUsedLogsViewApp}". Allowed values are "${DISCOVER_APP_ID}" and "${OBSERVABILITY_LOGS_EXPLORER_APP_ID}"`
);
}
useEffect(() => {
if (lastUsedLogsViewApp === DISCOVER_APP_ID) {
core.application.navigateToApp(DISCOVER_APP_ID, { replace: true, path });
}
if (lastUsedLogsViewApp === OBSERVABILITY_LOGS_EXPLORER_APP_ID) {
core.application.navigateToApp(OBSERVABILITY_LOGS_EXPLORER_APP_ID, { replace: true, path });
}
}, [core, path, lastUsedLogsViewApp]);
return <></>;
};

View file

@ -97,6 +97,22 @@ export class ObservabilityLogsExplorerPlugin
},
});
// App used solely to redirect to either "/app/observability-logs-explorer" or "/app/discover"
// based on the last used app value in localStorage
core.application.register({
id: 'last-used-logs-viewer',
title: logsExplorerAppTitle,
visibleIn: [],
mount: async (appMountParams: AppMountParameters) => {
const [coreStart] = await core.getStartServices();
const { renderLastUsedLogsViewerRedirect } = await import(
'./applications/last_used_logs_viewer'
);
return renderLastUsedLogsViewerRedirect(coreStart, appMountParams);
},
});
core.analytics.registerEventType(DATA_RECEIVED_TELEMETRY_EVENT);
// Register Locators

View file

@ -50,6 +50,7 @@
"@kbn/core-analytics-browser",
"@kbn/react-hooks",
"@kbn/logs-data-access-plugin",
"@kbn/deeplinks-analytics",
],
"exclude": [
"target/**/*"

View file

@ -24,7 +24,7 @@ export const navigationTree: NavigationTreeDefinition = {
title: i18n.translate('xpack.serverlessObservability.nav.discover', {
defaultMessage: 'Discover',
}),
link: 'observability-logs-explorer',
link: 'last-used-logs-viewer',
// avoid duplicate "Discover" breadcrumbs
breadcrumbStatus: 'hidden',
renderAs: 'item',

View file

@ -35,9 +35,10 @@ export default function ({ getPageObject, getService }: FtrProviderContext) {
await svlCommonNavigation.sidenav.expectSectionClosed('project_settings_project_nav');
// navigate to the logs explorer tab by default
await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'observability-logs-explorer' });
// 'last-used-logs-viewer' is wrapper app to handle the navigation between logs explorer and discover
await svlCommonNavigation.sidenav.clickLink({ deepLinkId: 'last-used-logs-viewer' });
await svlCommonNavigation.sidenav.expectLinkActive({
deepLinkId: 'observability-logs-explorer',
deepLinkId: 'last-used-logs-viewer',
});
await svlCommonNavigation.breadcrumbs.expectBreadcrumbExists({
deepLinkId: 'observability-logs-explorer',