Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions zeppelin-web-angular/projects/zeppelin-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ src/
│ └── PublishedParagraph.tsx # entry component + mount()
├── templates/
│ └── SingleResultRenderer.tsx # routes result types to renderers
├── theme/ # host theme detection, antd + chart.js theming
├── utils/ # tableUtils, textUtils, exportFile
└── main.ts # re-exports for Module Federation
```
Expand All @@ -109,27 +110,33 @@ export function mount(element: HTMLElement, props: Props): ReactMountHandle;

1. Create a component (e.g. `src/components/<area>/ExampleFeature.tsx`).
2. Wrap its render tree in `<ReactErrorBoundary onError={props.onError}>`.
3. Export a `mount(element, props)` function that:
3. Wrap it in `<ZeppelinThemeProvider>` as well (see `src/theme/`), otherwise
antd builds its styles from the default light algorithm and the module only
looks right in dark mode while the shell's global `.ant-*` rules happen to
cover the components in use. Pass surface specific tokens through its
`token` prop, and read `useHostThemeMode()` when you draw outside antd, as
a canvas chart does.
4. Export a `mount(element, props)` function that:
- Creates a single `Root` via `createRoot(element)`.
- Calls `root.render(<Wrapped {...props}/>)` on initial mount AND on
every `update(newProps)` call. React's reconciler preserves state.
- Returns `{ update, unmount }`. `unmount` calls `root.unmount()`.
4. Register in `webpack.config.js` under `exposes`:
5. Register in `webpack.config.js` under `exposes`:
```js
exposes: {
'./PublishedParagraph': './src/pages/PublishedParagraph',
'./ParagraphFooter': './src/components/paragraph/ParagraphFooter',
'./ExampleFeature': './src/components/<area>/ExampleFeature'
}
```
5. Re-export from `main.ts`:
6. Re-export from `main.ts`:
```ts
export {
ExampleFeature,
mount as mountExampleFeature
} from './components/<area>/ExampleFeature';
```
6. Use from Angular by adding the directive to your template:
7. Use from Angular by adding the directive to your template:
```html
<div
zeppelin-react-mount="./ExampleFeature"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { Table } from 'antd';
import { VisualizationControls } from './VisualizationControls';
import { applyChartTheme, useHostThemeMode } from '@/theme';
import { parseTableData, exportFile } from '@/utils';
import type { ParagraphConfigResult, ParagraphIResultsMsgItem, VisualizationMode } from '@zeppelin/sdk';
import type { Chart, ChartConfiguration } from 'chart.js';
Expand All @@ -25,6 +26,7 @@ interface TableVisualizationProps {
export const TableVisualization = ({ result, config }: TableVisualizationProps) => {
const [currentMode, setCurrentMode] = useState<VisualizationMode>(config?.graph.mode || 'table');
const chartRef = useRef<HTMLDivElement>(null);
const themeMode = useHostThemeMode();

const tableData = useMemo(() => parseTableData(result.data), [result.data]);

Expand Down Expand Up @@ -86,6 +88,10 @@ export const TableVisualization = ({ result, config }: TableVisualizationProps)

const ChartConstructor = module.Chart || module.default;

// Ticks, legend labels and grid lines all resolve from these two
// globals, and a canvas is out of reach of the shell's stylesheets.
applyChartTheme(ChartConstructor, themeMode);

const canvas = document.createElement('canvas');
canvas.style.width = '100%';
canvas.style.height = '100%';
Expand Down Expand Up @@ -222,7 +228,7 @@ export const TableVisualization = ({ result, config }: TableVisualizationProps)
container.innerHTML = '';
}
};
}, [currentMode, tableData]);
}, [currentMode, tableData, themeMode]);

return (
<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,40 +11,36 @@
*/

import { createRoot } from 'react-dom/client';
import { ConfigProvider } from 'antd';
import { Empty } from '@/components';
import { SingleResultRenderer } from '@/templates';
import { ZeppelinThemeProvider } from '@/theme';
import type { ParagraphConfigResults, ParagraphIResultsMsgItem } from '@zeppelin/sdk';

const RESULT_FONT_FAMILY = "'Lucida Console', Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace";

export interface PublishedParagraphProps {
paragraphId: string;
results?: ParagraphIResultsMsgItem[];
config?: ParagraphConfigResults;
}

export const PublishedParagraph = ({ results, config }: PublishedParagraphProps) => {
if (!results || results.length === 0) {
return <Empty />;
}

return (
<ConfigProvider
theme={{
token: {
fontFamily: "'Lucida Console', Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace"
}
}}
>
export const PublishedParagraph = ({ results, config }: PublishedParagraphProps) => (
// The empty state is inside the provider too: antd's Empty illustration is
// themed, so leaving it outside would leak a light widget into a dark page.
<ZeppelinThemeProvider token={{ fontFamily: RESULT_FONT_FAMILY }}>
{!results || results.length === 0 ? (
<Empty />
) : (
<div data-testid="react-published-paragraph">
{results.map((result, index) => (
<div key={index}>
<SingleResultRenderer result={result} index={index} config={config} />
</div>
))}
</div>
</ConfigProvider>
);
};
)}
</ZeppelinThemeProvider>
);

export const mount = (element: HTMLElement, props?: PublishedParagraphProps) => {
if (!element) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { act } from 'react';
import { render, screen } from '@testing-library/react';
import { theme as antdTheme } from 'antd';
import { afterEach, describe, expect, it } from 'vitest';
import { useHostThemeMode, ZeppelinThemeProvider } from './ZeppelinThemeProvider';
import { HostThemeMode } from './hostTheme';

const Probe = () => {
const { token } = antdTheme.useToken();
return (
<>
<span data-testid="container-bg">{token.colorBgContainer}</span>
<span data-testid="font">{token.fontFamily}</span>
<span data-testid="mode">{useHostThemeMode()}</span>
</>
);
};

const setHostTheme = (mode: HostThemeMode) => {
document.documentElement.setAttribute('data-theme', mode);
};

describe('ZeppelinThemeProvider', () => {
afterEach(() => {
document.documentElement.removeAttribute('data-theme');
});

it('builds antd tokens from the dark algorithm when the shell is dark', () => {
setHostTheme('dark');
render(
<ZeppelinThemeProvider>
<Probe />
</ZeppelinThemeProvider>
);

// Light tokens would put a white container on the shell's dark page; today
// that only goes unnoticed because the shell's global .ant-* rules cover it.
expect(screen.getByTestId('container-bg').textContent).toBe('#141414');
expect(screen.getByTestId('mode').textContent).toBe('dark');
});

it('builds antd tokens from the default algorithm when the shell is light', () => {
setHostTheme('light');
render(
<ZeppelinThemeProvider>
<Probe />
</ZeppelinThemeProvider>
);

expect(screen.getByTestId('container-bg').textContent).toBe('#ffffff');
expect(screen.getByTestId('mode').textContent).toBe('light');
});

it('re-themes in place when the shell toggles the theme', async () => {
setHostTheme('light');
render(
<ZeppelinThemeProvider>
<Probe />
</ZeppelinThemeProvider>
);
expect(screen.getByTestId('container-bg').textContent).toBe('#ffffff');

await act(async () => {
setHostTheme('dark');
});

expect(screen.getByTestId('container-bg').textContent).toBe('#141414');
});

it('keeps surface tokens while switching algorithms', () => {
setHostTheme('dark');
render(
<ZeppelinThemeProvider token={{ fontFamily: 'Consolas' }}>
<Probe />
</ZeppelinThemeProvider>
);

expect(screen.getByTestId('font').textContent).toBe('Consolas');
expect(screen.getByTestId('container-bg').textContent).toBe('#141414');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { createContext, ReactNode, useContext } from 'react';
import { ConfigProvider, theme as antdTheme, ThemeConfig } from 'antd';
import { HostThemeMode, useHostTheme } from './hostTheme';

const HostThemeContext = createContext<HostThemeMode>('light');

/** Resolved host theme for code that draws outside antd, such as canvas charts. */
export const useHostThemeMode = (): HostThemeMode => useContext(HostThemeContext);

export interface ZeppelinThemeProviderProps {
children: ReactNode;
/** Extra tokens for a single surface, e.g. a monospace result font. */
token?: ThemeConfig['token'];
}

/**
* Every exposed module should render inside this provider. Without it antd
* builds its styles from the default (light) algorithm, and the remote looks
* dark only for as long as the shell's global `.ant-*` rules happen to cover
* the components in use.
*/
export const ZeppelinThemeProvider = ({ children, token }: ZeppelinThemeProviderProps) => {
const mode = useHostTheme();

return (
<ConfigProvider
theme={{
algorithm: mode === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
token
}}
>
<HostThemeContext.Provider value={mode}>{children}</HostThemeContext.Provider>
</ConfigProvider>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { describe, expect, it } from 'vitest';
import { applyChartTheme, CHART_THEME } from './chartTheme';

// chart.js ships '#666' text and 'rgba(0, 0, 0, 0.1)' grid lines, both of
// which are meant for a light canvas.
const chartJsDefaults = () => ({ defaults: { color: '#666', borderColor: 'rgba(0, 0, 0, 0.1)' } });

describe('applyChartTheme', () => {
it('replaces the chart.js defaults with the dark palette', () => {
const chart = chartJsDefaults();

applyChartTheme(chart, 'dark');

expect(chart.defaults.color).toBe(CHART_THEME.dark.text);
expect(chart.defaults.borderColor).toBe(CHART_THEME.dark.grid);
});

it('replaces the chart.js defaults with the light palette', () => {
const chart = chartJsDefaults();

applyChartTheme(chart, 'light');

expect(chart.defaults.color).toBe(CHART_THEME.light.text);
expect(chart.defaults.borderColor).toBe(CHART_THEME.light.grid);
});

it('leaves no chart.js default in place for either mode', () => {
// The point of the issue: axis labels at '#666' sit at about 3.2:1 against
// the shell's dark background, below the 4.5:1 the rest of the UI meets.
const untouched = chartJsDefaults().defaults;

for (const mode of ['light', 'dark'] as const) {
const chart = chartJsDefaults();
applyChartTheme(chart, mode);
expect(chart.defaults.color).not.toBe(untouched.color);
expect(chart.defaults.borderColor).not.toBe(untouched.borderColor);
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { HostThemeMode } from './hostTheme';

/**
* Charts are painted on a canvas, so no stylesheet reaches them. chart.js
* defaults to '#666' text and 'rgba(0, 0, 0, 0.1)' grid lines, which leaves
* axis labels at roughly 3.2:1 against the dark background and the grid
* invisible. These values follow antd's secondary text and split tokens.
*/
export const CHART_THEME: Record<HostThemeMode, { text: string; grid: string }> = {
light: { text: 'rgba(0, 0, 0, 0.65)', grid: 'rgba(0, 0, 0, 0.06)' },
dark: { text: 'rgba(255, 255, 255, 0.65)', grid: 'rgba(255, 255, 255, 0.12)' }
};

/** The two globals chart.js resolves ticks, legend labels and grid lines from. */
export interface ChartThemeTarget {
defaults: {
color: unknown;
borderColor: unknown;
};
}

export const applyChartTheme = (chart: ChartThemeTarget, mode: HostThemeMode): void => {
chart.defaults.color = CHART_THEME[mode].text;
chart.defaults.borderColor = CHART_THEME[mode].grid;
};
Loading
Loading