From f8cf67347f069310b8cc6a2d9ff83345a81e3247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 10 May 2023 15:37:04 +0200 Subject: [PATCH] Themes: Adds support for extraThemes (behind feature toggle) (#67981) * Foundations to support more themes * Fixes * add another test theme * Refactorings * more refactoring * Update * Fixing tests * Fix * Update --- .../feature-toggles/index.md | 1 + packages/grafana-data/src/themes/index.ts | 1 + packages/grafana-data/src/themes/registry.ts | 93 +++++++++++++++++++ .../src/types/featureToggles.gen.ts | 1 + packages/grafana-runtime/src/config.ts | 34 +------ pkg/api/dtos/index.go | 2 +- pkg/api/index.go | 36 +++---- pkg/api/preferences.go | 13 +-- pkg/services/featuremgmt/registry.go | 7 ++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/preference/themes.go | 34 +++++++ .../SharedPreferences.test.tsx | 14 +-- .../SharedPreferences/SharedPreferences.tsx | 48 ++++++---- public/app/core/services/theme.ts | 56 +++++------ public/app/core/utils/ConfigProvider.tsx | 3 +- public/views/index-template.html | 6 +- 17 files changed, 240 insertions(+), 114 deletions(-) create mode 100644 packages/grafana-data/src/themes/registry.ts create mode 100644 pkg/services/preference/themes.go diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index dba553cc3fc..9f8b2025c95 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -111,6 +111,7 @@ Alpha features might be changed or removed without prior notice. | `pluginsAPIManifestKey` | Use grafana.com API to retrieve the public manifest key | | `advancedDataSourcePicker` | Enable a new data source picker with contextual information, recently used order, CSV upload and advanced mode | | `opensearchDetectVersion` | Enable version detection in OpenSearch | +| `extraThemes` | Enables extra themes | ## Development feature toggles diff --git a/packages/grafana-data/src/themes/index.ts b/packages/grafana-data/src/themes/index.ts index 9fac0e68a3c..deec6335d72 100644 --- a/packages/grafana-data/src/themes/index.ts +++ b/packages/grafana-data/src/themes/index.ts @@ -1,4 +1,5 @@ export { createTheme } from './createTheme'; +export { getThemeById, getBuiltInThemes, type ThemeRegistryItem } from './registry'; export type { NewThemeOptions } from './createTheme'; export type { ThemeRichColor, GrafanaTheme2 } from './types'; export type { ThemeColors } from './createColors'; diff --git a/packages/grafana-data/src/themes/registry.ts b/packages/grafana-data/src/themes/registry.ts new file mode 100644 index 00000000000..85e9e399153 --- /dev/null +++ b/packages/grafana-data/src/themes/registry.ts @@ -0,0 +1,93 @@ +import { Registry, RegistryItem } from '../utils/Registry'; + +import { createTheme } from './createTheme'; +import { GrafanaTheme2 } from './types'; + +export interface ThemeRegistryItem extends RegistryItem { + isExtra?: boolean; + build: () => GrafanaTheme2; +} + +/** + * @internal + * Only for internal use, never use this from a plugin + **/ +export function getThemeById(id: string): GrafanaTheme2 { + const theme = themeRegistry.getIfExists(id) ?? themeRegistry.get('dark'); + return theme.build(); +} + +/** + * @internal + * For internal use only + */ +export function getBuiltInThemes(includeExtras?: boolean) { + return themeRegistry.list().filter((item) => { + return includeExtras ? true : !item.isExtra; + }); +} + +/** + * There is also a backend list at services/perferences/themes.go + */ +const themeRegistry = new Registry(() => { + return [ + { id: 'system', name: 'System preference', build: getSystemPreferenceTheme }, + { id: 'dark', name: 'Dark', build: () => createTheme({ colors: { mode: 'dark' } }) }, + { id: 'light', name: 'Light', build: () => createTheme({ colors: { mode: 'light' } }) }, + { id: 'blue-night', name: 'Blue night', build: createBlueNight, isExtra: true }, + { id: 'midnight', name: 'Midnight', build: createMidnight, isExtra: true }, + ]; +}); + +function getSystemPreferenceTheme() { + const mediaResult = window.matchMedia('(prefers-color-scheme: dark)'); + const id = mediaResult.matches ? 'dark' : 'light'; + return getThemeById(id); +} + +/** + * Just a temporary placeholder for a possible new theme + */ +function createMidnight(): GrafanaTheme2 { + const whiteBase = '204, 204, 220'; + + return createTheme({ + name: 'Midnight', + colors: { + mode: 'dark', + background: { + canvas: '#000000', + primary: '#000000', + secondary: '#181818', + }, + border: { + weak: `rgba(${whiteBase}, 0.17)`, + medium: `rgba(${whiteBase}, 0.25)`, + strong: `rgba(${whiteBase}, 0.35)`, + }, + }, + }); +} + +/** + * Just a temporary placeholder for a possible new theme + */ +function createBlueNight(): GrafanaTheme2 { + return createTheme({ + name: 'Blue night', + colors: { + mode: 'dark', + background: { + canvas: '#15161d', + primary: '#15161d', + secondary: '#1d1f2e', + }, + border: { + weak: `#2e304f`, + medium: `#2e304f`, + strong: `#2e304f`, + }, + }, + }); +} diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index a3e1fd422c7..727e6dcdbc2 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -97,4 +97,5 @@ export interface FeatureToggles { opensearchDetectVersion?: boolean; faroDatasourceSelector?: boolean; enableDatagridEditing?: boolean; + extraThemes?: boolean; } diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 39748fd3546..d4dc6088f4c 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -4,7 +4,6 @@ import { AuthSettings, BootData, BuildInfo, - createTheme, DataSourceInstanceSettings, FeatureToggles, GrafanaConfig, @@ -16,7 +15,7 @@ import { PanelPluginMeta, systemDateFormats, SystemDateFormatSettings, - NewThemeOptions, + getThemeById, } from '@grafana/data'; export interface AzureSettings { @@ -158,7 +157,6 @@ export class GrafanaBootConfig implements GrafanaConfig { constructor(options: GrafanaBootConfig) { this.bootData = options.bootData; - this.bootData.user.lightTheme = getThemeMode(options) === 'light'; this.isPublicDashboardView = options.bootData.settings.isPublicDashboardView; const defaults = { @@ -195,39 +193,15 @@ export class GrafanaBootConfig implements GrafanaConfig { } // Creating theme after applying feature toggle overrides in case we need to toggle anything - this.theme2 = createTheme(getThemeCustomizations(this)); - + this.theme2 = getThemeById(this.bootData.user.theme); + this.bootData.user.lightTheme = this.theme2.isLight; this.theme = this.theme2.v1; + // Special feature toggle that impact theme/component looks this.theme2.flags.topnav = this.featureToggles.topnav; } } -function getThemeMode(config: GrafanaBootConfig) { - let mode: 'light' | 'dark' = 'dark'; - const themePref = config.bootData.user.theme; - - if (themePref === 'light' || themePref === 'dark') { - mode = themePref; - } else if (themePref === 'system') { - const mediaResult = window.matchMedia('(prefers-color-scheme: dark)'); - mode = mediaResult.matches ? 'dark' : 'light'; - } - - return mode; -} - -function getThemeCustomizations(config: GrafanaBootConfig) { - // if/when we remove CurrentUserDTO.lightTheme, change this to use getThemeMode instead - const mode = config.bootData.user.lightTheme ? 'light' : 'dark'; - - const themeOptions: NewThemeOptions = { - colors: { mode }, - }; - - return themeOptions; -} - function overrideFeatureTogglesFromUrl(config: GrafanaBootConfig) { if (window.location.href.indexOf('__feature') === -1) { return; diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index 99ffc5de835..55cd9fc0683 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -18,7 +18,7 @@ type IndexViewData struct { NavTree *navtree.NavTreeRoot BuildVersion string BuildCommit string - Theme string + ThemeType string NewGrafanaVersionExists bool NewGrafanaVersion string AppName string diff --git a/pkg/api/index.go b/pkg/api/index.go index 067c5828683..456a60dcd8c 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -16,13 +16,6 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -const ( - // Themes - lightName = "light" - darkName = "dark" - systemName = "system" -) - func (hs *HTTPServer) editorInAnyFolder(c *contextmodel.ReqContext) bool { hasEditPermissionInFoldersQuery := folder.HasEditPermissionInFoldersQuery{SignedInUser: c.SignedInUser} hasEditPermissionInFoldersQueryResult, err := hs.DashboardService.HasEditPermissionInFolders(c.Req.Context(), &hasEditPermissionInFoldersQuery) @@ -91,6 +84,8 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV weekStart = *prefs.WeekStart } + theme := hs.getThemeForIndexData(prefs.Theme, c.Query("theme")) + data := dtos.IndexViewData{ User: &dtos.CurrentUser{ Id: c.UserID, @@ -105,8 +100,8 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV OrgRole: c.OrgRole, GravatarUrl: dtos.GetGravatarUrl(c.Email), IsGrafanaAdmin: c.IsGrafanaAdmin, - Theme: prefs.Theme, - LightTheme: prefs.Theme == lightName, + Theme: theme.ID, + LightTheme: theme.Type == "light", Timezone: prefs.Timezone, WeekStart: weekStart, Locale: locale, @@ -119,7 +114,7 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV }, }, Settings: settings, - Theme: prefs.Theme, + ThemeType: theme.Type, AppUrl: appURL, AppSubUrl: appSubURL, GoogleAnalyticsId: settings.GoogleAnalyticsId, @@ -164,12 +159,6 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV data.User.Name = data.User.Login } - themeURLParam := c.Query("theme") - if themeURLParam == lightName || themeURLParam == darkName || themeURLParam == systemName { - data.User.Theme = themeURLParam - data.Theme = themeURLParam - } - hs.HooksService.RunIndexDataHooks(&data, c) data.NavTree.ApplyAdminIA() @@ -201,3 +190,18 @@ func (hs *HTTPServer) NotFoundHandler(c *contextmodel.ReqContext) { c.HTML(404, "index", data) } + +func (hs *HTTPServer) getThemeForIndexData(themePrefId string, themeURLParam string) *pref.ThemeDTO { + if themeURLParam != "" && pref.IsValidThemeID(themeURLParam) { + return pref.GetThemeByID(themeURLParam) + } + + if pref.IsValidThemeID(themePrefId) { + theme := pref.GetThemeByID(themePrefId) + if !theme.IsExtra || hs.Features.IsEnabled(featuremgmt.FlagExtraThemes) { + return theme + } + } + + return pref.GetThemeByID(hs.Cfg.DefaultTheme) +} diff --git a/pkg/api/preferences.go b/pkg/api/preferences.go index f4808ec4636..55c0535477e 100644 --- a/pkg/api/preferences.go +++ b/pkg/api/preferences.go @@ -13,13 +13,6 @@ import ( "github.com/grafana/grafana/pkg/web" ) -const ( - defaultTheme string = "" - darkTheme string = "dark" - lightTheme string = "light" - systemTheme string = "system" -) - // POST /api/preferences/set-home-dash func (hs *HTTPServer) SetHomeDashboard(c *contextmodel.ReqContext) response.Response { cmd := pref.SavePreferenceCommand{} @@ -135,8 +128,8 @@ func (hs *HTTPServer) UpdateUserPreferences(c *contextmodel.ReqContext) response } func (hs *HTTPServer) updatePreferencesFor(ctx context.Context, orgID, userID, teamId int64, dtoCmd *dtos.UpdatePrefsCmd) response.Response { - if dtoCmd.Theme != lightTheme && dtoCmd.Theme != darkTheme && dtoCmd.Theme != defaultTheme && dtoCmd.Theme != systemTheme { - return response.Error(400, "Invalid theme", nil) + if !pref.IsValidThemeID(dtoCmd.Theme) { + return response.Error(http.StatusBadRequest, "Invalid theme", nil) } dashboardID := dtoCmd.HomeDashboardID @@ -193,7 +186,7 @@ func (hs *HTTPServer) PatchUserPreferences(c *contextmodel.ReqContext) response. } func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, teamId int64, dtoCmd *dtos.PatchPrefsCmd) response.Response { - if dtoCmd.Theme != nil && *dtoCmd.Theme != lightTheme && *dtoCmd.Theme != darkTheme && *dtoCmd.Theme != defaultTheme && *dtoCmd.Theme != systemTheme { + if dtoCmd.Theme != nil && !pref.IsValidThemeID(*dtoCmd.Theme) { return response.Error(http.StatusBadRequest, "Invalid theme", nil) } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d8ccafd62f3..dccf221e600 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -530,5 +530,12 @@ var ( State: FeatureStateBeta, Owner: grafanaBiSquad, }, + { + Name: "extraThemes", + Description: "Enables extra themes", + FrontendOnly: true, + State: FeatureStateAlpha, + Owner: grafanaUserEssentialsSquad, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index d71c6171a70..4bd8c246c89 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -78,3 +78,4 @@ advancedDataSourcePicker,alpha,@grafana/dashboards-squad,false,false,false,true opensearchDetectVersion,alpha,@grafana/aws-plugins,false,false,false,true faroDatasourceSelector,beta,@grafana/app-o11y,false,false,false,true enableDatagridEditing,beta,@grafana/grafana-bi-squad,false,false,false,true +extraThemes,alpha,@grafana/user-essentials,false,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 53bb402ff0e..4a06797b78c 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -322,4 +322,8 @@ const ( // FlagEnableDatagridEditing // Enables the edit functionality in the datagrid panel FlagEnableDatagridEditing = "enableDatagridEditing" + + // FlagExtraThemes + // Enables extra themes + FlagExtraThemes = "extraThemes" ) diff --git a/pkg/services/preference/themes.go b/pkg/services/preference/themes.go new file mode 100644 index 00000000000..9ed9ba84bfc --- /dev/null +++ b/pkg/services/preference/themes.go @@ -0,0 +1,34 @@ +package pref + +type ThemeDTO struct { + ID string `json:"id"` + Type string `json:"type"` + IsExtra bool `json:"isExtra"` +} + +var themes = []ThemeDTO{ + {ID: "light", Type: "light"}, + {ID: "dark", Type: "dark"}, + {ID: "system", Type: "dark"}, + {ID: "midnight", Type: "dark", IsExtra: true}, + {ID: "blue-night", Type: "dark", IsExtra: true}, +} + +func GetThemeByID(id string) *ThemeDTO { + for _, theme := range themes { + if theme.ID == id { + return &theme + } + } + + return nil +} + +func IsValidThemeID(id string) bool { + for _, theme := range themes { + if theme.ID == id { + return true + } + } + return false +} diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx index f76896edbfe..1e692fae541 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.test.tsx @@ -1,7 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { assertInstanceOf } from 'test/helpers/asserts'; import { getSelectParent, selectOptionInTest } from 'test/helpers/selectOptionInTest'; import { Preferences as UserPreferencesDTO } from '@grafana/schema/src/raw/preferences/x/preferences_types.gen'; @@ -129,8 +128,8 @@ describe('SharedPreferences', () => { }); it('renders the theme preference', () => { - const lightThemeRadio = assertInstanceOf(screen.getByLabelText('Light'), HTMLInputElement); - expect(lightThemeRadio.checked).toBeTruthy(); + const themeSelect = getSelectParent(screen.getByLabelText('Interface theme')); + expect(themeSelect).toHaveTextContent('Light'); }); it('renders the home dashboard preference', async () => { @@ -156,14 +155,13 @@ describe('SharedPreferences', () => { }); it("saves the user's new preferences", async () => { - const darkThemeRadio = assertInstanceOf(screen.getByLabelText('Dark'), HTMLInputElement); - await userEvent.click(darkThemeRadio); - + await selectOptionInTest(screen.getByLabelText('Interface theme'), 'Dark'); await selectOptionInTest(screen.getByLabelText('Timezone'), 'Australia/Sydney'); await selectOptionInTest(screen.getByLabelText('Week start'), 'Saturday'); await selectOptionInTest(screen.getByLabelText(/language/i), 'Français'); await userEvent.click(screen.getByText('Save')); + expect(mockPrefsUpdate).toHaveBeenCalledWith({ timezone: 'Australia/Sydney', weekStart: 'saturday', @@ -177,9 +175,7 @@ describe('SharedPreferences', () => { }); it("saves the user's default preferences", async () => { - const defThemeRadio = assertInstanceOf(screen.getByLabelText('Default'), HTMLInputElement); - await userEvent.click(defThemeRadio); - + await selectOptionInTest(screen.getByLabelText('Interface theme'), 'Default'); await selectOptionInTest(screen.getByLabelText('Home Dashboard'), 'Default'); await selectOptionInTest(screen.getByLabelText('Timezone'), 'Default'); await selectOptionInTest(screen.getByLabelText('Week start'), 'Default'); diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.tsx index 6b4d6904ac7..71165a6bd2d 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import React, { PureComponent } from 'react'; -import { FeatureState, SelectableValue } from '@grafana/data'; +import { FeatureState, SelectableValue, getBuiltInThemes, ThemeRegistryItem } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config, reportInteraction } from '@grafana/runtime'; import { Preferences as UserPreferencesDTO } from '@grafana/schema/src/raw/preferences/x/preferences_types.gen'; @@ -11,7 +11,6 @@ import { FieldSet, Form, Label, - RadioButtonGroup, Select, stylesFactory, TimeZonePicker, @@ -22,6 +21,7 @@ import { DashboardPicker } from 'app/core/components/Select/DashboardPicker'; import { t, Trans } from 'app/core/internationalization'; import { LANGUAGES } from 'app/core/internationalization/constants'; import { PreferencesService } from 'app/core/services/PreferencesService'; +import { changeTheme } from 'app/core/services/theme'; export interface Props { resourceUri: string; @@ -67,12 +67,13 @@ export class SharedPreferences extends PureComponent { queryHistory: { homeTab: '' }, }; - this.themeOptions = [ - { value: '', label: t('shared-preferences.theme.default-label', 'Default') }, - { value: 'dark', label: t('shared-preferences.theme.dark-label', 'Dark') }, - { value: 'light', label: t('shared-preferences.theme.light-label', 'Light') }, - { value: 'system', label: t('shared-preferences.theme.system-label', 'System') }, - ]; + this.themeOptions = getBuiltInThemes(config.featureToggles.extraThemes).map((theme) => ({ + value: theme.id, + label: getTranslatedThemeName(theme), + })); + + // Add default option + this.themeOptions.unshift({ value: '', label: t('shared-preferences.theme.default-label', 'Default') }); } async componentDidMount() { @@ -98,8 +99,12 @@ export class SharedPreferences extends PureComponent { } }; - onThemeChanged = (value: string) => { - this.setState({ theme: value }); + onThemeChanged = (value: SelectableValue) => { + this.setState({ theme: value.value }); + + if (value.value) { + changeTheme(value.value, true); + } }; onTimeZoneChanged = (timezone?: string) => { @@ -131,21 +136,19 @@ export class SharedPreferences extends PureComponent { const { disabled } = this.props; const styles = getStyles(); const languages = getLanguageOptions(); - let currentThemeOption = this.themeOptions[0].value; - if (theme?.length) { - currentThemeOption = this.themeOptions.find((item) => item.value === theme)?.value; - } + const currentThemeOption = this.themeOptions.find((x) => x.value === theme) ?? this.themeOptions[0]; return (
{() => { return (
Preferences} disabled={disabled}> - - +