mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
PublicDashboards: A unique page for public dashboards (#60744)
Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
parent
d19d8c6625
commit
2505f112f5
@ -58,6 +58,7 @@ export const PageToolbar: FC<Props> = React.memo(
|
||||
styles.toolbar,
|
||||
{
|
||||
['page-toolbar--fullscreen']: isFullscreen,
|
||||
[styles.noPageIcon]: !pageIcon,
|
||||
},
|
||||
className
|
||||
);
|
||||
@ -160,6 +161,15 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
gap: ${theme.spacing(2)};
|
||||
justify-content: space-between;
|
||||
padding: ${theme.spacing(1.5, 2)};
|
||||
|
||||
${theme.breakpoints.down('md')} {
|
||||
padding-left: 53px;
|
||||
}
|
||||
`,
|
||||
noPageIcon: css`
|
||||
${theme.breakpoints.down('md')} {
|
||||
padding-left: ${theme.spacing(2)};
|
||||
}
|
||||
`,
|
||||
leftWrapper: css`
|
||||
display: flex;
|
||||
|
@ -44,6 +44,7 @@ func (api *Api) ViewPublicDashboard(c *models.ReqContext) response.Response {
|
||||
PublicDashboardAccessToken: pubdash.AccessToken,
|
||||
PublicDashboardUID: pubdash.Uid,
|
||||
}
|
||||
dash.Data.Get("timepicker").Set("hidden", !pubdash.TimeSelectionEnabled)
|
||||
|
||||
dto := dtos.DashboardFullWithMeta{Meta: meta, Dashboard: dash.Data}
|
||||
|
||||
|
@ -20,7 +20,7 @@ export function AppChrome({ children }: Props) {
|
||||
const { chrome } = useGrafana();
|
||||
const state = chrome.useState();
|
||||
|
||||
if (!config.featureToggles.topnav || config.isPublicDashboardView) {
|
||||
if (!config.featureToggles.topnav) {
|
||||
return <main className="main-view">{children}</main>;
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2, colorManipulator } from '@grafana/data';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
export interface PublicDashboardFooterCfg {
|
||||
@ -38,14 +38,10 @@ export let getPublicDashboardFooterConfig = (): PublicDashboardFooterCfg => ({
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
footer: css`
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
height: 30px;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background-color: ${colorManipulator.alpha(theme.colors.background.canvas, 0.7)};
|
||||
text-align: right;
|
||||
font-size: ${theme.typography.body.fontSize};
|
||||
z-index: ${theme.zIndex.navbarFixed};
|
||||
padding: ${theme.spacing(0, 1, 0, 1)};
|
||||
`,
|
||||
logoText: css`
|
||||
margin-right: ${theme.spacing(1)};
|
||||
|
@ -320,20 +320,4 @@ describe('DashboardPage', () => {
|
||||
expect(screen.queryAllByLabelText(selectors.pages.Dashboard.SubMenu.submenu)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
dashboardPageScenario('When dashboard is public', (ctx) => {
|
||||
ctx.setup(() => {
|
||||
locationService.partial({ kiosk: false });
|
||||
ctx.mount({
|
||||
queryParams: {},
|
||||
dashboard: getTestDashboard(),
|
||||
});
|
||||
ctx.rerender({ dashboard: ctx.dashboard, isPublic: true });
|
||||
});
|
||||
|
||||
it('should not render page toolbar and submenu', () => {
|
||||
expect(screen.queryAllByTestId(selectors.pages.Dashboard.DashNav.navV2)).toHaveLength(0);
|
||||
expect(screen.queryAllByLabelText(selectors.pages.Dashboard.SubMenu.submenu)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -28,7 +28,6 @@ import { DashboardPrompt } from '../components/DashboardPrompt/DashboardPrompt';
|
||||
import { DashboardSettings } from '../components/DashboardSettings';
|
||||
import { PanelInspector } from '../components/Inspector/PanelInspector';
|
||||
import { PanelEditor } from '../components/PanelEditor/PanelEditor';
|
||||
import { PublicDashboardFooter } from '../components/PublicDashboardFooter/PublicDashboardsFooter';
|
||||
import { SubMenu } from '../components/SubMenu/SubMenu';
|
||||
import { DashboardGrid } from '../dashgrid/DashboardGrid';
|
||||
import { liveTimer } from '../dashgrid/liveTimer';
|
||||
@ -75,12 +74,7 @@ const mapDispatchToProps = {
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
type OwnProps = {
|
||||
isPublic?: boolean;
|
||||
};
|
||||
|
||||
export type Props = OwnProps &
|
||||
Themeable2 &
|
||||
export type Props = Themeable2 &
|
||||
GrafanaRouteComponentProps<DashboardPageRouteParams, DashboardPageRouteSearchParams> &
|
||||
ConnectedProps<typeof connector>;
|
||||
|
||||
@ -129,7 +123,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
initDashboard() {
|
||||
const { dashboard, isPublic, match, queryParams } = this.props;
|
||||
const { dashboard, match, queryParams } = this.props;
|
||||
|
||||
if (dashboard) {
|
||||
this.closeDashboard();
|
||||
@ -142,7 +136,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
|
||||
urlFolderUid: queryParams.folderUid,
|
||||
panelType: queryParams.panelType,
|
||||
routeName: this.props.route.routeName,
|
||||
fixUrl: !isPublic,
|
||||
fixUrl: true,
|
||||
accessToken: match.params.accessToken,
|
||||
keybindingSrv: this.context.keybindings,
|
||||
});
|
||||
@ -341,9 +335,9 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dashboard, initError, queryParams, isPublic } = this.props;
|
||||
const { dashboard, initError, queryParams } = this.props;
|
||||
const { editPanel, viewPanel, updateScrollTop, pageNav, sectionNav } = this.state;
|
||||
const kioskMode = !isPublic ? getKioskMode(this.props.queryParams) : KioskMode.Full;
|
||||
const kioskMode = getKioskMode(this.props.queryParams);
|
||||
|
||||
if (!dashboard || !pageNav || !sectionNav) {
|
||||
return <DashboardLoading initPhase={this.props.initPhase} />;
|
||||
@ -418,10 +412,6 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
|
||||
sectionNav={sectionNav}
|
||||
/>
|
||||
)}
|
||||
{
|
||||
// TODO: assess if there are other places where we may want a footer, which may reveal a better place to add this
|
||||
isPublic && <PublicDashboardFooter />
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,207 @@
|
||||
import { render, RenderResult, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { useEffectOnce } from 'react-use';
|
||||
import { AutoSizerProps } from 'react-virtualized-auto-sizer';
|
||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { Dashboard, DashboardCursorSync } from '@grafana/schema/src';
|
||||
import { GrafanaContext } from 'app/core/context/GrafanaContext';
|
||||
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
|
||||
import { DashboardInitPhase, DashboardMeta, DashboardRoutes } from 'app/types';
|
||||
import * as appTypes from 'app/types';
|
||||
|
||||
import { SafeDynamicImport } from '../../../core/components/DynamicImports/SafeDynamicImport';
|
||||
import { configureStore } from '../../../store/configureStore';
|
||||
import { Props as LazyLoaderProps } from '../dashgrid/LazyLoader';
|
||||
import { DashboardModel } from '../state';
|
||||
import { initDashboard } from '../state/initDashboard';
|
||||
|
||||
import PublicDashboardPage, { Props } from './PublicDashboardPage';
|
||||
|
||||
jest.mock('app/features/dashboard/dashgrid/LazyLoader', () => {
|
||||
const LazyLoader = ({ children, onLoad }: Pick<LazyLoaderProps, 'children' | 'onLoad'>) => {
|
||||
useEffectOnce(() => {
|
||||
onLoad?.();
|
||||
});
|
||||
return <>{typeof children === 'function' ? children({ isInView: true }) : children}</>;
|
||||
};
|
||||
return { LazyLoader };
|
||||
});
|
||||
|
||||
jest.mock('react-virtualized-auto-sizer', () => {
|
||||
// // // The size of the children need to be small enough to be outside the view.
|
||||
// // // So it does not trigger the query to be run by the PanelQueryRunner.
|
||||
return ({ children }: AutoSizerProps) => children({ height: 1, width: 1 });
|
||||
});
|
||||
|
||||
jest.mock('app/features/dashboard/state/initDashboard', () => ({
|
||||
...jest.requireActual('app/features/dashboard/state/initDashboard'),
|
||||
initDashboard: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('app/types', () => ({
|
||||
...jest.requireActual('app/types'),
|
||||
useDispatch: () => jest.fn(),
|
||||
}));
|
||||
|
||||
const renderWithProvider = ({
|
||||
props,
|
||||
initialState,
|
||||
}: {
|
||||
props: Props;
|
||||
initialState?: Partial<appTypes.StoreState>;
|
||||
}): RenderResult => {
|
||||
const context = getGrafanaContextMock();
|
||||
const store = configureStore(initialState);
|
||||
|
||||
return render(
|
||||
<GrafanaContext.Provider value={context}>
|
||||
<Provider store={store}>
|
||||
<Router history={locationService.getHistory()}>
|
||||
<PublicDashboardPage {...props} />
|
||||
</Router>
|
||||
</Provider>
|
||||
</GrafanaContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
interface ScenarioContext {
|
||||
mount: () => void;
|
||||
rerender: ({
|
||||
propOverrides,
|
||||
newState,
|
||||
}: {
|
||||
propOverrides?: Partial<Props>;
|
||||
newState?: Partial<appTypes.StoreState>;
|
||||
}) => void;
|
||||
setup: (fn: () => void) => void;
|
||||
}
|
||||
|
||||
const getTestDashboard = (overrides?: Partial<Dashboard>, metaOverrides?: Partial<DashboardMeta>): DashboardModel => {
|
||||
const data: Dashboard = Object.assign(
|
||||
{
|
||||
title: 'My dashboard',
|
||||
revision: 1,
|
||||
editable: false,
|
||||
graphTooltip: DashboardCursorSync.Off,
|
||||
schemaVersion: 1,
|
||||
style: 'dark',
|
||||
panels: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'timeseries',
|
||||
title: 'My panel title',
|
||||
gridPos: { x: 0, y: 0, w: 1, h: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides
|
||||
);
|
||||
|
||||
return new DashboardModel(data, metaOverrides);
|
||||
};
|
||||
|
||||
function dashboardPageScenario(description: string, scenarioFn: (ctx: ScenarioContext) => void) {
|
||||
describe(description, () => {
|
||||
let setupFn: () => void;
|
||||
|
||||
const ctx: ScenarioContext = {
|
||||
setup: (fn) => {
|
||||
setupFn = fn;
|
||||
},
|
||||
mount: () => {
|
||||
const props: Props = {
|
||||
...getRouteComponentProps({
|
||||
match: { params: { accessToken: 'an-access-token' }, isExact: true, url: '', path: '' },
|
||||
route: {
|
||||
routeName: DashboardRoutes.Public,
|
||||
path: '/public-dashboards/:accessToken',
|
||||
component: SafeDynamicImport(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "PublicDashboardPage"*/ 'app/features/dashboard/containers/PublicDashboardPage'
|
||||
)
|
||||
),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const { rerender } = renderWithProvider({ props });
|
||||
|
||||
ctx.rerender = ({
|
||||
propsOverride,
|
||||
newState,
|
||||
}: {
|
||||
propsOverride?: Partial<Props>;
|
||||
newState?: Partial<appTypes.StoreState>;
|
||||
}) => {
|
||||
Object.assign(props, propsOverride);
|
||||
|
||||
const context = getGrafanaContextMock();
|
||||
const store = configureStore(newState);
|
||||
|
||||
rerender(
|
||||
<GrafanaContext.Provider value={context}>
|
||||
<Provider store={store}>
|
||||
<Router history={locationService.getHistory()}>
|
||||
<PublicDashboardPage {...props} />
|
||||
</Router>
|
||||
</Provider>
|
||||
</GrafanaContext.Provider>
|
||||
);
|
||||
};
|
||||
},
|
||||
rerender: () => {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setupFn();
|
||||
});
|
||||
|
||||
scenarioFn(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
describe('PublicDashboardPage', () => {
|
||||
dashboardPageScenario('Given initial state', (ctx) => {
|
||||
ctx.setup(() => {
|
||||
ctx.mount();
|
||||
});
|
||||
|
||||
it('Should call initDashboard on mount', () => {
|
||||
expect(initDashboard).toBeCalledWith({
|
||||
fixUrl: false,
|
||||
accessToken: 'an-access-token',
|
||||
routeName: 'public-dashboard',
|
||||
keybindingSrv: expect.anything(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
dashboardPageScenario('Given a simple dashboard', (ctx) => {
|
||||
ctx.setup(() => {
|
||||
ctx.mount();
|
||||
ctx.rerender({
|
||||
newState: {
|
||||
dashboard: {
|
||||
getModel: getTestDashboard,
|
||||
initError: null,
|
||||
initPhase: DashboardInitPhase.Completed,
|
||||
permissions: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('Should render panels', () => {
|
||||
expect(screen.getByText('My panel title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should update title', () => {
|
||||
expect(document.title).toBe('My dashboard - Grafana');
|
||||
});
|
||||
});
|
||||
});
|
@ -1,13 +1,112 @@
|
||||
import React from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useEffect } from 'react';
|
||||
import { usePrevious } from 'react-use';
|
||||
|
||||
import { GrafanaRouteComponentProps } from '../../../core/navigation/types';
|
||||
import { GrafanaTheme2, PageLayoutType, TimeZone } from '@grafana/data';
|
||||
import { PageToolbar, useStyles2 } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
import { updateTimeZoneForSession } from 'app/features/profile/state/reducers';
|
||||
import { useSelector, useDispatch } from 'app/types';
|
||||
|
||||
import DashboardPage, { DashboardPageRouteParams, DashboardPageRouteSearchParams } from './DashboardPage';
|
||||
import { DashNavTimeControls } from '../components/DashNav/DashNavTimeControls';
|
||||
import { DashboardFailed } from '../components/DashboardLoading/DashboardFailed';
|
||||
import { DashboardLoading } from '../components/DashboardLoading/DashboardLoading';
|
||||
import { PublicDashboardFooter } from '../components/PublicDashboardFooter/PublicDashboardsFooter';
|
||||
import { DashboardGrid } from '../dashgrid/DashboardGrid';
|
||||
import { getTimeSrv } from '../services/TimeSrv';
|
||||
import { DashboardModel } from '../state';
|
||||
import { initDashboard } from '../state/initDashboard';
|
||||
|
||||
export type Props = GrafanaRouteComponentProps<DashboardPageRouteParams, DashboardPageRouteSearchParams>;
|
||||
interface PublicDashboardPageRouteParams {
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
const PublicDashboardPage = (props: Props) => {
|
||||
return <DashboardPage isPublic {...props} />;
|
||||
interface PublicDashboardPageRouteSearchParams {
|
||||
from?: string;
|
||||
to?: string;
|
||||
refresh?: string;
|
||||
}
|
||||
|
||||
export type Props = GrafanaRouteComponentProps<PublicDashboardPageRouteParams, PublicDashboardPageRouteSearchParams>;
|
||||
|
||||
const Toolbar = ({ dashboard }: { dashboard: DashboardModel }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onChangeTimeZone = (timeZone: TimeZone) => {
|
||||
dispatch(updateTimeZoneForSession(timeZone));
|
||||
};
|
||||
|
||||
return (
|
||||
<PageToolbar title={dashboard.title} buttonOverflowAlignment="right">
|
||||
{!dashboard.timepicker.hidden && (
|
||||
<DashNavTimeControls dashboard={dashboard} onChangeTimeZone={onChangeTimeZone} />
|
||||
)}
|
||||
</PageToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
const PublicDashboardPage = (props: Props) => {
|
||||
const { match, route, location } = props;
|
||||
const dispatch = useDispatch();
|
||||
const context = useGrafana();
|
||||
const prevProps = usePrevious(props);
|
||||
const styles = useStyles2(getStyles);
|
||||
const dashboardState = useSelector((store) => store.dashboard);
|
||||
const dashboard = dashboardState.getModel();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(
|
||||
initDashboard({
|
||||
routeName: route.routeName,
|
||||
fixUrl: false,
|
||||
accessToken: match.params.accessToken,
|
||||
keybindingSrv: context.keybindings,
|
||||
})
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevProps?.location.search !== location.search) {
|
||||
const prevUrlParams = prevProps?.queryParams;
|
||||
const urlParams = props.queryParams;
|
||||
|
||||
if (urlParams?.from !== prevUrlParams?.from || urlParams?.to !== prevUrlParams?.to) {
|
||||
getTimeSrv().updateTimeRangeFromUrl();
|
||||
}
|
||||
|
||||
if (!prevUrlParams?.refresh && urlParams?.refresh) {
|
||||
getTimeSrv().setAutoRefresh(urlParams.refresh);
|
||||
}
|
||||
}
|
||||
}, [prevProps, location.search, props.queryParams]);
|
||||
|
||||
if (!dashboard) {
|
||||
return <DashboardLoading initPhase={dashboardState.initPhase} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page
|
||||
pageNav={{ text: dashboard.title }}
|
||||
layout={PageLayoutType.Custom}
|
||||
toolbar={<Toolbar dashboard={dashboard} />}
|
||||
>
|
||||
{dashboardState.initError && <DashboardFailed />}
|
||||
<div className={styles.gridContainer}>
|
||||
<DashboardGrid dashboard={dashboard} isEditable={false} viewPanel={null} editPanel={null} />
|
||||
</div>
|
||||
<PublicDashboardFooter />
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
gridContainer: css({
|
||||
padding: theme.spacing(0, 2, 2, 2),
|
||||
overflow: 'auto',
|
||||
}),
|
||||
});
|
||||
|
||||
export default PublicDashboardPage;
|
||||
|
@ -21,6 +21,7 @@ export const getPublicDashboardRoutes = (): RouteDescriptor[] => {
|
||||
path: '/public-dashboards/:accessToken',
|
||||
pageClass: 'page-dashboard',
|
||||
routeName: DashboardRoutes.Public,
|
||||
chromeless: true,
|
||||
component: SafeDynamicImport(
|
||||
() =>
|
||||
import(
|
||||
|
@ -57,8 +57,6 @@
|
||||
|
||||
@include media-breakpoint-down(sm) {
|
||||
nav.page-toolbar {
|
||||
padding-left: 53px;
|
||||
|
||||
&--fullscreen {
|
||||
padding-left: $space-md;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user