diff --git a/.gitignore b/.gitignore index 216ba60082..a9ccc179b1 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ packages/*/package-lock.json /playwright/.cache/ /tests/*-snapshots/* !/tests/*-snapshots/*-linux* +vite.config.local.ts \ No newline at end of file diff --git a/README.md b/README.md index 830cd6c656..5618976194 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,45 @@ If your DHC address is different from the default `http://localhost:10000`, edit VITE_PROXY_URL=http://: ``` +## Local Plugin Development +The plugins repo supports [serving plugins locally](https://github.com/deephaven/deephaven-plugins/blob/main/README.md#serve-plugins). DHC can be configured to proxy `js-plugins`requests to the local dev server by setting `VITE_JS_PLUGINS_DEV_PORT` in `packages/code-studio/.env.development.local`. + +e.g. To point to the default dev port: + +``` +VITE_JS_PLUGINS_DEV_PORT=4100 +``` + +## Local Vite Config +If you'd like to override the vite config for local dev, you can define a `packages/code-studio/vite.config.local.ts` file that extends from `vite.config.ts`. This file is excluded via `.gitignore` which makes it easy to keep local overrides in tact. + +The config can be used by running: + +`npm run start:app -- -- -- --config=vite.config.local.ts` + +For example, to proxy `js-plugins` requests to a local server, you could use this `vite.config.local.ts`: + +```typescript +export default defineConfig((config: ConfigEnv) => { + const baseConfig = (createBaseConfig as UserConfigFn)(config) as UserConfig; + + return { + ...baseConfig, + server: { + ...baseConfig.server, + proxy: { + ...baseConfig.server?.proxy, + '/js-plugins': { + target: 'http://localhost:5173', + changeOrigin: true, + rewrite: path => path.replace(/^\/js-plugins/, ''), + }, + }, + }, + }; +}); +``` + ## Debugging from VSCode We have a pre-defined launch config that lets you set breakpoints directly in VSCode for debugging browser code. The `Launch Deephaven` config will launch a new Chrome window that stores its data in your repo workspace. With this setup, you only need to install the React and Redux devtool extensions once. They will persist to future launches using the launch config. diff --git a/package-lock.json b/package-lock.json index cb16133cc9..8f56af58e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27950,6 +27950,7 @@ "@deephaven/auth-plugins": "file:../auth-plugins", "@deephaven/chart": "file:../chart", "@deephaven/components": "file:../components", + "@deephaven/console": "file:../console", "@deephaven/dashboard": "file:../dashboard", "@deephaven/icons": "file:../icons", "@deephaven/iris-grid": "file:../iris-grid", @@ -28699,6 +28700,7 @@ "@deephaven/icons": "file:../icons", "@deephaven/iris-grid": "file:../iris-grid", "@deephaven/jsapi-types": "file:../jsapi-types", + "@deephaven/log": "file:../log", "@deephaven/react-hooks": "file:../react-hooks", "@fortawesome/fontawesome-common-types": "^6.1.1", "@fortawesome/react-fontawesome": "^0.2.0" @@ -30097,6 +30099,7 @@ "@deephaven/auth-plugins": "file:../auth-plugins", "@deephaven/chart": "file:../chart", "@deephaven/components": "file:../components", + "@deephaven/console": "file:../console", "@deephaven/dashboard": "file:../dashboard", "@deephaven/icons": "file:../icons", "@deephaven/iris-grid": "file:../iris-grid", @@ -30583,6 +30586,7 @@ "@deephaven/icons": "file:../icons", "@deephaven/iris-grid": "file:../iris-grid", "@deephaven/jsapi-types": "file:../jsapi-types", + "@deephaven/log": "file:../log", "@deephaven/react-hooks": "file:../react-hooks", "@fortawesome/fontawesome-common-types": "^6.1.1", "@fortawesome/react-fontawesome": "^0.2.0" diff --git a/packages/app-utils/package.json b/packages/app-utils/package.json index e2503c39a7..dd789e4f20 100644 --- a/packages/app-utils/package.json +++ b/packages/app-utils/package.json @@ -32,6 +32,7 @@ "@deephaven/auth-plugins": "file:../auth-plugins", "@deephaven/chart": "file:../chart", "@deephaven/components": "file:../components", + "@deephaven/console": "file:../console", "@deephaven/dashboard": "file:../dashboard", "@deephaven/icons": "file:../icons", "@deephaven/iris-grid": "file:../iris-grid", diff --git a/packages/app-utils/src/components/AppBootstrap.test.tsx b/packages/app-utils/src/components/AppBootstrap.test.tsx index dc838d24b7..1b8b8cfe90 100644 --- a/packages/app-utils/src/components/AppBootstrap.test.tsx +++ b/packages/app-utils/src/components/AppBootstrap.test.tsx @@ -1,6 +1,8 @@ import React, { useContext } from 'react'; +import { act, render, screen } from '@testing-library/react'; import { AUTH_HANDLER_TYPE_ANONYMOUS } from '@deephaven/auth-plugins'; import { ApiContext } from '@deephaven/jsapi-bootstrap'; +import { PluginModuleMap, PluginsContext } from '@deephaven/plugin'; import { BROADCAST_LOGIN_MESSAGE } from '@deephaven/jsapi-utils'; import type { CoreClient, @@ -8,10 +10,7 @@ import type { dh as DhType, } from '@deephaven/jsapi-types'; import { TestUtils } from '@deephaven/utils'; -import { act, render, screen } from '@testing-library/react'; import AppBootstrap from './AppBootstrap'; -import { PluginsContext } from './PluginsBootstrap'; -import { PluginModuleMap } from '../plugins'; const { asMock } = TestUtils; diff --git a/packages/app-utils/src/components/ThemeBootstrap.tsx b/packages/app-utils/src/components/ThemeBootstrap.tsx index 2beeabb2e0..9d683ad984 100644 --- a/packages/app-utils/src/components/ThemeBootstrap.tsx +++ b/packages/app-utils/src/components/ThemeBootstrap.tsx @@ -1,8 +1,9 @@ import { useContext, useMemo } from 'react'; import { ChartThemeProvider } from '@deephaven/chart'; +import { MonacoThemeProvider } from '@deephaven/console'; import { ThemeProvider } from '@deephaven/components'; -import { PluginsContext } from '@deephaven/plugin'; -import { getThemeDataFromPlugins } from '../plugins'; +import { IrisGridThemeProvider } from '@deephaven/iris-grid'; +import { getThemeDataFromPlugins, PluginsContext } from '@deephaven/plugin'; export interface ThemeBootstrapProps { children: React.ReactNode; @@ -22,7 +23,11 @@ export function ThemeBootstrap({ children }: ThemeBootstrapProps): JSX.Element { return ( - {children} + + + {children} + + ); } diff --git a/packages/app-utils/src/plugins/PluginUtils.test.tsx b/packages/app-utils/src/plugins/PluginUtils.test.tsx deleted file mode 100644 index d3551471f2..0000000000 --- a/packages/app-utils/src/plugins/PluginUtils.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { ThemeData } from '@deephaven/components'; -import { DashboardPlugin, PluginModule, ThemePlugin } from '@deephaven/plugin'; -import { getThemeDataFromPlugins } from './PluginUtils'; - -beforeEach(() => { - document.body.removeAttribute('style'); - document.head.innerHTML = ''; - jest.clearAllMocks(); - expect.hasAssertions(); -}); - -describe('getThemeDataFromPlugins', () => { - const themePluginSingleDark: ThemePlugin = { - name: 'mock.themePluginNameA', - type: 'ThemePlugin', - themes: { - name: 'mock.customDark', - baseTheme: 'dark', - styleContent: 'mock.styleContent', - }, - }; - - const themePluginSingleLight: ThemePlugin = { - name: 'mock.themePluginNameB', - type: 'ThemePlugin', - themes: { - name: 'mock.customLight', - baseTheme: 'light', - styleContent: 'mock.styleContent', - }, - }; - - const themePluginMultiConfig: ThemePlugin = { - name: 'mock.themePluginNameC', - type: 'ThemePlugin', - themes: [ - { - name: 'mock.customDark', - baseTheme: 'dark', - styleContent: 'mock.styleContent', - }, - { - name: 'mock.customLight', - baseTheme: 'light', - styleContent: 'mock.styleContent', - }, - { - name: 'mock.customUndefined', - styleContent: 'mock.styleContent', - }, - ], - }; - - const otherPlugin: DashboardPlugin = { - name: 'mock.otherPluginName', - type: 'DashboardPlugin', - component: () => null, - }; - - const pluginMap = new Map([ - ['mock.themePluginNameA', themePluginSingleDark], - ['mock.themePluginNameB', themePluginSingleLight], - ['mock.themePluginNameC', themePluginMultiConfig], - ['mock.otherPluginName', otherPlugin], - ]); - - it('should return theme data from plugins', () => { - const actual = getThemeDataFromPlugins(pluginMap); - const expected: ThemeData[] = [ - { - name: 'mock.customDark', - baseThemeKey: 'default-dark', - themeKey: 'mock.themePluginNameA_mock.customDark', - styleContent: 'mock.styleContent', - }, - { - name: 'mock.customLight', - baseThemeKey: 'default-light', - themeKey: 'mock.themePluginNameB_mock.customLight', - styleContent: 'mock.styleContent', - }, - { - name: 'mock.customDark', - baseThemeKey: 'default-dark', - themeKey: 'mock.themePluginNameC_mock.customDark', - styleContent: 'mock.styleContent', - }, - { - name: 'mock.customLight', - baseThemeKey: 'default-light', - themeKey: 'mock.themePluginNameC_mock.customLight', - styleContent: 'mock.styleContent', - }, - { - name: 'mock.customUndefined', - baseThemeKey: 'default-dark', - themeKey: 'mock.themePluginNameC_mock.customUndefined', - styleContent: 'mock.styleContent', - }, - ]; - - expect(actual).toEqual(expected); - }); -}); diff --git a/packages/app-utils/src/plugins/PluginUtils.tsx b/packages/app-utils/src/plugins/PluginUtils.tsx index a7098041b6..75003fc18f 100644 --- a/packages/app-utils/src/plugins/PluginUtils.tsx +++ b/packages/app-utils/src/plugins/PluginUtils.tsx @@ -1,4 +1,3 @@ -import { getThemeKey, ThemeData } from '@deephaven/components'; import Log from '@deephaven/log'; import { type PluginModuleMap, @@ -11,8 +10,6 @@ import { PluginType, isLegacyAuthPlugin, isLegacyPlugin, - isThemePlugin, - ThemePlugin, } from '@deephaven/plugin'; import loadRemoteModule from './loadRemoteModule'; @@ -172,37 +169,3 @@ export function getAuthPluginComponent( return component; } - -/** - * Extract theme data from theme plugins in the given plugin map. - * @param pluginMap - */ -export function getThemeDataFromPlugins( - pluginMap: PluginModuleMap -): ThemeData[] { - const themePluginEntries = [...pluginMap.entries()].filter( - (entry): entry is [string, ThemePlugin] => isThemePlugin(entry[1]) - ); - - log.debug('Getting theme data from plugins', themePluginEntries); - - return themePluginEntries - .map(([pluginName, plugin]) => { - // Normalize to an array since config can be an array of configs or a - // single config - const configs = Array.isArray(plugin.themes) - ? plugin.themes - : [plugin.themes]; - - return configs.map( - ({ name, baseTheme, styleContent }) => - ({ - baseThemeKey: `default-${baseTheme ?? 'dark'}`, - themeKey: getThemeKey(pluginName, name), - name, - styleContent, - }) as const - ); - }) - .flat(); -} diff --git a/packages/app-utils/tsconfig.json b/packages/app-utils/tsconfig.json index 5f14608102..48d570ad4c 100644 --- a/packages/app-utils/tsconfig.json +++ b/packages/app-utils/tsconfig.json @@ -10,6 +10,7 @@ { "path": "../auth-plugins" }, { "path": "../chart" }, { "path": "../components" }, + { "path": "../console" }, { "path": "../dashboard" }, { "path": "../iris-grid" }, { "path": "../jsapi-bootstrap" }, diff --git a/packages/code-studio/src/settings/SettingsMenu.tsx b/packages/code-studio/src/settings/SettingsMenu.tsx index 474cdc19b6..c61ec5c0b3 100644 --- a/packages/code-studio/src/settings/SettingsMenu.tsx +++ b/packages/code-studio/src/settings/SettingsMenu.tsx @@ -6,16 +6,24 @@ import { vsRecordKeys, vsInfo, vsLayers, + vsPaintcan, dhUserIncognito, dhUser, } from '@deephaven/icons'; -import { Button, CopyButton, Tooltip } from '@deephaven/components'; +import { + Button, + CopyButton, + ThemeContext, + ThemePicker, + Tooltip, +} from '@deephaven/components'; import { ServerConfigValues, User } from '@deephaven/redux'; import { BROADCAST_CHANNEL_NAME, BROADCAST_LOGOUT_MESSAGE, makeMessage, } from '@deephaven/jsapi-utils'; +import { assertNotNull } from '@deephaven/utils'; import Logo from './community-wordmark-app.svg'; import FormattingSectionContent from './FormattingSectionContent'; import LegalNotice from './LegalNotice'; @@ -51,6 +59,8 @@ export class SettingsMenu extends Component< static SHORTCUT_SECTION_KEY = 'SettingsMenu.shortcuts'; + static THEME_SECTION_KEY = 'SettingsMenu.theme'; + static focusFirstInputInContainer(container: HTMLDivElement | null): void { const input = container?.querySelector('input, select, textarea'); if (input) { @@ -232,6 +242,34 @@ export class SettingsMenu extends Component< + + {contextValue => { + assertNotNull(contextValue, 'ThemeContext value is null'); + + return contextValue.themes.length > 1 ? ( + + + Theme + + } + > + + + ) : null; + }} + + 1; return (
@@ -68,7 +70,10 @@ function StyleGuide(): React.ReactElement { UNSAFE_className={HIDE_FROM_E2E_TESTS_CLASS} marginTop={-56} top={20} + gap={10} + alignItems="end" > + {hasMultipleThemes ? : null} diff --git a/packages/code-studio/src/styleguide/ThemeColors.tsx b/packages/code-studio/src/styleguide/ThemeColors.tsx index c9a61520dd..75f7d09b59 100644 --- a/packages/code-studio/src/styleguide/ThemeColors.tsx +++ b/packages/code-studio/src/styleguide/ThemeColors.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/jsx-props-no-spreading */ -import React from 'react'; -import { Tooltip } from '@deephaven/components'; +import React, { useMemo } from 'react'; +import { Tooltip, useTheme } from '@deephaven/components'; import { ColorUtils } from '@deephaven/utils'; import palette from '@deephaven/components/src/theme/theme-dark/theme-dark-palette.css?inline'; import semantic from '@deephaven/components/src/theme/theme-dark/theme-dark-semantic.css?inline'; @@ -16,16 +16,21 @@ import { INVALID_COLOR_BORDER_STYLE, } from './colorUtils'; -const swatchDataGroups = { - 'Theme Color Palette': buildColorGroups('palette', palette, 1), - 'Semantic Colors': buildColorGroups('semantic', semantic, 1), - 'Chart Colors': buildColorGroups('chart', chart, 2), - 'Editor Colors': buildColorGroups('editor', semanticEditor, 2), - 'Grid Colors': buildColorGroups('grid', semanticGrid, 2), - 'Component Colors': buildColorGroups('component', components, 1), -}; +function buildSwatchDataGroups() { + return { + 'Theme Color Palette': buildColorGroups('palette', palette, 1), + 'Semantic Colors': buildColorGroups('semantic', semantic, 1), + 'Chart Colors': buildColorGroups('chart', chart, 2), + 'Editor Colors': buildColorGroups('editor', semanticEditor, 2), + 'Grid Colors': buildColorGroups('grid', semanticGrid, 2), + 'Component Colors': buildColorGroups('component', components, 1), + }; +} export function ThemeColors(): JSX.Element { + const { selectedThemeKey } = useTheme(); + const swatchDataGroups = useMemo(buildSwatchDataGroups, [selectedThemeKey]); + return ( <> {Object.entries(swatchDataGroups).map(([label, data]) => ( diff --git a/packages/code-studio/src/styleguide/index.tsx b/packages/code-studio/src/styleguide/index.tsx index e2bb431cce..a08f6c21a0 100644 --- a/packages/code-studio/src/styleguide/index.tsx +++ b/packages/code-studio/src/styleguide/index.tsx @@ -1,22 +1,12 @@ import React, { Suspense } from 'react'; import ReactDOM from 'react-dom'; import '@deephaven/components/scss/BaseStyleSheet.scss'; -import { - LoadingOverlay, - preloadTheme, - ThemeData, - ThemeProvider, -} from '@deephaven/components'; +import { LoadingOverlay } from '@deephaven/components'; import { ApiBootstrap } from '@deephaven/jsapi-bootstrap'; import logInit from '../log/LogInit'; logInit(); -preloadTheme(); - -// Provide a non-null array to ThemeProvider to tell it to initialize -const customThemes: ThemeData[] = []; - // eslint-disable-next-line react-refresh/only-export-components const StyleGuideRoot = React.lazy(() => import('./StyleGuideRoot')); @@ -34,11 +24,9 @@ const apiURL = new URL( ReactDOM.render( }> - - - - - + + + , document.getElementById('root') diff --git a/packages/code-studio/vite.config.ts b/packages/code-studio/vite.config.ts index 2b79e00d3c..fdae3080cf 100644 --- a/packages/code-studio/vite.config.ts +++ b/packages/code-studio/vite.config.ts @@ -55,6 +55,16 @@ export default defineConfig(({ mode }) => { }); } + // Proxy to local dev server for js-plugins + if (env.VITE_JS_PLUGINS_DEV_PORT && env.VITE_MODULE_PLUGINS_URL) { + const route = new URL(env.VITE_MODULE_PLUGINS_URL, baseURL).pathname; + proxy[route] = { + target: `http://localhost:${env.VITE_JS_PLUGINS_DEV_PORT}`, + changeOrigin: true, + rewrite: path => path.replace(/^\/js-plugins/, ''), + }; + } + return { // Vite does not read this env variable, it sets it based on the config // For easy changes using our .env files, read it here and vite will just set it to the existing value diff --git a/packages/components/src/theme/ThemePicker.tsx b/packages/components/src/theme/ThemePicker.tsx new file mode 100644 index 0000000000..9169752285 --- /dev/null +++ b/packages/components/src/theme/ThemePicker.tsx @@ -0,0 +1,27 @@ +import { Key, useCallback } from 'react'; +import { Item, Picker } from '@adobe/react-spectrum'; +import useTheme from './useTheme'; + +export function ThemePicker(): JSX.Element | null { + const { selectedThemeKey, setSelectedThemeKey, themes } = useTheme(); + + const onSelectionChange = useCallback( + (key: Key) => { + setSelectedThemeKey(key as string); + }, + [setSelectedThemeKey] + ); + + return ( + + {item => {item.name}} + + ); +} + +export default ThemePicker; diff --git a/packages/components/src/theme/ThemeProvider.tsx b/packages/components/src/theme/ThemeProvider.tsx index fd51edc4f7..9de00822ac 100644 --- a/packages/components/src/theme/ThemeProvider.tsx +++ b/packages/components/src/theme/ThemeProvider.tsx @@ -15,6 +15,7 @@ import './theme-svg.scss'; export interface ThemeContextValue { activeThemes: ThemeData[] | null; selectedThemeKey: string; + themes: ThemeData[]; setSelectedThemeKey: (themeKey: string) => void; } @@ -33,7 +34,7 @@ export interface ThemeProviderProps { } export function ThemeProvider({ - themes, + themes: customThemes, children, }: ThemeProviderProps): JSX.Element { const baseThemes = useMemo(() => getDefaultBaseThemes(), []); @@ -45,19 +46,24 @@ export function ThemeProvider({ // Calculate active themes once a non-null themes array is provided. const activeThemes = useMemo( () => - themes == null + customThemes == null ? null : getActiveThemes(selectedThemeKey, { base: baseThemes, - custom: themes ?? [], + custom: customThemes ?? [], }), - [baseThemes, selectedThemeKey, themes] + [baseThemes, selectedThemeKey, customThemes] + ); + + const themes = useMemo( + () => [...baseThemes, ...(customThemes ?? [])], + [baseThemes, customThemes] ); useEffect( function updateThemePreloadData() { // Don't update preload data until themes have been loaded and activated - if (activeThemes == null || themes == null) { + if (activeThemes == null || customThemes == null) { return; } @@ -69,7 +75,7 @@ export function ThemeProvider({ log.debug2('updateThemePreloadData:', { active: activeThemes.map(theme => theme.themeKey), - all: themes.map(theme => theme.themeKey), + custom: customThemes.map(theme => theme.themeKey), preloadStyleContent, selectedThemeKey, }); @@ -79,16 +85,17 @@ export function ThemeProvider({ preloadStyleContent, }); }, - [activeThemes, selectedThemeKey, themes] + [activeThemes, selectedThemeKey, customThemes] ); const value = useMemo( () => ({ activeThemes, selectedThemeKey, + themes, setSelectedThemeKey, }), - [activeThemes, selectedThemeKey] + [activeThemes, selectedThemeKey, themes] ); return ( diff --git a/packages/components/src/theme/ThemeUtils.test.ts b/packages/components/src/theme/ThemeUtils.test.ts index 88207c0735..86f31e1935 100644 --- a/packages/components/src/theme/ThemeUtils.test.ts +++ b/packages/components/src/theme/ThemeUtils.test.ts @@ -308,11 +308,6 @@ describe('getDefaultBaseThemes', () => { './theme-dark-components.css?raw', ].join('\n'), }, - { - name: 'Default Light', - themeKey: 'default-light', - styleContent: './theme-light-palette.css?raw', - }, ]); }); }); diff --git a/packages/components/src/theme/ThemeUtils.ts b/packages/components/src/theme/ThemeUtils.ts index 398fa453a3..86ac76da2f 100644 --- a/packages/components/src/theme/ThemeUtils.ts +++ b/packages/components/src/theme/ThemeUtils.ts @@ -1,22 +1,8 @@ import Log from '@deephaven/log'; import { assertNotNull, ColorUtils } 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 { themeDark } from './theme-dark'; -import { themeLight } from './theme-light'; import { DEFAULT_DARK_THEME_KEY, - DEFAULT_LIGHT_THEME_KEY, DEFAULT_PRELOAD_DATA_VARIABLES, ThemeData, ThemePreloadData, @@ -158,11 +144,14 @@ export function getDefaultBaseThemes(): ThemeData[] { themeKey: DEFAULT_DARK_THEME_KEY, styleContent: themeDark, }, - { - name: 'Default Light', - themeKey: DEFAULT_LIGHT_THEME_KEY, - styleContent: themeLight, - }, + // The ThemePicker shows whenever more than 1 theme is available. Disable + // light theme for now to keep the picker hidden until it is fully + // implemented by #1539. + // { + // name: 'Default Light', + // themeKey: DEFAULT_LIGHT_THEME_KEY, + // styleContent: themeLight, + // }, ]; } @@ -393,6 +382,7 @@ export function preloadTheme(): void { log.debug('Preloading theme content:', `'${preloadStyleContent}'`); const style = document.createElement('style'); + style.id = 'theme-preload'; 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 index bd68d2ab79..a4c3b2b601 100644 --- a/packages/components/src/theme/__snapshots__/ThemeProvider.test.tsx.snap +++ b/packages/components/src/theme/__snapshots__/ThemeProvider.test.tsx.snap @@ -4,9 +4,14 @@ exports[`ThemeProvider setSelectedThemeKey: [ [Object] ] should change selected
{children}; +} diff --git a/packages/console/src/monaco/MonacoUtils.ts b/packages/console/src/monaco/MonacoUtils.ts index 1dca213375..3fade7a5eb 100644 --- a/packages/console/src/monaco/MonacoUtils.ts +++ b/packages/console/src/monaco/MonacoUtils.ts @@ -43,7 +43,22 @@ class MonacoUtils { MonacoUtils.registerGetWorker(getWorker); } - const { registerLanguages, removeHashtag } = MonacoUtils; + const { initTheme, registerLanguages } = MonacoUtils; + + initTheme(); + + registerLanguages([DbLang, PyLang, GroovyLang, LogLang, ScalaLang]); + + MonacoUtils.removeConflictingKeybindings(); + + log.debug('Monaco initialized.'); + } + + /** + * Initialize current Monaco theme based on the current DH theme. + */ + static initTheme(): void { + const { removeHashtag } = MonacoUtils; const MonacoTheme = resolveCssVariablesInRecord(MonacoThemeRaw); log.debug2('Monaco theme:', MonacoThemeRaw); @@ -83,7 +98,7 @@ class MonacoUtils { }, { token: 'error.log', - foreground: MonacoTheme['log-error'].substring(1), + foreground: MonacoTheme['log-error']?.substring(1) ?? '', }, { token: 'warn.log', @@ -165,18 +180,13 @@ class MonacoUtils { try { monaco.editor.setTheme('dh-dark'); - } catch { + } catch (err) { log.error( - `Failed to set 'dh-dark' Monaco theme, falling back to vs-dark` + `Failed to set 'dh-dark' Monaco theme, falling back to vs-dark`, + err ); monaco.editor.setTheme('vs-dark'); } - - registerLanguages([DbLang, PyLang, GroovyLang, LogLang, ScalaLang]); - - MonacoUtils.removeConflictingKeybindings(); - - log.debug('Monaco initialized.'); } /** @@ -195,8 +205,8 @@ class MonacoUtils { * Monaco expects colors to be the value only, no hashtag. * @param color The hex color string to remove the hashtag from, eg. '#ffffff' */ - static removeHashtag(color: string): string { - return color.substring(1); + static removeHashtag(color?: string): string { + return color?.substring(1) ?? ''; } static registerLanguages(languages: Language[]): void { diff --git a/packages/console/src/monaco/index.ts b/packages/console/src/monaco/index.ts index 98cf56f2af..8ad0b9f10a 100644 --- a/packages/console/src/monaco/index.ts +++ b/packages/console/src/monaco/index.ts @@ -1,3 +1,4 @@ export { default as MonacoUtils } from './MonacoUtils'; export { default as MonacoProviders } from './MonacoProviders'; export { default as MonacoTheme } from './MonacoTheme.module.scss'; +export * from './MonacoThemeProvider'; diff --git a/packages/grid/src/ThemeContext.ts b/packages/grid/src/ThemeContext.ts index 5fb3e3cd2e..4b89a746d9 100644 --- a/packages/grid/src/ThemeContext.ts +++ b/packages/grid/src/ThemeContext.ts @@ -1,7 +1,9 @@ import React from 'react'; import { GridTheme as GridThemeType } from './GridTheme'; -export const ThemeContext: React.Context> = +export type ThemeContextValue = Partial; + +export const ThemeContext: React.Context = React.createContext({}); export default ThemeContext; diff --git a/packages/grid/src/index.ts b/packages/grid/src/index.ts index a8d177b299..d19a66d01b 100644 --- a/packages/grid/src/index.ts +++ b/packages/grid/src/index.ts @@ -25,7 +25,7 @@ export * from './key-handlers'; export * from './mouse-handlers'; export * from './errors'; export * from './EventHandlerResult'; -export { default as ThemeContext } from './ThemeContext'; +export * from './ThemeContext'; export type { default as CellRenderer, CellRenderType } from './CellRenderer'; export { default as TextCellRenderer } from './TextCellRenderer'; export { default as DataBarCellRenderer } from './DataBarCellRenderer'; diff --git a/packages/iris-grid/src/IrisGrid.tsx b/packages/iris-grid/src/IrisGrid.tsx index b4bd389e17..7f7605097c 100644 --- a/packages/iris-grid/src/IrisGrid.tsx +++ b/packages/iris-grid/src/IrisGrid.tsx @@ -187,6 +187,7 @@ import { OperationMap, } from './CommonTypes'; import ColumnHeaderGroup from './ColumnHeaderGroup'; +import { IrisGridThemeContext } from './IrisGridThemeProvider'; const log = Log.module('IrisGrid'); @@ -437,6 +438,8 @@ export interface IrisGridState { } export class IrisGrid extends Component { + static contextType = IrisGridThemeContext; + static minDebounce = 150; static maxDebounce = 500; @@ -1359,6 +1362,7 @@ export class IrisGrid extends Component { getCachedTheme = memoize( ( + contextTheme: GridThemeType | null, theme: GridThemeType | null, isEditable: boolean, floatingRowCount: number @@ -1368,11 +1372,14 @@ export class IrisGrid extends Component { // We only show the row footers when we have floating rows for aggregations const rowFooterWidth = floatingRowCount > 0 - ? theme?.rowFooterWidth ?? defaultTheme.rowFooterWidth + ? theme?.rowFooterWidth ?? + contextTheme?.rowFooterWidth ?? + defaultTheme.rowFooterWidth : 0; return { ...defaultTheme, + ...contextTheme, ...theme, autoSelectRow: !isEditable, rowFooterWidth, @@ -1436,7 +1443,9 @@ export class IrisGrid extends Component { getTheme(): Partial { const { model, theme } = this.props; + return this.getCachedTheme( + this.context, theme, (isEditableGridModel(model) && model.isEditable) ?? false, model.floatingTopRowCount + model.floatingBottomRowCount diff --git a/packages/iris-grid/src/IrisGridThemeProvider.tsx b/packages/iris-grid/src/IrisGridThemeProvider.tsx new file mode 100644 index 0000000000..38c315e127 --- /dev/null +++ b/packages/iris-grid/src/IrisGridThemeProvider.tsx @@ -0,0 +1,35 @@ +import { useTheme } from '@deephaven/components'; +import { createContext, ReactNode, useEffect, useState } from 'react'; +import { createDefaultIrisGridTheme, IrisGridThemeType } from './IrisGridTheme'; + +export type IrisGridThemeContextValue = Partial; + +export const IrisGridThemeContext = + createContext(null); + +export interface IrisGridThemeProviderProps { + children: ReactNode; +} + +export function IrisGridThemeProvider({ + children, +}: IrisGridThemeProviderProps): JSX.Element { + const { activeThemes } = useTheme(); + + const [gridTheme, setGridTheme] = useState({}); + + useEffect( + function refreshIrisGridTheme() { + if (activeThemes != null) { + setGridTheme(createDefaultIrisGridTheme()); + } + }, + [activeThemes] + ); + + return ( + + {children} + + ); +} diff --git a/packages/iris-grid/src/index.ts b/packages/iris-grid/src/index.ts index 0d5efc174a..0f9df3920d 100644 --- a/packages/iris-grid/src/index.ts +++ b/packages/iris-grid/src/index.ts @@ -18,6 +18,7 @@ export * from './IrisGridTreeTableModel'; export { default as IrisGridModelFactory } from './IrisGridModelFactory'; export { createDefaultIrisGridTheme } from './IrisGridTheme'; export type { IrisGridThemeType } from './IrisGridTheme'; +export * from './IrisGridThemeProvider'; export { default as IrisGridTestUtils } from './IrisGridTestUtils'; export { default as IrisGridUtils } from './IrisGridUtils'; export * from './IrisGridUtils'; diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 49a2237868..e0ea0803a1 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -27,6 +27,7 @@ "@deephaven/icons": "file:../icons", "@deephaven/iris-grid": "file:../iris-grid", "@deephaven/jsapi-types": "file:../jsapi-types", + "@deephaven/log": "file:../log", "@deephaven/react-hooks": "file:../react-hooks", "@fortawesome/fontawesome-common-types": "^6.1.1", "@fortawesome/react-fontawesome": "^0.2.0" diff --git a/packages/plugin/src/PluginTypes.ts b/packages/plugin/src/PluginTypes.ts index 27cb33861a..15ea49f05a 100644 --- a/packages/plugin/src/PluginTypes.ts +++ b/packages/plugin/src/PluginTypes.ts @@ -42,6 +42,8 @@ export function isLegacyAuthPlugin( return 'AuthPlugin' in plugin; } +export type PluginModuleMap = Map; + /** * @deprecated Use TablePlugin instead */ diff --git a/packages/plugin/src/PluginUtils.test.tsx b/packages/plugin/src/PluginUtils.test.tsx index 27bbac972e..af34950ac6 100644 --- a/packages/plugin/src/PluginUtils.test.tsx +++ b/packages/plugin/src/PluginUtils.test.tsx @@ -1,8 +1,19 @@ import React from 'react'; -import { dhTruck, vsPreview } from '@deephaven/icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { DashboardPlugin, PluginType, type WidgetPlugin } from './PluginTypes'; -import { pluginSupportsType, getIconForPlugin } from './PluginUtils'; +import { ThemeData } from '@deephaven/components'; +import { dhTruck, vsPreview } from '@deephaven/icons'; +import { + type DashboardPlugin, + type PluginModule, + PluginType, + type ThemePlugin, + type WidgetPlugin, +} from './PluginTypes'; +import { + pluginSupportsType, + getIconForPlugin, + getThemeDataFromPlugins, +} from './PluginUtils'; function TestWidget() { return
TestWidget
; @@ -58,3 +69,104 @@ describe('getIconForPlugin', () => { expect(getIconForPlugin(customWidgetPlugin)).toEqual(customIcon); }); }); + +describe('getThemeDataFromPlugins', () => { + beforeEach(() => { + document.body.removeAttribute('style'); + document.head.innerHTML = ''; + jest.clearAllMocks(); + expect.hasAssertions(); + }); + + const themePluginSingleDark: ThemePlugin = { + name: 'mock.themePluginNameA', + type: 'ThemePlugin', + themes: { + name: 'mock.customDark', + baseTheme: 'dark', + styleContent: 'mock.styleContent', + }, + }; + + const themePluginSingleLight: ThemePlugin = { + name: 'mock.themePluginNameB', + type: 'ThemePlugin', + themes: { + name: 'mock.customLight', + baseTheme: 'light', + styleContent: 'mock.styleContent', + }, + }; + + const themePluginMultiConfig: ThemePlugin = { + name: 'mock.themePluginNameC', + type: 'ThemePlugin', + themes: [ + { + name: 'mock.customDark', + baseTheme: 'dark', + styleContent: 'mock.styleContent', + }, + { + name: 'mock.customLight', + baseTheme: 'light', + styleContent: 'mock.styleContent', + }, + { + name: 'mock.customUndefined', + styleContent: 'mock.styleContent', + }, + ], + }; + + const otherPlugin: DashboardPlugin = { + name: 'mock.otherPluginName', + type: 'DashboardPlugin', + component: () => null, + }; + + const pluginMap = new Map([ + ['mock.themePluginNameA', themePluginSingleDark], + ['mock.themePluginNameB', themePluginSingleLight], + ['mock.themePluginNameC', themePluginMultiConfig], + ['mock.otherPluginName', otherPlugin], + ]); + + it('should return theme data from plugins', () => { + const actual = getThemeDataFromPlugins(pluginMap); + const expected: ThemeData[] = [ + { + name: 'mock.customDark', + baseThemeKey: 'default-dark', + themeKey: 'mock.themePluginNameA_mock.customDark', + styleContent: 'mock.styleContent', + }, + { + name: 'mock.customLight', + baseThemeKey: 'default-light', + themeKey: 'mock.themePluginNameB_mock.customLight', + styleContent: 'mock.styleContent', + }, + { + name: 'mock.customDark', + baseThemeKey: 'default-dark', + themeKey: 'mock.themePluginNameC_mock.customDark', + styleContent: 'mock.styleContent', + }, + { + name: 'mock.customLight', + baseThemeKey: 'default-light', + themeKey: 'mock.themePluginNameC_mock.customLight', + styleContent: 'mock.styleContent', + }, + { + name: 'mock.customUndefined', + baseThemeKey: 'default-dark', + themeKey: 'mock.themePluginNameC_mock.customUndefined', + styleContent: 'mock.styleContent', + }, + ]; + + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/plugin/src/PluginUtils.tsx b/packages/plugin/src/PluginUtils.tsx index 6fa9024153..a2ef110b6b 100644 --- a/packages/plugin/src/PluginUtils.tsx +++ b/packages/plugin/src/PluginUtils.tsx @@ -1,7 +1,17 @@ import { isValidElement } from 'react'; -import { vsPreview } from '@deephaven/icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { type PluginModule, isWidgetPlugin } from './PluginTypes'; +import { getThemeKey, type ThemeData } from '@deephaven/components'; +import { vsPreview } from '@deephaven/icons'; +import Log from '@deephaven/log'; +import { + type PluginModule, + isWidgetPlugin, + PluginModuleMap, + ThemePlugin, + isThemePlugin, +} from './PluginTypes'; + +const log = Log.module('@deephaven/plugin.PluginUtils'); export function pluginSupportsType( plugin: PluginModule | undefined, @@ -32,3 +42,37 @@ export function getIconForPlugin(plugin: PluginModule): React.ReactElement { return ; } + +/** + * Extract theme data from theme plugins in the given plugin map. + * @param pluginMap + */ +export function getThemeDataFromPlugins( + pluginMap: PluginModuleMap +): ThemeData[] { + const themePluginEntries = [...pluginMap.entries()].filter( + (entry): entry is [string, ThemePlugin] => isThemePlugin(entry[1]) + ); + + log.debug('Getting theme data from plugins', themePluginEntries); + + return themePluginEntries + .map(([pluginName, plugin]) => { + // Normalize to an array since config can be an array of configs or a + // single config + const configs = Array.isArray(plugin.themes) + ? plugin.themes + : [plugin.themes]; + + return configs.map( + ({ name, baseTheme, styleContent }) => + ({ + baseThemeKey: `default-${baseTheme ?? 'dark'}`, + themeKey: getThemeKey(pluginName, name), + name, + styleContent, + }) as const + ); + }) + .flat(); +} diff --git a/packages/plugin/src/PluginsContext.ts b/packages/plugin/src/PluginsContext.ts index 81232ec3b5..6c2c39f03c 100644 --- a/packages/plugin/src/PluginsContext.ts +++ b/packages/plugin/src/PluginsContext.ts @@ -1,7 +1,5 @@ import { createContext } from 'react'; -import { type PluginModule } from './PluginTypes'; - -export type PluginModuleMap = Map; +import { type PluginModuleMap } from './PluginTypes'; export const PluginsContext = createContext(null); diff --git a/packages/plugin/src/usePlugins.ts b/packages/plugin/src/usePlugins.ts index 33bd7d8c2d..30f59235fb 100644 --- a/packages/plugin/src/usePlugins.ts +++ b/packages/plugin/src/usePlugins.ts @@ -1,5 +1,6 @@ import { useContextOrThrow } from '@deephaven/react-hooks'; -import { type PluginModuleMap, PluginsContext } from './PluginsContext'; +import { PluginsContext } from './PluginsContext'; +import { type PluginModuleMap } from './PluginTypes'; export function usePlugins(): PluginModuleMap { return useContextOrThrow( diff --git a/packages/plugin/tsconfig.json b/packages/plugin/tsconfig.json index 2965f3904c..b841f0614e 100644 --- a/packages/plugin/tsconfig.json +++ b/packages/plugin/tsconfig.json @@ -10,6 +10,7 @@ { "path": "../components" }, { "path": "../golden-layout" }, { "path": "../iris-grid" }, + { "path": "../log" }, { "path": "../jsapi-types" }, { "path": "../react-hooks" } ] diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 8fda64ef51..8cd9e023ba 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -5,6 +5,8 @@ "packages/*/src/**/**.tsx", "packages/*/src/**/**.js", "packages/*/src/**/**.jsx", + /* Vite config including any local overrides (e.g. vite.config.local.ts) */ + "packages/*/vite.config*.ts", "tests/**/**.ts" ], "exclude": []