mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Sidecar: Specify where it can be opened and when it automatically closes (#96683)
This commit is contained in:
parent
fd3ecacd05
commit
58e9f22c1b
@ -1,5 +1,6 @@
|
||||
import * as H from 'history';
|
||||
import React, { useContext } from 'react';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
|
||||
import { deprecationWarning, UrlQueryMap, urlUtil } from '@grafana/data';
|
||||
import { attachDebugger, createLogger } from '@grafana/ui';
|
||||
@ -21,6 +22,7 @@ export interface LocationService {
|
||||
getHistory: () => H.History;
|
||||
getSearch: () => URLSearchParams;
|
||||
getSearchObject: () => UrlQueryMap;
|
||||
getLocationObservable: () => Observable<H.Location>;
|
||||
|
||||
/**
|
||||
* This is from the old LocationSrv interface
|
||||
@ -31,6 +33,7 @@ export interface LocationService {
|
||||
/** @internal */
|
||||
export class HistoryWrapper implements LocationService {
|
||||
private readonly history: H.History;
|
||||
private locationObservable: BehaviorSubject<H.Location>;
|
||||
|
||||
constructor(history?: H.History) {
|
||||
// If no history passed create an in memory one if being called from test
|
||||
@ -40,6 +43,12 @@ export class HistoryWrapper implements LocationService {
|
||||
? H.createMemoryHistory({ initialEntries: ['/'] })
|
||||
: H.createBrowserHistory({ basename: config.appSubUrl ?? '/' }));
|
||||
|
||||
this.locationObservable = new BehaviorSubject(this.history.location);
|
||||
|
||||
this.history.listen((location) => {
|
||||
this.locationObservable.next(location);
|
||||
});
|
||||
|
||||
this.partial = this.partial.bind(this);
|
||||
this.push = this.push.bind(this);
|
||||
this.replace = this.replace.bind(this);
|
||||
@ -48,6 +57,10 @@ export class HistoryWrapper implements LocationService {
|
||||
this.getLocation = this.getLocation.bind(this);
|
||||
}
|
||||
|
||||
getLocationObservable() {
|
||||
return this.locationObservable.asObservable();
|
||||
}
|
||||
|
||||
getHistory() {
|
||||
return this.history;
|
||||
}
|
||||
|
@ -1,15 +1,12 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import { useObservable } from 'react-use';
|
||||
|
||||
import { locationService as mainLocationService } from './LocationService';
|
||||
import { SidecarService_EXPERIMENTAL, sidecarServiceSingleton_EXPERIMENTAL } from './SidecarService_EXPERIMENTAL';
|
||||
|
||||
export const SidecarContext_EXPERIMENTAL = createContext<SidecarService_EXPERIMENTAL>(
|
||||
sidecarServiceSingleton_EXPERIMENTAL
|
||||
);
|
||||
|
||||
const HIDDEN_ROUTES = ['/login'];
|
||||
|
||||
/**
|
||||
* This is the main way to interact with the sidecar service inside a react context. It provides a wrapper around the
|
||||
* service props so that even though they are observables we just pass actual values to the components.
|
||||
@ -27,32 +24,25 @@ export function useSidecar_EXPERIMENTAL() {
|
||||
const initialContext = useObservable(service.initialContextObservable, service.initialContext);
|
||||
const activePluginId = useObservable(service.activePluginIdObservable, service.activePluginId);
|
||||
const locationService = service.getLocationService();
|
||||
const forceHidden = HIDDEN_ROUTES.includes(mainLocationService.getLocation().pathname);
|
||||
|
||||
return {
|
||||
activePluginId: forceHidden ? undefined : activePluginId,
|
||||
initialContext: forceHidden ? undefined : initialContext,
|
||||
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, context?: unknown) => {
|
||||
if (forceHidden) {
|
||||
return;
|
||||
}
|
||||
return service.openApp(pluginId, context);
|
||||
},
|
||||
openAppV2: (pluginId: string, path?: string) => {
|
||||
if (forceHidden) {
|
||||
return;
|
||||
}
|
||||
return service.openAppV2(pluginId, path);
|
||||
},
|
||||
openAppV3: (options: { pluginId: string; path?: string; follow?: boolean }) => {
|
||||
return service.openAppV3(options);
|
||||
},
|
||||
closeApp: () => service.closeApp(),
|
||||
isAppOpened: (pluginId: string) => {
|
||||
if (forceHidden) {
|
||||
return false;
|
||||
}
|
||||
return service.isAppOpened(pluginId);
|
||||
},
|
||||
};
|
||||
|
@ -1,17 +1,28 @@
|
||||
import * as H from 'history';
|
||||
|
||||
import { config } from '../config';
|
||||
|
||||
import { HistoryWrapper } from './LocationService';
|
||||
import { SidecarService_EXPERIMENTAL } from './SidecarService_EXPERIMENTAL';
|
||||
|
||||
describe('SidecarService_EXPERIMENTAL', () => {
|
||||
beforeEach(() => {
|
||||
let mainLocationService: HistoryWrapper;
|
||||
let sidecarService: SidecarService_EXPERIMENTAL;
|
||||
|
||||
beforeAll(() => {
|
||||
config.featureToggles.appSidecar = true;
|
||||
});
|
||||
afterEach(() => {
|
||||
config.featureToggles.appSidecar = undefined;
|
||||
|
||||
afterAll(() => {
|
||||
config.featureToggles.appSidecar = false;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mainLocationService = new HistoryWrapper(H.createMemoryHistory({ initialEntries: ['/explore'] }));
|
||||
sidecarService = new SidecarService_EXPERIMENTAL(mainLocationService);
|
||||
});
|
||||
|
||||
it('has the correct state after opening and closing an app', () => {
|
||||
const sidecarService = new SidecarService_EXPERIMENTAL();
|
||||
sidecarService.openApp('pluginId', { filter: 'test' });
|
||||
|
||||
expect(sidecarService.activePluginId).toBe('pluginId');
|
||||
@ -25,7 +36,6 @@ describe('SidecarService_EXPERIMENTAL', () => {
|
||||
});
|
||||
|
||||
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');
|
||||
@ -37,8 +47,19 @@ describe('SidecarService_EXPERIMENTAL', () => {
|
||||
expect(sidecarService.getLocationService().getLocation().pathname).toBe('/');
|
||||
});
|
||||
|
||||
it('has the correct state after opening and closing an app v3', () => {
|
||||
sidecarService.openAppV3({ pluginId: 'pluginId', path: '/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();
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
||||
|
||||
sidecarService.openApp('pluginId');
|
||||
@ -49,7 +70,6 @@ describe('SidecarService_EXPERIMENTAL', () => {
|
||||
});
|
||||
|
||||
it('reports correct opened state v2', () => {
|
||||
const sidecarService = new SidecarService_EXPERIMENTAL();
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
||||
|
||||
sidecarService.openAppV2('pluginId');
|
||||
@ -58,4 +78,46 @@ describe('SidecarService_EXPERIMENTAL', () => {
|
||||
sidecarService.closeApp();
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
||||
});
|
||||
|
||||
it('reports correct opened state v3', () => {
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
||||
|
||||
sidecarService.openAppV3({ pluginId: 'pluginId' });
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
||||
|
||||
sidecarService.closeApp();
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
||||
});
|
||||
|
||||
it('autocloses on not allowed routes', () => {
|
||||
sidecarService.openAppV3({ pluginId: 'pluginId' });
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
||||
mainLocationService.push('/config');
|
||||
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
||||
});
|
||||
|
||||
it('autocloses on when changing route', () => {
|
||||
sidecarService.openAppV3({ pluginId: 'pluginId' });
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
||||
mainLocationService.push('/a/other-app');
|
||||
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not autocloses when set to follow', () => {
|
||||
sidecarService.openAppV3({ pluginId: 'pluginId', follow: true });
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
||||
mainLocationService.push('/a/other-app');
|
||||
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
||||
});
|
||||
|
||||
it('autocloses on not allowed routes when set to follow', () => {
|
||||
sidecarService.openAppV3({ pluginId: 'pluginId', follow: true });
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(true);
|
||||
mainLocationService.push('/config');
|
||||
|
||||
expect(sidecarService.isAppOpened('pluginId')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
@ -5,7 +5,17 @@ import { BehaviorSubject, map, Observable } from 'rxjs';
|
||||
import { reportInteraction } from '../analytics/utils';
|
||||
import { config } from '../config';
|
||||
|
||||
import { HistoryWrapper, LocationService, locationService } from './LocationService';
|
||||
import { HistoryWrapper, locationService as mainLocationService, LocationService } from './LocationService';
|
||||
|
||||
// Only allow sidecar to be opened on these routes. It does not seem to make sense to keep the sidecar opened on
|
||||
// config/admin pages for example.
|
||||
// At this moment let's be restrictive about where the sidecar can show and add more routes if there is a need.
|
||||
const ALLOW_ROUTES = [
|
||||
/(^\/d\/)/, // dashboards
|
||||
/^\/explore/, // explore + explore metrics
|
||||
/^\/a\/[^\/]+/, // app plugins
|
||||
/^\/alerting/,
|
||||
];
|
||||
|
||||
/**
|
||||
* This is a service that handles state and operation of a sidecar feature (sideview to render a second app in grafana).
|
||||
@ -19,15 +29,26 @@ import { HistoryWrapper, LocationService, locationService } from './LocationServ
|
||||
*/
|
||||
export class SidecarService_EXPERIMENTAL {
|
||||
private _initialContext: BehaviorSubject<unknown | undefined>;
|
||||
private memoryLocationService: LocationService;
|
||||
private history: LocationStorageHistory;
|
||||
|
||||
constructor() {
|
||||
private sidecarLocationService: LocationService;
|
||||
private mainLocationService: LocationService;
|
||||
|
||||
// If true we don't close the sidecar when user navigates to another app or part of Grafana from where the sidecar
|
||||
// was opened.
|
||||
private follow = false;
|
||||
|
||||
// Keep track of where the sidecar was originally opened for autoclose behaviour.
|
||||
private mainLocationWhenOpened: string | undefined;
|
||||
|
||||
private mainOnAllowedRoute = false;
|
||||
|
||||
constructor(mainLocationService: LocationService) {
|
||||
this._initialContext = new BehaviorSubject<unknown | undefined>(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);
|
||||
this.mainLocationService = mainLocationService;
|
||||
this.sidecarLocationService = new HistoryWrapper(
|
||||
createLocationStorageHistory({ storageKey: 'grafana.sidecar.history' })
|
||||
);
|
||||
this.handleMainLocationChanges();
|
||||
}
|
||||
|
||||
private assertFeatureEnabled() {
|
||||
@ -39,6 +60,48 @@ export class SidecarService_EXPERIMENTAL {
|
||||
return true;
|
||||
}
|
||||
|
||||
private updateMainLocationWhenOpened() {
|
||||
const pathname = this.mainLocationService.getLocation().pathname;
|
||||
for (const route of ALLOW_ROUTES) {
|
||||
const match = pathname.match(route)?.[0];
|
||||
if (match) {
|
||||
this.mainLocationWhenOpened = match;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every time the main location changes we check if we should keep the sidecar open or close it based on list
|
||||
* of allowed routes and also based on the follow flag when opening the app.
|
||||
*/
|
||||
private handleMainLocationChanges() {
|
||||
this.mainOnAllowedRoute = ALLOW_ROUTES.some((prefix) =>
|
||||
this.mainLocationService.getLocation().pathname.match(prefix)
|
||||
);
|
||||
|
||||
this.mainLocationService.getLocationObservable().subscribe((location) => {
|
||||
if (!this.activePluginId) {
|
||||
return;
|
||||
}
|
||||
this.mainOnAllowedRoute = ALLOW_ROUTES.some((prefix) => location.pathname.match(prefix));
|
||||
|
||||
if (!this.mainOnAllowedRoute) {
|
||||
this.closeApp();
|
||||
return;
|
||||
}
|
||||
|
||||
// We check if we moved to some other app or part of grafana from where we opened the sidecar.
|
||||
const isTheSameLocation = Boolean(
|
||||
this.mainLocationWhenOpened && location.pathname.startsWith(this.mainLocationWhenOpened)
|
||||
);
|
||||
|
||||
if (!(isTheSameLocation || this.follow)) {
|
||||
this.closeApp();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current app id of the app in sidecar. This is most probably provisional. In the future
|
||||
* this should be driven by URL addressing so that routing for the apps don't change. Useful just internally
|
||||
@ -47,7 +110,7 @@ export class SidecarService_EXPERIMENTAL {
|
||||
* @experimental
|
||||
*/
|
||||
get activePluginIdObservable() {
|
||||
return this.history.getLocationObservable().pipe(
|
||||
return this.sidecarLocationService.getLocationObservable().pipe(
|
||||
map((val) => {
|
||||
return getPluginIdFromUrl(val?.pathname || '');
|
||||
})
|
||||
@ -75,11 +138,11 @@ export class SidecarService_EXPERIMENTAL {
|
||||
* @experimental
|
||||
*/
|
||||
get activePluginId() {
|
||||
return getPluginIdFromUrl(this.memoryLocationService.getLocation().pathname);
|
||||
return getPluginIdFromUrl(this.sidecarLocationService.getLocation().pathname);
|
||||
}
|
||||
|
||||
getLocationService() {
|
||||
return this.memoryLocationService;
|
||||
return this.sidecarLocationService;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -88,26 +151,41 @@ export class SidecarService_EXPERIMENTAL {
|
||||
* @experimental
|
||||
*/
|
||||
openApp(pluginId: string, context?: unknown) {
|
||||
if (!this.assertFeatureEnabled()) {
|
||||
if (!(this.assertFeatureEnabled() && this.mainOnAllowedRoute)) {
|
||||
return;
|
||||
}
|
||||
this._initialContext.next(context);
|
||||
this.memoryLocationService.push({ pathname: `/a/${pluginId}` });
|
||||
|
||||
reportInteraction('sidecar_service_open_app', { pluginId, version: 1 });
|
||||
this.openAppV3({ pluginId, follow: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an app in a sidecar. You can also relative path inside the app to open.
|
||||
* @deprecated
|
||||
* @experimental
|
||||
*/
|
||||
openAppV2(pluginId: string, path?: string) {
|
||||
if (!this.assertFeatureEnabled()) {
|
||||
this.openAppV3({ pluginId, path, follow: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an app in a sidecar. You can also relative path inside the app to open.
|
||||
* @param options.pluginId Plugin ID of the app to open
|
||||
* @param options.path Relative path inside the app to open
|
||||
* @param options.follow If true, the sidecar will stay open even if the main location change to another app or
|
||||
* Grafana section
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
openAppV3(options: { pluginId: string; path?: string; follow?: boolean }) {
|
||||
if (!(this.assertFeatureEnabled() && this.mainOnAllowedRoute)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.memoryLocationService.push({ pathname: `/a/${pluginId}${path || ''}` });
|
||||
reportInteraction('sidecar_service_open_app', { pluginId, version: 2 });
|
||||
this.follow = options.follow || false;
|
||||
|
||||
this.updateMainLocationWhenOpened();
|
||||
this.sidecarLocationService.push({ pathname: `/a/${options.pluginId}${options.path || ''}` });
|
||||
reportInteraction('sidecar_service_open_app', { pluginId: options.pluginId, follow: options.follow });
|
||||
}
|
||||
|
||||
/**
|
||||
@ -118,8 +196,10 @@ export class SidecarService_EXPERIMENTAL {
|
||||
return;
|
||||
}
|
||||
|
||||
this.follow = false;
|
||||
this.mainLocationWhenOpened = undefined;
|
||||
this._initialContext.next(undefined);
|
||||
this.memoryLocationService.replace({ pathname: '/' });
|
||||
this.sidecarLocationService.replace({ pathname: '/' });
|
||||
|
||||
reportInteraction('sidecar_service_close_app');
|
||||
}
|
||||
@ -160,7 +240,7 @@ function getPluginIdFromUrl(url: string) {
|
||||
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();
|
||||
const { pathname } = mainLocationService.getLocation();
|
||||
|
||||
// A naive way to sort of simulate core features being an app and having an appID
|
||||
let mainApp = getPluginIdFromUrl(pathname);
|
||||
@ -258,4 +338,4 @@ function createLocationStorageHistory(options: LocalStorageHistoryOptions): Loca
|
||||
};
|
||||
}
|
||||
|
||||
export const sidecarServiceSingleton_EXPERIMENTAL = new SidecarService_EXPERIMENTAL();
|
||||
export const sidecarServiceSingleton_EXPERIMENTAL = new SidecarService_EXPERIMENTAL(mainLocationService);
|
||||
|
Loading…
Reference in New Issue
Block a user