Sidecar: Route handling rework (#96337)

This commit is contained in:
Andrej Ocenas 2024-11-14 17:28:12 +01:00 committed by GitHub
parent 54cc666aa0
commit 19844c4ba8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 178 additions and 153 deletions

View File

@ -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),
};
}

View File

@ -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);
});
});

View File

@ -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();

View File

@ -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>

View File

@ -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);
},
};
}