Skip to content

Commit

Permalink
feat: Theme Plugin Loading (deephaven#1524)
Browse files Browse the repository at this point in the history
- 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 deephaven#1530
  • Loading branch information
bmingles authored Oct 5, 2023
1 parent ee7d1c1 commit a9541b1
Show file tree
Hide file tree
Showing 33 changed files with 1,165 additions and 61 deletions.
4 changes: 4 additions & 0 deletions jest.config.base.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ module.exports = {
'./__mocks__/spectrumTheme$1Mock.js'
),
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(css|less|scss|sass)\\?inline$': path.join(
__dirname,
'./__mocks__/fileMock.js'
),
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
path.join(__dirname, './__mocks__/fileMock.js'),
'^fira$': 'identity-obj-proxy',
Expand Down
2 changes: 2 additions & 0 deletions packages/app-utils/src/components/AppBootstrap.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ beforeEach(() => {
});

it('should throw if api has not been bootstrapped', () => {
TestUtils.disableConsoleOutput();

expect(() =>
render(
<AppBootstrap serverUrl={API_URL} pluginsUrl={PLUGINS_URL}>
Expand Down
37 changes: 20 additions & 17 deletions packages/app-utils/src/components/AppBootstrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getConnectOptions } from '../utils';
import FontsLoaded from './FontsLoaded';
import UserBootstrap from './UserBootstrap';
import ServerConfigBootstrap from './ServerConfigBootstrap';
import ThemeBootstrap from './ThemeBootstrap';

export type AppBootstrapProps = {
/** URL of the server. */
Expand Down Expand Up @@ -57,23 +58,25 @@ export function AppBootstrap({
return (
<FontBootstrap fontClassNames={fontClassNames}>
<PluginsBootstrap getCorePlugins={getCorePlugins} pluginsUrl={pluginsUrl}>
<ClientBootstrap
serverUrl={serverUrl}
options={clientOptions}
key={logoutCount}
>
<RefreshTokenBootstrap>
<AuthBootstrap>
<ServerConfigBootstrap>
<UserBootstrap>
<ConnectionBootstrap>
<FontsLoaded>{children}</FontsLoaded>
</ConnectionBootstrap>
</UserBootstrap>
</ServerConfigBootstrap>
</AuthBootstrap>
</RefreshTokenBootstrap>
</ClientBootstrap>
<ThemeBootstrap>
<ClientBootstrap
serverUrl={serverUrl}
options={clientOptions}
key={logoutCount}
>
<RefreshTokenBootstrap>
<AuthBootstrap>
<ServerConfigBootstrap>
<UserBootstrap>
<ConnectionBootstrap>
<FontsLoaded>{children}</FontsLoaded>
</ConnectionBootstrap>
</UserBootstrap>
</ServerConfigBootstrap>
</AuthBootstrap>
</RefreshTokenBootstrap>
</ClientBootstrap>
</ThemeBootstrap>
</PluginsBootstrap>
</FontBootstrap>
);
Expand Down
25 changes: 25 additions & 0 deletions packages/app-utils/src/components/ThemeBootstrap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ThemeProvider } from '@deephaven/components';
import { useContext, useMemo } from 'react';
import { getThemeDataFromPlugins } from '../plugins';
import { PluginsContext } from './PluginsBootstrap';

export interface ThemeBootstrapProps {
children: React.ReactNode;
}

export function ThemeBootstrap({ children }: ThemeBootstrapProps): JSX.Element {
// The `usePlugins` hook throws if the context value is null. Since this is
// the state while plugins load asynchronously, we are using `useContext`
// directly to avoid the exception.
const pluginModules = useContext(PluginsContext);

const themes = useMemo(
() =>
pluginModules == null ? null : getThemeDataFromPlugins(pluginModules),
[pluginModules]
);

return <ThemeProvider themes={themes}>{children}</ThemeProvider>;
}

export default ThemeBootstrap;
1 change: 1 addition & 0 deletions packages/app-utils/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from './ConnectionBootstrap';
export * from './FontBootstrap';
export * from './FontsLoaded';
export * from './PluginsBootstrap';
export * from './ThemeBootstrap';
export * from './usePlugins';
export * from './useConnection';
export * from './useServerConfig';
Expand Down
2 changes: 1 addition & 1 deletion packages/app-utils/src/components/usePlugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { PluginsContext } from './PluginsBootstrap';
export function usePlugins(): PluginModuleMap {
return useContextOrThrow(
PluginsContext,
'No Plugins available in usePlugins. Was code wrapped in PluginsBootstrap or PluginsContext.Provider?'
'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.'
);
}

Expand Down
104 changes: 104 additions & 0 deletions packages/app-utils/src/plugins/PluginUtils.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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<string, PluginModule>([
['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);
});
});
57 changes: 52 additions & 5 deletions packages/app-utils/src/plugins/PluginUtils.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getThemeKey, ThemeData } from '@deephaven/components';
import Log from '@deephaven/log';
import {
type PluginModule,
Expand All @@ -10,6 +11,8 @@ import {
PluginType,
isLegacyAuthPlugin,
isLegacyPlugin,
isThemePlugin,
ThemePlugin,
} from '@deephaven/plugin';
import loadRemoteModule from './loadRemoteModule';

Expand Down Expand Up @@ -77,19 +80,29 @@ export async function loadModulePlugins(
const pluginMainUrl = `${modulePluginsUrl}/${name}/${main}`;
pluginPromises.push(loadModulePlugin(pluginMainUrl));
}

const pluginModules = await Promise.allSettled(pluginPromises);

const pluginMap: PluginModuleMap = new Map();
for (let i = 0; i < pluginModules.length; i += 1) {
const module = pluginModules[i];
const { name } = manifest.plugins[i];
if (module.status === 'fulfilled') {
pluginMap.set(
name,
isLegacyPlugin(module.value) ? module.value : module.value.default
);
const moduleValue = isLegacyPlugin(module.value)
? module.value
: // TypeScript builds CJS default exports differently depending on
// whether there are also named exports. If the default is the only
// export, it will be the value. If there are also named exports,
// it will be assigned to the `default` property on the value.
module.value.default ?? module.value;

if (moduleValue == null) {
log.error(`Plugin '${name}' is missing an exported value.`);
} else {
pluginMap.set(name, moduleValue);
}
} else {
log.error(`Unable to load plugin ${name}`, module.reason);
log.error(`Unable to load plugin '${name}'`, module.reason);
}
}
log.info('Plugins loaded:', pluginMap);
Expand Down Expand Up @@ -161,3 +174,37 @@ 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();
}
4 changes: 2 additions & 2 deletions packages/babel-preset/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ module.exports = api => ({
'transform-rename-import',
{
// The babel-plugin-add-import-extension adds the .js to .scss imports, just convert them back to .css
original: '^(.+?)\\.s?css.js$',
replacement: '$1.css',
original: '^(.+?)\\.s?css(\\?inline)?\\.js$',
replacement: '$1.css$2',
},
],
].filter(Boolean),
Expand Down
4 changes: 3 additions & 1 deletion packages/code-studio/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import React, { Suspense } from 'react';
import ReactDOM from 'react-dom';
import '@deephaven/components/scss/BaseStyleSheet.scss';
import { LoadingOverlay } from '@deephaven/components';
import { LoadingOverlay, preloadTheme } from '@deephaven/components';
import { ApiBootstrap } from '@deephaven/jsapi-bootstrap';
import logInit from './log/LogInit';

logInit();

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 AppRoot = React.lazy(() => import('./AppRoot'));
Expand Down
4 changes: 2 additions & 2 deletions packages/components/scss/BaseStyleSheet.scss
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ html {

body {
min-height: 100%;
background-color: $background;
background-color: var(--dh-background-color, $background);
color: $foreground;
margin: 0;
padding: 0;
Expand All @@ -30,7 +30,7 @@ body {
}

#root {
background-color: $background;
background-color: var(--dh-background-color, $background);

.app {
height: 100vh;
Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/EditableItemList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import classNames from 'classnames';
import clamp from 'lodash.clamp';
import { vsAdd, vsTrash } from '@deephaven/icons';
import { Range, RangeUtils } from '@deephaven/utils';
import { Button, ItemList } from '.';
import Button from './Button';
import ItemList from './ItemList';

export interface EditableItemListProps {
isInvalid?: boolean;
Expand Down
10 changes: 10 additions & 0 deletions packages/components/src/declaration.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,14 @@ declare module '*.module.scss' {
export default content;
}

declare module '*.css?inline' {
const content: string;
export default content;
}

declare module '*.scss?inline' {
const content: string;
export default content;
}

declare module '*.scss';
1 change: 1 addition & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export { default as SocketedButton } from './SocketedButton';
export * from './SpectrumUtils';
export * from './TableViewEmptyState';
export * from './TextWithTooltip';
export * from './theme';
export { default as ThemeExport } from './ThemeExport';
export { default as TimeInput } from './TimeInput';
export { default as TimeSlider } from './TimeSlider';
Expand Down
Loading

0 comments on commit a9541b1

Please sign in to comment.