From 1b8b1d6c7a927a1097db4ef29e7be36530772137 Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Thu, 17 Oct 2024 12:00:50 +0200 Subject: [PATCH] Sidecar: Add local storage persistence for the sidecar state (#94820) * Add local storage persistence * Fix arg name --- public/app/core/services/SidecarService.ts | 23 +++++- public/app/routes/RoutesWrapper.tsx | 7 +- public/app/routes/utils.ts | 85 ++++++++++++++++++++++ 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/public/app/core/services/SidecarService.ts b/public/app/core/services/SidecarService.ts index 08005da697b..c3041d432f5 100644 --- a/public/app/core/services/SidecarService.ts +++ b/public/app/core/services/SidecarService.ts @@ -2,12 +2,23 @@ import { BehaviorSubject } from 'rxjs'; import { config, locationService } from '@grafana/runtime'; +interface Options { + localStorageKey?: string; +} + export class SidecarService { // The ID of the app plugin that is currently opened in the sidecar view private _activePluginId: BehaviorSubject; + private localStorageKey: string | undefined; - constructor() { - this._activePluginId = new BehaviorSubject(undefined); + constructor(options: Options) { + this.localStorageKey = options.localStorageKey; + let initialId = undefined; + if (this.localStorageKey) { + initialId = localStorage.getItem(this.localStorageKey) || undefined; + } + + this._activePluginId = new BehaviorSubject(initialId); } private assertFeatureEnabled() { @@ -28,6 +39,9 @@ export class SidecarService { return false; } + if (this.localStorageKey) { + localStorage.setItem(this.localStorageKey, pluginId); + } return this._activePluginId.next(pluginId); } @@ -37,6 +51,9 @@ export class SidecarService { } if (this._activePluginId.getValue() === pluginId) { + if (this.localStorageKey) { + localStorage.removeItem(this.localStorageKey); + } return this._activePluginId.next(undefined); } } @@ -54,7 +71,7 @@ export class SidecarService { } } -export const sidecarService = new SidecarService(); +export const sidecarService = new SidecarService({ localStorageKey: 'grafana.sidecar.activePluginId' }); // The app plugin that is "open" in the main Grafana view function getMainAppPluginId() { diff --git a/public/app/routes/RoutesWrapper.tsx b/public/app/routes/RoutesWrapper.tsx index cdb362edeb4..68d12a38236 100644 --- a/public/app/routes/RoutesWrapper.tsx +++ b/public/app/routes/RoutesWrapper.tsx @@ -1,5 +1,4 @@ import { css } from '@emotion/css'; -import * as H from 'history'; import { ComponentType } from 'react'; import { Router } from 'react-router-dom'; import { CompatRouter } from 'react-router-dom-v5-compat'; @@ -15,6 +14,8 @@ import { ModalsContextProvider } from '../core/context/ModalsContextProvider'; import { useSidecar } from '../core/context/SidecarContext'; import AppRootPage from '../features/plugins/components/AppRootPage'; +import { createLocationStorageHistory } from './utils'; + type RouterWrapperProps = { routes?: JSX.Element | false; bodyRenderHooks: ComponentType[]; @@ -75,7 +76,9 @@ export function ExperimentalSplitPaneRouterWrapper(props: RouterWrapperProps) { const headerHeight = useChromeHeaderHeight(); const styles = useStyles2(getStyles, headerHeight); - const memoryLocationService = new HistoryWrapper(H.createMemoryHistory({ initialEntries: ['/'] })); + const memoryLocationService = new HistoryWrapper( + createLocationStorageHistory({ storageKey: 'grafana.sidecar.history' }) + ); return ( // Why do we need these 2 wrappers here? We want for one app case to render very similar as if there was no split diff --git a/public/app/routes/utils.ts b/public/app/routes/utils.ts index 745776d76d9..1680f11e55d 100644 --- a/public/app/routes/utils.ts +++ b/public/app/routes/utils.ts @@ -1,3 +1,6 @@ +import * as H from 'history'; +import { pick } from 'lodash'; + import { NavLinkDTO } from '@grafana/data'; export function isSoloRoute(path: string): boolean { @@ -12,3 +15,85 @@ export function pluginHasRootPage(pluginId: string, navTree: NavLinkDTO[]): bool ?.children?.some((page) => page.url?.endsWith(`/a/${pluginId}`)) ); } + +type LocalStorageHistoryOptions = { + storageKey: string; +}; + +/** + * Simple wrapper over the memory history that persists the location in the localStorage. + * @param options + */ +export function createLocationStorageHistory(options: LocalStorageHistoryOptions): H.MemoryHistory { + const storedLocation = localStorage.getItem(options.storageKey); + const initialEntry = storedLocation ? JSON.parse(storedLocation) : '/'; + + const memoryHistory = H.createMemoryHistory({ initialEntries: [initialEntry] }); + + // We have to check whether location was actually changed by this way because the function don't actually offer + // a return value that would tell us whether the change was successful or not and there are a few ways where the + // actual location change could be blocked. + let currentLocation = memoryHistory.location; + function maybeUpdateLocation() { + if (memoryHistory.location !== currentLocation) { + localStorage.setItem( + options.storageKey, + JSON.stringify(pick(memoryHistory.location, 'pathname', 'search', 'hash')) + ); + currentLocation = memoryHistory.location; + } + } + + // This creates a sort of proxy over the memory location just to add the localStorage persistence. We could achieve + // the same effect by a listener but that would create a memory leak as there would be no reasonable way to + // unsubcribe the listener later on. + return { + get index() { + return memoryHistory.index; + }, + get entries() { + return memoryHistory.entries; + }, + canGo(n: number) { + return memoryHistory.canGo(n); + }, + get length() { + return memoryHistory.length; + }, + get action() { + return memoryHistory.action; + }, + get location() { + return memoryHistory.location; + }, + push(location: H.Path | H.LocationDescriptor, state?: H.LocationState) { + memoryHistory.push(location, state); + maybeUpdateLocation(); + }, + replace(location: H.Path | H.LocationDescriptor, state?: H.LocationState) { + memoryHistory.replace(location, state); + maybeUpdateLocation(); + }, + go(n: number) { + memoryHistory.go(n); + maybeUpdateLocation(); + }, + goBack() { + memoryHistory.goBack(); + maybeUpdateLocation(); + }, + goForward() { + memoryHistory.goForward(); + maybeUpdateLocation(); + }, + block(prompt?: boolean | string | H.TransitionPromptHook) { + return memoryHistory.block(prompt); + }, + listen(listener: H.LocationListener) { + return memoryHistory.listen(listener); + }, + createHref(location: H.LocationDescriptorObject) { + return memoryHistory.createHref(location); + }, + }; +}