From 3e55c967eee8fae0aa3196dafd55324f15390d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Sat, 20 Feb 2021 09:02:06 +0100 Subject: [PATCH] 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 --- packages/grafana-data/src/types/config.ts | 1 + packages/grafana-runtime/src/config.ts | 1 + packages/grafana-ui/src/types/icon.ts | 2 + .../TracePageHeader/SpanGraph/Scrubber.tsx | 56 ++++--- .../src/TracePageHeader/TracePageHeader.tsx | 20 +-- pkg/api/dtos/index.go | 3 + pkg/api/index.go | 2 +- .../components/OrgActionBar/OrgActionBar.tsx | 2 +- public/app/core/components/Page/Page.tsx | 88 +++++------ .../app/core/components/Page/PageContents.tsx | 14 +- .../components/PageHeader/PageHeader.test.tsx | 26 ++-- .../core/components/PageHeader/PageHeader.tsx | 143 ++++++++---------- public/app/core/utils/ConfigProvider.tsx | 33 ++-- public/app/plugins/panel/graph/graph.ts | 4 +- public/app/types/events.ts | 6 +- public/sass/components/_page_header.scss | 6 - 16 files changed, 197 insertions(+), 210 deletions(-) diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 43efe6b1786..655b6396742 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -125,4 +125,5 @@ export interface GrafanaConfig { http2Enabled: boolean; dateFormats?: SystemDateFormatSettings; sentry: SentryConfig; + customTheme?: any; } diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 5685b0674ce..4eb8e96c0c2 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -69,6 +69,7 @@ export class GrafanaBootConfig implements GrafanaConfig { }; marketplaceUrl?: string; expressionsEnabled = false; + customTheme?: any; constructor(options: GrafanaBootConfig) { this.theme = options.bootData.user.lightTheme ? getTheme(GrafanaThemeType.Light) : getTheme(GrafanaThemeType.Dark); diff --git a/packages/grafana-ui/src/types/icon.ts b/packages/grafana-ui/src/types/icon.ts index 72eead3f81c..03a6156aa9b 100644 --- a/packages/grafana-ui/src/types/icon.ts +++ b/packages/grafana-ui/src/types/icon.ts @@ -122,6 +122,7 @@ export type IconName = | 'cloud' | 'draggabledots' | 'folder-upload' + | 'palette' | 'gf-interpolation-linear' | 'gf-interpolation-smooth' | 'gf-interpolation-step-before' @@ -246,6 +247,7 @@ export const getAvailableIcons = (): IconName[] => [ 'cloud', 'draggabledots', 'folder-upload', + 'palette', 'gf-interpolation-linear', 'gf-interpolation-smooth', 'gf-interpolation-step-before', diff --git a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.tsx b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.tsx index 61364e37dda..017ff5ea4d4 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.tsx +++ b/packages/jaeger-ui-components/src/TracePageHeader/SpanGraph/Scrubber.tsx @@ -19,50 +19,56 @@ import { createStyle } from '../../Theme'; import { css } from 'emotion'; export const getStyles = createStyle(() => { - const ScrubberHandleExpansion = css` - label: ScrubberHandleExpansion; - cursor: col-resize; - fill-opacity: 0; - fill: #44f; - `; - const ScrubberHandle = css` - label: ScrubberHandle; - cursor: col-resize; - fill: #555; - `; - const ScrubberLine = css` - label: ScrubberLine; - pointer-events: none; - stroke: #555; - `; return { + ScrubberHandleExpansion: cx( + css` + label: ScrubberHandleExpansion; + cursor: col-resize; + fill-opacity: 0; + fill: #44f; + `, + 'scrubber-handle-expansion' + ), + ScrubberHandle: cx( + css` + label: ScrubberHandle; + cursor: col-resize; + fill: #555; + `, + 'scrubber-handle' + ), + ScrubberLine: cx( + css` + label: ScrubberLine; + pointer-events: none; + stroke: #555; + `, + 'scrubber-line' + ), ScrubberDragging: css` label: ScrubberDragging; - & .${ScrubberHandleExpansion} { + & .scrubber-handle-expansion { fill-opacity: 1; } - & .${ScrubberHandle} { + & .scrubber-handle { fill: #44f; } - & > .${ScrubberLine} { + & > .scrubber-line { stroke: #44f; } `, ScrubberHandles: css` label: ScrubberHandles; - &:hover > .${ScrubberHandleExpansion} { + &:hover > .scrubber-handle-expansion { fill-opacity: 1; } - &:hover > .${ScrubberHandle} { + &:hover > .scrubber-handle { fill: #44f; } - &:hover + .${ScrubberLine} { + &:hover + .scrubber.line { stroke: #44f; } `, - ScrubberHandleExpansion, - ScrubberHandle, - ScrubberLine, }; }); diff --git a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx b/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx index fcd3771c40f..ffcc1743e4b 100644 --- a/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx +++ b/packages/jaeger-ui-components/src/TracePageHeader/TracePageHeader.tsx @@ -36,10 +36,6 @@ import { createStyle } from '../Theme'; import { uTxMuted } from '../uberUtilityStyles'; const getStyles = createStyle((theme: Theme) => { - const TracePageHeaderOverviewItemValueDetail = css` - label: TracePageHeaderOverviewItemValueDetail; - color: #aaa; - `; return { TracePageHeader: css` label: TracePageHeader; @@ -117,10 +113,16 @@ const getStyles = createStyle((theme: Theme) => { border-bottom: 1px solid #e4e4e4; padding: 0.25rem 0.5rem !important; `, - TracePageHeaderOverviewItemValueDetail, + TracePageHeaderOverviewItemValueDetail: cx( + css` + label: TracePageHeaderOverviewItemValueDetail; + color: #aaa; + `, + 'trace-item-value-detail' + ), TracePageHeaderOverviewItemValue: css` label: TracePageHeaderOverviewItemValue; - &:hover > .${TracePageHeaderOverviewItemValueDetail} { + &:hover > .trace-item-value-detail { color: unset; } `, @@ -163,13 +165,13 @@ export const HEADER_ITEMS = [ { key: 'timestamp', label: 'Trace Start', - renderer(trace: Trace, styles?: ReturnType) { + renderer(trace: Trace, styles: ReturnType) { const dateStr = formatDatetime(trace.startTime); const match = dateStr.match(/^(.+)(:\d\d\.\d+)$/); return match ? ( - + {match[1]} - {match[2]} + {match[2]} ) : ( dateStr diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index 00ddf796241..da40cea68ab 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -62,3 +62,6 @@ type NavLink struct { HideFromTabs bool `json:"hideFromTabs,omitempty"` Children []*NavLink `json:"children,omitempty"` } + +// NavIDCfg is the id for org configuration navigation node +const NavIDCfg = "cfg" diff --git a/pkg/api/index.go b/pkg/api/index.go index ae2165cd6e1..adf939e06c9 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -282,7 +282,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto if len(configNodes) > 0 { navTree = append(navTree, &dtos.NavLink{ - Id: "cfg", + Id: dtos.NavIDCfg, Text: "Configuration", SubTitle: "Organization: " + c.OrgName, Icon: "cog", diff --git a/public/app/core/components/OrgActionBar/OrgActionBar.tsx b/public/app/core/components/OrgActionBar/OrgActionBar.tsx index 06d6b532ddc..f4afea627cc 100644 --- a/public/app/core/components/OrgActionBar/OrgActionBar.tsx +++ b/public/app/core/components/OrgActionBar/OrgActionBar.tsx @@ -4,7 +4,7 @@ import { LinkButton } from '@grafana/ui'; export interface Props { searchQuery: string; - setSearchQuery: (value: string) => {}; + setSearchQuery: (value: string) => void; linkButton: { href: string; title: string }; target?: string; } diff --git a/public/app/core/components/Page/Page.tsx b/public/app/core/components/Page/Page.tsx index 15a5e665ca1..46ab58c25a9 100644 --- a/public/app/core/components/Page/Page.tsx +++ b/public/app/core/components/Page/Page.tsx @@ -1,62 +1,58 @@ // Libraries -import React, { Component, HTMLAttributes } from 'react'; +import React, { FC, HTMLAttributes, useEffect } from 'react'; import { getTitleFromNavModel } from 'app/core/selectors/navModel'; // Components import PageHeader from '../PageHeader/PageHeader'; import { Footer } from '../Footer/Footer'; -import PageContents from './PageContents'; -import { CustomScrollbar } from '@grafana/ui'; -import { NavModel } from '@grafana/data'; -import { isEqual } from 'lodash'; +import { PageContents } from './PageContents'; +import { CustomScrollbar, useStyles } from '@grafana/ui'; +import { GrafanaTheme, NavModel } from '@grafana/data'; import { Branding } from '../Branding/Branding'; +import { css } from 'emotion'; interface Props extends HTMLAttributes { children: React.ReactNode; navModel: NavModel; } -class Page extends Component { - static Header = PageHeader; - static Contents = PageContents; - - componentDidMount() { - this.updateTitle(); - } - - componentDidUpdate(prevProps: Props) { - if (!isEqual(prevProps.navModel, this.props.navModel)) { - this.updateTitle(); - } - } - - 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 ( -
- -
- - {children} -
-
-
-
- ); - } +export interface PageType extends FC { + Header: typeof PageHeader; + Contents: typeof PageContents; } +export const Page: PageType = ({ navModel, children, ...otherProps }) => { + const styles = useStyles(getStyles); + + useEffect(() => { + const title = getTitleFromNavModel(navModel); + document.title = title ? `${title} - ${Branding.AppTitle}` : Branding.AppTitle; + }, [navModel]); + + return ( +
+ +
+ + {children} +
+
+
+
+ ); +}; + +Page.Header = PageHeader; +Page.Contents = PageContents; + export default Page; + +const getStyles = (theme: GrafanaTheme) => ({ + wrapper: css` + position: absolute; + top: 0; + bottom: 0; + width: 100%; + background: ${theme.colors.bg1}; + `, +}); diff --git a/public/app/core/components/Page/PageContents.tsx b/public/app/core/components/Page/PageContents.tsx index a4dbdb2af6b..b9b4ef3156e 100644 --- a/public/app/core/components/Page/PageContents.tsx +++ b/public/app/core/components/Page/PageContents.tsx @@ -1,5 +1,5 @@ // Libraries -import React, { Component } from 'react'; +import React, { FC } from 'react'; // Components import PageLoader from '../PageLoader/PageLoader'; @@ -9,12 +9,6 @@ interface Props { children: React.ReactNode; } -class PageContents extends Component { - render() { - const { isLoading } = this.props; - - return
{isLoading ? : this.props.children}
; - } -} - -export default PageContents; +export const PageContents: FC = ({ isLoading, children }) => { + return
{isLoading ? : children}
; +}; diff --git a/public/app/core/components/PageHeader/PageHeader.test.tsx b/public/app/core/components/PageHeader/PageHeader.test.tsx index f38839f145a..03ac4b58663 100644 --- a/public/app/core/components/PageHeader/PageHeader.test.tsx +++ b/public/app/core/components/PageHeader/PageHeader.test.tsx @@ -1,12 +1,10 @@ import React from 'react'; import PageHeader from './PageHeader'; -import { shallow, ShallowWrapper } from 'enzyme'; +import { render, screen } from '@testing-library/react'; describe('PageHeader', () => { - let wrapper: ShallowWrapper; - describe('when the nav tree has a node with a title', () => { - beforeAll(() => { + it('should render the title', async () => { const nav = { main: { icon: 'folder-open', @@ -17,17 +15,15 @@ describe('PageHeader', () => { }, node: {}, }; - wrapper = shallow(); - }); - it('should render the title', () => { - const title = wrapper.find('.page-header__title'); - expect(title.text()).toBe('node'); + render(); + + expect(screen.getByRole('heading', { name: 'node' })).toBeInTheDocument(); }); }); 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 = { main: { icon: 'folder-open', @@ -39,15 +35,11 @@ describe('PageHeader', () => { }, node: {}, }; - wrapper = shallow(); - }); - it('should render the title with breadcrumbs first and then title last', () => { - const title = wrapper.find('.page-header__title'); - expect(title.text()).toBe('Parent / child'); + render(); - const parentLink = wrapper.find('.page-header__title > a.text-link'); - expect(parentLink.prop('href')).toBe('parentUrl'); + expect(screen.getByRole('heading', { name: 'Parent / child' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Parent' })).toBeInTheDocument(); }); }); }); diff --git a/public/app/core/components/PageHeader/PageHeader.tsx b/public/app/core/components/PageHeader/PageHeader.tsx index ee343356888..fa305235e3f 100644 --- a/public/app/core/components/PageHeader/PageHeader.tsx +++ b/public/app/core/components/PageHeader/PageHeader.tsx @@ -1,7 +1,7 @@ -import React from 'react'; +import React, { FC } from 'react'; import { css } from 'emotion'; -import { Tab, TabsBar, Icon, IconName } from '@grafana/ui'; -import { NavModel, NavModelItem, NavModelBreadcrumb } from '@grafana/data'; +import { Tab, TabsBar, Icon, IconName, useStyles } from '@grafana/ui'; +import { NavModel, NavModelItem, NavModelBreadcrumb, GrafanaTheme } from '@grafana/data'; import { PanelHeaderMenuItem } from 'app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem'; export interface Props { @@ -71,86 +71,77 @@ const Navigation = ({ children }: { children: NavModelItem[] }) => { ); }; -export default class PageHeader extends React.Component { - constructor(props: Props) { - super(props); +export const PageHeader: FC = ({ model }) => { + const styles = useStyles(getStyles); + + if (!model) { + return null; } - shouldComponentUpdate() { - //Hack to re-render on changed props from angular with the @observer decorator - return true; - } + const main = model.main; + const children = main.children; - renderTitle(title: string, breadcrumbs: NavModelBreadcrumb[]) { - if (!title && (!breadcrumbs || breadcrumbs.length === 0)) { - return null; - } - - if (!breadcrumbs || breadcrumbs.length === 0) { - return

{title}

; - } - - const breadcrumbsResult = []; - for (const bc of breadcrumbs) { - if (bc.url) { - breadcrumbsResult.push( - - {bc.title} - - ); - } else { - breadcrumbsResult.push( / {bc.title}); - } - } - breadcrumbsResult.push( / {title}); - - return

{breadcrumbsResult}

; - } - - renderHeaderTitle(main: NavModelItem) { - const iconClassName = - main.icon === 'grafana' - ? css` - margin-top: 12px; - ` - : css` - margin-top: 14px; - `; - - return ( -
- - {main.icon && } - {main.img && {`logo} - - -
- {this.renderTitle(main.text, main.breadcrumbs ?? [])} - {main.subTitle &&
{main.subTitle}
} + return ( +
+
+
+ {renderHeaderTitle(main)} + {children && children.length && {children}}
- ); - } +
+ ); +}; - render() { - const { model } = this.props; +function renderHeaderTitle(main: NavModelItem) { + const marginTop = main.icon === 'grafana' ? 12 : 14; - if (!model) { - return null; - } + return ( +
+ + {main.icon && } + {main.img && {`logo} + - const main = model.main; - const children = main.children; - - return ( -
-
-
- {this.renderHeaderTitle(main)} - {children && children.length && {children}} -
-
+
+ {renderTitle(main.text, main.breadcrumbs ?? [])} + {main.subTitle &&
{main.subTitle}
}
- ); - } +
+ ); } + +function renderTitle(title: string, breadcrumbs: NavModelBreadcrumb[]) { + if (!title && (!breadcrumbs || breadcrumbs.length === 0)) { + return null; + } + + if (!breadcrumbs || breadcrumbs.length === 0) { + return

{title}

; + } + + const breadcrumbsResult = []; + for (const bc of breadcrumbs) { + if (bc.url) { + breadcrumbsResult.push( + + {bc.title} + + ); + } else { + breadcrumbsResult.push( / {bc.title}); + } + } + breadcrumbsResult.push( / {title}); + + return

{breadcrumbsResult}

; +} + +const getStyles = (theme: GrafanaTheme) => ({ + headerCanvas: css` + background: ${theme.colors.bg2}; + border-bottom: 1px solid ${theme.colors.border1}; + `, +}); + +export default PageHeader; diff --git a/public/app/core/utils/ConfigProvider.tsx b/public/app/core/utils/ConfigProvider.tsx index 97200b0cbca..23bafdc410b 100644 --- a/public/app/core/utils/ConfigProvider.tsx +++ b/public/app/core/utils/ConfigProvider.tsx @@ -1,7 +1,9 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { config, GrafanaBootConfig } from '@grafana/runtime'; -import { ThemeContext, getTheme } from '@grafana/ui'; -import { GrafanaThemeType } from '@grafana/data'; +import { ThemeContext } from '@grafana/ui'; +import { appEvents } from '../core'; +import { ThemeChangedEvent } from 'app/types/events'; +import { GrafanaTheme } from '@grafana/data'; export const ConfigContext = React.createContext(config); export const ConfigConsumer = ConfigContext.Consumer; @@ -10,23 +12,22 @@ export const provideConfig = (component: React.ComponentType) => { const ConfigProvider = (props: any) => ( {React.createElement(component, { ...props })} ); - return ConfigProvider; }; -export const getCurrentThemeName = () => - config.bootData.user.lightTheme ? GrafanaThemeType.Light : GrafanaThemeType.Dark; - -export const getCurrentTheme = () => getTheme(getCurrentThemeName()); - export const ThemeProvider = ({ children }: { children: React.ReactNode }) => { - return ( - - {(config) => { - return {children}; - }} - - ); + const [theme, setTheme] = useState(config.theme); + + useEffect(() => { + const sub = appEvents.subscribe(ThemeChangedEvent, (event) => { + config.theme = event.payload; + setTheme(event.payload); + }); + + return () => sub.unsubscribe(); + }, []); + + return {children}; }; export const provideTheme = (component: React.ComponentType) => { diff --git a/public/app/plugins/panel/graph/graph.ts b/public/app/plugins/panel/graph/graph.ts index 91721fccb16..b415981378b 100644 --- a/public/app/plugins/panel/graph/graph.ts +++ b/public/app/plugins/panel/graph/graph.ts @@ -25,7 +25,7 @@ import { GraphLegendProps, Legend } from './Legend/Legend'; import { GraphCtrl } from './module'; 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 { DataFrame, DataFrameView, @@ -284,7 +284,7 @@ class GraphElement { }; const fieldDisplay = getDisplayProcessor({ field: { config: fieldConfig, type: FieldType.number }, - theme: getCurrentTheme(), + theme: config.theme, timeZone: this.dashboard.getTimezone(), })(field.values.get(dataIndex)); linksSupplier = links.length diff --git a/public/app/types/events.ts b/public/app/types/events.ts index ee323736397..0f922a8214b 100644 --- a/public/app/types/events.ts +++ b/public/app/types/events.ts @@ -1,4 +1,4 @@ -import { BusEventBase, eventFactory, TimeRange } from '@grafana/data'; +import { BusEventBase, BusEventWithPayload, eventFactory, GrafanaTheme, TimeRange } from '@grafana/data'; import { DashboardModel } from 'app/features/dashboard/state'; /** @@ -156,3 +156,7 @@ export class RefreshEvent extends BusEventBase { export class RenderEvent extends BusEventBase { static type = 'render'; } + +export class ThemeChangedEvent extends BusEventWithPayload { + static type = 'theme-changed'; +} diff --git a/public/sass/components/_page_header.scss b/public/sass/components/_page_header.scss index f345a7e43ae..e10f65ce223 100644 --- a/public/sass/components/_page_header.scss +++ b/public/sass/components/_page_header.scss @@ -1,9 +1,3 @@ -.page-header-canvas { - background: $page-header-bg; - box-shadow: $page-header-shadow; - border-bottom: 1px solid $page-header-border-color; -} - .page-header { padding: $space-xl 0 0 0;