Embedded Dashboard Panels: Add Grafana Branding (#115198)
* feat: add Grafana logo to embedded panels - Add Grafana logo watermark to solo panel view (embedded panels) - Logo appears in top-right corner with subtle background container - Logo hides on hover to avoid interfering with panel content - Uses React state to track hover for reliable behavior across nested elements * minor formatting * update changes to match public dashboards styling * match styles of public dashboards * feat: add responsive Grafana branding to embedded panels - Add 'Powered by Grafana' branding with text logo to solo panel view - Implement responsive scaling based on panel dimensions (0.6x to 1.0x) - Logo and text scale proportionally with panel size - Branding hides on hover to avoid interfering with panel content - Matches public dashboard branding pattern for consistency - Uses ResizeObserver for efficient responsive updates * feat: add Grafana branding to embedded solo panels - Add 'Powered by Grafana' branding with text logo to embedded panels - Create SoloPanelPageLogo component for reusable branding - Implement responsive scaling based on panel dimensions - Add hover-to-hide functionality to avoid content overlap - Logo scales between 0.6x and 1.0x based on panel size * refactor: move scale calculation into SoloPanelPageLogo component - Move responsive scale calculation logic from SoloPanelRenderer to SoloPanelPageLogo - Logo component now manages its own scaling based on container dimensions - Improves separation of concerns and component encapsulation * feat: add hideLogo query parameter to disable embedded panel branding - Add hideLogo query parameter support to SoloPanelPage - Logo can be hidden via ?hideLogo, ?hideLogo=true, or ?hideLogo=1 - Useful for customers who want to disable branding and for image rendering scenarios - Update Props interface to include hideLogo in queryParams type * feat: hide logo in panel image renderer URLs - Add hideLogo=true parameter to image renderer URLs in ShareLinkTab - Ensures logo is hidden when generating panel images through share feature - Update test to expect hideLogo=true in render URL * feat: hide logo in old dashboard sharing panel image URLs - Add hideLogo=true parameter to buildImageUrl in ShareModal utils - Ensures logo is hidden when generating panel images through old share modal - Update all ShareLink tests to expect hideLogo=true in render URLs * test: add comprehensive tests for SoloPanelPage and SoloPanelPageLogo - Add SoloPanelPageLogo tests covering rendering, hover behavior, theme selection, and scaling - Add SoloPanelPage tests covering logo visibility based on hideLogo prop - Test logo hiding functionality (most important behavior) - Test responsive scaling based on container dimensions - Test ResizeObserver integration - All 14 tests passing * refactor: centralize hideLogo handling in SoloPanelPageLogo Move hideLogo parsing and decision-making into SoloPanelPageLogo so SoloPanelPage/SoloPanelRenderer only pass through the raw query param value. * chore: clean up solo logo test and share link params Remove a duplicate SVG mock in SoloPanelPageLogo.test, and simplify ShareLinkTab image URL building without changing behavior. * chore: revert ShareLinkTab image query refactor Restore the previous image URL query-param mutation logic in ShareLinkTab to reduce risk. * chore: set hideLogo once for ShareLinkTab image URLs Avoid passing hideLogo twice when building the rendered image URL. * fix: handle boolean hideLogo query param in SoloPanelPageLogo Handle query params that are represented as booleans (e.g., ?hideLogo) and arrays, and avoid calling trim() on non-strings. * fix i18n * fix(dashboard-scene): address SoloPanelPageLogo review feedback Avoid double-scaling logo margin, clarify scaling comments, and extend tests for null/array values and ResizeObserver cleanup. * update margin left on logo to better match text spacing
This commit is contained in:
@@ -89,7 +89,7 @@ describe('ShareLinkTab', () => {
|
|||||||
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
|
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
|
||||||
).toHaveAttribute(
|
).toHaveAttribute(
|
||||||
'href',
|
'href',
|
||||||
'http://dashboards.grafana.com/grafana/render/d-solo/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&panelId=A$panel-12&__feature.dashboardSceneSolo=true&width=1000&height=500&tz=Pacific%2FEaster'
|
'http://dashboards.grafana.com/grafana/render/d-solo/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&panelId=A$panel-12&__feature.dashboardSceneSolo=true&hideLogo=true&width=1000&height=500&tz=Pacific%2FEaster'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ export class ShareLinkTab extends SceneObjectBase<ShareLinkTabState> implements
|
|||||||
imageQueryParams['__feature.dashboardSceneSolo'] = true;
|
imageQueryParams['__feature.dashboardSceneSolo'] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hide Grafana logo in the rendered image
|
||||||
|
urlParamsUpdate.hideLogo = 'true';
|
||||||
|
|
||||||
const imageUrl = getDashboardUrl({
|
const imageUrl = getDashboardUrl({
|
||||||
uid: dashboard.state.uid,
|
uid: dashboard.state.uid,
|
||||||
currentQueryParams: window.location.search,
|
currentQueryParams: window.location.search,
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { useParams } from 'react-router-dom-v5-compat';
|
||||||
|
|
||||||
|
import { SceneTimeRange, VizPanel } from '@grafana/scenes';
|
||||||
|
|
||||||
|
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
|
||||||
|
import { DashboardScene } from '../scene/DashboardScene';
|
||||||
|
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
|
||||||
|
|
||||||
|
import { SoloPanelRenderer } from './SoloPanelPage';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('react-router-dom-v5-compat', () => ({
|
||||||
|
useParams: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../pages/DashboardScenePageStateManager', () => ({
|
||||||
|
getDashboardScenePageStateManager: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../scene/SoloPanelContext', () => ({
|
||||||
|
SoloPanelContextProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
useDefineSoloPanelContext: jest.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('./SoloPanelPageLogo', () => ({
|
||||||
|
shouldHideSoloPanelLogo: (hideLogo?: unknown) => {
|
||||||
|
if (hideLogo === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (hideLogo === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (hideLogo === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (Array.isArray(hideLogo)) {
|
||||||
|
hideLogo = hideLogo[0] ?? '';
|
||||||
|
}
|
||||||
|
const normalized = String(hideLogo).trim().toLowerCase();
|
||||||
|
return normalized !== 'false' && normalized !== '0';
|
||||||
|
},
|
||||||
|
SoloPanelPageLogo: ({ isHovered, hideLogo }: { isHovered: boolean; hideLogo?: unknown }) => {
|
||||||
|
if (hideLogo === true) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (hideLogo === false) {
|
||||||
|
return (
|
||||||
|
<div data-testid="solo-panel-logo" data-hovered={String(isHovered)}>
|
||||||
|
Logo
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Array.isArray(hideLogo)) {
|
||||||
|
hideLogo = hideLogo[0] ?? '';
|
||||||
|
}
|
||||||
|
if (hideLogo !== undefined) {
|
||||||
|
const normalized = String(hideLogo).trim().toLowerCase();
|
||||||
|
if (normalized !== 'false' && normalized !== '0') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div data-testid="solo-panel-logo" data-hovered={String(isHovered)}>
|
||||||
|
Logo
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('SoloPanelPage', () => {
|
||||||
|
const mockStateManager = {
|
||||||
|
useState: jest.fn(() => ({
|
||||||
|
dashboard: null,
|
||||||
|
loadError: null,
|
||||||
|
})),
|
||||||
|
loadDashboard: jest.fn(),
|
||||||
|
clearState: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue(mockStateManager);
|
||||||
|
(useParams as jest.Mock).mockReturnValue({ uid: 'test-uid', type: undefined, slug: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SoloPanelRenderer', () => {
|
||||||
|
const createMockDashboard = () => {
|
||||||
|
const panel = new VizPanel({
|
||||||
|
title: 'Test Panel',
|
||||||
|
pluginId: 'table',
|
||||||
|
key: 'panel-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const dashboard = new DashboardScene({
|
||||||
|
title: 'Test Dashboard',
|
||||||
|
uid: 'test-dash',
|
||||||
|
$timeRange: new SceneTimeRange({}),
|
||||||
|
body: DefaultGridLayoutManager.fromVizPanels([panel]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the activate method
|
||||||
|
dashboard.activate = jest.fn(() => jest.fn());
|
||||||
|
|
||||||
|
// Mock useState to return the dashboard state object with required properties
|
||||||
|
dashboard.useState = jest.fn(() => ({
|
||||||
|
controls: {
|
||||||
|
useState: jest.fn(() => ({
|
||||||
|
refreshPicker: {
|
||||||
|
activate: jest.fn(() => jest.fn()),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
Component: () => <div data-testid="panel-content">Panel Content</div>,
|
||||||
|
},
|
||||||
|
})) as unknown as typeof dashboard.useState;
|
||||||
|
|
||||||
|
return dashboard;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should render the panel', () => {
|
||||||
|
const dashboard = createMockDashboard();
|
||||||
|
render(<SoloPanelRenderer dashboard={dashboard} panelId="panel-1" hideLogo={undefined} />);
|
||||||
|
|
||||||
|
// The panel should be rendered (we can't easily test the actual panel content without more setup)
|
||||||
|
expect(screen.getByTestId('solo-panel-logo')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render logo when hideLogo is false', () => {
|
||||||
|
const dashboard = createMockDashboard();
|
||||||
|
render(<SoloPanelRenderer dashboard={dashboard} panelId="panel-1" hideLogo={undefined} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('solo-panel-logo')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render logo when hideLogo is true', () => {
|
||||||
|
const dashboard = createMockDashboard();
|
||||||
|
render(<SoloPanelRenderer dashboard={dashboard} panelId="panel-1" hideLogo="true" />);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('solo-panel-logo')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with isHovered as false', () => {
|
||||||
|
const dashboard = createMockDashboard();
|
||||||
|
render(<SoloPanelRenderer dashboard={dashboard} panelId="panel-1" hideLogo={undefined} />);
|
||||||
|
|
||||||
|
const logo = screen.getByTestId('solo-panel-logo');
|
||||||
|
expect(logo).toHaveAttribute('data-hovered', 'false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
// Libraries
|
// Libraries
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom-v5-compat';
|
import { useParams } from 'react-router-dom-v5-compat';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2, UrlQueryValue } from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { UrlSyncContextProvider } from '@grafana/scenes';
|
import { UrlSyncContextProvider } from '@grafana/scenes';
|
||||||
import { Alert, Box, useStyles2 } from '@grafana/ui';
|
import { Alert, Box, useStyles2 } from '@grafana/ui';
|
||||||
@@ -17,7 +17,10 @@ import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageSt
|
|||||||
import { DashboardScene } from '../scene/DashboardScene';
|
import { DashboardScene } from '../scene/DashboardScene';
|
||||||
import { SoloPanelContextProvider, useDefineSoloPanelContext } from '../scene/SoloPanelContext';
|
import { SoloPanelContextProvider, useDefineSoloPanelContext } from '../scene/SoloPanelContext';
|
||||||
|
|
||||||
export interface Props extends GrafanaRouteComponentProps<DashboardPageRouteParams, { panelId: string }> {}
|
import { SoloPanelPageLogo } from './SoloPanelPageLogo';
|
||||||
|
|
||||||
|
export interface Props
|
||||||
|
extends GrafanaRouteComponentProps<DashboardPageRouteParams, { panelId: string; hideLogo?: UrlQueryValue }> {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used for iframe embedding and image rendering of single panels
|
* Used for iframe embedding and image rendering of single panels
|
||||||
@@ -52,18 +55,28 @@ export function SoloPanelPage({ queryParams }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<UrlSyncContextProvider scene={dashboard}>
|
<UrlSyncContextProvider scene={dashboard}>
|
||||||
<SoloPanelRenderer dashboard={dashboard} panelId={queryParams.panelId} />
|
<SoloPanelRenderer dashboard={dashboard} panelId={queryParams.panelId} hideLogo={queryParams.hideLogo} />
|
||||||
</UrlSyncContextProvider>
|
</UrlSyncContextProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SoloPanelPage;
|
export default SoloPanelPage;
|
||||||
|
|
||||||
export function SoloPanelRenderer({ dashboard, panelId }: { dashboard: DashboardScene; panelId: string }) {
|
export function SoloPanelRenderer({
|
||||||
|
dashboard,
|
||||||
|
panelId,
|
||||||
|
hideLogo,
|
||||||
|
}: {
|
||||||
|
dashboard: DashboardScene;
|
||||||
|
panelId: string;
|
||||||
|
hideLogo?: UrlQueryValue;
|
||||||
|
}) {
|
||||||
const { controls, body } = dashboard.useState();
|
const { controls, body } = dashboard.useState();
|
||||||
const refreshPicker = controls?.useState()?.refreshPicker;
|
const refreshPicker = controls?.useState()?.refreshPicker;
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const soloPanelContext = useDefineSoloPanelContext(panelId)!;
|
const soloPanelContext = useDefineSoloPanelContext(panelId)!;
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dashDeactivate = dashboard.activate();
|
const dashDeactivate = dashboard.activate();
|
||||||
@@ -76,11 +89,19 @@ export function SoloPanelRenderer({ dashboard, panelId }: { dashboard: Dashboard
|
|||||||
}, [dashboard, refreshPicker]);
|
}, [dashboard, refreshPicker]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={styles.container}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<SoloPanelPageLogo containerRef={containerRef} isHovered={isHovered} hideLogo={hideLogo} />
|
||||||
{renderHiddenVariables(dashboard)}
|
{renderHiddenVariables(dashboard)}
|
||||||
<SoloPanelContextProvider value={soloPanelContext} dashboard={dashboard} singleMatch={true}>
|
<div className={styles.panelWrapper}>
|
||||||
<body.Component model={body} />
|
<SoloPanelContextProvider value={soloPanelContext} dashboard={dashboard} singleMatch={true}>
|
||||||
</SoloPanelContextProvider>
|
<body.Component model={body} />
|
||||||
|
</SoloPanelContextProvider>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -107,15 +128,23 @@ function renderHiddenVariables(dashboard: DashboardScene) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => ({
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
container: css({
|
const panelWrapper = css({
|
||||||
position: 'fixed',
|
|
||||||
bottom: 0,
|
|
||||||
right: 0,
|
|
||||||
margin: 0,
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
}),
|
});
|
||||||
});
|
|
||||||
|
return {
|
||||||
|
container: css({
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
margin: 0,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
}),
|
||||||
|
panelWrapper,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { createRef } from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
|
||||||
|
import { shouldHideSoloPanelLogo, SoloPanelPageLogo } from './SoloPanelPageLogo';
|
||||||
|
|
||||||
|
// Mock the theme hook
|
||||||
|
const mockUseTheme2 = jest.fn();
|
||||||
|
const mockUseStyles2 = jest.fn((fn) => fn({} as GrafanaTheme2));
|
||||||
|
|
||||||
|
jest.mock('@grafana/ui', () => ({
|
||||||
|
...jest.requireActual('@grafana/ui'),
|
||||||
|
useTheme2: () => mockUseTheme2(),
|
||||||
|
useStyles2: (fn: (theme: GrafanaTheme2) => Record<string, unknown>) => mockUseStyles2(fn),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the logo images for dark and light modes
|
||||||
|
jest.mock('img/grafana_text_logo_dark.svg', () => 'grafana-text-logo-dark.svg');
|
||||||
|
jest.mock('img/grafana_text_logo_light.svg', () => 'grafana-text-logo-light.svg');
|
||||||
|
|
||||||
|
// Mock ResizeObserver
|
||||||
|
global.ResizeObserver = jest.fn().mockImplementation((callback) => {
|
||||||
|
return {
|
||||||
|
observe: jest.fn(),
|
||||||
|
unobserve: jest.fn(),
|
||||||
|
disconnect: jest.fn(),
|
||||||
|
// Helper to trigger resize
|
||||||
|
trigger: (width: number, height: number) => {
|
||||||
|
callback([{ contentRect: { width, height } }]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to assign a mock div to a ref
|
||||||
|
function assignMockDivToRef(ref: React.RefObject<HTMLDivElement>, mockDiv: HTMLDivElement) {
|
||||||
|
// Use type assertion to bypass readonly restriction in tests
|
||||||
|
(ref as { current: HTMLDivElement | null }).current = mockDiv;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SoloPanelPageLogo', () => {
|
||||||
|
describe('shouldHideSoloPanelLogo', () => {
|
||||||
|
it('treats null as false', () => {
|
||||||
|
expect(shouldHideSoloPanelLogo(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats presence (empty string) as true', () => {
|
||||||
|
expect(shouldHideSoloPanelLogo('')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats true/1 as true', () => {
|
||||||
|
expect(shouldHideSoloPanelLogo('true')).toBe(true);
|
||||||
|
expect(shouldHideSoloPanelLogo('1')).toBe(true);
|
||||||
|
expect(shouldHideSoloPanelLogo(' TRUE ')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats false/0 as false', () => {
|
||||||
|
expect(shouldHideSoloPanelLogo('false')).toBe(false);
|
||||||
|
expect(shouldHideSoloPanelLogo('0')).toBe(false);
|
||||||
|
expect(shouldHideSoloPanelLogo(' FALSE ')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats boolean true as true and boolean false as false', () => {
|
||||||
|
expect(shouldHideSoloPanelLogo(true)).toBe(true);
|
||||||
|
expect(shouldHideSoloPanelLogo(false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats undefined as false', () => {
|
||||||
|
expect(shouldHideSoloPanelLogo(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles array values (uses the first value)', () => {
|
||||||
|
expect(shouldHideSoloPanelLogo([''])).toBe(true);
|
||||||
|
expect(shouldHideSoloPanelLogo(['true'])).toBe(true);
|
||||||
|
expect(shouldHideSoloPanelLogo(['1'])).toBe(true);
|
||||||
|
expect(shouldHideSoloPanelLogo(['false'])).toBe(false);
|
||||||
|
expect(shouldHideSoloPanelLogo(['0'])).toBe(false);
|
||||||
|
expect(shouldHideSoloPanelLogo(['false', 'true'])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockTheme = {
|
||||||
|
isDark: false,
|
||||||
|
colors: {
|
||||||
|
background: { primary: '#ffffff' },
|
||||||
|
border: { weak: '#e0e0e0' },
|
||||||
|
text: { secondary: '#666666' },
|
||||||
|
},
|
||||||
|
shape: { radius: { default: '4px' } },
|
||||||
|
shadows: { z3: '0 2px 4px rgba(0,0,0,0.1)' },
|
||||||
|
typography: { body: { fontSize: '14px' } },
|
||||||
|
spacing: jest.fn((n: number) => `${n * 8}px`),
|
||||||
|
transitions: {
|
||||||
|
handleMotion: jest.fn(() => ({})),
|
||||||
|
},
|
||||||
|
} as unknown as GrafanaTheme2;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockUseTheme2.mockReturnValue({
|
||||||
|
...mockTheme,
|
||||||
|
isDark: false,
|
||||||
|
});
|
||||||
|
mockUseStyles2.mockImplementation((fn) => fn(mockTheme));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the logo component', () => {
|
||||||
|
const containerRef = createRef<HTMLDivElement>();
|
||||||
|
const mockDiv = document.createElement('div');
|
||||||
|
mockDiv.getBoundingClientRect = jest.fn(() => ({
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 600,
|
||||||
|
right: 800,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
assignMockDivToRef(containerRef, mockDiv);
|
||||||
|
|
||||||
|
render(<SoloPanelPageLogo containerRef={containerRef} isHovered={false} hideLogo={undefined} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Powered by')).toBeInTheDocument();
|
||||||
|
expect(screen.getByAltText('Grafana')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should hide logo when isHovered is true', () => {
|
||||||
|
const containerRef = createRef<HTMLDivElement>();
|
||||||
|
const mockDiv = document.createElement('div');
|
||||||
|
mockDiv.getBoundingClientRect = jest.fn(() => ({
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 600,
|
||||||
|
right: 800,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: jest.fn(),
|
||||||
|
}));
|
||||||
|
assignMockDivToRef(containerRef, mockDiv);
|
||||||
|
|
||||||
|
render(<SoloPanelPageLogo containerRef={containerRef} isHovered={true} hideLogo={undefined} />);
|
||||||
|
|
||||||
|
// The logo should still be in the DOM but with reduced opacity
|
||||||
|
const poweredByText = screen.getByText('Powered by');
|
||||||
|
expect(poweredByText).toBeInTheDocument();
|
||||||
|
// The logoHidden class should be applied (we can't easily test the class name without more setup)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show logo when isHovered is false', () => {
|
||||||
|
const containerRef = createRef<HTMLDivElement>();
|
||||||
|
const mockDiv = document.createElement('div');
|
||||||
|
mockDiv.getBoundingClientRect = jest.fn(() => ({
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 600,
|
||||||
|
right: 800,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: jest.fn(),
|
||||||
|
}));
|
||||||
|
assignMockDivToRef(containerRef, mockDiv);
|
||||||
|
|
||||||
|
render(<SoloPanelPageLogo containerRef={containerRef} isHovered={false} hideLogo={undefined} />);
|
||||||
|
|
||||||
|
// The logo should be visible
|
||||||
|
expect(screen.getByText('Powered by')).toBeInTheDocument();
|
||||||
|
expect(screen.getByAltText('Grafana')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use dark logo in dark theme', () => {
|
||||||
|
const containerRef = createRef<HTMLDivElement>();
|
||||||
|
const mockDiv = document.createElement('div');
|
||||||
|
mockDiv.getBoundingClientRect = jest.fn(() => ({
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 600,
|
||||||
|
right: 800,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: jest.fn(),
|
||||||
|
}));
|
||||||
|
assignMockDivToRef(containerRef, mockDiv);
|
||||||
|
|
||||||
|
mockUseTheme2.mockReturnValue({
|
||||||
|
...mockTheme,
|
||||||
|
isDark: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SoloPanelPageLogo containerRef={containerRef} isHovered={false} hideLogo={undefined} />);
|
||||||
|
|
||||||
|
const logo = screen.getByAltText('Grafana');
|
||||||
|
expect(logo).toHaveAttribute('src', 'grafana-text-logo-light.svg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use correct logo based on theme', () => {
|
||||||
|
const containerRef = createRef<HTMLDivElement>();
|
||||||
|
const mockDiv = document.createElement('div');
|
||||||
|
mockDiv.getBoundingClientRect = jest.fn(() => ({
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 600,
|
||||||
|
right: 800,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: jest.fn(),
|
||||||
|
}));
|
||||||
|
assignMockDivToRef(containerRef, mockDiv);
|
||||||
|
|
||||||
|
// The beforeEach sets isDark: false by default, so this should work
|
||||||
|
// But the previous test might have changed it, so let's ensure it's reset
|
||||||
|
mockUseTheme2.mockClear();
|
||||||
|
mockUseTheme2.mockReturnValue({
|
||||||
|
...mockTheme,
|
||||||
|
isDark: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<SoloPanelPageLogo containerRef={containerRef} isHovered={false} hideLogo={undefined} />);
|
||||||
|
|
||||||
|
const logo = screen.getByAltText('Grafana');
|
||||||
|
// Verify logo is rendered (the exact src depends on theme, which is tested in other tests)
|
||||||
|
expect(logo).toBeInTheDocument();
|
||||||
|
expect(logo).toHaveAttribute('src');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply scaling styles based on container dimensions', () => {
|
||||||
|
const containerRef = createRef<HTMLDivElement>();
|
||||||
|
const mockDiv = document.createElement('div');
|
||||||
|
mockDiv.getBoundingClientRect = jest.fn(() => ({
|
||||||
|
width: 400,
|
||||||
|
height: 300,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 300,
|
||||||
|
right: 400,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: jest.fn(),
|
||||||
|
}));
|
||||||
|
assignMockDivToRef(containerRef, mockDiv);
|
||||||
|
|
||||||
|
render(<SoloPanelPageLogo containerRef={containerRef} isHovered={false} hideLogo={undefined} />);
|
||||||
|
|
||||||
|
// Find the logo container by looking for the "Powered by" text's parent
|
||||||
|
const poweredByText = screen.getByText('Powered by');
|
||||||
|
const logoContainer = poweredByText.parentElement as HTMLElement;
|
||||||
|
expect(logoContainer).toBeInTheDocument();
|
||||||
|
// Check that inline styles are applied (scaling should be between 0.6 and 1.0)
|
||||||
|
expect(logoContainer.style.fontSize).toBeTruthy();
|
||||||
|
expect(logoContainer.style.top).toBeTruthy();
|
||||||
|
expect(logoContainer.style.right).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should observe container resize', () => {
|
||||||
|
const containerRef = createRef<HTMLDivElement>();
|
||||||
|
const mockDiv = document.createElement('div');
|
||||||
|
mockDiv.getBoundingClientRect = jest.fn(() => ({
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 600,
|
||||||
|
right: 800,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
toJSON: jest.fn(),
|
||||||
|
}));
|
||||||
|
assignMockDivToRef(containerRef, mockDiv);
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<SoloPanelPageLogo containerRef={containerRef} isHovered={false} hideLogo={undefined} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ResizeObserver).toHaveBeenCalled();
|
||||||
|
const resizeObserverInstance = (ResizeObserver as jest.Mock).mock.results[0].value;
|
||||||
|
expect(resizeObserverInstance.observe).toHaveBeenCalledWith(mockDiv);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
expect(resizeObserverInstance.disconnect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { css, cx } from '@emotion/css';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2, UrlQueryValue } from '@grafana/data';
|
||||||
|
import { Trans } from '@grafana/i18n';
|
||||||
|
import { useStyles2, useTheme2 } from '@grafana/ui';
|
||||||
|
import grafanaTextLogoDarkSvg from 'img/grafana_text_logo_dark.svg';
|
||||||
|
import grafanaTextLogoLightSvg from 'img/grafana_text_logo_light.svg';
|
||||||
|
|
||||||
|
interface SoloPanelPageLogoProps {
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>;
|
||||||
|
isHovered: boolean;
|
||||||
|
hideLogo?: UrlQueryValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldHideSoloPanelLogo(hideLogo?: UrlQueryValue): boolean {
|
||||||
|
if (hideLogo === undefined || hideLogo === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// React-router / locationSearchToObject can represent a "present but no value" query param as boolean true.
|
||||||
|
if (hideLogo === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hideLogo === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = Array.isArray(hideLogo) ? String(hideLogo[0] ?? '') : String(hideLogo);
|
||||||
|
|
||||||
|
// Treat presence as "true", except explicit disable values.
|
||||||
|
// Examples:
|
||||||
|
// - ?hideLogo => hide
|
||||||
|
// - ?hideLogo=true => hide
|
||||||
|
// - ?hideLogo=1 => hide
|
||||||
|
// - ?hideLogo=false => show
|
||||||
|
// - ?hideLogo=0 => show
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return normalized !== 'false' && normalized !== '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SoloPanelPageLogo({ containerRef, isHovered, hideLogo }: SoloPanelPageLogoProps) {
|
||||||
|
const shouldHide = shouldHideSoloPanelLogo(hideLogo);
|
||||||
|
const [scale, setScale] = useState(1);
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const theme = useTheme2();
|
||||||
|
const grafanaLogo = theme.isDark ? grafanaTextLogoLightSvg : grafanaTextLogoDarkSvg;
|
||||||
|
|
||||||
|
// Calculate responsive scale based on panel dimensions
|
||||||
|
useEffect(() => {
|
||||||
|
const updateScale = () => {
|
||||||
|
if (!containerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width, height } = containerRef.current.getBoundingClientRect();
|
||||||
|
// Use the smaller dimension to ensure it scales appropriately for both wide and tall panels
|
||||||
|
const minDimension = Math.min(width, height);
|
||||||
|
|
||||||
|
// Base scale calculation: scales from 0.6 (for small panels ~200px) up to 1.0 when the smaller dimension is ~800px
|
||||||
|
// Clamp to a maximum of 1.0 for larger panels
|
||||||
|
const baseScale = Math.max(0.6, Math.min(1.0, 0.6 + (minDimension - 200) / 600));
|
||||||
|
|
||||||
|
// Also consider width specifically for very wide but short panels; reaches 1.0 when width is ~1000px
|
||||||
|
const widthScale = Math.max(0.6, Math.min(1.0, 0.6 + (width - 200) / 800));
|
||||||
|
|
||||||
|
// Use the average of both for balanced scaling; panels around 1000x1000px (or larger in both dimensions) reach a scale of 1.0
|
||||||
|
const finalScale = Math.min(1.0, (baseScale + widthScale) / 2);
|
||||||
|
setScale(finalScale);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateScale();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(updateScale);
|
||||||
|
if (containerRef.current) {
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [containerRef]);
|
||||||
|
|
||||||
|
if (shouldHide) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(styles.logoContainer, isHovered && styles.logoHidden)}
|
||||||
|
style={{
|
||||||
|
fontSize: `${scale * 100}%`,
|
||||||
|
top: `${8 * scale}px`,
|
||||||
|
right: `${8 * scale}px`,
|
||||||
|
padding: `${8 * scale}px ${8 * scale}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.text}>
|
||||||
|
<Trans i18nKey="embedded-panel.powered-by">Powered by</Trans>
|
||||||
|
</span>
|
||||||
|
<img
|
||||||
|
src={grafanaLogo}
|
||||||
|
alt="Grafana"
|
||||||
|
className={styles.logo}
|
||||||
|
style={{
|
||||||
|
height: `${16 * scale}px`,
|
||||||
|
marginLeft: '0.25em',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
const logoContainer = css({
|
||||||
|
position: 'absolute',
|
||||||
|
// top, right, and padding will be set via inline styles for scaling
|
||||||
|
backgroundColor: theme.colors.background.primary,
|
||||||
|
borderRadius: theme.shape.radius.default,
|
||||||
|
opacity: 0.9,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 1000,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
boxShadow: theme.shadows.z3,
|
||||||
|
border: `1px solid ${theme.colors.border.weak}`,
|
||||||
|
// Base font size - will be scaled via inline style
|
||||||
|
fontSize: theme.typography.body.fontSize,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
|
||||||
|
transition: 'opacity 0.2s ease-in-out',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const logoHidden = css({
|
||||||
|
opacity: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = css({
|
||||||
|
color: theme.colors.text.secondary,
|
||||||
|
// fontSize will be inherited from parent container's scale
|
||||||
|
lineHeight: 1.2,
|
||||||
|
display: 'block',
|
||||||
|
});
|
||||||
|
|
||||||
|
const logo = css({
|
||||||
|
// height will be set via inline style (16px * scale) to scale with panel size
|
||||||
|
display: 'block',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
logoContainer,
|
||||||
|
logoHidden,
|
||||||
|
text,
|
||||||
|
logo,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -106,7 +106,7 @@ describe('ShareModal', () => {
|
|||||||
render(<ShareLink {...props} />);
|
render(<ShareLink {...props} />);
|
||||||
|
|
||||||
const base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash';
|
const base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash';
|
||||||
const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&scale=1&tz=UTC';
|
const params = '?from=1000&to=2000&orgId=1&panelId=22&hideLogo=true&width=1000&height=500&scale=1&tz=UTC';
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
|
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
|
||||||
).toHaveAttribute('href', base + params);
|
).toHaveAttribute('href', base + params);
|
||||||
@@ -117,7 +117,7 @@ describe('ShareModal', () => {
|
|||||||
render(<ShareLink {...props} />);
|
render(<ShareLink {...props} />);
|
||||||
|
|
||||||
const base = 'http://dashboards.grafana.com/render/dashboard-solo/script/my-dash.js';
|
const base = 'http://dashboards.grafana.com/render/dashboard-solo/script/my-dash.js';
|
||||||
const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&scale=1&tz=UTC';
|
const params = '?from=1000&to=2000&orgId=1&panelId=22&hideLogo=true&width=1000&height=500&scale=1&tz=UTC';
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
|
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
|
||||||
).toHaveAttribute('href', base + params);
|
).toHaveAttribute('href', base + params);
|
||||||
@@ -154,7 +154,7 @@ describe('ShareModal', () => {
|
|||||||
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
|
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
|
||||||
).toHaveAttribute(
|
).toHaveAttribute(
|
||||||
'href',
|
'href',
|
||||||
base + path + '?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&scale=1&tz=UTC'
|
base + path + '?from=1000&to=2000&orgId=1&panelId=1&hideLogo=true&width=1000&height=500&scale=1&tz=UTC'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ describe('ShareModal', () => {
|
|||||||
render(<ShareLink {...props} />);
|
render(<ShareLink {...props} />);
|
||||||
|
|
||||||
const base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash';
|
const base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash';
|
||||||
const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&scale=1&tz=UTC';
|
const params = '?from=1000&to=2000&orgId=1&panelId=22&hideLogo=true&width=1000&height=500&scale=1&tz=UTC';
|
||||||
expect(
|
expect(
|
||||||
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
|
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
|
||||||
).toHaveAttribute('href', base + params);
|
).toHaveAttribute('href', base + params);
|
||||||
@@ -213,7 +213,7 @@ describe('when appUrl is set in the grafana config', () => {
|
|||||||
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
|
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
|
||||||
).toHaveAttribute(
|
).toHaveAttribute(
|
||||||
'href',
|
'href',
|
||||||
`http://dashboards.grafana.com/render/d-solo/${mockDashboard.uid}?orgId=1&from=1000&to=2000&panelId=${mockPanel.id}&width=1000&height=500&scale=1&tz=UTC`
|
`http://dashboards.grafana.com/render/d-solo/${mockDashboard.uid}?orgId=1&from=1000&to=2000&panelId=${mockPanel.id}&hideLogo=true&width=1000&height=500&scale=1&tz=UTC`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export function buildImageUrl(
|
|||||||
let imageUrl = soloUrl.replace(config.appSubUrl + '/dashboard-solo/', config.appSubUrl + '/render/dashboard-solo/');
|
let imageUrl = soloUrl.replace(config.appSubUrl + '/dashboard-solo/', config.appSubUrl + '/render/dashboard-solo/');
|
||||||
imageUrl = imageUrl.replace(config.appSubUrl + '/d-solo/', config.appSubUrl + '/render/d-solo/');
|
imageUrl = imageUrl.replace(config.appSubUrl + '/d-solo/', config.appSubUrl + '/render/d-solo/');
|
||||||
imageUrl +=
|
imageUrl +=
|
||||||
|
`&hideLogo=true` +
|
||||||
`&width=${config.rendererDefaultImageWidth}` +
|
`&width=${config.rendererDefaultImageWidth}` +
|
||||||
`&height=${config.rendererDefaultImageHeight}` +
|
`&height=${config.rendererDefaultImageHeight}` +
|
||||||
`&scale=${config.rendererDefaultImageScale}` +
|
`&scale=${config.rendererDefaultImageScale}` +
|
||||||
|
|||||||
@@ -7166,6 +7166,9 @@
|
|||||||
"time-range-label": "Lock time range"
|
"time-range-label": "Lock time range"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"embedded-panel": {
|
||||||
|
"powered-by": "Powered by"
|
||||||
|
},
|
||||||
"empty-list-cta": {
|
"empty-list-cta": {
|
||||||
"pro-tip": "ProTip: {{proTip}}"
|
"pro-tip": "ProTip: {{proTip}}"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user