Merge pull request #31140 from backstage/rugvip/bui-themer
plugins: add bui themer plugin to convert MUI themes to BUI
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-mui-to-bui': minor
|
||||
---
|
||||
|
||||
This is the first release of the Material UI to Backstage UI migration helper plugin. It adds a new page at `/mui-to-bui` that converts an existing MUI v5 theme into Backstage UI (BUI) CSS variables, with live preview and copy/download.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/theme': patch
|
||||
---
|
||||
|
||||
The `UnifiedThemeProvider` now coordinates theme attributes on the document `body` in case multiple theme providers are rendered.
|
||||
@@ -56,6 +56,7 @@
|
||||
"@backstage/plugin-home": "workspace:^",
|
||||
"@backstage/plugin-kubernetes": "workspace:^",
|
||||
"@backstage/plugin-kubernetes-cluster": "workspace:^",
|
||||
"@backstage/plugin-mui-to-bui": "workspace:^",
|
||||
"@backstage/plugin-notifications": "workspace:^",
|
||||
"@backstage/plugin-org": "workspace:^",
|
||||
"@backstage/plugin-permission-react": "workspace:^",
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
} from '@backstage/plugin-notifications';
|
||||
import { CustomizableHomePage } from './components/home/CustomizableHomePage';
|
||||
import { HomePage } from './components/home/HomePage';
|
||||
import { BuiThemerPage } from '@backstage/plugin-mui-to-bui';
|
||||
|
||||
const app = createApp({
|
||||
apis,
|
||||
@@ -208,6 +209,7 @@ const routes = (
|
||||
{customDevToolsPage}
|
||||
</Route>
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
<Route path="/mui-to-bui" element={<BuiThemerPage />} />
|
||||
</FlatRoutes>
|
||||
);
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ describe('createPublicSignInApp', () => {
|
||||
<body
|
||||
data-theme-mode="light"
|
||||
data-theme-name="backstage"
|
||||
data-unified-theme-stack="[{"mode":"light","name":"backstage"}]"
|
||||
>
|
||||
<div>
|
||||
<form
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import CssBaseline from '@material-ui/core/CssBaseline';
|
||||
import {
|
||||
ThemeProvider,
|
||||
@@ -58,6 +58,8 @@ const generateV4ClassName = createGenerateClassName({
|
||||
productionPrefix: 'jss4-',
|
||||
});
|
||||
|
||||
import { useApplyThemeAttributes } from './useApplyThemeAttributes';
|
||||
|
||||
/**
|
||||
* Provides themes for all Material UI versions supported by the provided unified theme.
|
||||
*
|
||||
@@ -70,18 +72,11 @@ export function UnifiedThemeProvider(
|
||||
|
||||
const v4Theme = theme.getTheme('v4') as Mui4Theme;
|
||||
const v5Theme = theme.getTheme('v5') as Mui5Theme;
|
||||
const themeMode = v4Theme ? v4Theme.palette.type : v5Theme?.palette.mode;
|
||||
const themeName = 'backstage';
|
||||
|
||||
useEffect(() => {
|
||||
document.body.setAttribute('data-theme-mode', themeMode);
|
||||
document.body.setAttribute('data-theme-name', themeName);
|
||||
|
||||
return () => {
|
||||
document.body.removeAttribute('data-theme-mode');
|
||||
document.body.removeAttribute('data-theme-name');
|
||||
};
|
||||
}, [themeMode, themeName]);
|
||||
useApplyThemeAttributes(
|
||||
v4Theme ? v4Theme.palette.type : v5Theme?.palette.mode,
|
||||
'backstage',
|
||||
);
|
||||
|
||||
let cssBaseline: JSX.Element | undefined = undefined;
|
||||
if (!noCssBaseline) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* 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 '@testing-library/jest-dom';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useApplyThemeAttributes } from './useApplyThemeAttributes';
|
||||
|
||||
describe('useApplyThemeAttributes', () => {
|
||||
beforeEach(() => {
|
||||
document.body.removeAttribute('data-unified-theme-stack');
|
||||
document.body.removeAttribute('data-theme-mode');
|
||||
document.body.removeAttribute('data-theme-name');
|
||||
});
|
||||
|
||||
it('pushes attributes on mount and pops on unmount', () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useApplyThemeAttributes('light', 'one'),
|
||||
);
|
||||
expect(document.body.getAttribute('data-theme-mode')).toBe('light');
|
||||
expect(document.body.getAttribute('data-theme-name')).toBe('one');
|
||||
expect(
|
||||
JSON.parse(document.body.getAttribute('data-unified-theme-stack') || '[]')
|
||||
.length,
|
||||
).toBe(1);
|
||||
|
||||
unmount();
|
||||
expect(document.body.getAttribute('data-theme-mode')).toBeNull();
|
||||
expect(document.body.getAttribute('data-theme-name')).toBeNull();
|
||||
expect(document.body.getAttribute('data-unified-theme-stack')).toBeNull();
|
||||
});
|
||||
|
||||
it('stacks multiple mounts and applies top-most', () => {
|
||||
const r1 = renderHook(() => useApplyThemeAttributes('light', 'one'));
|
||||
const r2 = renderHook(() => useApplyThemeAttributes('dark', 'two'));
|
||||
|
||||
expect(document.body.getAttribute('data-theme-mode')).toBe('dark');
|
||||
expect(document.body.getAttribute('data-theme-name')).toBe('two');
|
||||
|
||||
r2.unmount();
|
||||
expect(document.body.getAttribute('data-theme-mode')).toBe('light');
|
||||
expect(document.body.getAttribute('data-theme-name')).toBe('one');
|
||||
|
||||
r1.unmount();
|
||||
expect(document.body.getAttribute('data-theme-mode')).toBeNull();
|
||||
expect(document.body.getAttribute('data-theme-name')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* 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 { useEffect } from 'react';
|
||||
|
||||
type ThemeFrame = { mode?: string; name?: string };
|
||||
|
||||
const STACK_ATTR = 'data-unified-theme-stack';
|
||||
const MODE_ATTR = 'data-theme-mode';
|
||||
const NAME_ATTR = 'data-theme-name';
|
||||
|
||||
function mutateAttrStack(mutator: (stack: ThemeFrame[]) => void) {
|
||||
const stack = (() => {
|
||||
const raw = document.body.getAttribute(STACK_ATTR);
|
||||
if (!raw) {
|
||||
return [] as ThemeFrame[];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as ThemeFrame[]) : [];
|
||||
} catch {
|
||||
return [] as ThemeFrame[];
|
||||
}
|
||||
})();
|
||||
|
||||
mutator(stack);
|
||||
|
||||
if (stack.length === 0) {
|
||||
document.body.removeAttribute(STACK_ATTR);
|
||||
} else {
|
||||
document.body.setAttribute(STACK_ATTR, JSON.stringify(stack));
|
||||
}
|
||||
|
||||
const top = stack[stack.length - 1];
|
||||
if (top?.mode) {
|
||||
document.body.setAttribute(MODE_ATTR, String(top.mode));
|
||||
} else {
|
||||
document.body.removeAttribute(MODE_ATTR);
|
||||
}
|
||||
if (top?.name) {
|
||||
document.body.setAttribute(NAME_ATTR, String(top.name));
|
||||
} else {
|
||||
document.body.removeAttribute(NAME_ATTR);
|
||||
}
|
||||
}
|
||||
|
||||
export function useApplyThemeAttributes(themeMode: string, themeName: string) {
|
||||
useEffect(() => {
|
||||
mutateAttrStack(stack => {
|
||||
stack.push({ mode: themeMode, name: themeName });
|
||||
});
|
||||
|
||||
return () => {
|
||||
mutateAttrStack(stack => {
|
||||
stack.pop();
|
||||
});
|
||||
};
|
||||
}, [themeMode, themeName]);
|
||||
}
|
||||
@@ -99,6 +99,7 @@ describe('appModulePublicSignIn', () => {
|
||||
<body
|
||||
data-theme-mode="light"
|
||||
data-theme-name="backstage"
|
||||
data-unified-theme-stack="[{"mode":"light","name":"backstage"}]"
|
||||
>
|
||||
<div>
|
||||
<form
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,63 @@
|
||||
# mui-to-bui
|
||||
|
||||
## Description
|
||||
|
||||
The Backstage UI Themer helps you convert an existing MUI v5 theme into Backstage UI (BUI) CSS custom properties. It detects installed app themes, generates a complete set of BUI CSS variables for each theme (light and dark), and lets you preview how common BUI components look with your colors and typography. You can copy the generated CSS to the clipboard or download it as a `.css` file, and it works with both the old and new Backstage frontend systems without requiring any backend setup.
|
||||
|
||||
## Installation
|
||||
|
||||
### 1) Add the dependency to your app
|
||||
|
||||
Run this from your Backstage repo root:
|
||||
|
||||
```bash
|
||||
yarn add --cwd packages/app @backstage/plugin-mui-to-bui
|
||||
```
|
||||
|
||||
### 2) Wire it up depending on your frontend system
|
||||
|
||||
#### Old frontend system (legacy `App.tsx` with `<FlatRoutes>`)
|
||||
|
||||
Add a route for the page in your app:
|
||||
|
||||
```tsx
|
||||
// packages/app/src/App.tsx
|
||||
import React from 'react';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { FlatRoutes } from '@backstage/core-app-api';
|
||||
import { BuiThemerPage } from '@backstage/plugin-mui-to-bui';
|
||||
|
||||
export const App = () => (
|
||||
<FlatRoutes>
|
||||
{/* ...your other routes */}
|
||||
<Route path="/mui-to-bui" element={<BuiThemerPage />} />
|
||||
</FlatRoutes>
|
||||
);
|
||||
```
|
||||
|
||||
#### New frontend system
|
||||
|
||||
If package discovery is enabled in your app, this plugin is picked up automatically after installation — no code changes required. Just navigate to `/mui-to-bui`.
|
||||
|
||||
If you prefer explicit registration (or don't use discovery), register the plugin as a feature. The page route (`/mui-to-bui`) is provided by the plugin.
|
||||
|
||||
```tsx
|
||||
// packages/app/src/App.tsx (or your app entry where you call createApp)
|
||||
import React from 'react';
|
||||
import { createApp } from '@backstage/frontend-defaults';
|
||||
import buiThemerPlugin from '@backstage/plugin-mui-to-bui';
|
||||
|
||||
const app = createApp({
|
||||
features: [
|
||||
// ...other features
|
||||
buiThemerPlugin,
|
||||
],
|
||||
});
|
||||
|
||||
export default app.createRoot();
|
||||
```
|
||||
|
||||
## Accessing the Themer page
|
||||
|
||||
- Navigate to `/mui-to-bui` in your Backstage app (for example `http://localhost:3000/mui-to-bui`).
|
||||
- Optional: Add a sidebar/link in your app that points to `/mui-to-bui` if you want a permanent navigation entry.
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: backstage-plugin-mui-to-bui
|
||||
title: '@backstage/plugin-mui-to-bui'
|
||||
spec:
|
||||
lifecycle: experimental
|
||||
type: backstage-frontend-plugin
|
||||
owner: maintainers
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* 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 { createDevApp } from '@backstage/dev-utils';
|
||||
import { buiThemerPlugin, BuiThemerPage } from '../src/plugin';
|
||||
|
||||
createDevApp()
|
||||
.registerPlugin(buiThemerPlugin)
|
||||
.addPage({
|
||||
element: <BuiThemerPage />,
|
||||
title: 'Root Page',
|
||||
path: '/mui-to-bui',
|
||||
})
|
||||
.render();
|
||||
@@ -0,0 +1,2 @@
|
||||
# Knip report
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"name": "@backstage/plugin-mui-to-bui",
|
||||
"version": "0.1.0",
|
||||
"backstage": {
|
||||
"role": "frontend-plugin",
|
||||
"pluginId": "mui-to-bui",
|
||||
"pluginPackages": [
|
||||
"@backstage/plugin-mui-to-bui"
|
||||
]
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "plugins/bui-themer"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"sideEffects": false,
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "backstage-cli package build",
|
||||
"clean": "backstage-cli package clean",
|
||||
"lint": "backstage-cli package lint",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack",
|
||||
"start": "backstage-cli package start",
|
||||
"test": "backstage-cli package test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/core-compat-api": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/theme": "workspace:^",
|
||||
"@backstage/ui": "workspace:^",
|
||||
"@mui/material": "^5.12.2",
|
||||
"@mui/system": "^5.16.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/dev-utils": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/react": "^18.0.0",
|
||||
"react": "^18.0.2",
|
||||
"react-dom": "^18.0.2",
|
||||
"react-router-dom": "^6.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^17.0.0 || ^18.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0",
|
||||
"react-router-dom": "^6.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
## API Report File for "@backstage/plugin-mui-to-bui"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
|
||||
import { JSX as JSX_2 } from 'react';
|
||||
import { JSX as JSX_3 } from 'react/jsx-runtime';
|
||||
import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api';
|
||||
import { RouteRef } from '@backstage/frontend-plugin-api';
|
||||
import { RouteRef as RouteRef_2 } from '@backstage/core-plugin-api';
|
||||
|
||||
// @public (undocumented)
|
||||
export const BuiThemerPage: () => JSX_3.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export const buiThemerPlugin: BackstagePlugin<
|
||||
{
|
||||
root: RouteRef_2<undefined>;
|
||||
},
|
||||
{}
|
||||
>;
|
||||
|
||||
// @public (undocumented)
|
||||
const _default: OverridableFrontendPlugin<
|
||||
{
|
||||
root: RouteRef<undefined>;
|
||||
},
|
||||
{},
|
||||
{
|
||||
'page:mui-to-bui': ExtensionDefinition<{
|
||||
kind: 'page';
|
||||
name: undefined;
|
||||
config: {
|
||||
path: string | undefined;
|
||||
};
|
||||
configInput: {
|
||||
path?: string | undefined;
|
||||
};
|
||||
output:
|
||||
| ExtensionDataRef<string, 'core.routing.path', {}>
|
||||
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
|
||||
| ExtensionDataRef<
|
||||
RouteRef<AnyRouteRefParams>,
|
||||
'core.routing.ref',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>;
|
||||
inputs: {};
|
||||
params: {
|
||||
defaultPath?: [Error: `Use the 'path' param instead`];
|
||||
path: string;
|
||||
loader: () => Promise<JSX.Element>;
|
||||
routeRef?: RouteRef;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
export default _default;
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* 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 { screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { BuiThemePreview } from './BuiThemePreview';
|
||||
|
||||
describe('BuiThemePreview', () => {
|
||||
it('renders headings and sample components', async () => {
|
||||
const { container } = await renderInTestApp(
|
||||
<BuiThemePreview
|
||||
mode="light"
|
||||
styleObject={{
|
||||
'--bui-bg-surface-2': '#ffffff',
|
||||
'--bui-border': '#cccccc',
|
||||
'--bui-radius-2': '4px',
|
||||
'--bui-space-3': '12px',
|
||||
'--bui-fg-secondary': '#777777',
|
||||
'--bui-bg-solid': '#000000',
|
||||
'--bui-fg-solid': '#ffffff',
|
||||
'--bui-fg-primary': '#111111',
|
||||
'--bui-bg-surface-1': '#f5f5f5',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveAttribute('data-theme-mode', 'light');
|
||||
expect(await screen.findByText('Button Variants')).toBeInTheDocument();
|
||||
expect(screen.getByText('Button Variants')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Primary' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Secondary' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Tertiary' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Form Inputs')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tag Variants')).toBeInTheDocument();
|
||||
expect(screen.getByText('Text Variants')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* 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 {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Checkbox,
|
||||
Flex,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Select,
|
||||
Text,
|
||||
TextField,
|
||||
TagGroup,
|
||||
Tag,
|
||||
} from '@backstage/ui';
|
||||
|
||||
interface IsolatedPreviewProps {
|
||||
mode: 'light' | 'dark';
|
||||
styleObject: Record<string, string>;
|
||||
}
|
||||
|
||||
export function BuiThemePreview({ mode, styleObject }: IsolatedPreviewProps) {
|
||||
return (
|
||||
<div
|
||||
// Setting the theme mode ensures that the correct defaults are picked up from the BUI theme
|
||||
data-theme-mode={mode}
|
||||
style={{
|
||||
// Apply the generated CSS variables directly to this container
|
||||
// This creates a scoped context where the variables take precedence
|
||||
...styleObject,
|
||||
width: '100%',
|
||||
backgroundColor: 'var(--bui-bg-surface-2)',
|
||||
padding: 'var(--bui-space-3)',
|
||||
borderRadius: 'var(--bui-radius-2)',
|
||||
border: '1px solid var(--bui-border)',
|
||||
}}
|
||||
>
|
||||
<Flex direction="column" gap="4">
|
||||
<Card>
|
||||
<CardHeader>Button Variants</CardHeader>
|
||||
<CardBody>
|
||||
<Flex gap="3">
|
||||
<Flex direction="column" gap="2">
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button isDisabled variant="primary">
|
||||
Disabled
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex direction="column" gap="2">
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button isDisabled variant="secondary">
|
||||
Disabled
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex direction="column" gap="2">
|
||||
<Button variant="tertiary">Tertiary</Button>
|
||||
<Button isDisabled variant="tertiary">
|
||||
Disabled
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>Form Inputs</CardHeader>
|
||||
<CardBody>
|
||||
<Flex direction="column" gap="3">
|
||||
<TextField label="Text Input" placeholder="Enter some text" />
|
||||
<Select
|
||||
label="Select Input"
|
||||
options={[
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
{ value: 'option3', label: 'Option 3' },
|
||||
]}
|
||||
/>
|
||||
<Checkbox label="Checkbox Option" />
|
||||
<RadioGroup label="Radio Group" orientation="horizontal">
|
||||
<Radio value="option-1">Option 1</Radio>
|
||||
<Radio value="option-2">Option 2</Radio>
|
||||
<Radio value="option-3">Option 3</Radio>
|
||||
</RadioGroup>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>Tag Variants</CardHeader>
|
||||
<CardBody>
|
||||
<Flex gap="3">
|
||||
<TagGroup>
|
||||
<Tag>Default</Tag>
|
||||
<Tag
|
||||
style={{
|
||||
backgroundColor: 'var(--bui-bg-danger)',
|
||||
color: 'var(--bui-fg-danger)',
|
||||
border: `1px solid var(--bui-border-danger)`,
|
||||
}}
|
||||
>
|
||||
Danger
|
||||
</Tag>
|
||||
<Tag
|
||||
style={{
|
||||
backgroundColor: 'var(--bui-bg-warning)',
|
||||
color: 'var(--bui-fg-warning)',
|
||||
border: `1px solid var(--bui-border-warning)`,
|
||||
}}
|
||||
>
|
||||
Warning
|
||||
</Tag>
|
||||
<Tag
|
||||
style={{
|
||||
backgroundColor: 'var(--bui-bg-success)',
|
||||
color: 'var(--bui-fg-success)',
|
||||
border: `1px solid var(--bui-border-success)`,
|
||||
}}
|
||||
>
|
||||
Success
|
||||
</Tag>
|
||||
</TagGroup>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>Text Variants</CardHeader>
|
||||
<CardBody>
|
||||
<Box
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 'var(--bui-space-3)',
|
||||
}}
|
||||
>
|
||||
<Text variant="title-x-small">Title X Small</Text>
|
||||
<Text variant="body-x-small">Body X Small</Text>
|
||||
<Text variant="title-small">Title Small</Text>
|
||||
<Text variant="body-small">Body Small</Text>
|
||||
<Text variant="title-medium">Title Medium</Text>
|
||||
<Text variant="body-medium">Body Medium</Text>
|
||||
<Text variant="title-large">Title Large</Text>
|
||||
<Text variant="body-large">Body Large</Text>
|
||||
</Box>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* 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 { screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { BuiThemerPage } from './BuiThemerPage';
|
||||
import { appThemeApiRef } from '@backstage/core-plugin-api';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
|
||||
function makeAppTheme(id: string, title: string, variant: 'light' | 'dark') {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
variant,
|
||||
Provider: ({ children }: { children: React.ReactNode }) => (
|
||||
<ThemeProvider theme={createTheme()}>{children}</ThemeProvider>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe('BuiThemerPage', () => {
|
||||
it('renders empty state when no themes installed', async () => {
|
||||
const apis = [[appThemeApiRef, { getInstalledThemes: () => [] }]] as const;
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={apis}>
|
||||
<BuiThemerPage />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/No themes found. Please install some themes/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ThemeContent for each installed theme', async () => {
|
||||
const themes = [
|
||||
makeAppTheme('light', 'Light', 'light'),
|
||||
makeAppTheme('dark', 'Dark', 'dark'),
|
||||
];
|
||||
const apis = [
|
||||
[appThemeApiRef, { getInstalledThemes: () => themes }],
|
||||
] as const;
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={apis}>
|
||||
<BuiThemerPage />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Light')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dark')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* 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 { useApi } from '@backstage/core-plugin-api';
|
||||
import { appThemeApiRef } from '@backstage/core-plugin-api';
|
||||
import { Box, Card, Container, Flex, HeaderPage, Text } from '@backstage/ui';
|
||||
import { ThemeContent } from './ThemeContent';
|
||||
import { MuiThemeExtractor } from './MuiThemeExtractor';
|
||||
|
||||
export const BuiThemerPage = () => {
|
||||
const appThemeApi = useApi(appThemeApiRef);
|
||||
const installedThemes = appThemeApi.getInstalledThemes();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<HeaderPage title="BUI Theme Converter" />
|
||||
<Box m="4">
|
||||
<Text variant="body-medium" color="secondary">
|
||||
Convert your MUI v5 theme into BUI CSS variables. Pick a theme to view
|
||||
and export the generated BUI CSS variable definitions for use in your
|
||||
project.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box mt="4">
|
||||
{installedThemes.length === 0 ? (
|
||||
<Card>
|
||||
<Box p="4">
|
||||
<Text>
|
||||
No themes found. Please install some themes in your Backstage
|
||||
app.
|
||||
</Text>
|
||||
</Box>
|
||||
</Card>
|
||||
) : (
|
||||
<Flex direction="column" gap="4">
|
||||
{installedThemes.map(theme => (
|
||||
<MuiThemeExtractor key={theme.id} appTheme={theme}>
|
||||
{muiTheme => (
|
||||
<ThemeContent
|
||||
key={theme.id}
|
||||
themeId={theme.id}
|
||||
themeTitle={theme.title}
|
||||
variant={theme.variant}
|
||||
muiTheme={muiTheme}
|
||||
/>
|
||||
)}
|
||||
</MuiThemeExtractor>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* 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 { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { MuiThemeExtractor } from './MuiThemeExtractor';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
|
||||
const mockAppTheme = {
|
||||
id: 'mock',
|
||||
title: 'Mock',
|
||||
variant: 'light' as const,
|
||||
Provider: ({ children }: { children: React.ReactNode }) => (
|
||||
<ThemeProvider theme={createTheme()}>{children}</ThemeProvider>
|
||||
),
|
||||
};
|
||||
|
||||
describe('MuiThemeExtractor', () => {
|
||||
it('invokes render prop with extracted theme', async () => {
|
||||
render(
|
||||
<MuiThemeExtractor appTheme={mockAppTheme}>
|
||||
{theme => <div>theme-palette-mode-{theme.palette.mode}</div>}
|
||||
</MuiThemeExtractor>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('theme-palette-mode-light')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* 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 { useEffect, useState, JSX } from 'react';
|
||||
import { AppTheme } from '@backstage/core-plugin-api';
|
||||
import { Theme, useTheme } from '@mui/material/styles';
|
||||
|
||||
export function MuiThemeExtractor(props: {
|
||||
appTheme: AppTheme;
|
||||
children: (theme: Theme) => JSX.Element;
|
||||
}): JSX.Element {
|
||||
const { appTheme, children } = props;
|
||||
const [theme, setTheme] = useState<Theme | null>(null);
|
||||
const { Provider } = appTheme;
|
||||
if (theme) {
|
||||
return children(theme);
|
||||
}
|
||||
|
||||
return (
|
||||
<Provider>
|
||||
<MuiThemeExtractorInner setTheme={setTheme} />
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function MuiThemeExtractorInner({
|
||||
setTheme,
|
||||
}: {
|
||||
setTheme: (theme: Theme) => void;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
useEffect(() => {
|
||||
setTheme(theme);
|
||||
}, [theme, setTheme]);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* 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 { screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { ThemeContent } from './ThemeContent';
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
|
||||
jest.mock('./convertMuiToBuiTheme', () => ({
|
||||
convertMuiToBuiTheme: () => ({
|
||||
css: ':root { --bui-color: #000; }',
|
||||
styleObject: { '--bui-color': '#000' },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ThemeContent', () => {
|
||||
const muiTheme = createTheme();
|
||||
|
||||
it('renders title, variant and tabs', async () => {
|
||||
await renderInTestApp(
|
||||
<ThemeContent
|
||||
themeId="light"
|
||||
themeTitle="Light Theme"
|
||||
variant="light"
|
||||
muiTheme={muiTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Light Theme')).toBeInTheDocument();
|
||||
expect(screen.getByText('light theme')).toBeInTheDocument();
|
||||
expect(screen.getByText('Generated CSS')).toBeInTheDocument();
|
||||
expect(screen.getByText('Live Preview')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(':root { --bui-color: #000; }'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles Copy CSS click', async () => {
|
||||
const writeText = jest.fn();
|
||||
Object.assign(window.navigator, { clipboard: { writeText } });
|
||||
|
||||
await renderInTestApp(
|
||||
<ThemeContent
|
||||
themeId="light"
|
||||
themeTitle="Light Theme"
|
||||
variant="light"
|
||||
muiTheme={muiTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy CSS' }));
|
||||
expect(writeText).toHaveBeenCalledWith(':root { --bui-color: #000; }');
|
||||
});
|
||||
|
||||
it('switches to Live Preview tab', async () => {
|
||||
await renderInTestApp(
|
||||
<ThemeContent
|
||||
themeId="light"
|
||||
themeTitle="Light Theme"
|
||||
variant="light"
|
||||
muiTheme={muiTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Live Preview'));
|
||||
// The preview renders content from BuiThemePreview; basic smoke check:
|
||||
expect(await screen.findByText('Button Variants')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* 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 { useMemo, useState, useCallback } from 'react';
|
||||
import { Theme } from '@mui/material/styles';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Text,
|
||||
Tabs,
|
||||
TabList,
|
||||
Tab,
|
||||
TabPanel,
|
||||
} from '@backstage/ui';
|
||||
import { convertMuiToBuiTheme } from './convertMuiToBuiTheme';
|
||||
import { BuiThemePreview } from './BuiThemePreview';
|
||||
|
||||
interface ThemeContentProps {
|
||||
themeId: string;
|
||||
themeTitle: string;
|
||||
variant: 'light' | 'dark';
|
||||
muiTheme: Theme;
|
||||
}
|
||||
|
||||
export function ThemeContent({
|
||||
themeId,
|
||||
themeTitle,
|
||||
variant,
|
||||
muiTheme,
|
||||
}: ThemeContentProps) {
|
||||
const [activeTab, setActiveTab] = useState<string>('css');
|
||||
|
||||
const { css, styleObject } = useMemo(() => {
|
||||
return convertMuiToBuiTheme(muiTheme);
|
||||
}, [muiTheme]);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
window.navigator.clipboard.writeText(css);
|
||||
}, [css]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const blob = new Blob([css], { type: 'text/css' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `bui-theme-${themeId}.css`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [css, themeId]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Box p="6">
|
||||
<Flex direction="column" gap="4">
|
||||
<Flex justify="between" align="center">
|
||||
<Text variant="title-medium">{themeTitle}</Text>
|
||||
<Text variant="body-small" color="secondary">
|
||||
{variant} theme
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex gap="3">
|
||||
<Button onClick={handleCopy} variant="secondary">
|
||||
Copy CSS
|
||||
</Button>
|
||||
<Button onClick={handleDownload} variant="secondary">
|
||||
Download CSS
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Tabs
|
||||
selectedKey={activeTab}
|
||||
onSelectionChange={key => setActiveTab(key as string)}
|
||||
>
|
||||
<TabList>
|
||||
<Tab id="css">Generated CSS</Tab>
|
||||
<Tab id="preview">Live Preview</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanel id="css">
|
||||
<Box
|
||||
p="3"
|
||||
style={{
|
||||
backgroundColor: 'var(--bui-bg-surface-2)',
|
||||
border: '1px solid var(--bui-border)',
|
||||
borderRadius: 'var(--bui-radius-2)',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
height: '600px',
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{css}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="preview">
|
||||
<Box>
|
||||
<BuiThemePreview mode={variant} styleObject={styleObject} />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* 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 { createTheme } from '@mui/material/styles';
|
||||
import { convertMuiToBuiTheme } from './convertMuiToBuiTheme';
|
||||
|
||||
describe('convertMuiToBuiTheme', () => {
|
||||
it('should generate CSS for light theme', () => {
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#1976d2',
|
||||
dark: '#115293',
|
||||
},
|
||||
background: {
|
||||
default: '#f5f5f5',
|
||||
paper: '#ffffff',
|
||||
},
|
||||
text: {
|
||||
primary: '#000000',
|
||||
secondary: '#666666',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: 'Roboto, sans-serif',
|
||||
h1: {
|
||||
fontSize: '2.5rem',
|
||||
},
|
||||
body1: {
|
||||
fontSize: '1rem',
|
||||
},
|
||||
},
|
||||
spacing: 8,
|
||||
shape: {
|
||||
borderRadius: 4,
|
||||
},
|
||||
});
|
||||
|
||||
const result = convertMuiToBuiTheme(theme);
|
||||
|
||||
expect(result.css).toContain(':root {');
|
||||
expect(result.css).toContain('--bui-font-regular: Roboto, sans-serif;');
|
||||
// Spacing is skipped for default 8px
|
||||
expect(result.css).not.toContain('--bui-space:');
|
||||
// Border radius tokens are only generated when radius is 0
|
||||
expect(result.css).not.toContain('--bui-radius-3:');
|
||||
// Background default maps to surface-2
|
||||
expect(result.css).toContain('--bui-bg-surface-2: #f5f5f5;');
|
||||
expect(result.css).toContain('--bui-bg-surface-1: #ffffff;');
|
||||
expect(result.css).toContain('--bui-fg-primary: #000000;');
|
||||
// Secondary may not be present without Backstage additions, so don't assert it
|
||||
expect(result.css).toContain('--bui-bg-solid: #1976d2;');
|
||||
|
||||
// Test style object
|
||||
expect(result.styleObject).toHaveProperty(
|
||||
'--bui-font-regular',
|
||||
'Roboto, sans-serif',
|
||||
);
|
||||
expect(result.styleObject).toHaveProperty('--bui-bg-solid', '#1976d2');
|
||||
expect(result.styleObject).toHaveProperty('--bui-fg-primary', '#000000');
|
||||
});
|
||||
|
||||
it('should generate CSS for dark theme', () => {
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: {
|
||||
main: '#90caf9',
|
||||
dark: '#42a5f5',
|
||||
},
|
||||
background: {
|
||||
default: '#121212',
|
||||
paper: '#1e1e1e',
|
||||
},
|
||||
text: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#b3b3b3',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = convertMuiToBuiTheme(theme);
|
||||
|
||||
expect(result.css).toContain("[data-theme-mode='dark'] {");
|
||||
// Background default maps to surface-2 in dark mode as well
|
||||
expect(result.css).toContain('--bui-bg-surface-2: #121212;');
|
||||
expect(result.css).toContain('--bui-bg-surface-1: #1e1e1e;');
|
||||
expect(result.css).toContain('--bui-fg-primary: #ffffff;');
|
||||
|
||||
// Test style object
|
||||
expect(result.styleObject).toHaveProperty('--bui-bg-surface-1', '#1e1e1e');
|
||||
expect(result.styleObject).toHaveProperty('--bui-fg-primary', '#ffffff');
|
||||
});
|
||||
|
||||
it('should handle missing theme properties gracefully', () => {
|
||||
const theme = createTheme({});
|
||||
|
||||
const result = convertMuiToBuiTheme(theme);
|
||||
|
||||
expect(result.css).toContain(':root {');
|
||||
expect(result.css).toContain('--bui-font-weight-regular: 400;');
|
||||
expect(result.css).toContain('--bui-font-weight-bold: 700;');
|
||||
// Should have default values even when theme properties are missing
|
||||
expect(result.css).toContain('--bui-black: #000;');
|
||||
expect(result.css).toContain('--bui-white: #fff;');
|
||||
});
|
||||
|
||||
it('should generate surface colors correctly', () => {
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#1976d2',
|
||||
dark: '#115293',
|
||||
},
|
||||
error: {
|
||||
main: '#f44336',
|
||||
light: '#ffcdd2',
|
||||
},
|
||||
warning: {
|
||||
main: '#ff9800',
|
||||
light: '#ffe0b2',
|
||||
},
|
||||
success: {
|
||||
main: '#4caf50',
|
||||
light: '#c8e6c9',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = convertMuiToBuiTheme(theme);
|
||||
|
||||
expect(result.css).toContain('--bui-bg-solid: #1976d2;');
|
||||
expect(result.css).toContain('--bui-bg-solid-hover: rgb(21, 100, 179);');
|
||||
expect(result.css).toContain('--bui-bg-danger: #ffcdd2;');
|
||||
expect(result.css).toContain('--bui-bg-warning: #ffe0b2;');
|
||||
expect(result.css).toContain('--bui-bg-success: #c8e6c9;');
|
||||
});
|
||||
|
||||
it('should generate foreground colors correctly', () => {
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#1976d2',
|
||||
dark: '#115293',
|
||||
},
|
||||
error: {
|
||||
main: '#f44336',
|
||||
},
|
||||
warning: {
|
||||
main: '#ff9800',
|
||||
},
|
||||
success: {
|
||||
main: '#4caf50',
|
||||
},
|
||||
text: {
|
||||
disabled: '#cccccc',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = convertMuiToBuiTheme(theme);
|
||||
|
||||
expect(result.css).toContain('--bui-fg-link: #1976d2;');
|
||||
expect(result.css).toContain('--bui-fg-link-hover: #115293;');
|
||||
expect(result.css).toContain('--bui-fg-disabled: #cccccc;');
|
||||
// Foreground danger/warning/success may be hex or rgb depending on theme
|
||||
expect(result.css).toMatch(
|
||||
/--bui-fg-danger:\s*(#[0-9a-f]{6}|rgb\([^)]*\));/i,
|
||||
);
|
||||
expect(result.css).toMatch(
|
||||
/--bui-fg-warning:\s*(#[0-9a-f]{6}|rgb\([^)]*\));/i,
|
||||
);
|
||||
expect(result.css).toMatch(
|
||||
/--bui-fg-success:\s*(#[0-9a-f]{6}|rgb\([^)]*\));/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate border colors correctly', () => {
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
error: {
|
||||
main: '#f44336',
|
||||
},
|
||||
warning: {
|
||||
main: '#ff9800',
|
||||
},
|
||||
success: {
|
||||
main: '#4caf50',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = convertMuiToBuiTheme(theme);
|
||||
|
||||
// Base border colors are included when border/divider exist; specific statuses always present
|
||||
expect(result.css).toContain('--bui-border-danger: #f44336;');
|
||||
expect(result.css).toContain('--bui-border-warning: #ff9800;');
|
||||
expect(result.css).toContain('--bui-border-success: #4caf50;');
|
||||
});
|
||||
|
||||
it('should handle function-based spacing', () => {
|
||||
const theme = createTheme({
|
||||
spacing: (factor: number) => `${factor * 4}px`,
|
||||
});
|
||||
|
||||
const result = convertMuiToBuiTheme(theme);
|
||||
|
||||
expect(result.css).toContain('--bui-space: calc(4px * 0.5);');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* 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 { Theme as Mui5Theme } from '@mui/material/styles';
|
||||
import { BackstagePaletteAdditions } from '@backstage/theme';
|
||||
import { blend, alpha } from '@mui/system/colorManipulator';
|
||||
|
||||
export interface ConvertMuiToBuiThemeResult {
|
||||
css: string;
|
||||
styleObject: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a MUI v5 Theme to BUI CSS variables
|
||||
* @param theme - The MUI v5 theme to convert
|
||||
* @returns Object containing CSS string and style object with BUI variables
|
||||
*/
|
||||
export function convertMuiToBuiTheme(
|
||||
theme: Mui5Theme,
|
||||
): ConvertMuiToBuiThemeResult {
|
||||
const styleObject = generateBuiVariables(theme);
|
||||
|
||||
const selector =
|
||||
theme.palette.mode === 'dark' ? "[data-theme-mode='dark']" : ':root';
|
||||
|
||||
const css = `${selector} {\n${Object.entries(styleObject)
|
||||
.map(([key, value]) => ` ${key}: ${value};`)
|
||||
.join('\n')}\n}`;
|
||||
|
||||
return { css, styleObject };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates BUI CSS variables from MUI theme
|
||||
*/
|
||||
function generateBuiVariables(theme: Mui5Theme): Record<string, string> {
|
||||
const styleObject: Record<string, string> = {};
|
||||
|
||||
// Font families
|
||||
if (theme.typography.fontFamily) {
|
||||
styleObject['--bui-font-regular'] = theme.typography.fontFamily;
|
||||
}
|
||||
|
||||
// Font weights
|
||||
if (theme.typography.fontWeightRegular) {
|
||||
styleObject['--bui-font-weight-regular'] = String(
|
||||
theme.typography.fontWeightRegular,
|
||||
);
|
||||
}
|
||||
if (theme.typography.fontWeightBold) {
|
||||
styleObject['--bui-font-weight-bold'] = String(
|
||||
theme.typography.fontWeightBold,
|
||||
);
|
||||
}
|
||||
|
||||
const spacing = theme.spacing(1);
|
||||
// Skip spacing if the theme is using the default
|
||||
if (spacing !== '8px') {
|
||||
styleObject['--bui-space'] = `calc(${spacing} * 0.5)`;
|
||||
}
|
||||
|
||||
// Border radius, only translate a 0 radius
|
||||
if (theme.shape.borderRadius === 0) {
|
||||
styleObject['--bui-radius-1'] = '0';
|
||||
styleObject['--bui-radius-2'] = '0';
|
||||
styleObject['--bui-radius-3'] = '0';
|
||||
styleObject['--bui-radius-4'] = '0';
|
||||
styleObject['--bui-radius-5'] = '0';
|
||||
styleObject['--bui-radius-6'] = '0';
|
||||
}
|
||||
|
||||
// Colors - map MUI palette to BUI color tokens
|
||||
const palette = theme.palette as typeof theme.palette &
|
||||
Partial<BackstagePaletteAdditions>;
|
||||
|
||||
// Base colors
|
||||
styleObject['--bui-black'] = palette.common.black;
|
||||
styleObject['--bui-white'] = palette.common.white;
|
||||
|
||||
// Generate foreground colors
|
||||
Object.entries({
|
||||
primary: palette.text.primary,
|
||||
secondary: palette.textSubtle,
|
||||
link: palette.link ?? palette.primary.main,
|
||||
'link-hover': palette.linkHover ?? palette.primary.dark,
|
||||
disabled: palette.text.disabled,
|
||||
solid: palette.primary.contrastText,
|
||||
'solid-disabled': palette.text.disabled,
|
||||
tint: palette.textSubtle,
|
||||
'tint-disabled': palette.textVerySubtle,
|
||||
danger: palette.error.dark,
|
||||
warning: palette.warning.dark,
|
||||
success: palette.success.dark,
|
||||
}).forEach(([key, value]) => {
|
||||
styleObject[`--bui-fg-${key}`] = value;
|
||||
});
|
||||
|
||||
// Generate surface colors
|
||||
Object.entries({
|
||||
'': palette.background.default,
|
||||
'surface-1': palette.background.paper,
|
||||
'surface-2': palette.background.default,
|
||||
solid: palette.primary.main,
|
||||
'solid-hover': blend(palette.primary.main, palette.primary.dark, 0.5),
|
||||
'solid-pressed': palette.primary.dark,
|
||||
'solid-disabled': palette.action.disabledBackground,
|
||||
tint: 'transparent',
|
||||
'tint-hover': alpha(palette.primary.main, 0.4),
|
||||
'tint-pressed': alpha(palette.primary.main, 0.6),
|
||||
'tint-disabled': palette.action.disabledBackground,
|
||||
danger: palette.error.light,
|
||||
warning: palette.warning.light,
|
||||
success: palette.success.light,
|
||||
}).forEach(([key, value]) => {
|
||||
styleObject[`--bui-bg-${key}`] = value;
|
||||
});
|
||||
|
||||
// Border colors
|
||||
Object.entries({
|
||||
danger: palette.error.main,
|
||||
warning: palette.warning.main,
|
||||
success: palette.success.main,
|
||||
}).forEach(([key, value]) => {
|
||||
styleObject[`--bui-border${key ? `-${key}` : ''}`] = value;
|
||||
});
|
||||
|
||||
// Base border color if available
|
||||
styleObject['--bui-border'] = palette.border || palette.divider;
|
||||
styleObject['--bui-border-danger'] = palette.error.main;
|
||||
styleObject['--bui-border-warning'] = palette.warning.main;
|
||||
styleObject['--bui-border-success'] = palette.success.main;
|
||||
|
||||
// Special colors
|
||||
styleObject['--bui-ring'] = palette.highlight || palette.primary.main;
|
||||
|
||||
return styleObject;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { BuiThemerPage } from './BuiThemerPage';
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { default, buiThemerPlugin, BuiThemerPage } from './plugin';
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* 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 { buiThemerPlugin } from './plugin';
|
||||
|
||||
describe('mui-to-bui', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(buiThemerPlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* 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 {
|
||||
convertLegacyRouteRef,
|
||||
convertLegacyRouteRefs,
|
||||
} from '@backstage/core-compat-api';
|
||||
import {
|
||||
createPlugin,
|
||||
createRoutableExtension,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import {
|
||||
createFrontendPlugin,
|
||||
PageBlueprint,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { rootRouteRef } from './routes';
|
||||
|
||||
// Old system
|
||||
/** @public */
|
||||
export const buiThemerPlugin = createPlugin({
|
||||
id: 'mui-to-bui',
|
||||
routes: {
|
||||
root: rootRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
/** @public */
|
||||
export const BuiThemerPage = buiThemerPlugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'BuiThemerPage',
|
||||
component: () =>
|
||||
import('./components/BuiThemerPage').then(m => m.BuiThemerPage),
|
||||
mountPoint: rootRouteRef,
|
||||
}),
|
||||
);
|
||||
|
||||
// New system
|
||||
/** @public */
|
||||
export default createFrontendPlugin({
|
||||
pluginId: 'mui-to-bui',
|
||||
extensions: [
|
||||
PageBlueprint.make({
|
||||
params: {
|
||||
path: '/mui-to-bui',
|
||||
loader: () =>
|
||||
import('./components/BuiThemerPage').then(m => <m.BuiThemerPage />),
|
||||
routeRef: convertLegacyRouteRef(rootRouteRef),
|
||||
},
|
||||
}),
|
||||
],
|
||||
routes: convertLegacyRouteRefs({
|
||||
root: rootRouteRef,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* 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 { createRouteRef } from '@backstage/core-plugin-api';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
id: 'mui-to-bui',
|
||||
});
|
||||
@@ -5708,6 +5708,37 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@backstage/plugin-mui-to-bui@workspace:^, @backstage/plugin-mui-to-bui@workspace:plugins/bui-themer":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@backstage/plugin-mui-to-bui@workspace:plugins/bui-themer"
|
||||
dependencies:
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/core-compat-api": "workspace:^"
|
||||
"@backstage/core-plugin-api": "workspace:^"
|
||||
"@backstage/dev-utils": "workspace:^"
|
||||
"@backstage/frontend-plugin-api": "workspace:^"
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
"@backstage/theme": "workspace:^"
|
||||
"@backstage/ui": "workspace:^"
|
||||
"@mui/material": "npm:^5.12.2"
|
||||
"@mui/system": "npm:^5.16.14"
|
||||
"@testing-library/jest-dom": "npm:^6.0.0"
|
||||
"@testing-library/react": "npm:^16.0.0"
|
||||
"@types/react": "npm:^18.0.0"
|
||||
react: "npm:^18.0.2"
|
||||
react-dom: "npm:^18.0.2"
|
||||
react-router-dom: "npm:^6.3.0"
|
||||
peerDependencies:
|
||||
"@types/react": ^17.0.0 || ^18.0.0
|
||||
react: ^17.0.0 || ^18.0.0
|
||||
react-dom: ^17.0.0 || ^18.0.0
|
||||
react-router-dom: ^6.3.0
|
||||
peerDependenciesMeta:
|
||||
"@types/react":
|
||||
optional: true
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@backstage/plugin-notifications-backend-module-email@workspace:plugins/notifications-backend-module-email":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@backstage/plugin-notifications-backend-module-email@workspace:plugins/notifications-backend-module-email"
|
||||
@@ -29673,6 +29704,7 @@ __metadata:
|
||||
"@backstage/plugin-home": "workspace:^"
|
||||
"@backstage/plugin-kubernetes": "workspace:^"
|
||||
"@backstage/plugin-kubernetes-cluster": "workspace:^"
|
||||
"@backstage/plugin-mui-to-bui": "workspace:^"
|
||||
"@backstage/plugin-notifications": "workspace:^"
|
||||
"@backstage/plugin-org": "workspace:^"
|
||||
"@backstage/plugin-permission-react": "workspace:^"
|
||||
|
||||
Reference in New Issue
Block a user