Skip to content

Commit a9541b1

Browse files
authored
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: deephaven/deephaven-plugins#65 ### Testing Setup Theme Plugins Locally - Checkout the draft plugins PR: deephaven/deephaven-plugins#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 `<style data-theme-key="default-dark">...` - Inspect the `<body>` el. There should be some `--dh-color-xxx` CSS variables loaded in the inspector. - In "Application" dev tools tab there should be an entry in localStorage `deephaven.themeCache` `{"themeKey":"default-dark","preloadStyleContent":":root{--dh-accent-color:#4c7dee;--dh-background-color:#1a171a}"}` - Switch default theme - In console run: `localStorage.setItem('deephaven.themeCache', '{"themeKey":"default-light"}')` and reload page Note: On the first page refresh, there will be a moment where the background shows the previously applied theme. Refreshing the page again should show the right color the entire time. This is due to how we are setting things via the console but won't be an issue once users can select themes from the UI. - UI should show white background in title bar (it won't look good, just proving we can switch color) - Its first child should be `<style data-theme-key="default-light">...` - Inspect the `<body>` el. There should be some `--dh-color-xxx` CSS variables loaded in the inspector. - In "Application" dev tools tab there should be an entry in localStorage `deephaven.themeCache` `{"themeKey":"default-light","preloadStyleContent":":root{--dh-accent-color:#4c7dee;--dh-background-color:#fdfdfd}"}` - Repeat, but this time run: `localStorage.setItem('deephaven.themeCache', '{"themeKey":"default-dark"}')` and reload page. Should see things go back to initial load state - Load custom themes There should be 4 custom themes provided by the plugins repo. They can be selected via the following: - `localStorage.setItem('deephaven.themeCache', '{"themeKey":"theme-multi-example_acme-dark"}')` - `localStorage.setItem('deephaven.themeCache', '{"themeKey":"theme-multi-example_acme-light"}')` - `localStorage.setItem('deephaven.themeCache', '{"themeKey":"theme-multi-example_acme-cool"}')` - `localStorage.setItem('deephaven.themeCache', '{"themeKey":"theme-single-example_single-dark"}')` - These should produce similar results as the default ones, except: - There should be 2 `style` tags under the `div id="root"`. The first will be either the dark or light base theme. The 2nd will correspond to the custom theme variables - Inspecting the body element should show additional `:root { ... }` css variables for the custom theme just above the base theme variables - localStorage should contain an entry like: `{"themeKey":"theme-multi-example_acme-light","preloadStyleContent":":root{--dh-accent-color:#4c7dee;--dh-background-color:#fdfdfd}"}` corresponding to the current theme. - Reloading the page should keep the same styling resolves #1530
1 parent ee7d1c1 commit a9541b1

33 files changed

Lines changed: 1165 additions & 61 deletions

jest.config.base.cjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ module.exports = {
1414
'./__mocks__/spectrumTheme$1Mock.js'
1515
),
1616
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
17+
'\\.(css|less|scss|sass)\\?inline$': path.join(
18+
__dirname,
19+
'./__mocks__/fileMock.js'
20+
),
1721
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
1822
path.join(__dirname, './__mocks__/fileMock.js'),
1923
'^fira$': 'identity-obj-proxy',

packages/app-utils/src/components/AppBootstrap.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ beforeEach(() => {
5858
});
5959

6060
it('should throw if api has not been bootstrapped', () => {
61+
TestUtils.disableConsoleOutput();
62+
6163
expect(() =>
6264
render(
6365
<AppBootstrap serverUrl={API_URL} pluginsUrl={PLUGINS_URL}>

packages/app-utils/src/components/AppBootstrap.tsx

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { getConnectOptions } from '../utils';
1414
import FontsLoaded from './FontsLoaded';
1515
import UserBootstrap from './UserBootstrap';
1616
import ServerConfigBootstrap from './ServerConfigBootstrap';
17+
import ThemeBootstrap from './ThemeBootstrap';
1718

1819
export type AppBootstrapProps = {
1920
/** URL of the server. */
@@ -57,23 +58,25 @@ export function AppBootstrap({
5758
return (
5859
<FontBootstrap fontClassNames={fontClassNames}>
5960
<PluginsBootstrap getCorePlugins={getCorePlugins} pluginsUrl={pluginsUrl}>
60-
<ClientBootstrap
61-
serverUrl={serverUrl}
62-
options={clientOptions}
63-
key={logoutCount}
64-
>
65-
<RefreshTokenBootstrap>
66-
<AuthBootstrap>
67-
<ServerConfigBootstrap>
68-
<UserBootstrap>
69-
<ConnectionBootstrap>
70-
<FontsLoaded>{children}</FontsLoaded>
71-
</ConnectionBootstrap>
72-
</UserBootstrap>
73-
</ServerConfigBootstrap>
74-
</AuthBootstrap>
75-
</RefreshTokenBootstrap>
76-
</ClientBootstrap>
61+
<ThemeBootstrap>
62+
<ClientBootstrap
63+
serverUrl={serverUrl}
64+
options={clientOptions}
65+
key={logoutCount}
66+
>
67+
<RefreshTokenBootstrap>
68+
<AuthBootstrap>
69+
<ServerConfigBootstrap>
70+
<UserBootstrap>
71+
<ConnectionBootstrap>
72+
<FontsLoaded>{children}</FontsLoaded>
73+
</ConnectionBootstrap>
74+
</UserBootstrap>
75+
</ServerConfigBootstrap>
76+
</AuthBootstrap>
77+
</RefreshTokenBootstrap>
78+
</ClientBootstrap>
79+
</ThemeBootstrap>
7780
</PluginsBootstrap>
7881
</FontBootstrap>
7982
);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { ThemeProvider } from '@deephaven/components';
2+
import { useContext, useMemo } from 'react';
3+
import { getThemeDataFromPlugins } from '../plugins';
4+
import { PluginsContext } from './PluginsBootstrap';
5+
6+
export interface ThemeBootstrapProps {
7+
children: React.ReactNode;
8+
}
9+
10+
export function ThemeBootstrap({ children }: ThemeBootstrapProps): JSX.Element {
11+
// The `usePlugins` hook throws if the context value is null. Since this is
12+
// the state while plugins load asynchronously, we are using `useContext`
13+
// directly to avoid the exception.
14+
const pluginModules = useContext(PluginsContext);
15+
16+
const themes = useMemo(
17+
() =>
18+
pluginModules == null ? null : getThemeDataFromPlugins(pluginModules),
19+
[pluginModules]
20+
);
21+
22+
return <ThemeProvider themes={themes}>{children}</ThemeProvider>;
23+
}
24+
25+
export default ThemeBootstrap;

packages/app-utils/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from './ConnectionBootstrap';
44
export * from './FontBootstrap';
55
export * from './FontsLoaded';
66
export * from './PluginsBootstrap';
7+
export * from './ThemeBootstrap';
78
export * from './usePlugins';
89
export * from './useConnection';
910
export * from './useServerConfig';

packages/app-utils/src/components/usePlugins.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { PluginsContext } from './PluginsBootstrap';
55
export function usePlugins(): PluginModuleMap {
66
return useContextOrThrow(
77
PluginsContext,
8-
'No Plugins available in usePlugins. Was code wrapped in PluginsBootstrap or PluginsContext.Provider?'
8+
'No Plugins available in usePlugins. This can happen when plugins have not finished loading or if code is not wrapped in PluginsBootstrap or PluginsContext.Provider.'
99
);
1010
}
1111

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { ThemeData } from '@deephaven/components';
2+
import { DashboardPlugin, PluginModule, ThemePlugin } from '@deephaven/plugin';
3+
import { getThemeDataFromPlugins } from './PluginUtils';
4+
5+
beforeEach(() => {
6+
document.body.removeAttribute('style');
7+
document.head.innerHTML = '';
8+
jest.clearAllMocks();
9+
expect.hasAssertions();
10+
});
11+
12+
describe('getThemeDataFromPlugins', () => {
13+
const themePluginSingleDark: ThemePlugin = {
14+
name: 'mock.themePluginNameA',
15+
type: 'ThemePlugin',
16+
themes: {
17+
name: 'mock.customDark',
18+
baseTheme: 'dark',
19+
styleContent: 'mock.styleContent',
20+
},
21+
};
22+
23+
const themePluginSingleLight: ThemePlugin = {
24+
name: 'mock.themePluginNameB',
25+
type: 'ThemePlugin',
26+
themes: {
27+
name: 'mock.customLight',
28+
baseTheme: 'light',
29+
styleContent: 'mock.styleContent',
30+
},
31+
};
32+
33+
const themePluginMultiConfig: ThemePlugin = {
34+
name: 'mock.themePluginNameC',
35+
type: 'ThemePlugin',
36+
themes: [
37+
{
38+
name: 'mock.customDark',
39+
baseTheme: 'dark',
40+
styleContent: 'mock.styleContent',
41+
},
42+
{
43+
name: 'mock.customLight',
44+
baseTheme: 'light',
45+
styleContent: 'mock.styleContent',
46+
},
47+
{
48+
name: 'mock.customUndefined',
49+
styleContent: 'mock.styleContent',
50+
},
51+
],
52+
};
53+
54+
const otherPlugin: DashboardPlugin = {
55+
name: 'mock.otherPluginName',
56+
type: 'DashboardPlugin',
57+
component: () => null,
58+
};
59+
60+
const pluginMap = new Map<string, PluginModule>([
61+
['mock.themePluginNameA', themePluginSingleDark],
62+
['mock.themePluginNameB', themePluginSingleLight],
63+
['mock.themePluginNameC', themePluginMultiConfig],
64+
['mock.otherPluginName', otherPlugin],
65+
]);
66+
67+
it('should return theme data from plugins', () => {
68+
const actual = getThemeDataFromPlugins(pluginMap);
69+
const expected: ThemeData[] = [
70+
{
71+
name: 'mock.customDark',
72+
baseThemeKey: 'default-dark',
73+
themeKey: 'mock.themePluginNameA_mock.customDark',
74+
styleContent: 'mock.styleContent',
75+
},
76+
{
77+
name: 'mock.customLight',
78+
baseThemeKey: 'default-light',
79+
themeKey: 'mock.themePluginNameB_mock.customLight',
80+
styleContent: 'mock.styleContent',
81+
},
82+
{
83+
name: 'mock.customDark',
84+
baseThemeKey: 'default-dark',
85+
themeKey: 'mock.themePluginNameC_mock.customDark',
86+
styleContent: 'mock.styleContent',
87+
},
88+
{
89+
name: 'mock.customLight',
90+
baseThemeKey: 'default-light',
91+
themeKey: 'mock.themePluginNameC_mock.customLight',
92+
styleContent: 'mock.styleContent',
93+
},
94+
{
95+
name: 'mock.customUndefined',
96+
baseThemeKey: 'default-dark',
97+
themeKey: 'mock.themePluginNameC_mock.customUndefined',
98+
styleContent: 'mock.styleContent',
99+
},
100+
];
101+
102+
expect(actual).toEqual(expected);
103+
});
104+
});

packages/app-utils/src/plugins/PluginUtils.tsx

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getThemeKey, ThemeData } from '@deephaven/components';
12
import Log from '@deephaven/log';
23
import {
34
type PluginModule,
@@ -10,6 +11,8 @@ import {
1011
PluginType,
1112
isLegacyAuthPlugin,
1213
isLegacyPlugin,
14+
isThemePlugin,
15+
ThemePlugin,
1316
} from '@deephaven/plugin';
1417
import loadRemoteModule from './loadRemoteModule';
1518

@@ -77,19 +80,29 @@ export async function loadModulePlugins(
7780
const pluginMainUrl = `${modulePluginsUrl}/${name}/${main}`;
7881
pluginPromises.push(loadModulePlugin(pluginMainUrl));
7982
}
83+
8084
const pluginModules = await Promise.allSettled(pluginPromises);
8185

8286
const pluginMap: PluginModuleMap = new Map();
8387
for (let i = 0; i < pluginModules.length; i += 1) {
8488
const module = pluginModules[i];
8589
const { name } = manifest.plugins[i];
8690
if (module.status === 'fulfilled') {
87-
pluginMap.set(
88-
name,
89-
isLegacyPlugin(module.value) ? module.value : module.value.default
90-
);
91+
const moduleValue = isLegacyPlugin(module.value)
92+
? module.value
93+
: // TypeScript builds CJS default exports differently depending on
94+
// whether there are also named exports. If the default is the only
95+
// export, it will be the value. If there are also named exports,
96+
// it will be assigned to the `default` property on the value.
97+
module.value.default ?? module.value;
98+
99+
if (moduleValue == null) {
100+
log.error(`Plugin '${name}' is missing an exported value.`);
101+
} else {
102+
pluginMap.set(name, moduleValue);
103+
}
91104
} else {
92-
log.error(`Unable to load plugin ${name}`, module.reason);
105+
log.error(`Unable to load plugin '${name}'`, module.reason);
93106
}
94107
}
95108
log.info('Plugins loaded:', pluginMap);
@@ -161,3 +174,37 @@ export function getAuthPluginComponent(
161174

162175
return component;
163176
}
177+
178+
/**
179+
* Extract theme data from theme plugins in the given plugin map.
180+
* @param pluginMap
181+
*/
182+
export function getThemeDataFromPlugins(
183+
pluginMap: PluginModuleMap
184+
): ThemeData[] {
185+
const themePluginEntries = [...pluginMap.entries()].filter(
186+
(entry): entry is [string, ThemePlugin] => isThemePlugin(entry[1])
187+
);
188+
189+
log.debug('Getting theme data from plugins', themePluginEntries);
190+
191+
return themePluginEntries
192+
.map(([pluginName, plugin]) => {
193+
// Normalize to an array since config can be an array of configs or a
194+
// single config
195+
const configs = Array.isArray(plugin.themes)
196+
? plugin.themes
197+
: [plugin.themes];
198+
199+
return configs.map(
200+
({ name, baseTheme, styleContent }) =>
201+
({
202+
baseThemeKey: `default-${baseTheme ?? 'dark'}`,
203+
themeKey: getThemeKey(pluginName, name),
204+
name,
205+
styleContent,
206+
}) as const
207+
);
208+
})
209+
.flat();
210+
}

packages/babel-preset/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ module.exports = api => ({
4141
'transform-rename-import',
4242
{
4343
// The babel-plugin-add-import-extension adds the .js to .scss imports, just convert them back to .css
44-
original: '^(.+?)\\.s?css.js$',
45-
replacement: '$1.css',
44+
original: '^(.+?)\\.s?css(\\?inline)?\\.js$',
45+
replacement: '$1.css$2',
4646
},
4747
],
4848
].filter(Boolean),

packages/code-studio/src/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import React, { Suspense } from 'react';
22
import ReactDOM from 'react-dom';
33
import '@deephaven/components/scss/BaseStyleSheet.scss';
4-
import { LoadingOverlay } from '@deephaven/components';
4+
import { LoadingOverlay, preloadTheme } from '@deephaven/components';
55
import { ApiBootstrap } from '@deephaven/jsapi-bootstrap';
66
import logInit from './log/LogInit';
77

88
logInit();
99

10+
preloadTheme();
11+
1012
// Lazy load components for code splitting and also to avoid importing the jsapi-shim before API is bootstrapped.
1113
// eslint-disable-next-line react-refresh/only-export-components
1214
const AppRoot = React.lazy(() => import('./AppRoot'));

0 commit comments

Comments
 (0)