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 })
|
||||
).toHaveAttribute(
|
||||
'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;
|
||||
}
|
||||
|
||||
// hide Grafana logo in the rendered image
|
||||
urlParamsUpdate.hideLogo = 'true';
|
||||
|
||||
const imageUrl = getDashboardUrl({
|
||||
uid: dashboard.state.uid,
|
||||
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
|
||||
import { css } from '@emotion/css';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
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 { UrlSyncContextProvider } from '@grafana/scenes';
|
||||
import { Alert, Box, useStyles2 } from '@grafana/ui';
|
||||
@@ -17,7 +17,10 @@ import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageSt
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
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
|
||||
@@ -52,18 +55,28 @@ export function SoloPanelPage({ queryParams }: Props) {
|
||||
|
||||
return (
|
||||
<UrlSyncContextProvider scene={dashboard}>
|
||||
<SoloPanelRenderer dashboard={dashboard} panelId={queryParams.panelId} />
|
||||
<SoloPanelRenderer dashboard={dashboard} panelId={queryParams.panelId} hideLogo={queryParams.hideLogo} />
|
||||
</UrlSyncContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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 refreshPicker = controls?.useState()?.refreshPicker;
|
||||
const styles = useStyles2(getStyles);
|
||||
const soloPanelContext = useDefineSoloPanelContext(panelId)!;
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dashDeactivate = dashboard.activate();
|
||||
@@ -76,11 +89,19 @@ export function SoloPanelRenderer({ dashboard, panelId }: { dashboard: Dashboard
|
||||
}, [dashboard, refreshPicker]);
|
||||
|
||||
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)}
|
||||
<SoloPanelContextProvider value={soloPanelContext} dashboard={dashboard} singleMatch={true}>
|
||||
<body.Component model={body} />
|
||||
</SoloPanelContextProvider>
|
||||
<div className={styles.panelWrapper}>
|
||||
<SoloPanelContextProvider value={soloPanelContext} dashboard={dashboard} singleMatch={true}>
|
||||
<body.Component model={body} />
|
||||
</SoloPanelContextProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -107,15 +128,23 @@ function renderHiddenVariables(dashboard: DashboardScene) {
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css({
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
margin: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
const panelWrapper = css({
|
||||
width: '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} />);
|
||||
|
||||
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(
|
||||
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
|
||||
).toHaveAttribute('href', base + params);
|
||||
@@ -117,7 +117,7 @@ describe('ShareModal', () => {
|
||||
render(<ShareLink {...props} />);
|
||||
|
||||
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(
|
||||
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
|
||||
).toHaveAttribute('href', base + params);
|
||||
@@ -154,7 +154,7 @@ describe('ShareModal', () => {
|
||||
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
|
||||
).toHaveAttribute(
|
||||
'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} />);
|
||||
|
||||
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(
|
||||
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
|
||||
).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 })
|
||||
).toHaveAttribute(
|
||||
'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/');
|
||||
imageUrl = imageUrl.replace(config.appSubUrl + '/d-solo/', config.appSubUrl + '/render/d-solo/');
|
||||
imageUrl +=
|
||||
`&hideLogo=true` +
|
||||
`&width=${config.rendererDefaultImageWidth}` +
|
||||
`&height=${config.rendererDefaultImageHeight}` +
|
||||
`&scale=${config.rendererDefaultImageScale}` +
|
||||
|
||||
@@ -7166,6 +7166,9 @@
|
||||
"time-range-label": "Lock time range"
|
||||
}
|
||||
},
|
||||
"embedded-panel": {
|
||||
"powered-by": "Powered by"
|
||||
},
|
||||
"empty-list-cta": {
|
||||
"pro-tip": "ProTip: {{proTip}}"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user