mirror of https://github.com/grafana/grafana
Sidecar: Add split view and basic APIs for extensions (#91648)
* Add split view and basic APIs to extensions * Add comments * Update public/app/AppWrapper.tsx Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com> * Moved the .grafana-app element and deduplicate some code * Remove the provider variants of usePluginLinks/Components * Change buildPluginSectionNav * Update comment * Use eventBus * Remove non existent exports * refactor: use a sidecar service to encapsulate the state * Don't wrap single app in split wrapper * Use hook splitter * Remove inline styles * Type the style props from useSplitter * Move the overflow style changes to appWrapper * Deduplicate some common top level providers * Move modals * Move routes wrappers to it's own file * Use better css and add comments * Remove query rows app extension point * Fix test --------- Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>pull/92592/head
parent
ba9f1da28e
commit
5e2ac24890
|
@ -0,0 +1,22 @@ |
|||||||
|
import { createContext, useContext } from 'react'; |
||||||
|
import { useObservable } from 'react-use'; |
||||||
|
|
||||||
|
import { SidecarService, sidecarService } from '../services/SidecarService'; |
||||||
|
|
||||||
|
export const SidecarContext = createContext<SidecarService>(sidecarService); |
||||||
|
|
||||||
|
export function useSidecar() { |
||||||
|
const activePluginId = useObservable(sidecarService.activePluginId); |
||||||
|
const context = useContext(SidecarContext); |
||||||
|
|
||||||
|
if (!context) { |
||||||
|
throw new Error('No SidecarContext found'); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
activePluginId, |
||||||
|
openApp: (pluginId: string) => context.openApp(pluginId), |
||||||
|
closeApp: (pluginId: string) => context.closeApp(pluginId), |
||||||
|
isAppOpened: (pluginId: string) => context.isAppOpened(pluginId), |
||||||
|
}; |
||||||
|
} |
@ -0,0 +1,74 @@ |
|||||||
|
import { BehaviorSubject } from 'rxjs'; |
||||||
|
|
||||||
|
import { config, locationService } from '@grafana/runtime'; |
||||||
|
|
||||||
|
export class SidecarService { |
||||||
|
// The ID of the app plugin that is currently opened in the sidecar view
|
||||||
|
private _activePluginId: BehaviorSubject<string | undefined>; |
||||||
|
|
||||||
|
constructor() { |
||||||
|
this._activePluginId = new BehaviorSubject<string | undefined>(undefined); |
||||||
|
} |
||||||
|
|
||||||
|
private assertFeatureEnabled() { |
||||||
|
if (!config.featureToggles.appSidecar) { |
||||||
|
console.warn('The `appSidecar` feature toggle is not enabled, doing nothing.'); |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
get activePluginId() { |
||||||
|
return this._activePluginId.asObservable(); |
||||||
|
} |
||||||
|
|
||||||
|
openApp(pluginId: string) { |
||||||
|
if (!this.assertFeatureEnabled()) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
return this._activePluginId.next(pluginId); |
||||||
|
} |
||||||
|
|
||||||
|
closeApp(pluginId: string) { |
||||||
|
if (!this.assertFeatureEnabled()) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
if (this._activePluginId.getValue() === pluginId) { |
||||||
|
return this._activePluginId.next(undefined); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
isAppOpened(pluginId: string) { |
||||||
|
if (!this.assertFeatureEnabled()) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
if (this._activePluginId.getValue() === pluginId || getMainAppPluginId() === pluginId) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const sidecarService = new SidecarService(); |
||||||
|
|
||||||
|
// The app plugin that is "open" in the main Grafana view
|
||||||
|
function getMainAppPluginId() { |
||||||
|
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]; |
||||||
|
if (!mainApp && pathname.match(/\/explore/)) { |
||||||
|
mainApp = 'explore'; |
||||||
|
} |
||||||
|
|
||||||
|
if (!mainApp && pathname.match(/\/d\//)) { |
||||||
|
mainApp = 'dashboards'; |
||||||
|
} |
||||||
|
|
||||||
|
return mainApp || 'unknown'; |
||||||
|
} |
@ -0,0 +1,150 @@ |
|||||||
|
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'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data/'; |
||||||
|
import { HistoryWrapper, locationService, LocationServiceProvider } from '@grafana/runtime'; |
||||||
|
import { GlobalStyles, IconButton, ModalRoot, Stack, useSplitter, useStyles2 } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { AngularRoot } from '../angular/AngularRoot'; |
||||||
|
import { AppChrome } from '../core/components/AppChrome/AppChrome'; |
||||||
|
import { TOP_BAR_LEVEL_HEIGHT } from '../core/components/AppChrome/types'; |
||||||
|
import { AppNotificationList } from '../core/components/AppNotifications/AppNotificationList'; |
||||||
|
import { ModalsContextProvider } from '../core/context/ModalsContextProvider'; |
||||||
|
import { useSidecar } from '../core/context/SidecarContext'; |
||||||
|
import AppRootPage from '../features/plugins/components/AppRootPage'; |
||||||
|
|
||||||
|
type RouterWrapperProps = { |
||||||
|
routes?: JSX.Element | false; |
||||||
|
bodyRenderHooks: ComponentType[]; |
||||||
|
pageBanners: ComponentType[]; |
||||||
|
}; |
||||||
|
export function RouterWrapper(props: RouterWrapperProps) { |
||||||
|
return ( |
||||||
|
<Router history={locationService.getHistory()}> |
||||||
|
<LocationServiceProvider service={locationService}> |
||||||
|
<CompatRouter> |
||||||
|
<ModalsContextProvider> |
||||||
|
<AppChrome> |
||||||
|
<AngularRoot /> |
||||||
|
<AppNotificationList /> |
||||||
|
<Stack gap={0} grow={1} direction="column"> |
||||||
|
{props.pageBanners.map((Banner, index) => ( |
||||||
|
<Banner key={index.toString()} /> |
||||||
|
))} |
||||||
|
{props.routes} |
||||||
|
</Stack> |
||||||
|
{props.bodyRenderHooks.map((Hook, index) => ( |
||||||
|
<Hook key={index.toString()} /> |
||||||
|
))} |
||||||
|
</AppChrome> |
||||||
|
<ModalRoot /> |
||||||
|
</ModalsContextProvider> |
||||||
|
</CompatRouter> |
||||||
|
</LocationServiceProvider> |
||||||
|
</Router> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Renders both the main app tree and a secondary sidecar app tree to show 2 apps at the same time in a resizable split |
||||||
|
* view. |
||||||
|
* @param props |
||||||
|
* @constructor |
||||||
|
*/ |
||||||
|
export function ExperimentalSplitPaneRouterWrapper(props: RouterWrapperProps) { |
||||||
|
const { activePluginId, closeApp } = useSidecar(); |
||||||
|
|
||||||
|
let { containerProps, primaryProps, secondaryProps, splitterProps } = useSplitter({ |
||||||
|
direction: 'row', |
||||||
|
initialSize: 0.6, |
||||||
|
dragPosition: 'end', |
||||||
|
}); |
||||||
|
|
||||||
|
// The style changes allow the resizing to be more flexible and not constrained by the content dimensions. In the
|
||||||
|
// future this could be a switch in the useSplitter but for now it's here until this feature is more final.
|
||||||
|
function alterStyles<T extends { style: React.CSSProperties }>(props: T): T { |
||||||
|
return { |
||||||
|
...props, |
||||||
|
style: { ...props.style, overflow: 'auto', minWidth: 'unset', minHeight: 'unset' }, |
||||||
|
}; |
||||||
|
} |
||||||
|
primaryProps = alterStyles(primaryProps); |
||||||
|
secondaryProps = alterStyles(secondaryProps); |
||||||
|
|
||||||
|
// TODO: this should be used to calculate the height of the header but right now results in a error loop when
|
||||||
|
// navigating to explore "Cannot destructure property 'range' of 'itemState' as it is undefined."
|
||||||
|
// const headerHeight = useChromeHeaderHeight();
|
||||||
|
const styles = useStyles2(getStyles, TOP_BAR_LEVEL_HEIGHT * 2); |
||||||
|
const memoryLocationService = new HistoryWrapper(H.createMemoryHistory({ initialEntries: ['/'] })); |
||||||
|
|
||||||
|
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
|
||||||
|
// wrapper at all but the split wrapper needs to have these wrappers to attach its container props to. At the same
|
||||||
|
// 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 })}> |
||||||
|
<RouterWrapper {...props} /> |
||||||
|
</div> |
||||||
|
{/* Sidecar */} |
||||||
|
{activePluginId && ( |
||||||
|
<> |
||||||
|
<div {...splitterProps} /> |
||||||
|
<div {...secondaryProps}> |
||||||
|
<Router history={memoryLocationService.getHistory()}> |
||||||
|
<LocationServiceProvider service={memoryLocationService}> |
||||||
|
<CompatRouter> |
||||||
|
<GlobalStyles /> |
||||||
|
<div className={styles.secondAppWrapper}> |
||||||
|
<div className={styles.secondAppToolbar}> |
||||||
|
<IconButton |
||||||
|
size={'lg'} |
||||||
|
style={{ margin: '8px' }} |
||||||
|
name={'times'} |
||||||
|
aria-label={'close'} |
||||||
|
onClick={() => closeApp(activePluginId)} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<AppRootPage pluginId={activePluginId} /> |
||||||
|
</div> |
||||||
|
</CompatRouter> |
||||||
|
</LocationServiceProvider> |
||||||
|
</Router> |
||||||
|
</div> |
||||||
|
</> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2, headerHeight: number | undefined) => { |
||||||
|
return { |
||||||
|
secondAppWrapper: css({ |
||||||
|
label: 'secondAppWrapper', |
||||||
|
display: 'flex', |
||||||
|
height: '100%', |
||||||
|
paddingTop: headerHeight || 0, |
||||||
|
flexGrow: 1, |
||||||
|
flexDirection: 'column', |
||||||
|
}), |
||||||
|
|
||||||
|
secondAppToolbar: css({ |
||||||
|
label: 'secondAppToolbar', |
||||||
|
display: 'flex', |
||||||
|
justifyContent: 'flex-end', |
||||||
|
}), |
||||||
|
|
||||||
|
// This is basically the same as grafana-app class. This means the 2 additional wrapper divs that are in between
|
||||||
|
// grafana-app div and the main app component don't actually change anything in the layout.
|
||||||
|
dummyWrapper: css({ |
||||||
|
label: 'dummyWrapper', |
||||||
|
display: 'flex', |
||||||
|
height: '100vh', |
||||||
|
flexDirection: 'column', |
||||||
|
}), |
||||||
|
}; |
||||||
|
}; |
Loading…
Reference in new issue