diff --git a/packages/grafana-runtime/src/services/SidecarContext_EXPERIMENTAL.ts b/packages/grafana-runtime/src/services/SidecarContext_EXPERIMENTAL.ts index 1597fb25b39..dd689499573 100644 --- a/packages/grafana-runtime/src/services/SidecarContext_EXPERIMENTAL.ts +++ b/packages/grafana-runtime/src/services/SidecarContext_EXPERIMENTAL.ts @@ -21,17 +21,20 @@ export function useSidecar_EXPERIMENTAL() { throw new Error('No SidecarContext found'); } - const activePluginId = useObservable(service.activePluginIdObservable, service.activePluginId); const initialContext = useObservable(service.initialContextObservable, service.initialContext); + const activePluginId = useObservable(service.activePluginIdObservable, service.activePluginId); + const locationService = service.getLocationService(); return { activePluginId, initialContext, + locationService, // TODO: currently this allows anybody to open any app, in the future we should probably scope this to the // current app but that means we will need to incorporate this better into the plugin platform APIs which // we will do once the functionality is reasonably stable - openApp: (pluginId: string) => service.openApp(pluginId), - closeApp: (pluginId: string) => service.closeApp(pluginId), + openApp: (pluginId: string, context?: unknown) => service.openApp(pluginId, context), + openAppV2: (pluginId: string, path?: string) => service.openAppV2(pluginId, path), + closeApp: () => service.closeApp(), isAppOpened: (pluginId: string) => service.isAppOpened(pluginId), }; } diff --git a/packages/grafana-runtime/src/services/SidecarService_EXPERIMENTAL.test.ts b/packages/grafana-runtime/src/services/SidecarService_EXPERIMENTAL.test.ts index a58d5483fd3..afa9f3aaf18 100644 --- a/packages/grafana-runtime/src/services/SidecarService_EXPERIMENTAL.test.ts +++ b/packages/grafana-runtime/src/services/SidecarService_EXPERIMENTAL.test.ts @@ -11,37 +11,51 @@ describe('SidecarService_EXPERIMENTAL', () => { }); it('has the correct state after opening and closing an app', () => { - const sidecarService = new SidecarService_EXPERIMENTAL({}); + const sidecarService = new SidecarService_EXPERIMENTAL(); sidecarService.openApp('pluginId', { filter: 'test' }); expect(sidecarService.activePluginId).toBe('pluginId'); expect(sidecarService.initialContext).toMatchObject({ filter: 'test' }); + expect(sidecarService.getLocationService().getLocation().pathname).toBe('/a/pluginId'); - sidecarService.closeApp('pluginId'); + sidecarService.closeApp(); expect(sidecarService.activePluginId).toBe(undefined); expect(sidecarService.initialContext).toBe(undefined); + expect(sidecarService.getLocationService().getLocation().pathname).toBe('/'); + }); + + it('has the correct state after opening and closing an app v2', () => { + const sidecarService = new SidecarService_EXPERIMENTAL(); + sidecarService.openAppV2('pluginId', '/test'); + + expect(sidecarService.activePluginId).toBe('pluginId'); + expect(sidecarService.getLocationService().getLocation().pathname).toBe('/a/pluginId/test'); + + sidecarService.closeApp(); + expect(sidecarService.activePluginId).toBe(undefined); + expect(sidecarService.initialContext).toBe(undefined); + expect(sidecarService.getLocationService().getLocation().pathname).toBe('/'); }); it('reports correct opened state', () => { - const sidecarService = new SidecarService_EXPERIMENTAL({}); + const sidecarService = new SidecarService_EXPERIMENTAL(); expect(sidecarService.isAppOpened('pluginId')).toBe(false); sidecarService.openApp('pluginId'); - expect(sidecarService.isAppOpened('pluginId')).toBe(true); - sidecarService.closeApp('pluginId'); - + sidecarService.closeApp(); expect(sidecarService.isAppOpened('pluginId')).toBe(false); }); - it('does not close app that is not opened', () => { - const sidecarService = new SidecarService_EXPERIMENTAL({}); - sidecarService.openApp('pluginId'); - - sidecarService.closeApp('foobar'); + it('reports correct opened state v2', () => { + const sidecarService = new SidecarService_EXPERIMENTAL(); + expect(sidecarService.isAppOpened('pluginId')).toBe(false); + sidecarService.openAppV2('pluginId'); expect(sidecarService.isAppOpened('pluginId')).toBe(true); - expect(sidecarService.activePluginId).toBe('pluginId'); + + sidecarService.closeApp(); + expect(sidecarService.isAppOpened('pluginId')).toBe(false); }); }); diff --git a/packages/grafana-runtime/src/services/SidecarService_EXPERIMENTAL.ts b/packages/grafana-runtime/src/services/SidecarService_EXPERIMENTAL.ts index 67e8dd776f7..c91d2dc9181 100644 --- a/packages/grafana-runtime/src/services/SidecarService_EXPERIMENTAL.ts +++ b/packages/grafana-runtime/src/services/SidecarService_EXPERIMENTAL.ts @@ -1,16 +1,14 @@ -import { BehaviorSubject } from 'rxjs'; +import * as H from 'history'; +import { pick } from 'lodash'; +import { BehaviorSubject, map, Observable } from 'rxjs'; import { config } from '../config'; -import { locationService } from './LocationService'; - -interface Options { - localStorageKey?: string; -} +import { HistoryWrapper, LocationService, locationService } from './LocationService'; /** * This is a service that handles state and operation of a sidecar feature (sideview to render a second app in grafana). - * At this moment this is highly experimental and if used should be understand to break easily with newer versions. + * At this moment this is highly experimental and if used should be understood to break easily with newer versions. * None of this functionality works without a feature toggle `appSidecar` being enabled. * * Right now this being in a single service is more of a practical tradeoff for easier isolation in the future these @@ -19,18 +17,16 @@ interface Options { * @experimental */ export class SidecarService_EXPERIMENTAL { - private _activePluginId: BehaviorSubject; private _initialContext: BehaviorSubject; - private localStorageKey: string | undefined; + private memoryLocationService: LocationService; + private history: LocationStorageHistory; - constructor(options: Options) { - this.localStorageKey = options.localStorageKey; - let initialId = undefined; - if (this.localStorageKey) { - initialId = localStorage.getItem(this.localStorageKey) || undefined; - } - this._activePluginId = new BehaviorSubject(initialId); + constructor() { this._initialContext = new BehaviorSubject(undefined); + // We need a local ref for this so we can tap into the location changes and drive rerendering of components based + // on it without having a parent Router. + this.history = createLocationStorageHistory({ storageKey: 'grafana.sidecar.history' }); + this.memoryLocationService = new HistoryWrapper(this.history); } private assertFeatureEnabled() { @@ -50,7 +46,11 @@ export class SidecarService_EXPERIMENTAL { * @experimental */ get activePluginIdObservable() { - return this._activePluginId.asObservable(); + return this.history.getLocationObservable().pipe( + map((val) => { + return getPluginIdFromUrl(val?.pathname || ''); + }) + ); } /** @@ -74,39 +74,48 @@ export class SidecarService_EXPERIMENTAL { * @experimental */ get activePluginId() { - return this._activePluginId.getValue(); + return getPluginIdFromUrl(this.memoryLocationService.getLocation().pathname); + } + + getLocationService() { + return this.memoryLocationService; } /** * Opens an app in a sidecar. You can also pass some context object that will be then available to the app. + * @deprecated * @experimental */ openApp(pluginId: string, context?: unknown) { if (!this.assertFeatureEnabled()) { return; } - if (this.localStorageKey) { - localStorage.setItem(this.localStorageKey, pluginId); + this._initialContext.next(context); + this.memoryLocationService.push({ pathname: `/a/${pluginId}` }); + } + + /** + * Opens an app in a sidecar. You can also relative path inside the app to open. + * @experimental + */ + openAppV2(pluginId: string, path?: string) { + if (!this.assertFeatureEnabled()) { + return; } - this._activePluginId.next(pluginId); - this._initialContext.next(context); + this.memoryLocationService.push({ pathname: `/a/${pluginId}${path || ''}` }); } /** * @experimental */ - closeApp(pluginId: string) { + closeApp() { if (!this.assertFeatureEnabled()) { return; } - if (this._activePluginId.getValue() === pluginId) { - if (this.localStorageKey) { - localStorage.removeItem(this.localStorageKey); - } - this._activePluginId.next(undefined); - this._initialContext.next(undefined); - } + + this._initialContext.next(undefined); + this.memoryLocationService.replace({ pathname: '/' }); } /** @@ -130,23 +139,23 @@ export class SidecarService_EXPERIMENTAL { return false; } - return !!( - this._activePluginId.getValue() && - (this._activePluginId.getValue() === pluginId || getMainAppPluginId() === pluginId) - ); + return !!(this.activePluginId && (this.activePluginId === pluginId || getMainAppPluginId() === pluginId)); } } -export const sidecarServiceSingleton_EXPERIMENTAL = new SidecarService_EXPERIMENTAL({ - localStorageKey: 'grafana.sidecar.activePluginId', -}); +const pluginIdUrlRegex = /a\/([^\/]+)/; +function getPluginIdFromUrl(url: string) { + return url.match(pluginIdUrlRegex)?.[1]; +} // The app plugin that is "open" in the main Grafana view function getMainAppPluginId() { + // TODO: not great but we have to get a handle on the other locationService used for the main view and easiest way + // right now is through this global singleton const { pathname } = locationService.getLocation(); // A naive way to sort of simulate core features being an app and having an appID - let mainApp = pathname.match(/\/a\/([^/]+)/)?.[1]; + let mainApp = getPluginIdFromUrl(pathname); if (!mainApp && pathname.match(/\/explore/)) { mainApp = 'explore'; } @@ -157,3 +166,88 @@ function getMainAppPluginId() { return mainApp || 'unknown'; } + +type LocalStorageHistoryOptions = { + storageKey: string; +}; + +interface LocationStorageHistory extends H.MemoryHistory { + getLocationObservable(): Observable; +} + +/** + * Simple wrapper over the memory history that persists the location in the localStorage. + * + * @param options + */ +function createLocationStorageHistory(options: LocalStorageHistoryOptions): LocationStorageHistory { + const storedLocation = localStorage.getItem(options.storageKey); + const initialEntry = storedLocation ? JSON.parse(storedLocation) : '/'; + const locationSubject = new BehaviorSubject(initialEntry); + const memoryHistory = H.createMemoryHistory({ initialEntries: [initialEntry] }); + + 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; + locationSubject.next(memoryHistory.location); + } + } + + // This creates a sort of proxy over the memory location just to add the localStorage persistence and the location + // observer. 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. + // Another issue is that react router for some reason does not care about proper `this` binding and just calls these + // as normal functions. So if this were to be a class we would still need to bind each of these methods to the + // instance so at that moment this just seems easier. + return { + ...memoryHistory, + // Getter aren't destructured as getter but as values, so they have to be still here even though we are not + // modifying them. + get index() { + return memoryHistory.index; + }, + get entries() { + return memoryHistory.entries; + }, + 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(); + }, + getLocationObservable() { + return locationSubject.asObservable(); + }, + }; +} + +export const sidecarServiceSingleton_EXPERIMENTAL = new SidecarService_EXPERIMENTAL(); diff --git a/public/app/routes/RoutesWrapper.tsx b/public/app/routes/RoutesWrapper.tsx index fc8514b75d2..06631c74c85 100644 --- a/public/app/routes/RoutesWrapper.tsx +++ b/public/app/routes/RoutesWrapper.tsx @@ -6,7 +6,6 @@ import { CompatRouter } from 'react-router-dom-v5-compat'; import { GrafanaTheme2 } from '@grafana/data/'; import { - HistoryWrapper, locationService, LocationServiceProvider, useChromeHeaderHeight, @@ -19,9 +18,6 @@ import { AppChrome } from '../core/components/AppChrome/AppChrome'; import { AppNotificationList } from '../core/components/AppNotifications/AppNotificationList'; import { ModalsContextProvider } from '../core/context/ModalsContextProvider'; import { QueriesDrawerContextProvider } from '../features/explore/QueriesDrawer/QueriesDrawerContext'; -import AppRootPage from '../features/plugins/components/AppRootPage'; - -import { createLocationStorageHistory } from './utils'; type RouterWrapperProps = { routes?: JSX.Element | false; @@ -64,7 +60,7 @@ export function RouterWrapper(props: RouterWrapperProps) { * @constructor */ export function ExperimentalSplitPaneRouterWrapper(props: RouterWrapperProps) { - const { activePluginId, closeApp } = useSidecar_EXPERIMENTAL(); + const { closeApp, locationService, activePluginId } = useSidecar_EXPERIMENTAL(); let { containerProps, primaryProps, secondaryProps, splitterProps } = useSplitter({ direction: 'row', @@ -85,9 +81,10 @@ export function ExperimentalSplitPaneRouterWrapper(props: RouterWrapperProps) { const headerHeight = useChromeHeaderHeight(); const styles = useStyles2(getStyles, headerHeight); - const memoryLocationService = new HistoryWrapper( - createLocationStorageHistory({ storageKey: 'grafana.sidecar.history' }) - ); + + // Right now we consider only app plugin to be opened here but in the future we might want to just open any kind + // of url and so this should check whether there is a location in the sidecar locationService. + const sidecarOpen = Boolean(activePluginId); 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 @@ -95,17 +92,17 @@ export function ExperimentalSplitPaneRouterWrapper(props: RouterWrapperProps) { // time we don't want to rerender the main app when going from 2 apps render to single app render which would happen // if we removed the wrappers. So the solution is to keep those 2 divs but make them no actually do anything in // case we are rendering a single app. -
-
+
+
{/* Sidecar */} - {activePluginId && ( + {sidecarOpen && ( <>
- - + +
@@ -115,11 +112,13 @@ export function ExperimentalSplitPaneRouterWrapper(props: RouterWrapperProps) { style={{ margin: '8px' }} name={'times'} aria-label={'close'} - onClick={() => closeApp(activePluginId)} + onClick={() => closeApp()} />
- + {/*We don't render anything other than app plugin but we want to keep the same routing layout so*/} + {/*there are is no difference with matching relative routes between main and sidecar view.*/} + {props.routes}
diff --git a/public/app/routes/utils.ts b/public/app/routes/utils.ts index 1680f11e55d..745776d76d9 100644 --- a/public/app/routes/utils.ts +++ b/public/app/routes/utils.ts @@ -1,6 +1,3 @@ -import * as H from 'history'; -import { pick } from 'lodash'; - import { NavLinkDTO } from '@grafana/data'; export function isSoloRoute(path: string): boolean { @@ -15,85 +12,3 @@ 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); - }, - }; -}