mirror of
https://github.com/grafana/grafana.git
synced 2024-12-24 16:10:22 -06:00
Sidecar: Route handling rework (#96337)
This commit is contained in:
parent
54cc666aa0
commit
19844c4ba8
@ -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),
|
||||
};
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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<string | undefined>;
|
||||
private _initialContext: BehaviorSubject<unknown | undefined>;
|
||||
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<string | undefined>(initialId);
|
||||
constructor() {
|
||||
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);
|
||||
}
|
||||
|
||||
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<H.Location | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<H.Location | undefined>(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<H.LocationState>, state?: H.LocationState) {
|
||||
memoryHistory.push(location, state);
|
||||
maybeUpdateLocation();
|
||||
},
|
||||
replace(location: H.Path | H.LocationDescriptor<H.LocationState>, 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();
|
||||
|
@ -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.
|
||||
<div {...(activePluginId ? containerProps : { className: styles.dummyWrapper })}>
|
||||
<div {...(activePluginId ? primaryProps : { className: styles.dummyWrapper })}>
|
||||
<div {...(sidecarOpen ? containerProps : { className: styles.dummyWrapper })}>
|
||||
<div {...(sidecarOpen ? primaryProps : { className: styles.dummyWrapper })}>
|
||||
<RouterWrapper {...props} />
|
||||
</div>
|
||||
{/* Sidecar */}
|
||||
{activePluginId && (
|
||||
{sidecarOpen && (
|
||||
<>
|
||||
<div {...splitterProps} />
|
||||
<div {...secondaryProps}>
|
||||
<Router history={memoryLocationService.getHistory()}>
|
||||
<LocationServiceProvider service={memoryLocationService}>
|
||||
<Router history={locationService.getHistory()}>
|
||||
<LocationServiceProvider service={locationService}>
|
||||
<CompatRouter>
|
||||
<GlobalStyles />
|
||||
<div className={styles.secondAppChrome}>
|
||||
@ -115,11 +112,13 @@ export function ExperimentalSplitPaneRouterWrapper(props: RouterWrapperProps) {
|
||||
style={{ margin: '8px' }}
|
||||
name={'times'}
|
||||
aria-label={'close'}
|
||||
onClick={() => closeApp(activePluginId)}
|
||||
onClick={() => closeApp()}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.secondAppWrapper}>
|
||||
<AppRootPage pluginId={activePluginId} />
|
||||
{/*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}
|
||||
</div>
|
||||
</div>
|
||||
</CompatRouter>
|
||||
|
@ -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<H.LocationState>, state?: H.LocationState) {
|
||||
memoryHistory.push(location, state);
|
||||
maybeUpdateLocation();
|
||||
},
|
||||
replace(location: H.Path | H.LocationDescriptor<H.LocationState>, 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<H.LocationState>) {
|
||||
return memoryHistory.block(prompt);
|
||||
},
|
||||
listen(listener: H.LocationListener<H.LocationState>) {
|
||||
return memoryHistory.listen(listener);
|
||||
},
|
||||
createHref(location: H.LocationDescriptorObject<H.LocationState>) {
|
||||
return memoryHistory.createHref(location);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user