Commit 3e55c967 by Torkel Ödegaard Committed by GitHub

Theming: Support for runtime theme switching and hooks for custom themes (#31301)

* WIP Custom themes

* Load custom themes from URL and via event

* Dynamic page background

* Header color change

* Fixing tests and emotion warnings

* Fixed test

* moving cx to getStyles

* Review fixes

* minor change
parent 58968e1c
...@@ -125,4 +125,5 @@ export interface GrafanaConfig { ...@@ -125,4 +125,5 @@ export interface GrafanaConfig {
http2Enabled: boolean; http2Enabled: boolean;
dateFormats?: SystemDateFormatSettings; dateFormats?: SystemDateFormatSettings;
sentry: SentryConfig; sentry: SentryConfig;
customTheme?: any;
} }
...@@ -69,6 +69,7 @@ export class GrafanaBootConfig implements GrafanaConfig { ...@@ -69,6 +69,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
}; };
marketplaceUrl?: string; marketplaceUrl?: string;
expressionsEnabled = false; expressionsEnabled = false;
customTheme?: any;
constructor(options: GrafanaBootConfig) { constructor(options: GrafanaBootConfig) {
this.theme = options.bootData.user.lightTheme ? getTheme(GrafanaThemeType.Light) : getTheme(GrafanaThemeType.Dark); this.theme = options.bootData.user.lightTheme ? getTheme(GrafanaThemeType.Light) : getTheme(GrafanaThemeType.Dark);
......
...@@ -122,6 +122,7 @@ export type IconName = ...@@ -122,6 +122,7 @@ export type IconName =
| 'cloud' | 'cloud'
| 'draggabledots' | 'draggabledots'
| 'folder-upload' | 'folder-upload'
| 'palette'
| 'gf-interpolation-linear' | 'gf-interpolation-linear'
| 'gf-interpolation-smooth' | 'gf-interpolation-smooth'
| 'gf-interpolation-step-before' | 'gf-interpolation-step-before'
...@@ -246,6 +247,7 @@ export const getAvailableIcons = (): IconName[] => [ ...@@ -246,6 +247,7 @@ export const getAvailableIcons = (): IconName[] => [
'cloud', 'cloud',
'draggabledots', 'draggabledots',
'folder-upload', 'folder-upload',
'palette',
'gf-interpolation-linear', 'gf-interpolation-linear',
'gf-interpolation-smooth', 'gf-interpolation-smooth',
'gf-interpolation-step-before', 'gf-interpolation-step-before',
......
...@@ -19,50 +19,56 @@ import { createStyle } from '../../Theme'; ...@@ -19,50 +19,56 @@ import { createStyle } from '../../Theme';
import { css } from 'emotion'; import { css } from 'emotion';
export const getStyles = createStyle(() => { export const getStyles = createStyle(() => {
const ScrubberHandleExpansion = css` return {
ScrubberHandleExpansion: cx(
css`
label: ScrubberHandleExpansion; label: ScrubberHandleExpansion;
cursor: col-resize; cursor: col-resize;
fill-opacity: 0; fill-opacity: 0;
fill: #44f; fill: #44f;
`; `,
const ScrubberHandle = css` 'scrubber-handle-expansion'
),
ScrubberHandle: cx(
css`
label: ScrubberHandle; label: ScrubberHandle;
cursor: col-resize; cursor: col-resize;
fill: #555; fill: #555;
`; `,
const ScrubberLine = css` 'scrubber-handle'
),
ScrubberLine: cx(
css`
label: ScrubberLine; label: ScrubberLine;
pointer-events: none; pointer-events: none;
stroke: #555; stroke: #555;
`; `,
return { 'scrubber-line'
),
ScrubberDragging: css` ScrubberDragging: css`
label: ScrubberDragging; label: ScrubberDragging;
& .${ScrubberHandleExpansion} { & .scrubber-handle-expansion {
fill-opacity: 1; fill-opacity: 1;
} }
& .${ScrubberHandle} { & .scrubber-handle {
fill: #44f; fill: #44f;
} }
& > .${ScrubberLine} { & > .scrubber-line {
stroke: #44f; stroke: #44f;
} }
`, `,
ScrubberHandles: css` ScrubberHandles: css`
label: ScrubberHandles; label: ScrubberHandles;
&:hover > .${ScrubberHandleExpansion} { &:hover > .scrubber-handle-expansion {
fill-opacity: 1; fill-opacity: 1;
} }
&:hover > .${ScrubberHandle} { &:hover > .scrubber-handle {
fill: #44f; fill: #44f;
} }
&:hover + .${ScrubberLine} { &:hover + .scrubber.line {
stroke: #44f; stroke: #44f;
} }
`, `,
ScrubberHandleExpansion,
ScrubberHandle,
ScrubberLine,
}; };
}); });
......
...@@ -36,10 +36,6 @@ import { createStyle } from '../Theme'; ...@@ -36,10 +36,6 @@ import { createStyle } from '../Theme';
import { uTxMuted } from '../uberUtilityStyles'; import { uTxMuted } from '../uberUtilityStyles';
const getStyles = createStyle((theme: Theme) => { const getStyles = createStyle((theme: Theme) => {
const TracePageHeaderOverviewItemValueDetail = css`
label: TracePageHeaderOverviewItemValueDetail;
color: #aaa;
`;
return { return {
TracePageHeader: css` TracePageHeader: css`
label: TracePageHeader; label: TracePageHeader;
...@@ -117,10 +113,16 @@ const getStyles = createStyle((theme: Theme) => { ...@@ -117,10 +113,16 @@ const getStyles = createStyle((theme: Theme) => {
border-bottom: 1px solid #e4e4e4; border-bottom: 1px solid #e4e4e4;
padding: 0.25rem 0.5rem !important; padding: 0.25rem 0.5rem !important;
`, `,
TracePageHeaderOverviewItemValueDetail, TracePageHeaderOverviewItemValueDetail: cx(
css`
label: TracePageHeaderOverviewItemValueDetail;
color: #aaa;
`,
'trace-item-value-detail'
),
TracePageHeaderOverviewItemValue: css` TracePageHeaderOverviewItemValue: css`
label: TracePageHeaderOverviewItemValue; label: TracePageHeaderOverviewItemValue;
&:hover > .${TracePageHeaderOverviewItemValueDetail} { &:hover > .trace-item-value-detail {
color: unset; color: unset;
} }
`, `,
...@@ -163,13 +165,13 @@ export const HEADER_ITEMS = [ ...@@ -163,13 +165,13 @@ export const HEADER_ITEMS = [
{ {
key: 'timestamp', key: 'timestamp',
label: 'Trace Start', label: 'Trace Start',
renderer(trace: Trace, styles?: ReturnType<typeof getStyles>) { renderer(trace: Trace, styles: ReturnType<typeof getStyles>) {
const dateStr = formatDatetime(trace.startTime); const dateStr = formatDatetime(trace.startTime);
const match = dateStr.match(/^(.+)(:\d\d\.\d+)$/); const match = dateStr.match(/^(.+)(:\d\d\.\d+)$/);
return match ? ( return match ? (
<span className={styles?.TracePageHeaderOverviewItemValue}> <span className={styles.TracePageHeaderOverviewItemValue}>
{match[1]} {match[1]}
<span className={styles?.TracePageHeaderOverviewItemValueDetail}>{match[2]}</span> <span className={styles.TracePageHeaderOverviewItemValueDetail}>{match[2]}</span>
</span> </span>
) : ( ) : (
dateStr dateStr
......
...@@ -62,3 +62,6 @@ type NavLink struct { ...@@ -62,3 +62,6 @@ type NavLink struct {
HideFromTabs bool `json:"hideFromTabs,omitempty"` HideFromTabs bool `json:"hideFromTabs,omitempty"`
Children []*NavLink `json:"children,omitempty"` Children []*NavLink `json:"children,omitempty"`
} }
// NavIDCfg is the id for org configuration navigation node
const NavIDCfg = "cfg"
...@@ -282,7 +282,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto ...@@ -282,7 +282,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
if len(configNodes) > 0 { if len(configNodes) > 0 {
navTree = append(navTree, &dtos.NavLink{ navTree = append(navTree, &dtos.NavLink{
Id: "cfg", Id: dtos.NavIDCfg,
Text: "Configuration", Text: "Configuration",
SubTitle: "Organization: " + c.OrgName, SubTitle: "Organization: " + c.OrgName,
Icon: "cog", Icon: "cog",
......
...@@ -4,7 +4,7 @@ import { LinkButton } from '@grafana/ui'; ...@@ -4,7 +4,7 @@ import { LinkButton } from '@grafana/ui';
export interface Props { export interface Props {
searchQuery: string; searchQuery: string;
setSearchQuery: (value: string) => {}; setSearchQuery: (value: string) => void;
linkButton: { href: string; title: string }; linkButton: { href: string; title: string };
target?: string; target?: string;
} }
......
// Libraries // Libraries
import React, { Component, HTMLAttributes } from 'react'; import React, { FC, HTMLAttributes, useEffect } from 'react';
import { getTitleFromNavModel } from 'app/core/selectors/navModel'; import { getTitleFromNavModel } from 'app/core/selectors/navModel';
// Components // Components
import PageHeader from '../PageHeader/PageHeader'; import PageHeader from '../PageHeader/PageHeader';
import { Footer } from '../Footer/Footer'; import { Footer } from '../Footer/Footer';
import PageContents from './PageContents'; import { PageContents } from './PageContents';
import { CustomScrollbar } from '@grafana/ui'; import { CustomScrollbar, useStyles } from '@grafana/ui';
import { NavModel } from '@grafana/data'; import { GrafanaTheme, NavModel } from '@grafana/data';
import { isEqual } from 'lodash';
import { Branding } from '../Branding/Branding'; import { Branding } from '../Branding/Branding';
import { css } from 'emotion';
interface Props extends HTMLAttributes<HTMLDivElement> { interface Props extends HTMLAttributes<HTMLDivElement> {
children: React.ReactNode; children: React.ReactNode;
navModel: NavModel; navModel: NavModel;
} }
class Page extends Component<Props> { export interface PageType extends FC<Props> {
static Header = PageHeader; Header: typeof PageHeader;
static Contents = PageContents; Contents: typeof PageContents;
}
componentDidMount() {
this.updateTitle(); export const Page: PageType = ({ navModel, children, ...otherProps }) => {
} const styles = useStyles(getStyles);
componentDidUpdate(prevProps: Props) { useEffect(() => {
if (!isEqual(prevProps.navModel, this.props.navModel)) { const title = getTitleFromNavModel(navModel);
this.updateTitle(); document.title = title ? `${title} - ${Branding.AppTitle}` : Branding.AppTitle;
} }, [navModel]);
}
updateTitle = () => {
const title = this.getPageTitle;
document.title = title ? title + ' - ' + Branding.AppTitle : Branding.AppTitle;
};
get getPageTitle() {
const { navModel } = this.props;
if (navModel) {
return getTitleFromNavModel(navModel) || undefined;
}
return undefined;
}
render() {
const { navModel, children, ...otherProps } = this.props;
return ( return (
<div {...otherProps} className="page-scrollbar-wrapper"> <div {...otherProps} className={styles.wrapper}>
<CustomScrollbar autoHeightMin={'100%'}> <CustomScrollbar autoHeightMin={'100%'}>
<div className="page-scrollbar-content"> <div className="page-scrollbar-content">
<PageHeader model={navModel} /> <PageHeader model={navModel} />
...@@ -56,7 +40,19 @@ class Page extends Component<Props> { ...@@ -56,7 +40,19 @@ class Page extends Component<Props> {
</CustomScrollbar> </CustomScrollbar>
</div> </div>
); );
} };
}
Page.Header = PageHeader;
Page.Contents = PageContents;
export default Page; export default Page;
const getStyles = (theme: GrafanaTheme) => ({
wrapper: css`
position: absolute;
top: 0;
bottom: 0;
width: 100%;
background: ${theme.colors.bg1};
`,
});
// Libraries // Libraries
import React, { Component } from 'react'; import React, { FC } from 'react';
// Components // Components
import PageLoader from '../PageLoader/PageLoader'; import PageLoader from '../PageLoader/PageLoader';
...@@ -9,12 +9,6 @@ interface Props { ...@@ -9,12 +9,6 @@ interface Props {
children: React.ReactNode; children: React.ReactNode;
} }
class PageContents extends Component<Props> { export const PageContents: FC<Props> = ({ isLoading, children }) => {
render() { return <div className="page-container page-body">{isLoading ? <PageLoader /> : children}</div>;
const { isLoading } = this.props; };
return <div className="page-container page-body">{isLoading ? <PageLoader /> : this.props.children}</div>;
}
}
export default PageContents;
import React from 'react'; import React from 'react';
import PageHeader from './PageHeader'; import PageHeader from './PageHeader';
import { shallow, ShallowWrapper } from 'enzyme'; import { render, screen } from '@testing-library/react';
describe('PageHeader', () => { describe('PageHeader', () => {
let wrapper: ShallowWrapper<PageHeader>;
describe('when the nav tree has a node with a title', () => { describe('when the nav tree has a node with a title', () => {
beforeAll(() => { it('should render the title', async () => {
const nav = { const nav = {
main: { main: {
icon: 'folder-open', icon: 'folder-open',
...@@ -17,17 +15,15 @@ describe('PageHeader', () => { ...@@ -17,17 +15,15 @@ describe('PageHeader', () => {
}, },
node: {}, node: {},
}; };
wrapper = shallow(<PageHeader model={nav as any} />);
});
it('should render the title', () => { render(<PageHeader model={nav as any} />);
const title = wrapper.find('.page-header__title');
expect(title.text()).toBe('node'); expect(screen.getByRole('heading', { name: 'node' })).toBeInTheDocument();
}); });
}); });
describe('when the nav tree has a node with breadcrumbs and a title', () => { describe('when the nav tree has a node with breadcrumbs and a title', () => {
beforeAll(() => { it('should render the title with breadcrumbs first and then title last', async () => {
const nav = { const nav = {
main: { main: {
icon: 'folder-open', icon: 'folder-open',
...@@ -39,15 +35,11 @@ describe('PageHeader', () => { ...@@ -39,15 +35,11 @@ describe('PageHeader', () => {
}, },
node: {}, node: {},
}; };
wrapper = shallow(<PageHeader model={nav as any} />);
});
it('should render the title with breadcrumbs first and then title last', () => { render(<PageHeader model={nav as any} />);
const title = wrapper.find('.page-header__title');
expect(title.text()).toBe('Parent / child');
const parentLink = wrapper.find('.page-header__title > a.text-link'); expect(screen.getByRole('heading', { name: 'Parent / child' })).toBeInTheDocument();
expect(parentLink.prop('href')).toBe('parentUrl'); expect(screen.getByRole('link', { name: 'Parent' })).toBeInTheDocument();
}); });
}); });
}); });
import React from 'react'; import React, { FC } from 'react';
import { css } from 'emotion'; import { css } from 'emotion';
import { Tab, TabsBar, Icon, IconName } from '@grafana/ui'; import { Tab, TabsBar, Icon, IconName, useStyles } from '@grafana/ui';
import { NavModel, NavModelItem, NavModelBreadcrumb } from '@grafana/data'; import { NavModel, NavModelItem, NavModelBreadcrumb, GrafanaTheme } from '@grafana/data';
import { PanelHeaderMenuItem } from 'app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem'; import { PanelHeaderMenuItem } from 'app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem';
export interface Props { export interface Props {
...@@ -71,17 +71,47 @@ const Navigation = ({ children }: { children: NavModelItem[] }) => { ...@@ -71,17 +71,47 @@ const Navigation = ({ children }: { children: NavModelItem[] }) => {
); );
}; };
export default class PageHeader extends React.Component<Props, any> { export const PageHeader: FC<Props> = ({ model }) => {
constructor(props: Props) { const styles = useStyles(getStyles);
super(props);
}
shouldComponentUpdate() { if (!model) {
//Hack to re-render on changed props from angular with the @observer decorator return null;
return true;
} }
renderTitle(title: string, breadcrumbs: NavModelBreadcrumb[]) { const main = model.main;
const children = main.children;
return (
<div className={styles.headerCanvas}>
<div className="page-container">
<div className="page-header">
{renderHeaderTitle(main)}
{children && children.length && <Navigation>{children}</Navigation>}
</div>
</div>
</div>
);
};
function renderHeaderTitle(main: NavModelItem) {
const marginTop = main.icon === 'grafana' ? 12 : 14;
return (
<div className="page-header__inner">
<span className="page-header__logo">
{main.icon && <Icon name={main.icon as IconName} size="xxxl" style={{ marginTop }} />}
{main.img && <img className="page-header__img" src={main.img} alt={`logo of ${main.text}`} />}
</span>
<div className="page-header__info-block">
{renderTitle(main.text, main.breadcrumbs ?? [])}
{main.subTitle && <div className="page-header__sub-title">{main.subTitle}</div>}
</div>
</div>
);
}
function renderTitle(title: string, breadcrumbs: NavModelBreadcrumb[]) {
if (!title && (!breadcrumbs || breadcrumbs.length === 0)) { if (!title && (!breadcrumbs || breadcrumbs.length === 0)) {
return null; return null;
} }
...@@ -105,52 +135,13 @@ export default class PageHeader extends React.Component<Props, any> { ...@@ -105,52 +135,13 @@ export default class PageHeader extends React.Component<Props, any> {
breadcrumbsResult.push(<span key={breadcrumbs.length + 1}> / {title}</span>); breadcrumbsResult.push(<span key={breadcrumbs.length + 1}> / {title}</span>);
return <h1 className="page-header__title">{breadcrumbsResult}</h1>; return <h1 className="page-header__title">{breadcrumbsResult}</h1>;
} }
renderHeaderTitle(main: NavModelItem) {
const iconClassName =
main.icon === 'grafana'
? css`
margin-top: 12px;
`
: css`
margin-top: 14px;
`;
return (
<div className="page-header__inner">
<span className="page-header__logo">
{main.icon && <Icon name={main.icon as IconName} size="xxxl" className={iconClassName} />}
{main.img && <img className="page-header__img" src={main.img} alt={`logo of ${main.text}`} />}
</span>
<div className="page-header__info-block">
{this.renderTitle(main.text, main.breadcrumbs ?? [])}
{main.subTitle && <div className="page-header__sub-title">{main.subTitle}</div>}
</div>
</div>
);
}
render() {
const { model } = this.props;
if (!model) {
return null;
}
const main = model.main; const getStyles = (theme: GrafanaTheme) => ({
const children = main.children; headerCanvas: css`
background: ${theme.colors.bg2};
border-bottom: 1px solid ${theme.colors.border1};
`,
});
return ( export default PageHeader;
<div className="page-header-canvas">
<div className="page-container">
<div className="page-header">
{this.renderHeaderTitle(main)}
{children && children.length && <Navigation>{children}</Navigation>}
</div>
</div>
</div>
);
}
}
import React from 'react'; import React, { useEffect, useState } from 'react';
import { config, GrafanaBootConfig } from '@grafana/runtime'; import { config, GrafanaBootConfig } from '@grafana/runtime';
import { ThemeContext, getTheme } from '@grafana/ui'; import { ThemeContext } from '@grafana/ui';
import { GrafanaThemeType } from '@grafana/data'; import { appEvents } from '../core';
import { ThemeChangedEvent } from 'app/types/events';
import { GrafanaTheme } from '@grafana/data';
export const ConfigContext = React.createContext<GrafanaBootConfig>(config); export const ConfigContext = React.createContext<GrafanaBootConfig>(config);
export const ConfigConsumer = ConfigContext.Consumer; export const ConfigConsumer = ConfigContext.Consumer;
...@@ -10,23 +12,22 @@ export const provideConfig = (component: React.ComponentType<any>) => { ...@@ -10,23 +12,22 @@ export const provideConfig = (component: React.ComponentType<any>) => {
const ConfigProvider = (props: any) => ( const ConfigProvider = (props: any) => (
<ConfigContext.Provider value={config}>{React.createElement(component, { ...props })}</ConfigContext.Provider> <ConfigContext.Provider value={config}>{React.createElement(component, { ...props })}</ConfigContext.Provider>
); );
return ConfigProvider; return ConfigProvider;
}; };
export const getCurrentThemeName = () => export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
config.bootData.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark; const [theme, setTheme] = useState<GrafanaTheme>(config.theme);
export const getCurrentTheme = () => getTheme(getCurrentThemeName()); useEffect(() => {
const sub = appEvents.subscribe(ThemeChangedEvent, (event) => {
config.theme = event.payload;
setTheme(event.payload);
});
export const ThemeProvider = ({ children }: { children: React.ReactNode }) => { return () => sub.unsubscribe();
return ( }, []);
<ConfigConsumer>
{(config) => { return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
return <ThemeContext.Provider value={getCurrentTheme()}>{children}</ThemeContext.Provider>;
}}
</ConfigConsumer>
);
}; };
export const provideTheme = (component: React.ComponentType<any>) => { export const provideTheme = (component: React.ComponentType<any>) => {
......
...@@ -25,7 +25,7 @@ import { GraphLegendProps, Legend } from './Legend/Legend'; ...@@ -25,7 +25,7 @@ import { GraphLegendProps, Legend } from './Legend/Legend';
import { GraphCtrl } from './module'; import { GraphCtrl } from './module';
import { graphTickFormatter, graphTimeFormat, IconName, MenuItem, MenuItemsGroup } from '@grafana/ui'; import { graphTickFormatter, graphTimeFormat, IconName, MenuItem, MenuItemsGroup } from '@grafana/ui';
import { getCurrentTheme, provideTheme } from 'app/core/utils/ConfigProvider'; import { provideTheme } from 'app/core/utils/ConfigProvider';
import { import {
DataFrame, DataFrame,
DataFrameView, DataFrameView,
...@@ -284,7 +284,7 @@ class GraphElement { ...@@ -284,7 +284,7 @@ class GraphElement {
}; };
const fieldDisplay = getDisplayProcessor({ const fieldDisplay = getDisplayProcessor({
field: { config: fieldConfig, type: FieldType.number }, field: { config: fieldConfig, type: FieldType.number },
theme: getCurrentTheme(), theme: config.theme,
timeZone: this.dashboard.getTimezone(), timeZone: this.dashboard.getTimezone(),
})(field.values.get(dataIndex)); })(field.values.get(dataIndex));
linksSupplier = links.length linksSupplier = links.length
......
import { BusEventBase, eventFactory, TimeRange } from '@grafana/data'; import { BusEventBase, BusEventWithPayload, eventFactory, GrafanaTheme, TimeRange } from '@grafana/data';
import { DashboardModel } from 'app/features/dashboard/state'; import { DashboardModel } from 'app/features/dashboard/state';
/** /**
...@@ -156,3 +156,7 @@ export class RefreshEvent extends BusEventBase { ...@@ -156,3 +156,7 @@ export class RefreshEvent extends BusEventBase {
export class RenderEvent extends BusEventBase { export class RenderEvent extends BusEventBase {
static type = 'render'; static type = 'render';
} }
export class ThemeChangedEvent extends BusEventWithPayload<GrafanaTheme> {
static type = 'theme-changed';
}
.page-header-canvas {
background: $page-header-bg;
box-shadow: $page-header-shadow;
border-bottom: 1px solid $page-header-border-color;
}
.page-header { .page-header {
padding: $space-xl 0 0 0; padding: $space-xl 0 0 0;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment