Compare commits

...

1 Commits

Author SHA1 Message Date
nmarrs
13eb993304 feat(dashboard-scene): show Powered by footer in kiosk mode
Reuse the public dashboard footer branding in scene dashboards when kiosk mode is enabled, with an opt-out via hideLogo.
2025-12-12 00:47:40 -08:00
5 changed files with 140 additions and 14 deletions

View File

@@ -1840,14 +1840,6 @@
"count": 1
}
},
"public/app/features/dashboard-scene/pages/DashboardScenePage.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 2
},
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1

View File

@@ -174,6 +174,56 @@ describe('DashboardScenePage', () => {
expect(await screen.findByText('Content B')).toBeInTheDocument();
});
it('shows Powered by footer in kiosk mode', async () => {
setup({ routeProps: { queryParams: { kiosk: true } } });
await waitForDashboardToRender();
expect(await screen.findByTestId(selectors.pages.PublicDashboard.footer)).toBeInTheDocument();
});
it('uses kiosk dashboard CTA url', async () => {
setup({ routeProps: { queryParams: { kiosk: true } } });
await waitForDashboardToRender();
const footer = await screen.findByTestId(selectors.pages.PublicDashboard.footer);
const link = footer.querySelector('a');
expect(link).toHaveAttribute('href', 'https://grafana.com/?src=grafananet&cnt=kiosk-dashboard');
});
it('hides Powered by footer in kiosk mode when hideLogo is present', async () => {
setup({ routeProps: { queryParams: { kiosk: true, hideLogo: true } } });
await waitForDashboardToRender();
expect(screen.queryByTestId(selectors.pages.PublicDashboard.footer)).not.toBeInTheDocument();
});
it('hides Powered by footer in kiosk mode when hideLogo=1', async () => {
setup({ routeProps: { queryParams: { kiosk: true, hideLogo: '1' } } });
await waitForDashboardToRender();
expect(screen.queryByTestId(selectors.pages.PublicDashboard.footer)).not.toBeInTheDocument();
});
it('shows Powered by footer in kiosk mode when hideLogo=0', async () => {
setup({ routeProps: { queryParams: { kiosk: true, hideLogo: '0' } } });
await waitForDashboardToRender();
expect(await screen.findByTestId(selectors.pages.PublicDashboard.footer)).toBeInTheDocument();
});
it('shows Powered by footer in kiosk mode when hideLogo=false', async () => {
setup({ routeProps: { queryParams: { kiosk: true, hideLogo: 'false' } } });
await waitForDashboardToRender();
expect(await screen.findByTestId(selectors.pages.PublicDashboard.footer)).toBeInTheDocument();
});
it('routeReloadCounter should trigger reload', async () => {
const { rerender, props } = setup();

View File

@@ -9,6 +9,7 @@ import { Box } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { PublicDashboardFooter } from 'app/features/dashboard/components/PublicDashboard/PublicDashboardsFooter';
import { DashboardPageError } from 'app/features/dashboard/containers/DashboardPageError';
import { DashboardPageRouteParams, DashboardPageRouteSearchParams } from 'app/features/dashboard/containers/types';
import { getDashboardSceneProfiler } from 'app/features/dashboard/services/DashboardProfiler';
@@ -21,6 +22,25 @@ import { preserveDashboardSceneStateInLocalStorage } from '../utils/dashboardSes
import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager';
const KIOSK_DASHBOARD_FOOTER_URL = 'https://grafana.com/?src=grafananet&cnt=kiosk-dashboard';
function shouldHideDashboardKioskFooter(hideLogo?: string | true): boolean {
if (hideLogo === undefined || hideLogo === null) {
return false;
}
if (hideLogo === true || hideLogo === '1') {
return true;
}
const normalized = String(hideLogo).trim().toLowerCase();
if (normalized === '') {
return true;
}
return normalized !== 'false' && normalized !== '0';
}
export interface Props
extends Omit<GrafanaRouteComponentProps<DashboardPageRouteParams, DashboardPageRouteSearchParams>, 'match'> {}
@@ -33,7 +53,32 @@ export function DashboardScenePage({ route, queryParams, location }: Props) {
const stateManager = getDashboardScenePageStateManager();
const { dashboard, isLoading, loadError } = stateManager.useState();
// After scene migration is complete and we get rid of old dashboard we should refactor dashboardWatcher so this route reload is not need
const routeReloadCounter = (location.state as any)?.routeReloadCounter;
const routeReloadCounter = (() => {
const state = location.state;
if (state && typeof state === 'object') {
const value = Reflect.get(state, 'routeReloadCounter');
return typeof value === 'number' ? value : undefined;
}
return undefined;
})();
const dashboardRoute = (() => {
switch (route.routeName) {
case DashboardRoutes.Home:
case DashboardRoutes.New:
case DashboardRoutes.Template:
case DashboardRoutes.Normal:
case DashboardRoutes.Provisioning:
case DashboardRoutes.Scripted:
case DashboardRoutes.Public:
case DashboardRoutes.Embedded:
case DashboardRoutes.Report:
return route.routeName;
default:
return DashboardRoutes.Normal;
}
})();
const prevParams = useRef<Params<string>>(params);
useEffect(() => {
@@ -44,7 +89,7 @@ export function DashboardScenePage({ route, queryParams, location }: Props) {
uid: (route.routeName === DashboardRoutes.Provisioning ? path : uid) ?? '',
type,
slug,
route: route.routeName as DashboardRoutes,
route: dashboardRoute,
urlFolderUid: queryParams.folderUid,
});
}
@@ -106,12 +151,18 @@ export function DashboardScenePage({ route, queryParams, location }: Props) {
return null;
}
const showKioskFooter = queryParams.kiosk === '1' || queryParams.kiosk === true;
const hideKioskFooter = shouldHideDashboardKioskFooter(queryParams.hideLogo);
return (
<UrlSyncContextProvider scene={dashboard} updateUrlOnInit={true} createBrowserHistorySteps={true}>
<DashboardPreviewBanner queryParams={queryParams} route={route.routeName} slug={slug} path={path} />
<DashboardConversionWarningBanner dashboard={dashboard} />
<dashboard.Component model={dashboard} key={dashboard.state.key} />
<DashboardPrompt dashboard={dashboard} />
{showKioskFooter && !hideKioskFooter && (
<PublicDashboardFooter paddingX={2} useMinHeight linkUrl={KIOSK_DASHBOARD_FOOTER_URL} />
)}
</UrlSyncContextProvider>
);
}

View File

@@ -1,20 +1,52 @@
import { css } from '@emotion/css';
import type { CSSProperties } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { useStyles2 } from '@grafana/ui';
import { useStyles2, useTheme2 } from '@grafana/ui';
import { useGetPublicDashboardConfig } from './usePublicDashboardConfig';
const selectors = e2eSelectors.pages.PublicDashboard;
export const PublicDashboardFooter = function () {
export interface PublicDashboardFooterProps {
/**
* Applies horizontal padding to the footer container.
* Useful when rendering the footer in layouts that don't already have page padding (e.g. kiosk mode).
*/
paddingX?: number;
/**
* When true, avoids clipping in containers with `overflow: hidden` by not relying on a fixed height.
*/
useMinHeight?: boolean;
/**
* Overrides the CTA link URL.
* Useful when reusing the footer outside public dashboards.
*/
linkUrl?: string;
}
export const PublicDashboardFooter = function ({ paddingX, useMinHeight, linkUrl }: PublicDashboardFooterProps) {
const styles = useStyles2(getStyles);
const theme = useTheme2();
const conf = useGetPublicDashboardConfig();
const footerStyle: CSSProperties = {};
if (paddingX !== undefined) {
footerStyle.boxSizing = 'border-box';
footerStyle.paddingLeft = theme.spacing(paddingX);
footerStyle.paddingRight = theme.spacing(paddingX);
}
if (useMinHeight) {
footerStyle.height = 'auto';
footerStyle.minHeight = '30px';
footerStyle.alignItems = 'center';
}
return conf.footerHide ? null : (
<div className={styles.footer} data-testid={selectors.footer}>
<a className={styles.link} href={conf.footerLink} target="_blank" rel="noreferrer noopener">
<div className={styles.footer} data-testid={selectors.footer} style={footerStyle}>
<a className={styles.link} href={linkUrl ?? conf.footerLink} target="_blank" rel="noreferrer noopener">
{conf.footerText} <img className={styles.logoImg} alt="" src={conf.footerLogo} />
</a>
</div>

View File

@@ -18,6 +18,7 @@ export type DashboardPageRouteSearchParams = {
to?: string;
refresh?: string;
kiosk?: string | true;
hideLogo?: string | true;
scenes?: boolean;
shareView?: string;
ref?: string; // used for repo preview