From a9541b108f1d998bb2713e70642f5a54aaf8bd97 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 5 Oct 2023 08:14:24 -0500 Subject: [PATCH] feat: Theme Plugin Loading (#1524) - First pass at base theme variables `theme_default_dark.scss` and `theme_default_light.scss` - Swapped a few places `--dh-background-color`. This is only consumed in a few places and is set to the original `#1a171a` color in theme_default_dark, so shouldn't result in any display changes by default. - Loading custom themes from a new `ThemePlugin` type. 2 sample themes can be found in this DRAFT PR: https://github.com/deephaven/deephaven-plugins/pull/65 ### Testing Setup Theme Plugins Locally - Checkout the draft plugins PR: https://github.com/deephaven/deephaven-plugins/pull/65 - Configure docker-compose for Community to load from checked out plugins directory e.g `START_OPTS=-Xmx4g -Ddeephaven.jsPlugins.resourceBase=/plugin-dev` and map a volume `/Users/jdoe/code/deephaven-plugins/plugins:/plugin-dev` - `docker-compose up` (note, this has to be restarted any time plugins config changes) Run web ui locally - On initial load - Nothing should look different in the UI. Inspect the `div id="root"` element. - Its first child should be ` + ))} + {children} + + ); +} + +export default ThemeProvider; diff --git a/packages/components/src/theme/ThemeUtils.test.ts b/packages/components/src/theme/ThemeUtils.test.ts new file mode 100644 index 0000000000..1647f2ba3e --- /dev/null +++ b/packages/components/src/theme/ThemeUtils.test.ts @@ -0,0 +1,191 @@ +import { + DEFAULT_PRELOAD_DATA_VARIABLES, + ThemeData, + ThemeRegistrationData, + THEME_CACHE_LOCAL_STORAGE_KEY, +} from './ThemeModel'; +import { + calculatePreloadStyleContent, + getActiveThemes, + getDefaultBaseThemes, + getThemeKey, + getThemePreloadData, + preloadTheme, + setThemePreloadData, +} from './ThemeUtils'; + +beforeEach(() => { + document.body.removeAttribute('style'); + document.head.innerHTML = ''; + localStorage.clear(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + expect.hasAssertions(); +}); + +describe('calculatePreloadStyleContent', () => { + it('should set defaults if css variables are not defined', () => { + expect(calculatePreloadStyleContent()).toEqual( + `:root{--dh-accent-color:${DEFAULT_PRELOAD_DATA_VARIABLES['--dh-accent-color']};--dh-background-color:${DEFAULT_PRELOAD_DATA_VARIABLES['--dh-background-color']}}` + ); + }); + + it('should resolve css variables', () => { + document.body.style.setProperty('--dh-accent-color', 'pink'); + document.body.style.setProperty('--dh-background-color', 'orange'); + + expect(calculatePreloadStyleContent()).toEqual( + ':root{--dh-accent-color:pink;--dh-background-color:orange}' + ); + }); +}); + +describe('getActiveThemes', () => { + const mockTheme = { + base: { + name: 'Base Theme', + baseThemeKey: undefined, + themeKey: 'default-dark', + styleContent: '', + }, + custom: { + name: 'Custom Theme', + baseThemeKey: 'default-dark', + themeKey: 'customTheme', + styleContent: '', + }, + customInvalid: { + name: 'Custom Theme - Invalid Base', + themeKey: 'customThemeInvalid', + styleContent: '', + }, + } satisfies Record; + + const themeRegistration: ThemeRegistrationData = { + base: [mockTheme.base], + custom: [mockTheme.custom], + }; + + it.each([null, mockTheme.customInvalid])( + 'should throw if base theme not found', + customTheme => { + expect(() => + getActiveThemes(customTheme?.themeKey ?? 'mock.themeKey', { + base: [], + custom: customTheme == null ? [] : [customTheme], + }) + ).toThrowError(`Default base theme 'default-dark' is not registered`); + } + ); + + it('should return a base theme if given base theme key', () => { + const actual = getActiveThemes(mockTheme.base.themeKey, themeRegistration); + expect(actual).toEqual([mockTheme.base]); + }); + + it('should return a base + custom theme if given a custom theme key', () => { + const actual = getActiveThemes( + mockTheme.custom.themeKey, + themeRegistration + ); + expect(actual).toEqual([mockTheme.base, mockTheme.custom]); + }); +}); + +describe('getDefaultBaseThemes', () => { + it('should return default base themes', () => { + const actual = getDefaultBaseThemes(); + expect(actual).toEqual([ + { + name: 'Default Dark', + themeKey: 'default-dark', + styleContent: 'test-file-stub', + }, + { + name: 'Default Light', + themeKey: 'default-light', + styleContent: 'test-file-stub', + }, + ]); + }); +}); + +describe('getThemeKey', () => { + it('should combine plugin name and theme name', () => { + const actual = getThemeKey('plugin', 'name'); + expect(actual).toBe('plugin_name'); + }); +}); + +describe('getThemePreloadData', () => { + const mockPreload = { + dataA: `{"themeKey":"aaa","preloadStyleContent":"':root{}"}`, + dataB: `{"themeKey":"bbb","preloadStyleContent":"':root{}"}`, + notParseable: '{', + }; + + it.each([ + [null, null], + [mockPreload.notParseable, null], + [mockPreload.dataA, JSON.parse(mockPreload.dataA)], + ])( + 'should parse local storage value or return null: %s, %s', + (localStorageValue, expected) => { + jest + .spyOn(Object.getPrototypeOf(localStorage), 'getItem') + .mockName('getItem'); + + if (localStorageValue != null) { + localStorage.setItem(THEME_CACHE_LOCAL_STORAGE_KEY, localStorageValue); + } + + const actual = getThemePreloadData(); + + expect(localStorage.getItem).toHaveBeenCalledWith( + THEME_CACHE_LOCAL_STORAGE_KEY + ); + expect(actual).toEqual(expected); + } + ); +}); + +describe('preloadTheme', () => { + it.each([ + null, + { + themeKey: 'mock.themeKey', + preloadStyleContent: ':root{mock.preloadStyleContent}', + }, + ] as const)('should set the style content: %s', preloadData => { + if (preloadData != null) { + localStorage.setItem( + THEME_CACHE_LOCAL_STORAGE_KEY, + JSON.stringify(preloadData) + ); + } + + preloadTheme(); + + const styleEl = document.querySelector('style'); + + expect(styleEl).not.toBeNull(); + expect(styleEl?.innerHTML).toEqual( + preloadData?.preloadStyleContent ?? calculatePreloadStyleContent() + ); + }); +}); + +describe('setThemePreloadData', () => { + it('should set the theme preload data', () => { + const preloadData = { + themeKey: 'mock.themeKey', + preloadStyleContent: ':root{mock.preloadStyleContent}', + } as const; + + setThemePreloadData(preloadData); + + expect(localStorage.getItem(THEME_CACHE_LOCAL_STORAGE_KEY)).toEqual( + JSON.stringify(preloadData) + ); + }); +}); diff --git a/packages/components/src/theme/ThemeUtils.ts b/packages/components/src/theme/ThemeUtils.ts new file mode 100644 index 0000000000..04768f736e --- /dev/null +++ b/packages/components/src/theme/ThemeUtils.ts @@ -0,0 +1,155 @@ +import Log from '@deephaven/log'; +import { assertNotNull } from '@deephaven/utils'; +// Note that ?inline imports are natively supported by Vite, but consumers of +// @deephaven/components using Webpack will need to add a rule to their module +// config. +// e.g. +// module: { +// rules: [ +// { +// resourceQuery: /inline/, +// type: 'asset/source', +// }, +// ], +// }, +import darkTheme from './theme_default_dark.css?inline'; +import lightTheme from './theme_default_light.css?inline'; +import { + DEFAULT_DARK_THEME_KEY, + DEFAULT_LIGHT_THEME_KEY, + DEFAULT_PRELOAD_DATA_VARIABLES, + ThemeData, + ThemePreloadData, + ThemePreloadStyleContent, + ThemeRegistrationData, + THEME_CACHE_LOCAL_STORAGE_KEY, +} from './ThemeModel'; + +const log = Log.module('ThemeUtils'); + +/** + * Creates a string containing preload style content for the current theme. + * This resolves the current values of a few CSS variables that can be used + * to style the page before the theme is loaded on next page load. + */ +export function calculatePreloadStyleContent(): ThemePreloadStyleContent { + const bodyStyle = getComputedStyle(document.body); + + // Calculate the current preload variables. If the variable is not set, use + // the default value. + const pairs = Object.entries(DEFAULT_PRELOAD_DATA_VARIABLES).map( + ([key, defaultValue]) => + `${key}:${bodyStyle.getPropertyValue(key) || defaultValue}` + ); + + return `:root{${pairs.join(';')}}`; +} + +/** + * Returns an array of the active themes. The first item will always be one + * of the base themes. Optionally, the second item will be a custom theme. + */ +export function getActiveThemes( + themeKey: string, + themeRegistration: ThemeRegistrationData +): [ThemeData] | [ThemeData, ThemeData] { + const custom = themeRegistration.custom.find( + theme => theme.themeKey === themeKey + ); + + const baseThemeKey = custom?.baseThemeKey ?? themeKey; + + let base = themeRegistration.base.find( + theme => theme.themeKey === baseThemeKey + ); + + if (base == null) { + log.error( + `No registered base theme found for theme key: '${baseThemeKey}'`, + 'Registered:', + themeRegistration.base.map(theme => theme.themeKey), + themeRegistration.custom.map(theme => theme.themeKey) + ); + base = themeRegistration.base.find( + theme => theme.themeKey === DEFAULT_DARK_THEME_KEY + ); + + assertNotNull( + base, + `Default base theme '${DEFAULT_DARK_THEME_KEY}' is not registered` + ); + } + + log.debug('Applied themes:', base.themeKey, custom?.themeKey); + + return custom == null ? [base] : [base, custom]; +} + +/** + * Get default base theme data. + */ +export function getDefaultBaseThemes(): ThemeData[] { + return [ + { + name: 'Default Dark', + themeKey: DEFAULT_DARK_THEME_KEY, + styleContent: darkTheme, + }, + { + name: 'Default Light', + themeKey: DEFAULT_LIGHT_THEME_KEY, + styleContent: lightTheme, + }, + ]; +} + +/** + * Get the preload data from local storage or null if it does not exist or is + * invalid + */ +export function getThemePreloadData(): ThemePreloadData | null { + const data = localStorage.getItem(THEME_CACHE_LOCAL_STORAGE_KEY); + + try { + return data == null ? null : JSON.parse(data); + } catch { + // ignore + } + + return null; +} + +/** + * Store theme preload data in local storage. + * @param preloadData The preload data to set + */ +export function setThemePreloadData(preloadData: ThemePreloadData): void { + localStorage.setItem( + THEME_CACHE_LOCAL_STORAGE_KEY, + JSON.stringify(preloadData) + ); +} + +/** + * Derive unique theme key from plugin root path and theme name. + * @param pluginName The root path of the plugin + * @param themeName The name of the theme + */ +export function getThemeKey(pluginName: string, themeName: string): string { + return `${pluginName}_${themeName}`; +} + +/** + * Preload minimal theme variables from the cache. + */ +export function preloadTheme(): void { + const preloadStyleContent = + getThemePreloadData()?.preloadStyleContent ?? + calculatePreloadStyleContent(); + + log.debug('Preloading theme content:', `'${preloadStyleContent}'`); + + const style = document.createElement('style'); + style.innerHTML = preloadStyleContent; + document.head.appendChild(style); +} diff --git a/packages/components/src/theme/__snapshots__/ThemeProvider.test.tsx.snap b/packages/components/src/theme/__snapshots__/ThemeProvider.test.tsx.snap new file mode 100644 index 0000000000..245e385a12 --- /dev/null +++ b/packages/components/src/theme/__snapshots__/ThemeProvider.test.tsx.snap @@ -0,0 +1,107 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ThemeProvider setSelectedThemeKey: [ [Object] ] should change selected theme: default-light 1`] = ` + +
+ +
+ Child +
+
+ +`; + +exports[`ThemeProvider setSelectedThemeKey: [ [Object] ] should change selected theme: themeA 1`] = ` + +
+ + + +
+ Child +
+
+ +`; + +exports[`ThemeProvider should load themes based on preload data or default: null, { themeKey: 'themeA' } 1`] = ` + +
+
+ Child +
+
+ +`; + +exports[`ThemeProvider should load themes based on preload data or default: null, null 1`] = ` + +
+
+ Child +
+
+ +`; diff --git a/packages/components/src/theme/index.ts b/packages/components/src/theme/index.ts new file mode 100644 index 0000000000..42d06c3079 --- /dev/null +++ b/packages/components/src/theme/index.ts @@ -0,0 +1,4 @@ +export * from './ThemeModel'; +export * from './ThemeProvider'; +export * from './ThemeUtils'; +export * from './useTheme'; diff --git a/packages/components/src/theme/theme_default_dark.css b/packages/components/src/theme/theme_default_dark.css new file mode 100644 index 0000000000..7e2c6d7cfc --- /dev/null +++ b/packages/components/src/theme/theme_default_dark.css @@ -0,0 +1,54 @@ +:root { + /* Grays */ + --dh-color-gray-900: #fcfcfa; + --dh-color-gray-800: #f0f0ee; + --dh-color-gray-700: #c0bfbf; + --dh-color-gray-600: #929192; + --dh-color-gray-500: #5b5a5c; + --dh-color-gray-400: #403e41; + --dh-color-gray-300: #373438; + --dh-color-gray-200: #322f33; + --dh-color-gray-100: #2d2a2e; + --dh-color-gray-75: #211f22; + --dh-color-gray-50: #1a171a; + + /* Blues */ + --dh-color-blue-100: #112451; + --dh-color-blue-200: #16306c; + --dh-color-blue-300: #1d3d88; + --dh-color-blue-400: #254ba4; + --dh-color-blue-500: #2f5bc0; + --dh-color-blue-600: #3b6bda; + --dh-color-blue-700: #4c7dee; + --dh-color-blue-800: #6390fa; + --dh-color-blue-900: #7ca4ff; + --dh-color-blue-1000: #97b7ff; + --dh-color-blue-1100: #afc9ff; + --dh-color-blue-1200: #c7d9ff; + --dh-color-blue-1300: #dbe6ff; + --dh-color-blue-1400: #ecf1ff; + + /* Seafoam */ + --dh-color-seafoam-100: #1f2925; + --dh-color-seafoam-200: #263630; + --dh-color-seafoam-300: #2f463e; + --dh-color-seafoam-400: #37574b; + --dh-color-seafoam-500: #3e6959; + --dh-color-seafoam-600: #447b68; + --dh-color-seafoam-700: #488f78; + --dh-color-seafoam-800: #4ca387; + --dh-color-seafoam-900: #4fb797; + --dh-color-seafoam-1000: #54cba6; + --dh-color-seafoam-1100: #5edeb7; + --dh-color-seafoam-1200: #78edc7; + --dh-color-seafoam-1300: #9ef8d7; + --dh-color-seafoam-1400: #cbfce8; + + /* Semantic */ + --dh-color-black: var(--dh-color-gray-50); + --dh-color-white: var(--dh-color-gray-800); + --dh-accent-color: var(--dh-color-blue-700); + --dh-background-color: var(--dh-color-black); + --dh-foreground-color: var(--dh-color-white); + --dh-grid-background-color: var(--dh-background-color); +} diff --git a/packages/components/src/theme/theme_default_light.css b/packages/components/src/theme/theme_default_light.css new file mode 100644 index 0000000000..20fb5c30f2 --- /dev/null +++ b/packages/components/src/theme/theme_default_light.css @@ -0,0 +1,54 @@ +:root { + /* Grays */ + --dh-color-gray-900: #000; + --dh-color-gray-800: #222; + --dh-color-gray-700: #464646; + --dh-color-gray-600: #6d6d6d; + --dh-color-gray-500: #909090; + --dh-color-gray-400: #b1b1b1; + --dh-color-gray-300: #d5d5d5; + --dh-color-gray-200: #e6e6e6; + --dh-color-gray-100: #f8f8f8; + --dh-color-gray-75: #fdfdfd; + --dh-color-gray-50: #fff; + + /* Blues */ + --dh-color-blue-100: #112451; + --dh-color-blue-200: #16306c; + --dh-color-blue-300: #1d3d88; + --dh-color-blue-400: #254ba4; + --dh-color-blue-500: #2f5bc0; + --dh-color-blue-600: #3b6bda; + --dh-color-blue-700: #4c7dee; + --dh-color-blue-800: #6390fa; + --dh-color-blue-900: #7ca4ff; + --dh-color-blue-1000: #97b7ff; + --dh-color-blue-1100: #afc9ff; + --dh-color-blue-1200: #c7d9ff; + --dh-color-blue-1300: #dbe6ff; + --dh-color-blue-1400: #ecf1ff; + + /* Seafoam */ + --dh-color-seafoam-100: #1f2925; + --dh-color-seafoam-200: #263630; + --dh-color-seafoam-300: #2f463e; + --dh-color-seafoam-400: #37574b; + --dh-color-seafoam-500: #3e6959; + --dh-color-seafoam-600: #447b68; + --dh-color-seafoam-700: #488f78; + --dh-color-seafoam-800: #4ca387; + --dh-color-seafoam-900: #4fb797; + --dh-color-seafoam-1000: #54cba6; + --dh-color-seafoam-1100: #5edeb7; + --dh-color-seafoam-1200: #78edc7; + --dh-color-seafoam-1300: #9ef8d7; + --dh-color-seafoam-1400: #cbfce8; + + /* Semantic */ + --dh-color-black: var(--dh-color-gray-50); + --dh-color-white: var(--dh-color-gray-75); + --dh-accent-color: var(--dh-color-blue-700); + --dh-background-color: var(--dh-color-white); + --dh-foreground-color: var(--dh-color-black); + --dh-grid-background-color: var(--dh-background-color); +} diff --git a/packages/components/src/theme/useTheme.test.ts b/packages/components/src/theme/useTheme.test.ts new file mode 100644 index 0000000000..60a9329d3d --- /dev/null +++ b/packages/components/src/theme/useTheme.test.ts @@ -0,0 +1,40 @@ +import { useContext } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { TestUtils } from '@deephaven/utils'; +import { useTheme } from './useTheme'; + +const { asMock } = TestUtils; + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useContext: jest.fn(), +})); + +const themeContextValue = {}; + +beforeEach(() => { + jest.clearAllMocks(); + expect.hasAssertions(); + + asMock(useContext).mockName('useContext'); +}); + +describe('useTheme', () => { + it('should return theme context value', () => { + asMock(useContext).mockReturnValue(themeContextValue); + + const { result } = renderHook(() => useTheme()); + expect(result.current).toBe(themeContextValue); + }); + + it('should throw if context is null', () => { + asMock(useContext).mockReturnValue(null); + + const { result } = renderHook(() => useTheme()); + expect(result.error).toEqual( + new Error( + 'No ThemeContext value found. Component must be wrapped in a ThemeContext.Provider' + ) + ); + }); +}); diff --git a/packages/components/src/theme/useTheme.ts b/packages/components/src/theme/useTheme.ts new file mode 100644 index 0000000000..aaf96fda06 --- /dev/null +++ b/packages/components/src/theme/useTheme.ts @@ -0,0 +1,14 @@ +import { useContextOrThrow } from '@deephaven/react-hooks'; +import { ThemeContext, ThemeContextValue } from './ThemeProvider'; + +/** + * Hook to get the current `ThemeContextValue`. + */ +export function useTheme(): ThemeContextValue { + return useContextOrThrow( + ThemeContext, + 'No ThemeContext value found. Component must be wrapped in a ThemeContext.Provider' + ); +} + +export default useTheme; diff --git a/packages/embed-chart/src/index.tsx b/packages/embed-chart/src/index.tsx index b89578fa32..f6856f2da7 100644 --- a/packages/embed-chart/src/index.tsx +++ b/packages/embed-chart/src/index.tsx @@ -4,10 +4,12 @@ import ReactDOM from 'react-dom'; // Need to import the base style sheet for proper styling // eslint-disable-next-line import/no-unresolved import '@deephaven/components/scss/BaseStyleSheet.scss'; -import { LoadingOverlay } from '@deephaven/components'; +import { LoadingOverlay, preloadTheme } from '@deephaven/components'; import { ApiBootstrap } from '@deephaven/jsapi-bootstrap'; import './index.scss'; +preloadTheme(); + // Lazy load components for code splitting and also to avoid importing the jsapi-shim before API is bootstrapped. // eslint-disable-next-line react-refresh/only-export-components const App = React.lazy(() => import('./App')); diff --git a/packages/embed-grid/src/index.tsx b/packages/embed-grid/src/index.tsx index b89578fa32..f6856f2da7 100644 --- a/packages/embed-grid/src/index.tsx +++ b/packages/embed-grid/src/index.tsx @@ -4,10 +4,12 @@ import ReactDOM from 'react-dom'; // Need to import the base style sheet for proper styling // eslint-disable-next-line import/no-unresolved import '@deephaven/components/scss/BaseStyleSheet.scss'; -import { LoadingOverlay } from '@deephaven/components'; +import { LoadingOverlay, preloadTheme } from '@deephaven/components'; import { ApiBootstrap } from '@deephaven/jsapi-bootstrap'; import './index.scss'; +preloadTheme(); + // Lazy load components for code splitting and also to avoid importing the jsapi-shim before API is bootstrapped. // eslint-disable-next-line react-refresh/only-export-components const App = React.lazy(() => import('./App')); diff --git a/packages/golden-layout/scss/goldenlayout-dark-theme.scss b/packages/golden-layout/scss/goldenlayout-dark-theme.scss index ef6617fb2e..d80d933f19 100644 --- a/packages/golden-layout/scss/goldenlayout-dark-theme.scss +++ b/packages/golden-layout/scss/goldenlayout-dark-theme.scss @@ -63,7 +63,7 @@ body:not(.lm_dragging) .lm_header .lm_tab .lm_close_tab:hover { // Entire GoldenLayout Container, if a background is set, it is visible as color of "pane header" and "splitters" (if these latest has opacity very low) .lm_goldenlayout { - background: $background; + background: var(--dh-background-color, $background); position: absolute; } diff --git a/packages/jsapi-bootstrap/src/ApiBootstrap.tsx b/packages/jsapi-bootstrap/src/ApiBootstrap.tsx index 1ae91584f0..cd287e95b7 100644 --- a/packages/jsapi-bootstrap/src/ApiBootstrap.tsx +++ b/packages/jsapi-bootstrap/src/ApiBootstrap.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useEffect, useState } from 'react'; +import { createContext, ReactNode, useEffect, useState } from 'react'; import { LoadingOverlay, Modal, @@ -17,7 +17,7 @@ export type ApiBootstrapProps = { apiUrl: string; /** Children to render when the API has loaded */ - children: JSX.Element; + children: ReactNode; /** Element to display if there is a failure loading the API */ failureElement?: JSX.Element; diff --git a/packages/plugin/src/PluginTypes.test.ts b/packages/plugin/src/PluginTypes.test.ts index 65e0e0c899..de75414961 100644 --- a/packages/plugin/src/PluginTypes.test.ts +++ b/packages/plugin/src/PluginTypes.test.ts @@ -3,31 +3,26 @@ import { isAuthPlugin, isDashboardPlugin, isTablePlugin, + isThemePlugin, } from './PluginTypes'; -test('isDashboardPlugin', () => { - expect( - isDashboardPlugin({ name: 'test', type: PluginType.DASHBOARD_PLUGIN }) - ).toBe(true); - expect( - isDashboardPlugin({ name: 'test', type: PluginType.TABLE_PLUGIN }) - ).toBe(false); -}); +const pluginTypeToTypeGuardMap = [ + [PluginType.DASHBOARD_PLUGIN, isDashboardPlugin], + [PluginType.AUTH_PLUGIN, isAuthPlugin], + [PluginType.TABLE_PLUGIN, isTablePlugin], + [PluginType.THEME_PLUGIN, isThemePlugin], +] as const; -test('isAuthPlugin', () => { - expect(isAuthPlugin({ name: 'test', type: PluginType.AUTH_PLUGIN })).toBe( - true - ); - expect(isAuthPlugin({ name: 'test', type: PluginType.TABLE_PLUGIN })).toBe( - false - ); -}); - -test('isTablePlugin', () => { - expect(isTablePlugin({ name: 'test', type: PluginType.TABLE_PLUGIN })).toBe( - true - ); - expect(isTablePlugin({ name: 'test', type: PluginType.AUTH_PLUGIN })).toBe( - false - ); -}); +describe.each(pluginTypeToTypeGuardMap)( + 'plugin type guard: %s', + (expectedPluginType, typeGuard) => { + it.each(pluginTypeToTypeGuardMap)( + 'should return true for expected plugin type: %s', + givenPluginType => { + expect(typeGuard({ name: 'test', type: givenPluginType })).toBe( + givenPluginType === expectedPluginType + ); + } + ); + } +); diff --git a/packages/plugin/src/PluginTypes.ts b/packages/plugin/src/PluginTypes.ts index 0b1b746e78..86b513dff7 100644 --- a/packages/plugin/src/PluginTypes.ts +++ b/packages/plugin/src/PluginTypes.ts @@ -1,9 +1,11 @@ +import type { BaseThemeType } from '@deephaven/components'; import type { TablePluginComponent } from './TablePlugin'; export const PluginType = Object.freeze({ AUTH_PLUGIN: 'AuthPlugin', DASHBOARD_PLUGIN: 'DashboardPlugin', TABLE_PLUGIN: 'TablePlugin', + THEME_PLUGIN: 'ThemePlugin', }); /** @@ -129,3 +131,19 @@ export interface AuthPlugin extends Plugin { export function isAuthPlugin(plugin: PluginModule): plugin is AuthPlugin { return 'type' in plugin && plugin.type === PluginType.AUTH_PLUGIN; } + +export interface ThemeConfig { + name: string; + baseTheme?: BaseThemeType; + styleContent: string; +} + +export interface ThemePlugin extends Plugin { + type: typeof PluginType.THEME_PLUGIN; + themes: ThemeConfig | ThemeConfig[]; +} + +/** Type guard to check if given plugin is a `ThemePlugin` */ +export function isThemePlugin(plugin: PluginModule): plugin is ThemePlugin { + return 'type' in plugin && plugin.type === PluginType.THEME_PLUGIN; +} diff --git a/packages/utils/src/Asserts.test.ts b/packages/utils/src/Asserts.test.ts index 0c40d5e0a4..95593b9604 100644 --- a/packages/utils/src/Asserts.test.ts +++ b/packages/utils/src/Asserts.test.ts @@ -14,6 +14,9 @@ describe('assertNever', () => { it('throws an error when a value is null', () => { expect(() => assertNotNull(null)).toThrowError('Value is null or undefined'); + expect(() => assertNotNull(null, 'Custom error message')).toThrowError( + 'Custom error message' + ); }); describe('getOrThrow', () => { diff --git a/packages/utils/src/Asserts.ts b/packages/utils/src/Asserts.ts index 4ecb2adc41..df07772fe5 100644 --- a/packages/utils/src/Asserts.ts +++ b/packages/utils/src/Asserts.ts @@ -30,9 +30,10 @@ export function assertNever(shouldBeNever: never, name?: string): never { } export function assertNotNull( - value: T | null | undefined + value: T | null | undefined, + message = 'Value is null or undefined' ): asserts value is T { - if (value == null) throw new Error('Value is null or undefined'); + if (value == null) throw new Error(message); } /**