From 077dee1902fb0c967bbf728497011db5f29c85af Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Sep 2025 18:53:05 +0200 Subject: [PATCH 01/31] plugins: scaffold bui-themer plugin Signed-off-by: Patrik Oldsberg --- packages/app/package.json | 1 + packages/app/src/App.tsx | 2 + plugins/bui-themer/.eslintrc.js | 1 + plugins/bui-themer/README.md | 13 + plugins/bui-themer/catalog-info.yaml | 9 + plugins/bui-themer/dev/index.tsx | 26 ++ plugins/bui-themer/package.json | 53 +++ .../ExampleComponent.test.tsx | 38 +++ .../ExampleComponent/ExampleComponent.tsx | 52 +++ .../src/components/ExampleComponent/index.ts | 16 + .../ExampleFetchComponent.test.tsx | 34 ++ .../ExampleFetchComponent.tsx | 322 ++++++++++++++++++ .../components/ExampleFetchComponent/index.ts | 16 + plugins/bui-themer/src/index.ts | 16 + plugins/bui-themer/src/plugin.test.ts | 22 ++ plugins/bui-themer/src/plugin.ts | 37 ++ plugins/bui-themer/src/routes.ts | 20 ++ plugins/bui-themer/src/setupTests.ts | 16 + yarn.lock | 131 +++++-- 19 files changed, 805 insertions(+), 20 deletions(-) create mode 100644 plugins/bui-themer/.eslintrc.js create mode 100644 plugins/bui-themer/README.md create mode 100644 plugins/bui-themer/catalog-info.yaml create mode 100644 plugins/bui-themer/dev/index.tsx create mode 100644 plugins/bui-themer/package.json create mode 100644 plugins/bui-themer/src/components/ExampleComponent/ExampleComponent.test.tsx create mode 100644 plugins/bui-themer/src/components/ExampleComponent/ExampleComponent.tsx create mode 100644 plugins/bui-themer/src/components/ExampleComponent/index.ts create mode 100644 plugins/bui-themer/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx create mode 100644 plugins/bui-themer/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx create mode 100644 plugins/bui-themer/src/components/ExampleFetchComponent/index.ts create mode 100644 plugins/bui-themer/src/index.ts create mode 100644 plugins/bui-themer/src/plugin.test.ts create mode 100644 plugins/bui-themer/src/plugin.ts create mode 100644 plugins/bui-themer/src/routes.ts create mode 100644 plugins/bui-themer/src/setupTests.ts diff --git a/packages/app/package.json b/packages/app/package.json index 56b5178692..56e619e65a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -46,6 +46,7 @@ "@backstage/integration-react": "workspace:^", "@backstage/plugin-api-docs": "workspace:^", "@backstage/plugin-auth-react": "workspace:^", + "@backstage/plugin-bui-themer": "workspace:^", "@backstage/plugin-catalog": "workspace:^", "@backstage/plugin-catalog-common": "workspace:^", "@backstage/plugin-catalog-graph": "workspace:^", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index ad525420a7..bd64dc504f 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -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-bui-themer'; const app = createApp({ apis, @@ -208,6 +209,7 @@ const routes = ( {customDevToolsPage} } /> + } /> ); diff --git a/plugins/bui-themer/.eslintrc.js b/plugins/bui-themer/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/bui-themer/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/bui-themer/README.md b/plugins/bui-themer/README.md new file mode 100644 index 0000000000..f73dd7875e --- /dev/null +++ b/plugins/bui-themer/README.md @@ -0,0 +1,13 @@ +# bui-themer + +Welcome to the bui-themer plugin! + +_This plugin was created through the Backstage CLI_ + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/bui-themer](http://localhost:3000/bui-themer). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. diff --git a/plugins/bui-themer/catalog-info.yaml b/plugins/bui-themer/catalog-info.yaml new file mode 100644 index 0000000000..15e907a0ac --- /dev/null +++ b/plugins/bui-themer/catalog-info.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-bui-themer + title: '@backstage/plugin-bui-themer' +spec: + lifecycle: experimental + type: backstage-frontend-plugin + owner: maintainers diff --git a/plugins/bui-themer/dev/index.tsx b/plugins/bui-themer/dev/index.tsx new file mode 100644 index 0000000000..ce3986f7da --- /dev/null +++ b/plugins/bui-themer/dev/index.tsx @@ -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: , + title: 'Root Page', + path: '/bui-themer', + }) + .render(); diff --git a/plugins/bui-themer/package.json b/plugins/bui-themer/package.json new file mode 100644 index 0000000000..c0d49b0879 --- /dev/null +++ b/plugins/bui-themer/package.json @@ -0,0 +1,53 @@ +{ + "name": "@backstage/plugin-bui-themer", + "version": "0.1.0", + "license": "Apache-2.0", + "private": true, + "main": "src/index.ts", + "types": "src/index.ts", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-plugin", + "pluginId": "bui-themer" + }, + "sideEffects": false, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/core-components": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", + "@backstage/theme": "workspace:^", + "@material-ui/core": "^4.9.13", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "^4.0.0-alpha.61", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0 || ^18.0.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/core-app-api": "workspace:^", + "@backstage/dev-utils": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.0.0", + "msw": "^1.0.0", + "react": "^16.13.1 || ^17.0.0 || ^18.0.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/bui-themer/src/components/ExampleComponent/ExampleComponent.test.tsx b/plugins/bui-themer/src/components/ExampleComponent/ExampleComponent.test.tsx new file mode 100644 index 0000000000..f5f7f185c5 --- /dev/null +++ b/plugins/bui-themer/src/components/ExampleComponent/ExampleComponent.test.tsx @@ -0,0 +1,38 @@ +/* + * 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 { ExampleComponent } from './ExampleComponent'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { screen } from '@testing-library/react'; +import { registerMswTestHooks, renderInTestApp } from '@backstage/test-utils'; + +describe('ExampleComponent', () => { + const server = setupServer(); + // Enable sane handlers for network requests + registerMswTestHooks(server); + + // setup mock response + beforeEach(() => { + server.use( + rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))), + ); + }); + + it('should render', async () => { + await renderInTestApp(); + expect(screen.getByText('Welcome to bui-themer!')).toBeInTheDocument(); + }); +}); diff --git a/plugins/bui-themer/src/components/ExampleComponent/ExampleComponent.tsx b/plugins/bui-themer/src/components/ExampleComponent/ExampleComponent.tsx new file mode 100644 index 0000000000..3a04bd7562 --- /dev/null +++ b/plugins/bui-themer/src/components/ExampleComponent/ExampleComponent.tsx @@ -0,0 +1,52 @@ +/* + * 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 { Typography, Grid } from '@material-ui/core'; +import { + InfoCard, + Header, + Page, + Content, + ContentHeader, + HeaderLabel, + SupportButton, +} from '@backstage/core-components'; +import { ExampleFetchComponent } from '../ExampleFetchComponent'; + +export const ExampleComponent = () => ( + +
+ + +
+ + + A description of your plugin goes here. + + + + + + All content should be wrapped in a card like this. + + + + + + + + +
+); diff --git a/plugins/bui-themer/src/components/ExampleComponent/index.ts b/plugins/bui-themer/src/components/ExampleComponent/index.ts new file mode 100644 index 0000000000..66b3fc6c97 --- /dev/null +++ b/plugins/bui-themer/src/components/ExampleComponent/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { ExampleComponent } from './ExampleComponent'; diff --git a/plugins/bui-themer/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx b/plugins/bui-themer/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx new file mode 100644 index 0000000000..a3085e3dcb --- /dev/null +++ b/plugins/bui-themer/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx @@ -0,0 +1,34 @@ +/* + * 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 { renderInTestApp } from '@backstage/test-utils'; +import { ExampleFetchComponent } from './ExampleFetchComponent'; + +describe('ExampleFetchComponent', () => { + it('renders the user table', async () => { + const { getAllByText, getByAltText, getByText, findByRole } = + await renderInTestApp(); + + // Wait for the table to render + const table = await findByRole('table'); + const nationality = getAllByText('GB'); + // Assert that the table contains the expected user data + expect(table).toBeInTheDocument(); + expect(getByAltText('Carolyn')).toBeInTheDocument(); + expect(getByText('Carolyn Moore')).toBeInTheDocument(); + expect(getByText('carolyn.moore@example.com')).toBeInTheDocument(); + expect(nationality[0]).toBeInTheDocument(); + }); +}); diff --git a/plugins/bui-themer/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx b/plugins/bui-themer/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx new file mode 100644 index 0000000000..6c9e77094d --- /dev/null +++ b/plugins/bui-themer/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx @@ -0,0 +1,322 @@ +/* + * 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 { makeStyles } from '@material-ui/core/styles'; +import { + Table, + TableColumn, + Progress, + ResponseErrorPanel, +} from '@backstage/core-components'; +import useAsync from 'react-use/lib/useAsync'; + +export const exampleUsers = { + results: [ + { + gender: 'female', + name: { + title: 'Miss', + first: 'Carolyn', + last: 'Moore', + }, + email: 'carolyn.moore@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Carolyn', + nat: 'GB', + }, + { + gender: 'female', + name: { + title: 'Ms', + first: 'Esma', + last: 'Berberoğlu', + }, + email: 'esma.berberoglu@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Esma', + nat: 'TR', + }, + { + gender: 'female', + name: { + title: 'Ms', + first: 'Isabella', + last: 'Rhodes', + }, + email: 'isabella.rhodes@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Isabella', + nat: 'GB', + }, + { + gender: 'male', + name: { + title: 'Mr', + first: 'Derrick', + last: 'Carter', + }, + email: 'derrick.carter@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Derrick', + nat: 'IE', + }, + { + gender: 'female', + name: { + title: 'Miss', + first: 'Mattie', + last: 'Lambert', + }, + email: 'mattie.lambert@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Mattie', + nat: 'AU', + }, + { + gender: 'male', + name: { + title: 'Mr', + first: 'Mijat', + last: 'Rakić', + }, + email: 'mijat.rakic@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Mijat', + nat: 'RS', + }, + { + gender: 'male', + name: { + title: 'Mr', + first: 'Javier', + last: 'Reid', + }, + email: 'javier.reid@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Javier', + nat: 'US', + }, + { + gender: 'female', + name: { + title: 'Ms', + first: 'Isabella', + last: 'Li', + }, + email: 'isabella.li@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Isabella', + nat: 'CA', + }, + { + gender: 'female', + name: { + title: 'Mrs', + first: 'Stephanie', + last: 'Garrett', + }, + email: 'stephanie.garrett@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Stephanie', + nat: 'AU', + }, + { + gender: 'female', + name: { + title: 'Ms', + first: 'Antonia', + last: 'Núñez', + }, + email: 'antonia.nunez@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Antonia', + nat: 'ES', + }, + { + gender: 'male', + name: { + title: 'Mr', + first: 'Donald', + last: 'Young', + }, + email: 'donald.young@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Donald', + nat: 'US', + }, + { + gender: 'male', + name: { + title: 'Mr', + first: 'Iegor', + last: 'Holodovskiy', + }, + email: 'iegor.holodovskiy@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Iegor', + nat: 'UA', + }, + { + gender: 'female', + name: { + title: 'Madame', + first: 'Jessica', + last: 'David', + }, + email: 'jessica.david@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Jessica', + nat: 'CH', + }, + { + gender: 'female', + name: { + title: 'Ms', + first: 'Eve', + last: 'Martinez', + }, + email: 'eve.martinez@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Eve', + nat: 'FR', + }, + { + gender: 'male', + name: { + title: 'Mr', + first: 'Caleb', + last: 'Silva', + }, + email: 'caleb.silva@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Caleb', + nat: 'US', + }, + { + gender: 'female', + name: { + title: 'Miss', + first: 'Marcia', + last: 'Jenkins', + }, + email: 'marcia.jenkins@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Marcia', + nat: 'US', + }, + { + gender: 'female', + name: { + title: 'Mrs', + first: 'Mackenzie', + last: 'Jones', + }, + email: 'mackenzie.jones@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Mackenzie', + nat: 'NZ', + }, + { + gender: 'male', + name: { + title: 'Mr', + first: 'Jeremiah', + last: 'Gutierrez', + }, + email: 'jeremiah.gutierrez@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Jeremiah', + nat: 'AU', + }, + { + gender: 'female', + name: { + title: 'Ms', + first: 'Luciara', + last: 'Souza', + }, + email: 'luciara.souza@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Luciara', + nat: 'BR', + }, + { + gender: 'male', + name: { + title: 'Mr', + first: 'Valgi', + last: 'da Cunha', + }, + email: 'valgi.dacunha@example.com', + picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Valgi', + nat: 'BR', + }, + ], +}; + +const useStyles = makeStyles({ + avatar: { + height: 32, + width: 32, + borderRadius: '50%', + }, +}); + +type User = { + gender: string; // "male" + name: { + title: string; // "Mr", + first: string; // "Duane", + last: string; // "Reed" + }; + email: string; // "duane.reed@example.com" + picture: string; // "https://api.dicebear.com/6.x/open-peeps/svg?seed=Duane" + nat: string; // "AU" +}; + +type DenseTableProps = { + users: User[]; +}; + +export const DenseTable = ({ users }: DenseTableProps) => { + const classes = useStyles(); + + const columns: TableColumn[] = [ + { title: 'Avatar', field: 'avatar' }, + { title: 'Name', field: 'name' }, + { title: 'Email', field: 'email' }, + { title: 'Nationality', field: 'nationality' }, + ]; + + const data = users.map(user => { + return { + avatar: ( + {user.name.first} + ), + name: `${user.name.first} ${user.name.last}`, + email: user.email, + nationality: user.nat, + }; + }); + + return ( + + ); +}; + +export const ExampleFetchComponent = () => { + const { value, loading, error } = useAsync(async (): Promise => { + // Would use fetch in a real world example + return exampleUsers.results; + }, []); + + if (loading) { + return ; + } else if (error) { + return ; + } + + return ; +}; diff --git a/plugins/bui-themer/src/components/ExampleFetchComponent/index.ts b/plugins/bui-themer/src/components/ExampleFetchComponent/index.ts new file mode 100644 index 0000000000..b0be0a2a71 --- /dev/null +++ b/plugins/bui-themer/src/components/ExampleFetchComponent/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { ExampleFetchComponent } from './ExampleFetchComponent'; diff --git a/plugins/bui-themer/src/index.ts b/plugins/bui-themer/src/index.ts new file mode 100644 index 0000000000..26bc35ebb1 --- /dev/null +++ b/plugins/bui-themer/src/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { buiThemerPlugin, BuiThemerPage } from './plugin'; diff --git a/plugins/bui-themer/src/plugin.test.ts b/plugins/bui-themer/src/plugin.test.ts new file mode 100644 index 0000000000..b19adaf007 --- /dev/null +++ b/plugins/bui-themer/src/plugin.test.ts @@ -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('bui-themer', () => { + it('should export plugin', () => { + expect(buiThemerPlugin).toBeDefined(); + }); +}); diff --git a/plugins/bui-themer/src/plugin.ts b/plugins/bui-themer/src/plugin.ts new file mode 100644 index 0000000000..d87d035b12 --- /dev/null +++ b/plugins/bui-themer/src/plugin.ts @@ -0,0 +1,37 @@ +/* + * 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 { + createPlugin, + createRoutableExtension, +} from '@backstage/core-plugin-api'; + +import { rootRouteRef } from './routes'; + +export const buiThemerPlugin = createPlugin({ + id: 'bui-themer', + routes: { + root: rootRouteRef, + }, +}); + +export const BuiThemerPage = buiThemerPlugin.provide( + createRoutableExtension({ + name: 'BuiThemerPage', + component: () => + import('./components/ExampleComponent').then(m => m.ExampleComponent), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/bui-themer/src/routes.ts b/plugins/bui-themer/src/routes.ts new file mode 100644 index 0000000000..efd79e62be --- /dev/null +++ b/plugins/bui-themer/src/routes.ts @@ -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: 'bui-themer', +}); diff --git a/plugins/bui-themer/src/setupTests.ts b/plugins/bui-themer/src/setupTests.ts new file mode 100644 index 0000000000..b57590b525 --- /dev/null +++ b/plugins/bui-themer/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * 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'; diff --git a/yarn.lock b/yarn.lock index c1a352407c..cb9a75fa61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4323,6 +4323,31 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-bui-themer@workspace:^, @backstage/plugin-bui-themer@workspace:plugins/bui-themer": + version: 0.0.0-use.local + resolution: "@backstage/plugin-bui-themer@workspace:plugins/bui-themer" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/core-app-api": "workspace:^" + "@backstage/core-components": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/dev-utils": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/theme": "workspace:^" + "@material-ui/core": "npm:^4.9.13" + "@material-ui/icons": "npm:^4.9.1" + "@material-ui/lab": "npm:^4.0.0-alpha.61" + "@testing-library/jest-dom": "npm:^6.0.0" + "@testing-library/react": "npm:^14.0.0" + "@testing-library/user-event": "npm:^14.0.0" + msw: "npm:^1.0.0" + react: "npm:^16.13.1 || ^17.0.0 || ^18.0.0" + react-use: "npm:^17.2.4" + peerDependencies: + react: ^16.13.1 || ^17.0.0 || ^18.0.0 + languageName: unknown + linkType: soft + "@backstage/plugin-catalog-backend-module-aws@workspace:plugins/catalog-backend-module-aws": version: 0.0.0-use.local resolution: "@backstage/plugin-catalog-backend-module-aws@workspace:plugins/catalog-backend-module-aws" @@ -19238,6 +19263,22 @@ __metadata: languageName: node linkType: hard +"@testing-library/dom@npm:^9.0.0": + version: 9.3.4 + resolution: "@testing-library/dom@npm:9.3.4" + dependencies: + "@babel/code-frame": "npm:^7.10.4" + "@babel/runtime": "npm:^7.12.5" + "@types/aria-query": "npm:^5.0.1" + aria-query: "npm:5.1.3" + chalk: "npm:^4.1.0" + dom-accessibility-api: "npm:^0.5.9" + lz-string: "npm:^1.5.0" + pretty-format: "npm:^27.0.2" + checksum: 10/510da752ea76f4a10a0a4e3a77917b0302cf03effe576cd3534cab7e796533ee2b0e9fb6fb11b911a1ebd7c70a0bb6f235bf4f816c9b82b95b8fe0cddfd10975 + languageName: node + linkType: hard + "@testing-library/jest-dom@npm:^6.0.0, @testing-library/jest-dom@npm:^6.6.3": version: 6.8.0 resolution: "@testing-library/jest-dom@npm:6.8.0" @@ -19274,6 +19315,20 @@ __metadata: languageName: node linkType: hard +"@testing-library/react@npm:^14.0.0": + version: 14.3.1 + resolution: "@testing-library/react@npm:14.3.1" + dependencies: + "@babel/runtime": "npm:^7.12.5" + "@testing-library/dom": "npm:^9.0.0" + "@types/react-dom": "npm:^18.0.0" + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + checksum: 10/83359dcdf9eaf067839f34604e1a181cbc14fc09f3a07672403700fcc6a900c4b8054ad1114fc24b4b9f89d84e2a09e1b7c9afce2306b1d4b4c9e30eb1cb12de + languageName: node + linkType: hard + "@testing-library/react@npm:^16.0.0": version: 16.3.0 resolution: "@testing-library/react@npm:16.3.0" @@ -23624,6 +23679,15 @@ __metadata: languageName: node linkType: hard +"aria-query@npm:5.1.3": + version: 5.1.3 + resolution: "aria-query@npm:5.1.3" + dependencies: + deep-equal: "npm:^2.0.5" + checksum: 10/e5da608a7c4954bfece2d879342b6c218b6b207e2d9e5af270b5e38ef8418f02d122afdc948b68e32649b849a38377785252059090d66fa8081da95d1609c0d2 + languageName: node + linkType: hard + "aria-query@npm:5.3.0": version: 5.3.0 resolution: "aria-query@npm:5.3.0" @@ -23640,7 +23704,7 @@ __metadata: languageName: node linkType: hard -"array-buffer-byte-length@npm:^1.0.1, array-buffer-byte-length@npm:^1.0.2": +"array-buffer-byte-length@npm:^1.0.0, array-buffer-byte-length@npm:^1.0.1, array-buffer-byte-length@npm:^1.0.2": version: 1.0.2 resolution: "array-buffer-byte-length@npm:1.0.2" dependencies: @@ -27393,6 +27457,32 @@ __metadata: languageName: node linkType: hard +"deep-equal@npm:^2.0.5": + version: 2.2.3 + resolution: "deep-equal@npm:2.2.3" + dependencies: + array-buffer-byte-length: "npm:^1.0.0" + call-bind: "npm:^1.0.5" + es-get-iterator: "npm:^1.1.3" + get-intrinsic: "npm:^1.2.2" + is-arguments: "npm:^1.1.1" + is-array-buffer: "npm:^3.0.2" + is-date-object: "npm:^1.0.5" + is-regex: "npm:^1.1.4" + is-shared-array-buffer: "npm:^1.0.2" + isarray: "npm:^2.0.5" + object-is: "npm:^1.1.5" + object-keys: "npm:^1.1.1" + object.assign: "npm:^4.1.4" + regexp.prototype.flags: "npm:^1.5.1" + side-channel: "npm:^1.0.4" + which-boxed-primitive: "npm:^1.0.2" + which-collection: "npm:^1.0.1" + which-typed-array: "npm:^1.1.13" + checksum: 10/1ce49d0b71d0f14d8ef991a742665eccd488dfc9b3cada069d4d7a86291e591c92d2589c832811dea182b4015736b210acaaebce6184be356c1060d176f5a05f + languageName: node + linkType: hard + "deep-equal@npm:~1.0.1": version: 1.0.1 resolution: "deep-equal@npm:1.0.1" @@ -28611,7 +28701,7 @@ __metadata: languageName: node linkType: hard -"es-get-iterator@npm:^1.0.2": +"es-get-iterator@npm:^1.0.2, es-get-iterator@npm:^1.1.3": version: 1.1.3 resolution: "es-get-iterator@npm:1.1.3" dependencies: @@ -29663,6 +29753,7 @@ __metadata: "@backstage/integration-react": "workspace:^" "@backstage/plugin-api-docs": "workspace:^" "@backstage/plugin-auth-react": "workspace:^" + "@backstage/plugin-bui-themer": "workspace:^" "@backstage/plugin-catalog": "workspace:^" "@backstage/plugin-catalog-common": "workspace:^" "@backstage/plugin-catalog-graph": "workspace:^" @@ -31215,7 +31306,7 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": +"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.2, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": version: 1.3.0 resolution: "get-intrinsic@npm:1.3.0" dependencies: @@ -33148,7 +33239,7 @@ __metadata: languageName: node linkType: hard -"is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": +"is-array-buffer@npm:^3.0.2, is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": version: 3.0.5 resolution: "is-array-buffer@npm:3.0.5" dependencies: @@ -33621,7 +33712,7 @@ __metadata: languageName: node linkType: hard -"is-regex@npm:^1.2.1": +"is-regex@npm:^1.1.4, is-regex@npm:^1.2.1": version: 1.2.1 resolution: "is-regex@npm:1.2.1" dependencies: @@ -33670,7 +33761,7 @@ __metadata: languageName: node linkType: hard -"is-shared-array-buffer@npm:^1.0.4": +"is-shared-array-buffer@npm:^1.0.2, is-shared-array-buffer@npm:^1.0.4": version: 1.0.4 resolution: "is-shared-array-buffer@npm:1.0.4" dependencies: @@ -43096,6 +43187,15 @@ __metadata: languageName: node linkType: hard +"react@npm:^16.13.1 || ^17.0.0 || ^18.0.0, react@npm:^18.0.2": + version: 18.3.1 + resolution: "react@npm:18.3.1" + dependencies: + loose-envify: "npm:^1.1.0" + checksum: 10/261137d3f3993eaa2368a83110466fc0e558bc2c7f7ae7ca52d94f03aac945f45146bd85e5f481044db1758a1dbb57879e2fcdd33924e2dde1bdc550ce73f7bf + languageName: node + linkType: hard + "react@npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0": version: 19.1.1 resolution: "react@npm:19.1.1" @@ -43113,15 +43213,6 @@ __metadata: languageName: node linkType: hard -"react@npm:^18.0.2": - version: 18.3.1 - resolution: "react@npm:18.3.1" - dependencies: - loose-envify: "npm:^1.1.0" - checksum: 10/261137d3f3993eaa2368a83110466fc0e558bc2c7f7ae7ca52d94f03aac945f45146bd85e5f481044db1758a1dbb57879e2fcdd33924e2dde1bdc550ce73f7bf - languageName: node - linkType: hard - "read-cmd-shim@npm:^2.0.0": version: 2.0.0 resolution: "read-cmd-shim@npm:2.0.0" @@ -43396,7 +43487,7 @@ __metadata: languageName: node linkType: hard -"regexp.prototype.flags@npm:^1.5.3": +"regexp.prototype.flags@npm:^1.5.1, regexp.prototype.flags@npm:^1.5.3": version: 1.5.4 resolution: "regexp.prototype.flags@npm:1.5.4" dependencies: @@ -44905,7 +44996,7 @@ __metadata: languageName: node linkType: hard -"side-channel@npm:^1.0.6, side-channel@npm:^1.1.0": +"side-channel@npm:^1.0.4, side-channel@npm:^1.0.6, side-channel@npm:^1.1.0": version: 1.1.0 resolution: "side-channel@npm:1.1.0" dependencies: @@ -49105,7 +49196,7 @@ __metadata: languageName: node linkType: hard -"which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": +"which-boxed-primitive@npm:^1.0.2, which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": version: 1.1.1 resolution: "which-boxed-primitive@npm:1.1.1" dependencies: @@ -49139,7 +49230,7 @@ __metadata: languageName: node linkType: hard -"which-collection@npm:^1.0.2": +"which-collection@npm:^1.0.1, which-collection@npm:^1.0.2": version: 1.0.2 resolution: "which-collection@npm:1.0.2" dependencies: @@ -49161,7 +49252,7 @@ __metadata: languageName: node linkType: hard -"which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.18, which-typed-array@npm:^1.1.2": +"which-typed-array@npm:^1.1.13, which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.18, which-typed-array@npm:^1.1.2": version: 1.1.19 resolution: "which-typed-array@npm:1.1.19" dependencies: From 501b159de42bde4a06769f19653dafc51fa15948 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Sep 2025 19:03:54 +0200 Subject: [PATCH 02/31] bui-themer: trim example components and add NFS support Signed-off-by: Patrik Oldsberg --- plugins/bui-themer/package.json | 2 + .../BuiThemerPage.tsx} | 5 +- .../index.ts | 3 +- .../ExampleComponent.test.tsx | 38 --- .../ExampleComponent/ExampleComponent.tsx | 52 --- .../ExampleFetchComponent.test.tsx | 34 -- .../ExampleFetchComponent.tsx | 322 ------------------ plugins/bui-themer/src/index.ts | 2 + .../bui-themer/src/{plugin.ts => plugin.tsx} | 31 +- yarn.lock | 2 + 10 files changed, 41 insertions(+), 450 deletions(-) rename plugins/bui-themer/src/components/{ExampleFetchComponent/index.ts => BuiThemerPage/BuiThemerPage.tsx} (90%) rename plugins/bui-themer/src/components/{ExampleComponent => BuiThemerPage}/index.ts (91%) delete mode 100644 plugins/bui-themer/src/components/ExampleComponent/ExampleComponent.test.tsx delete mode 100644 plugins/bui-themer/src/components/ExampleComponent/ExampleComponent.tsx delete mode 100644 plugins/bui-themer/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx delete mode 100644 plugins/bui-themer/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx rename plugins/bui-themer/src/{plugin.ts => plugin.tsx} (59%) diff --git a/plugins/bui-themer/package.json b/plugins/bui-themer/package.json index c0d49b0879..f6f5faffd4 100644 --- a/plugins/bui-themer/package.json +++ b/plugins/bui-themer/package.json @@ -25,8 +25,10 @@ "postpack": "backstage-cli package postpack" }, "dependencies": { + "@backstage/core-compat-api": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/frontend-plugin-api": "workspace:^", "@backstage/theme": "workspace:^", "@material-ui/core": "^4.9.13", "@material-ui/icons": "^4.9.1", diff --git a/plugins/bui-themer/src/components/ExampleFetchComponent/index.ts b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx similarity index 90% rename from plugins/bui-themer/src/components/ExampleFetchComponent/index.ts rename to plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx index b0be0a2a71..0328a3e6d6 100644 --- a/plugins/bui-themer/src/components/ExampleFetchComponent/index.ts +++ b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx @@ -13,4 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { ExampleFetchComponent } from './ExampleFetchComponent'; + +export function BuiThemerPage() { + return
TODO
; +} diff --git a/plugins/bui-themer/src/components/ExampleComponent/index.ts b/plugins/bui-themer/src/components/BuiThemerPage/index.ts similarity index 91% rename from plugins/bui-themer/src/components/ExampleComponent/index.ts rename to plugins/bui-themer/src/components/BuiThemerPage/index.ts index 66b3fc6c97..3d32f11259 100644 --- a/plugins/bui-themer/src/components/ExampleComponent/index.ts +++ b/plugins/bui-themer/src/components/BuiThemerPage/index.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { ExampleComponent } from './ExampleComponent'; + +export { BuiThemerPage } from './BuiThemerPage'; diff --git a/plugins/bui-themer/src/components/ExampleComponent/ExampleComponent.test.tsx b/plugins/bui-themer/src/components/ExampleComponent/ExampleComponent.test.tsx deleted file mode 100644 index f5f7f185c5..0000000000 --- a/plugins/bui-themer/src/components/ExampleComponent/ExampleComponent.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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 { ExampleComponent } from './ExampleComponent'; -import { rest } from 'msw'; -import { setupServer } from 'msw/node'; -import { screen } from '@testing-library/react'; -import { registerMswTestHooks, renderInTestApp } from '@backstage/test-utils'; - -describe('ExampleComponent', () => { - const server = setupServer(); - // Enable sane handlers for network requests - registerMswTestHooks(server); - - // setup mock response - beforeEach(() => { - server.use( - rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))), - ); - }); - - it('should render', async () => { - await renderInTestApp(); - expect(screen.getByText('Welcome to bui-themer!')).toBeInTheDocument(); - }); -}); diff --git a/plugins/bui-themer/src/components/ExampleComponent/ExampleComponent.tsx b/plugins/bui-themer/src/components/ExampleComponent/ExampleComponent.tsx deleted file mode 100644 index 3a04bd7562..0000000000 --- a/plugins/bui-themer/src/components/ExampleComponent/ExampleComponent.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * 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 { Typography, Grid } from '@material-ui/core'; -import { - InfoCard, - Header, - Page, - Content, - ContentHeader, - HeaderLabel, - SupportButton, -} from '@backstage/core-components'; -import { ExampleFetchComponent } from '../ExampleFetchComponent'; - -export const ExampleComponent = () => ( - -
- - -
- - - A description of your plugin goes here. - - - - - - All content should be wrapped in a card like this. - - - - - - - - -
-); diff --git a/plugins/bui-themer/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx b/plugins/bui-themer/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx deleted file mode 100644 index a3085e3dcb..0000000000 --- a/plugins/bui-themer/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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 { renderInTestApp } from '@backstage/test-utils'; -import { ExampleFetchComponent } from './ExampleFetchComponent'; - -describe('ExampleFetchComponent', () => { - it('renders the user table', async () => { - const { getAllByText, getByAltText, getByText, findByRole } = - await renderInTestApp(); - - // Wait for the table to render - const table = await findByRole('table'); - const nationality = getAllByText('GB'); - // Assert that the table contains the expected user data - expect(table).toBeInTheDocument(); - expect(getByAltText('Carolyn')).toBeInTheDocument(); - expect(getByText('Carolyn Moore')).toBeInTheDocument(); - expect(getByText('carolyn.moore@example.com')).toBeInTheDocument(); - expect(nationality[0]).toBeInTheDocument(); - }); -}); diff --git a/plugins/bui-themer/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx b/plugins/bui-themer/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx deleted file mode 100644 index 6c9e77094d..0000000000 --- a/plugins/bui-themer/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx +++ /dev/null @@ -1,322 +0,0 @@ -/* - * 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 { makeStyles } from '@material-ui/core/styles'; -import { - Table, - TableColumn, - Progress, - ResponseErrorPanel, -} from '@backstage/core-components'; -import useAsync from 'react-use/lib/useAsync'; - -export const exampleUsers = { - results: [ - { - gender: 'female', - name: { - title: 'Miss', - first: 'Carolyn', - last: 'Moore', - }, - email: 'carolyn.moore@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Carolyn', - nat: 'GB', - }, - { - gender: 'female', - name: { - title: 'Ms', - first: 'Esma', - last: 'Berberoğlu', - }, - email: 'esma.berberoglu@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Esma', - nat: 'TR', - }, - { - gender: 'female', - name: { - title: 'Ms', - first: 'Isabella', - last: 'Rhodes', - }, - email: 'isabella.rhodes@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Isabella', - nat: 'GB', - }, - { - gender: 'male', - name: { - title: 'Mr', - first: 'Derrick', - last: 'Carter', - }, - email: 'derrick.carter@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Derrick', - nat: 'IE', - }, - { - gender: 'female', - name: { - title: 'Miss', - first: 'Mattie', - last: 'Lambert', - }, - email: 'mattie.lambert@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Mattie', - nat: 'AU', - }, - { - gender: 'male', - name: { - title: 'Mr', - first: 'Mijat', - last: 'Rakić', - }, - email: 'mijat.rakic@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Mijat', - nat: 'RS', - }, - { - gender: 'male', - name: { - title: 'Mr', - first: 'Javier', - last: 'Reid', - }, - email: 'javier.reid@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Javier', - nat: 'US', - }, - { - gender: 'female', - name: { - title: 'Ms', - first: 'Isabella', - last: 'Li', - }, - email: 'isabella.li@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Isabella', - nat: 'CA', - }, - { - gender: 'female', - name: { - title: 'Mrs', - first: 'Stephanie', - last: 'Garrett', - }, - email: 'stephanie.garrett@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Stephanie', - nat: 'AU', - }, - { - gender: 'female', - name: { - title: 'Ms', - first: 'Antonia', - last: 'Núñez', - }, - email: 'antonia.nunez@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Antonia', - nat: 'ES', - }, - { - gender: 'male', - name: { - title: 'Mr', - first: 'Donald', - last: 'Young', - }, - email: 'donald.young@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Donald', - nat: 'US', - }, - { - gender: 'male', - name: { - title: 'Mr', - first: 'Iegor', - last: 'Holodovskiy', - }, - email: 'iegor.holodovskiy@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Iegor', - nat: 'UA', - }, - { - gender: 'female', - name: { - title: 'Madame', - first: 'Jessica', - last: 'David', - }, - email: 'jessica.david@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Jessica', - nat: 'CH', - }, - { - gender: 'female', - name: { - title: 'Ms', - first: 'Eve', - last: 'Martinez', - }, - email: 'eve.martinez@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Eve', - nat: 'FR', - }, - { - gender: 'male', - name: { - title: 'Mr', - first: 'Caleb', - last: 'Silva', - }, - email: 'caleb.silva@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Caleb', - nat: 'US', - }, - { - gender: 'female', - name: { - title: 'Miss', - first: 'Marcia', - last: 'Jenkins', - }, - email: 'marcia.jenkins@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Marcia', - nat: 'US', - }, - { - gender: 'female', - name: { - title: 'Mrs', - first: 'Mackenzie', - last: 'Jones', - }, - email: 'mackenzie.jones@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Mackenzie', - nat: 'NZ', - }, - { - gender: 'male', - name: { - title: 'Mr', - first: 'Jeremiah', - last: 'Gutierrez', - }, - email: 'jeremiah.gutierrez@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Jeremiah', - nat: 'AU', - }, - { - gender: 'female', - name: { - title: 'Ms', - first: 'Luciara', - last: 'Souza', - }, - email: 'luciara.souza@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Luciara', - nat: 'BR', - }, - { - gender: 'male', - name: { - title: 'Mr', - first: 'Valgi', - last: 'da Cunha', - }, - email: 'valgi.dacunha@example.com', - picture: 'https://api.dicebear.com/6.x/open-peeps/svg?seed=Valgi', - nat: 'BR', - }, - ], -}; - -const useStyles = makeStyles({ - avatar: { - height: 32, - width: 32, - borderRadius: '50%', - }, -}); - -type User = { - gender: string; // "male" - name: { - title: string; // "Mr", - first: string; // "Duane", - last: string; // "Reed" - }; - email: string; // "duane.reed@example.com" - picture: string; // "https://api.dicebear.com/6.x/open-peeps/svg?seed=Duane" - nat: string; // "AU" -}; - -type DenseTableProps = { - users: User[]; -}; - -export const DenseTable = ({ users }: DenseTableProps) => { - const classes = useStyles(); - - const columns: TableColumn[] = [ - { title: 'Avatar', field: 'avatar' }, - { title: 'Name', field: 'name' }, - { title: 'Email', field: 'email' }, - { title: 'Nationality', field: 'nationality' }, - ]; - - const data = users.map(user => { - return { - avatar: ( - {user.name.first} - ), - name: `${user.name.first} ${user.name.last}`, - email: user.email, - nationality: user.nat, - }; - }); - - return ( -
- ); -}; - -export const ExampleFetchComponent = () => { - const { value, loading, error } = useAsync(async (): Promise => { - // Would use fetch in a real world example - return exampleUsers.results; - }, []); - - if (loading) { - return ; - } else if (error) { - return ; - } - - return ; -}; diff --git a/plugins/bui-themer/src/index.ts b/plugins/bui-themer/src/index.ts index 26bc35ebb1..4cf05a7f23 100644 --- a/plugins/bui-themer/src/index.ts +++ b/plugins/bui-themer/src/index.ts @@ -13,4 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export { buiThemerPlugin, BuiThemerPage } from './plugin'; +export { default } from './plugin'; diff --git a/plugins/bui-themer/src/plugin.ts b/plugins/bui-themer/src/plugin.tsx similarity index 59% rename from plugins/bui-themer/src/plugin.ts rename to plugins/bui-themer/src/plugin.tsx index d87d035b12..78d3196126 100644 --- a/plugins/bui-themer/src/plugin.ts +++ b/plugins/bui-themer/src/plugin.tsx @@ -13,13 +13,22 @@ * 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 export const buiThemerPlugin = createPlugin({ id: 'bui-themer', routes: { @@ -31,7 +40,25 @@ export const BuiThemerPage = buiThemerPlugin.provide( createRoutableExtension({ name: 'BuiThemerPage', component: () => - import('./components/ExampleComponent').then(m => m.ExampleComponent), + import('./components/BuiThemerPage').then(m => m.BuiThemerPage), mountPoint: rootRouteRef, }), ); + +// New system +export default createFrontendPlugin({ + pluginId: 'bui-themer', + extensions: [ + PageBlueprint.make({ + params: { + path: '/bui-themer', + loader: () => + import('./components/BuiThemerPage').then(m => ), + routeRef: convertLegacyRouteRef(rootRouteRef), + }, + }), + ], + routes: convertLegacyRouteRefs({ + root: rootRouteRef, + }), +}); diff --git a/yarn.lock b/yarn.lock index cb9a75fa61..f22d88ee20 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4329,9 +4329,11 @@ __metadata: dependencies: "@backstage/cli": "workspace:^" "@backstage/core-app-api": "workspace:^" + "@backstage/core-compat-api": "workspace:^" "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/dev-utils": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" "@material-ui/core": "npm:^4.9.13" From c56cb0460e6abdeec4e38aae8d61931d0e4b3748 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Sep 2025 19:11:20 +0200 Subject: [PATCH 03/31] bui-themer: add implementation plan Signed-off-by: Patrik Oldsberg --- .../components/BuiThemerPage/BuiThemerPage.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx index 0328a3e6d6..8fb72eda1c 100644 --- a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx +++ b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx @@ -14,6 +14,21 @@ * limitations under the License. */ +/* + +IMPLEMENTATION PLAN: + +- create a utility that converts the MUI theme palette into printed CSS variables +populating the variables in packages/ui/src/css/core.css +- the conversion utility will be created in an adjacent `convertMuiToBuiTheme` file, which defines a `convertMuiToBuiTheme` function that accepts a MUI theme instance and returns CSS in text format +- create tests for the conversion utility +- Create a page that shows the generated CSS variables and allows the user to copy them +- The page should show coverted CSS variables for all installed themes in the app, which can be acquired via the AppThemeApi, defined at packages/core-plugin-api/src/apis/definitions/AppThemeApi.ts +- The AppThemeApi can be accessed via the useApi(appThemeApiRef) hook +- The app themes only provide a `Provider` component that in turn provides the MUI theme. The theme can be accessed by wrapping the component in the theme's `Provider` component and then using the `useTheme` hook from MUI + +*/ + export function BuiThemerPage() { return
TODO
; } From 05ff15d1c0bee41d9e3ac4219c4c142d42e31541 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Sep 2025 19:20:54 +0200 Subject: [PATCH 04/31] bui-themer: refined implementation plan Signed-off-by: Patrik Oldsberg --- .../BuiThemerPage/BuiThemerPage.tsx | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx index 8fb72eda1c..2da27cad36 100644 --- a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx +++ b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx @@ -18,14 +18,36 @@ IMPLEMENTATION PLAN: -- create a utility that converts the MUI theme palette into printed CSS variables -populating the variables in packages/ui/src/css/core.css -- the conversion utility will be created in an adjacent `convertMuiToBuiTheme` file, which defines a `convertMuiToBuiTheme` function that accepts a MUI theme instance and returns CSS in text format -- create tests for the conversion utility -- Create a page that shows the generated CSS variables and allows the user to copy them -- The page should show coverted CSS variables for all installed themes in the app, which can be acquired via the AppThemeApi, defined at packages/core-plugin-api/src/apis/definitions/AppThemeApi.ts -- The AppThemeApi can be accessed via the useApi(appThemeApiRef) hook -- The app themes only provide a `Provider` component that in turn provides the MUI theme. The theme can be accessed by wrapping the component in the theme's `Provider` component and then using the `useTheme` hook from MUI +- Create a converter utility that maps a MUI v5 `Theme` to BUI CSS variables, + covering palette, typography, spacing, and shape tokens to match + `packages/ui/src/css/core.css`. +- Place it in an adjacent `convertMuiToBuiTheme.ts` file exporting + `convertMuiToBuiTheme(theme: Theme, options?): string`, returning + deterministic CSS text. +- Scope output blocks as: + - `:root { ... }` for light themes + - `[data-theme-mode='dark'] { ... }` for dark themes + Optionally prefix by theme id (e.g. `[data-app-theme='']`) so multiple + theme blocks can coexist without conflicts. +- Do not modify `packages/ui/src/css/core.css`; generate CSS for copy/download + only. +- Define a mapping from MUI fields to `--bui-*` tokens with sensible fallbacks + aligning with core defaults. Handle success/warning/error/background/text + surfaces, borders, and link/hover states. +- Add tests for light/dark and fallback behavior using MUI `createTheme` and + snapshot the generated CSS. +- Build the page to list all installed themes via `useApi(appThemeApiRef)`, + render a minimal child under each theme `Provider`, and read `useTheme()` to + get the runtime theme instance. Provide Copy and Download actions and an + optional live preview applying the generated variables. +- Performance: memoize generated CSS by theme id and regenerate when the + installed theme list changes. Observing `activeThemeId$()` is optional unless + previewing the active theme. + +REQUIREMENTS: + +- No changes can be made outside the plugins/bui-themer/src directory +- Only components from the `@backstage/ui` package at `packages/ui` can be used */ From 6262191013b65f95fb8d2d49089b880621c51ac6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 12 Sep 2025 19:46:05 +0200 Subject: [PATCH 05/31] bui-themer: initial implementation plan execution Signed-off-by: Patrik Oldsberg --- plugins/bui-themer/package.json | 2 + .../BuiThemerPage/BuiThemerPage.tsx | 297 ++++++++++++++++-- .../convertMuiToBuiTheme.test.ts | 278 ++++++++++++++++ .../BuiThemerPage/convertMuiToBuiTheme.ts | 286 +++++++++++++++++ yarn.lock | 185 ++++++++++- 5 files changed, 1015 insertions(+), 33 deletions(-) create mode 100644 plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.test.ts create mode 100644 plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.ts diff --git a/plugins/bui-themer/package.json b/plugins/bui-themer/package.json index f6f5faffd4..ff97c92ecb 100644 --- a/plugins/bui-themer/package.json +++ b/plugins/bui-themer/package.json @@ -30,9 +30,11 @@ "@backstage/core-plugin-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/theme": "workspace:^", + "@backstage/ui": "workspace:^", "@material-ui/core": "^4.9.13", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.61", + "@mui/material": "^7.3.2", "react-use": "^17.2.4" }, "peerDependencies": { diff --git a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx index 2da27cad36..b36c550081 100644 --- a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx +++ b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx @@ -14,43 +14,276 @@ * limitations under the License. */ -/* +import { useMemo, useState, useCallback, useEffect } from 'react'; +import { useApi } from '@backstage/core-plugin-api'; +import { appThemeApiRef } from '@backstage/core-plugin-api'; +import { useTheme } from '@mui/material/styles'; +import { + Box, + Button, + Card, + Container, + Flex, + Grid, + HeaderPage, + Text, + TextField, + Switch, +} from '@backstage/ui'; +import { + convertMuiToBuiTheme, + ConvertMuiToBuiThemeOptions, +} from './convertMuiToBuiTheme'; -IMPLEMENTATION PLAN: +interface ThemePreviewProps { + themeId: string; + themeTitle: string; + variant: 'light' | 'dark'; + Provider: React.ComponentType<{ children: React.ReactNode }>; +} -- Create a converter utility that maps a MUI v5 `Theme` to BUI CSS variables, - covering palette, typography, spacing, and shape tokens to match - `packages/ui/src/css/core.css`. -- Place it in an adjacent `convertMuiToBuiTheme.ts` file exporting - `convertMuiToBuiTheme(theme: Theme, options?): string`, returning - deterministic CSS text. -- Scope output blocks as: - - `:root { ... }` for light themes - - `[data-theme-mode='dark'] { ... }` for dark themes - Optionally prefix by theme id (e.g. `[data-app-theme='']`) so multiple - theme blocks can coexist without conflicts. -- Do not modify `packages/ui/src/css/core.css`; generate CSS for copy/download - only. -- Define a mapping from MUI fields to `--bui-*` tokens with sensible fallbacks - aligning with core defaults. Handle success/warning/error/background/text - surfaces, borders, and link/hover states. -- Add tests for light/dark and fallback behavior using MUI `createTheme` and - snapshot the generated CSS. -- Build the page to list all installed themes via `useApi(appThemeApiRef)`, - render a minimal child under each theme `Provider`, and read `useTheme()` to - get the runtime theme instance. Provide Copy and Download actions and an - optional live preview applying the generated variables. -- Performance: memoize generated CSS by theme id and regenerate when the - installed theme list changes. Observing `activeThemeId$()` is optional unless - previewing the active theme. +// Memoization cache for generated CSS +const cssCache = new Map(); -REQUIREMENTS: +interface ThemeContentProps { + themeId: string; + themeTitle: string; + variant: 'light' | 'dark'; +} -- No changes can be made outside the plugins/bui-themer/src directory -- Only components from the `@backstage/ui` package at `packages/ui` can be used +function ThemeContent({ themeId, themeTitle, variant }: ThemeContentProps) { + const [generatedCss, setGeneratedCss] = useState(''); + const [isPreviewMode, setIsPreviewMode] = useState(false); + const [includeThemeId, setIncludeThemeId] = useState(false); + const muiTheme = useTheme(); -*/ + const css = useMemo(() => { + // Create cache key based on theme properties and options + const cacheKey = `${themeId}-${includeThemeId}-${JSON.stringify({ + palette: muiTheme.palette, + typography: muiTheme.typography, + spacing: muiTheme.spacing, + shape: muiTheme.shape, + })}`; + + // Check cache first + if (cssCache.has(cacheKey)) { + return cssCache.get(cacheKey)!; + } + + const options: ConvertMuiToBuiThemeOptions = { + themeId, + includeThemeId, + }; + const result = convertMuiToBuiTheme(muiTheme, options); + + // Cache the result + cssCache.set(cacheKey, result); + + // Clean up old cache entries (keep only last 50) + if (cssCache.size > 50) { + const firstKey = cssCache.keys().next().value; + if (firstKey) { + cssCache.delete(firstKey); + } + } + + return result; + }, [muiTheme, themeId, includeThemeId]); + + useEffect(() => { + setGeneratedCss(css); + }, [css]); + + const handleCopy = useCallback(() => { + window.navigator.clipboard.writeText(generatedCss); + }, [generatedCss]); + + const handleDownload = useCallback(() => { + const blob = new Blob([generatedCss], { 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); + }, [generatedCss, themeId]); + + const handlePreviewToggle = useCallback(() => { + setIsPreviewMode(!isPreviewMode); + if (!isPreviewMode) { + // Apply the generated CSS to a style element + const styleId = `bui-theme-preview-${themeId}`; + let styleElement = document.getElementById(styleId); + if (!styleElement) { + styleElement = document.createElement('style'); + styleElement.id = styleId; + document.head.appendChild(styleElement); + } + styleElement.textContent = generatedCss; + } else { + // Remove the preview styles + const styleElement = document.getElementById( + `bui-theme-preview-${themeId}`, + ); + if (styleElement) { + styleElement.remove(); + } + } + }, [isPreviewMode, generatedCss, themeId]); + + return ( + + + + + {themeTitle} + + {variant} theme + + + + + + + + + + + + + + + + Generated CSS: + + + + + {isPreviewMode && ( + + + Live Preview: + + + Solid Button + + + Tint Button + + + Surface Card + + + )} + + + + ); +} + +function ThemePreview({ + themeId, + themeTitle, + variant, + Provider, +}: ThemePreviewProps) { + return ( + + + + ); +} export function BuiThemerPage() { - return
TODO
; + const appThemeApi = useApi(appThemeApiRef); + const installedThemes = appThemeApi.getInstalledThemes(); + + return ( + + + + + Convert MUI v5 themes to BUI CSS variables. Select a theme to generate + the corresponding BUI CSS variables that can be copied or downloaded. + + + + + {installedThemes.length === 0 ? ( + + + + No themes found. Please install some themes in your Backstage + app. + + + + ) : ( + + {installedThemes.map(theme => ( + + + + ))} + + )} + + + ); } diff --git a/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.test.ts b/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.test.ts new file mode 100644 index 0000000000..e45f48f3a0 --- /dev/null +++ b/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.test.ts @@ -0,0 +1,278 @@ +/* + * 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).toContain(':root {'); + expect(result).toContain('--bui-font-regular: Roboto, sans-serif;'); + expect(result).toContain('--bui-space: 8px;'); + expect(result).toContain('--bui-radius-3: 4px;'); + expect(result).toContain('--bui-bg: #f5f5f5;'); + expect(result).toContain('--bui-bg-surface-1: #ffffff;'); + expect(result).toContain('--bui-fg-primary: #000000;'); + expect(result).toContain('--bui-fg-secondary: #666666;'); + expect(result).toContain('--bui-bg-solid: #1976d2;'); + }); + + 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).toContain("[data-theme-mode='dark'] {"); + expect(result).toContain('--bui-bg: #121212;'); + expect(result).toContain('--bui-bg-surface-1: #1e1e1e;'); + expect(result).toContain('--bui-fg-primary: #ffffff;'); + expect(result).toContain('--bui-fg-secondary: #b3b3b3;'); + }); + + it('should include theme ID scoping when requested', () => { + const theme = createTheme({ + palette: { + mode: 'light', + primary: { + main: '#1976d2', + }, + }, + }); + + const result = convertMuiToBuiTheme(theme, { + themeId: 'my-theme', + includeThemeId: true, + }); + + expect(result).toContain("[data-app-theme='my-theme'] :root {"); + }); + + it('should handle missing theme properties gracefully', () => { + const theme = createTheme({}); + + const result = convertMuiToBuiTheme(theme); + + expect(result).toContain(':root {'); + expect(result).toContain('--bui-font-weight-regular: 400;'); + expect(result).toContain('--bui-font-weight-bold: 700;'); + // Should have default values even when theme properties are missing + expect(result).toContain('--bui-black: #000;'); + expect(result).toContain('--bui-white: #fff;'); + }); + + it('should generate proper gray scale for light theme', () => { + const theme = createTheme({ + palette: { + mode: 'light', + primary: { + main: '#1976d2', + }, + }, + }); + + const result = convertMuiToBuiTheme(theme); + + expect(result).toContain('--bui-gray-1: #f8f8f8;'); + expect(result).toContain('--bui-gray-8: #595959;'); + }); + + it('should generate proper gray scale for dark theme', () => { + const theme = createTheme({ + palette: { + mode: 'dark', + primary: { + main: '#90caf9', + }, + }, + }); + + const result = convertMuiToBuiTheme(theme); + + expect(result).toContain('--bui-gray-1: #191919;'); + expect(result).toContain('--bui-gray-8: #b4b4b4;'); + }); + + 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).toContain('--bui-bg-solid: #1976d2;'); + expect(result).toContain('--bui-bg-solid-hover: #115293;'); + expect(result).toContain('--bui-bg-danger: #ffcdd2;'); + expect(result).toContain('--bui-bg-warning: #ffe0b2;'); + expect(result).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).toContain('--bui-fg-link: #1976d2;'); + expect(result).toContain('--bui-fg-link-hover: #115293;'); + expect(result).toContain('--bui-fg-disabled: #cccccc;'); + expect(result).toContain('--bui-fg-danger: #f44336;'); + expect(result).toContain('--bui-fg-warning: #ff9800;'); + expect(result).toContain('--bui-fg-success: #4caf50;'); + }); + + 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); + + expect(result).toContain('--bui-border: rgba(0, 0, 0, 0.1);'); + expect(result).toContain('--bui-border-hover: rgba(0, 0, 0, 0.2);'); + expect(result).toContain('--bui-border-danger: #f44336;'); + expect(result).toContain('--bui-border-warning: #ff9800;'); + expect(result).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).toContain('--bui-space: 4px;'); + }); + + it('should handle string-based spacing', () => { + const theme = createTheme({ + spacing: '8px', + }); + + const result = convertMuiToBuiTheme(theme); + + expect(result).toContain('--bui-space: calc(1 * 8px);'); + }); + + it('should handle string-based border radius', () => { + const theme = createTheme({ + shape: { + borderRadius: '8px', + }, + }); + + const result = convertMuiToBuiTheme(theme); + + expect(result).toContain('--bui-radius-3: 8px;'); + }); +}); diff --git a/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.ts b/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.ts new file mode 100644 index 0000000000..e1f1618760 --- /dev/null +++ b/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.ts @@ -0,0 +1,286 @@ +/* + * 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'; + +export interface ConvertMuiToBuiThemeOptions { + /** + * Theme ID to use for scoping CSS variables + */ + themeId?: string; + /** + * Whether to include theme ID scoping in the CSS + */ + includeThemeId?: boolean; +} + +/** + * Converts a MUI v5 Theme to BUI CSS variables + * @param theme - The MUI v5 theme to convert + * @param options - Conversion options + * @returns CSS string with BUI variables + */ +export function convertMuiToBuiTheme( + theme: Mui5Theme, + options: ConvertMuiToBuiThemeOptions = {}, +): string { + const { themeId, includeThemeId = false } = options; + const isDark = theme.palette.mode === 'dark'; + + // Generate CSS variables based on theme + const variables = generateBuiVariables(theme); + + // Create CSS selector based on theme mode and ID + let selector = isDark ? "[data-theme-mode='dark']" : ':root'; + if (includeThemeId && themeId) { + selector = `[data-app-theme='${themeId}'] ${selector}`; + } + + return `${selector} {\n${variables}\n}`; +} + +/** + * Generates BUI CSS variables from MUI theme + */ +function generateBuiVariables(theme: Mui5Theme): string { + const variables: string[] = []; + + // Font families + if (theme.typography.fontFamily) { + variables.push(` --bui-font-regular: ${theme.typography.fontFamily};`); + } + + // Font weights + variables.push( + ` --bui-font-weight-regular: ${ + theme.typography.fontWeightRegular || 400 + };`, + ); + variables.push( + ` --bui-font-weight-bold: ${theme.typography.fontWeightBold || 600};`, + ); + + // Font sizes - map MUI typography scale to BUI scale + const fontSizeMap = { + h1: '--bui-font-size-10', + h2: '--bui-font-size-8', + h3: '--bui-font-size-7', + h4: '--bui-font-size-6', + h5: '--bui-font-size-5', + h6: '--bui-font-size-4', + body1: '--bui-font-size-4', + body2: '--bui-font-size-3', + caption: '--bui-font-size-2', + overline: '--bui-font-size-1', + }; + + Object.entries(fontSizeMap).forEach(([muiKey, buiVar]) => { + const typographyVariant = + theme.typography[muiKey as keyof typeof theme.typography]; + const fontSize = + typeof typographyVariant === 'object' && typographyVariant?.fontSize + ? typographyVariant.fontSize + : undefined; + if (fontSize) { + variables.push(` ${buiVar}: ${fontSize};`); + } + }); + + // Spacing - map MUI spacing to BUI spacing scale + if (theme.spacing) { + const spacingUnit = + typeof theme.spacing === 'function' ? theme.spacing(1) : theme.spacing; + const spacingValue = + typeof spacingUnit === 'number' ? `${spacingUnit}px` : spacingUnit; + variables.push(` --bui-space: ${spacingValue};`); + } + + // Border radius + if (theme.shape?.borderRadius) { + const radius = theme.shape.borderRadius; + const radiusValue = typeof radius === 'number' ? `${radius}px` : radius; + variables.push(` --bui-radius-3: ${radiusValue};`); + } + + // Colors - map MUI palette to BUI color tokens + const palette = theme.palette; + + // Base colors + if (palette.common?.black) { + variables.push(` --bui-black: ${palette.common.black};`); + } + if (palette.common?.white) { + variables.push(` --bui-white: ${palette.common.white};`); + } + + // Gray scale - generate from primary color or use defaults + const grayScale = generateGrayScale(palette.mode === 'dark'); + Object.entries(grayScale).forEach(([key, value]) => { + variables.push(` --bui-gray-${key}: ${value};`); + }); + + // Background colors + if (palette.background?.default) { + variables.push(` --bui-bg: ${palette.background.default};`); + } + if (palette.background?.paper) { + variables.push(` --bui-bg-surface-1: ${palette.background.paper};`); + } + + // Generate surface colors + const surfaceColors = generateSurfaceColors(palette); + Object.entries(surfaceColors).forEach(([key, value]) => { + variables.push(` --bui-bg-${key}: ${value};`); + }); + + // Foreground colors + if (palette.text?.primary) { + variables.push(` --bui-fg-primary: ${palette.text.primary};`); + } + if (palette.text?.secondary) { + variables.push(` --bui-fg-secondary: ${palette.text.secondary};`); + } + + // Generate foreground colors + const foregroundColors = generateForegroundColors(palette); + Object.entries(foregroundColors).forEach(([key, value]) => { + variables.push(` --bui-fg-${key}: ${value};`); + }); + + // Border colors + const borderColors = generateBorderColors(palette); + Object.entries(borderColors).forEach(([key, value]) => { + variables.push(` --bui-border${key ? `-${key}` : ''}: ${value};`); + }); + + // Special colors + if (palette.primary?.main) { + variables.push(` --bui-ring: ${palette.primary.main};`); + } + + return variables.join('\n'); +} + +/** + * Generates gray scale colors + */ +function generateGrayScale(isDark: boolean): Record { + if (isDark) { + return { + '1': '#191919', + '2': '#242424', + '3': '#373737', + '4': '#464646', + '5': '#575757', + '6': '#7b7b7b', + '7': '#9e9e9e', + '8': '#b4b4b4', + }; + } + + return { + '1': '#f8f8f8', + '2': '#ececec', + '3': '#d9d9d9', + '4': '#c1c1c1', + '5': '#9e9e9e', + '6': '#8c8c8c', + '7': '#757575', + '8': '#595959', + }; +} + +/** + * Generates surface background colors + */ +function generateSurfaceColors( + palette: Mui5Theme['palette'], +): Record { + const isDark = palette.mode === 'dark'; + + // Helper function to get tint colors with proper fallbacks + const getTintColor = (opacity: string) => { + if (palette.primary?.main) { + return `${palette.primary.main}${opacity}`; + } + return isDark + ? `rgba(156, 201, 255, ${opacity === '40' ? '0.12' : '0.16'})` + : `rgba(31, 84, 147, ${opacity === '40' ? '0.4' : '0.6'})`; + }; + + // Helper function to get fallback colors based on theme mode + const getFallbackColor = (lightColor: string, darkColor: string) => { + return isDark ? darkColor : lightColor; + }; + + return { + 'surface-2': getFallbackColor('#ececec', '#242424'), + solid: palette.primary?.main || getFallbackColor('#1f5493', '#9cc9ff'), + 'solid-hover': + palette.primary?.dark || getFallbackColor('#163a66', '#83b9fd'), + 'solid-pressed': + palette.primary?.dark || getFallbackColor('#0f2b4e', '#83b9fd'), + 'solid-disabled': getFallbackColor('#ebebeb', '#222222'), + tint: 'transparent', + 'tint-hover': getTintColor('40'), + 'tint-pressed': getTintColor('60'), + 'tint-disabled': getFallbackColor('#ebebeb', 'transparent'), + danger: palette.error?.light || getFallbackColor('#feebe7', '#3b1219'), + warning: palette.warning?.light || getFallbackColor('#fff2b2', '#302008'), + success: palette.success?.light || getFallbackColor('#e6f6eb', '#132d21'), + }; +} + +/** + * Generates foreground colors + */ +function generateForegroundColors( + palette: Mui5Theme['palette'], +): Record { + const isDark = palette.mode === 'dark'; + + return { + link: palette.primary?.main || (isDark ? '#9cc9ff' : '#1f5493'), + 'link-hover': palette.primary?.dark || (isDark ? '#7eb5f7' : '#1f2d5c'), + disabled: palette.text?.disabled || (isDark ? '#9e9e9e' : '#9e9e9e'), + solid: isDark ? '#101821' : '#ffffff', + 'solid-disabled': isDark ? '#575757' : '#9c9c9c', + tint: palette.primary?.main || (isDark ? '#9cc9ff' : '#1f5493'), + 'tint-disabled': isDark ? '#575757' : '#9e9e9e', + danger: palette.error?.main || '#e22b2b', + warning: palette.warning?.main || '#e36d05', + success: palette.success?.main || '#1db954', + }; +} + +/** + * Generates border colors + */ +function generateBorderColors( + palette: Mui5Theme['palette'], +): Record { + const isDark = palette.mode === 'dark'; + + return { + '': isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.1)', + hover: isDark ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.2)', + pressed: isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.4)', + disabled: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)', + danger: palette.error?.main || '#f87a7a', + warning: palette.warning?.main || '#e36d05', + success: palette.success?.main || '#53db83', + }; +} diff --git a/yarn.lock b/yarn.lock index f22d88ee20..41f7dc6d64 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2435,6 +2435,13 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.28.3": + version: 7.28.4 + resolution: "@babel/runtime@npm:7.28.4" + checksum: 10/6c9a70452322ea80b3c9b2a412bcf60771819213a67576c8cec41e88a95bb7bf01fc983754cda35dc19603eef52df22203ccbf7777b9d6316932f9fb77c25163 + languageName: node + linkType: hard + "@babel/template@npm:^7.27.2, @babel/template@npm:^7.3.3": version: 7.27.2 resolution: "@babel/template@npm:7.27.2" @@ -4336,9 +4343,11 @@ __metadata: "@backstage/frontend-plugin-api": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" + "@backstage/ui": "workspace:^" "@material-ui/core": "npm:^4.9.13" "@material-ui/icons": "npm:^4.9.1" "@material-ui/lab": "npm:^4.0.0-alpha.61" + "@mui/material": "npm:^7.3.2" "@testing-library/jest-dom": "npm:^6.0.0" "@testing-library/react": "npm:^14.0.0" "@testing-library/user-event": "npm:^14.0.0" @@ -8184,6 +8193,19 @@ __metadata: languageName: node linkType: hard +"@emotion/cache@npm:^11.14.0": + version: 11.14.0 + resolution: "@emotion/cache@npm:11.14.0" + dependencies: + "@emotion/memoize": "npm:^0.9.0" + "@emotion/sheet": "npm:^1.4.0" + "@emotion/utils": "npm:^1.4.2" + "@emotion/weak-memoize": "npm:^0.4.0" + stylis: "npm:4.2.0" + checksum: 10/52336b28a27b07dde8fcdfd80851cbd1487672bbd4db1e24cca1440c95d8a6a968c57b0453c2b7c88d9b432b717f99554dbecc05b5cdef27933299827e69fd8e + languageName: node + linkType: hard + "@emotion/hash@npm:^0.8.0": version: 0.8.0 resolution: "@emotion/hash@npm:0.8.0" @@ -11170,6 +11192,13 @@ __metadata: languageName: node linkType: hard +"@mui/core-downloads-tracker@npm:^7.3.2": + version: 7.3.2 + resolution: "@mui/core-downloads-tracker@npm:7.3.2" + checksum: 10/01c0976e5fb6a8f0b59ebd58019af5452d7761e97daf0f2ef121bc3323508edf2fea1b13ea1a54b0098d6ee5eef70041c9722fe43291b237b989b6813a24b71e + languageName: node + linkType: hard + "@mui/material@npm:^5.12.2": version: 5.16.14 resolution: "@mui/material@npm:5.16.14" @@ -11203,6 +11232,42 @@ __metadata: languageName: node linkType: hard +"@mui/material@npm:^7.3.2": + version: 7.3.2 + resolution: "@mui/material@npm:7.3.2" + dependencies: + "@babel/runtime": "npm:^7.28.3" + "@mui/core-downloads-tracker": "npm:^7.3.2" + "@mui/system": "npm:^7.3.2" + "@mui/types": "npm:^7.4.6" + "@mui/utils": "npm:^7.3.2" + "@popperjs/core": "npm:^2.11.8" + "@types/react-transition-group": "npm:^4.4.12" + clsx: "npm:^2.1.1" + csstype: "npm:^3.1.3" + prop-types: "npm:^15.8.1" + react-is: "npm:^19.1.1" + react-transition-group: "npm:^4.4.5" + peerDependencies: + "@emotion/react": ^11.5.0 + "@emotion/styled": ^11.3.0 + "@mui/material-pigment-css": ^7.3.2 + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/react": + optional: true + "@emotion/styled": + optional: true + "@mui/material-pigment-css": + optional: true + "@types/react": + optional: true + checksum: 10/e9a5348060d6e6e6cb1ce0fe299a582fa9b37b56dff46b62f72d029d5078acefd56a258e559b29bb3d12e89d0a81fd4f1194ce07cc136bbf03fdb4878fd9b3c0 + languageName: node + linkType: hard + "@mui/private-theming@npm:^5.16.14": version: 5.16.14 resolution: "@mui/private-theming@npm:5.16.14" @@ -11220,6 +11285,23 @@ __metadata: languageName: node linkType: hard +"@mui/private-theming@npm:^7.3.2": + version: 7.3.2 + resolution: "@mui/private-theming@npm:7.3.2" + dependencies: + "@babel/runtime": "npm:^7.28.3" + "@mui/utils": "npm:^7.3.2" + prop-types: "npm:^15.8.1" + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/5b733fc9e09e5b056af3e448899c665c6d1560ec56f593811fc88b9c5499ad42d8365551b182473880de6141a206da4f1f9e5b2ebcea12461dc6d8954e2a3ed7 + languageName: node + linkType: hard + "@mui/styled-engine@npm:^5.16.14": version: 5.16.14 resolution: "@mui/styled-engine@npm:5.16.14" @@ -11241,6 +11323,29 @@ __metadata: languageName: node linkType: hard +"@mui/styled-engine@npm:^7.3.2": + version: 7.3.2 + resolution: "@mui/styled-engine@npm:7.3.2" + dependencies: + "@babel/runtime": "npm:^7.28.3" + "@emotion/cache": "npm:^11.14.0" + "@emotion/serialize": "npm:^1.3.3" + "@emotion/sheet": "npm:^1.4.0" + csstype: "npm:^3.1.3" + prop-types: "npm:^15.8.1" + peerDependencies: + "@emotion/react": ^11.4.1 + "@emotion/styled": ^11.3.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/react": + optional: true + "@emotion/styled": + optional: true + checksum: 10/f31a6080b950cc66edd19a5285d30e0557fc16ef07a5e9027d3479f18dda4a569a62976c0fa396a99b077c01000c4fced3c8ab3ea0f6bcd82d6db06b821503b2 + languageName: node + linkType: hard + "@mui/styles@npm:^5.14.18": version: 5.16.14 resolution: "@mui/styles@npm:5.16.14" @@ -11300,6 +11405,34 @@ __metadata: languageName: node linkType: hard +"@mui/system@npm:^7.3.2": + version: 7.3.2 + resolution: "@mui/system@npm:7.3.2" + dependencies: + "@babel/runtime": "npm:^7.28.3" + "@mui/private-theming": "npm:^7.3.2" + "@mui/styled-engine": "npm:^7.3.2" + "@mui/types": "npm:^7.4.6" + "@mui/utils": "npm:^7.3.2" + clsx: "npm:^2.1.1" + csstype: "npm:^3.1.3" + prop-types: "npm:^15.8.1" + peerDependencies: + "@emotion/react": ^11.5.0 + "@emotion/styled": ^11.3.0 + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/react": + optional: true + "@emotion/styled": + optional: true + "@types/react": + optional: true + checksum: 10/1e157027a693877a246c36e5c98dd988172daedb3430f95843a508fa3f8a707c394fe216078a17868d5d25690ea2f4c5af007dffff639436c36d982fb16e920a + languageName: node + linkType: hard + "@mui/types@npm:^7.2.15": version: 7.2.19 resolution: "@mui/types@npm:7.2.19" @@ -11312,6 +11445,20 @@ __metadata: languageName: node linkType: hard +"@mui/types@npm:^7.4.6": + version: 7.4.6 + resolution: "@mui/types@npm:7.4.6" + dependencies: + "@babel/runtime": "npm:^7.28.3" + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/6263ef077332b75e01c1b347339f100f8743d8135efdee4a6c167ce70c69ca29d9face54c53395cbc1c8a8f5453fe8ef3f4457842cf0c05318f6ef6cde44456e + languageName: node + linkType: hard + "@mui/utils@npm:^5.14.15, @mui/utils@npm:^5.16.14": version: 5.16.14 resolution: "@mui/utils@npm:5.16.14" @@ -11332,6 +11479,26 @@ __metadata: languageName: node linkType: hard +"@mui/utils@npm:^7.3.2": + version: 7.3.2 + resolution: "@mui/utils@npm:7.3.2" + dependencies: + "@babel/runtime": "npm:^7.28.3" + "@mui/types": "npm:^7.4.6" + "@types/prop-types": "npm:^15.7.15" + clsx: "npm:^2.1.1" + prop-types: "npm:^15.8.1" + react-is: "npm:^19.1.1" + peerDependencies: + "@types/react": ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/defcebcb8e49cdbdf271e49066d78046d2d25d4c8177176a6c5d3368fe85e6a280c7be71dbf356b434863d96d69236956afe6a4a47bc394de6aeecfa8bc24fda + languageName: node + linkType: hard + "@n1ru4l/push-pull-async-iterable-iterator@npm:^3.1.0": version: 3.1.0 resolution: "@n1ru4l/push-pull-async-iterable-iterator@npm:3.1.0" @@ -20765,7 +20932,7 @@ __metadata: languageName: node linkType: hard -"@types/prop-types@npm:*, @types/prop-types@npm:^15.0.0, @types/prop-types@npm:^15.7.12, @types/prop-types@npm:^15.7.3": +"@types/prop-types@npm:*, @types/prop-types@npm:^15.0.0, @types/prop-types@npm:^15.7.12, @types/prop-types@npm:^15.7.15, @types/prop-types@npm:^15.7.3": version: 15.7.15 resolution: "@types/prop-types@npm:15.7.15" checksum: 10/31aa2f59b28f24da6fb4f1d70807dae2aedfce090ec63eaf9ea01727a9533ef6eaf017de5bff99fbccad7d1c9e644f52c6c2ba30869465dd22b1a7221c29f356 @@ -20879,6 +21046,15 @@ __metadata: languageName: node linkType: hard +"@types/react-transition-group@npm:^4.4.12": + version: 4.4.12 + resolution: "@types/react-transition-group@npm:4.4.12" + peerDependencies: + "@types/react": "*" + checksum: 10/ea14bc84f529a3887f9954b753843820ac8a3c49fcdfec7840657ecc6a8800aad98afdbe4b973eb96c7252286bde38476fcf64b1c09527354a9a9366e516d9a2 + languageName: node + linkType: hard + "@types/react-virtualized-auto-sizer@npm:^1.0.1": version: 1.0.4 resolution: "@types/react-virtualized-auto-sizer@npm:1.0.4" @@ -42751,6 +42927,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^19.1.1": + version: 19.1.1 + resolution: "react-is@npm:19.1.1" + checksum: 10/44e0937da1f0da1d5dbd4f01972870768ef207f8a49717f4491f5022454f34c956cb66be560aee5286387169853b0283a81e1f419b51dc62654c8710dc98065a + languageName: node + linkType: hard + "react-json-view@npm:^1.21.3": version: 1.21.3 resolution: "react-json-view@npm:1.21.3" From 0d4ded62f81e76c674bbf05ab41857aeb10df11b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Sep 2025 12:34:35 +0200 Subject: [PATCH 06/31] bui-themer: get rid of most hardcoded defaults Signed-off-by: Patrik Oldsberg --- .../convertMuiToBuiTheme.test.ts | 60 +++++ .../BuiThemerPage/convertMuiToBuiTheme.ts | 228 +++++++++++++++--- 2 files changed, 261 insertions(+), 27 deletions(-) diff --git a/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.test.ts b/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.test.ts index e45f48f3a0..ffbd4fc392 100644 --- a/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.test.ts +++ b/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.test.ts @@ -275,4 +275,64 @@ describe('convertMuiToBuiTheme', () => { expect(result).toContain('--bui-radius-3: 8px;'); }); + + describe('early validation', () => { + it('should throw error when theme is undefined', () => { + expect(() => convertMuiToBuiTheme(undefined as any)).toThrow( + 'Theme is required', + ); + }); + + it('should throw error when theme palette is missing', () => { + const theme = { typography: {}, spacing: () => 8, shape: {} } as any; + expect(() => convertMuiToBuiTheme(theme)).toThrow( + 'Theme palette is required', + ); + }); + + it('should throw error when theme palette mode is missing', () => { + const theme = { + palette: {}, + typography: {}, + spacing: () => 8, + shape: {}, + } as any; + expect(() => convertMuiToBuiTheme(theme)).toThrow( + 'Theme palette mode is required', + ); + }); + + it('should throw error when theme typography is missing', () => { + const theme = { + palette: { mode: 'light' }, + spacing: () => 8, + shape: {}, + } as any; + expect(() => convertMuiToBuiTheme(theme)).toThrow( + 'Theme typography is required', + ); + }); + + it('should throw error when theme spacing is missing', () => { + const theme = { + palette: { mode: 'light' }, + typography: {}, + shape: {}, + } as any; + expect(() => convertMuiToBuiTheme(theme)).toThrow( + 'Theme spacing is required', + ); + }); + + it('should throw error when theme shape is missing', () => { + const theme = { + palette: { mode: 'light' }, + typography: {}, + spacing: () => 8, + } as any; + expect(() => convertMuiToBuiTheme(theme)).toThrow( + 'Theme shape is required', + ); + }); + }); }); diff --git a/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.ts b/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.ts index e1f1618760..dee1c1fd72 100644 --- a/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.ts +++ b/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.ts @@ -15,6 +15,7 @@ */ import { Theme as Mui5Theme } from '@mui/material/styles'; +import { themes } from '@backstage/theme'; export interface ConvertMuiToBuiThemeOptions { /** @@ -37,6 +38,31 @@ export function convertMuiToBuiTheme( theme: Mui5Theme, options: ConvertMuiToBuiThemeOptions = {}, ): string { + // Early validation of required theme properties + if (!theme) { + throw new Error('Theme is required'); + } + + if (!theme.palette) { + throw new Error('Theme palette is required'); + } + + if (!theme.palette.mode) { + throw new Error('Theme palette mode is required'); + } + + if (!theme.typography) { + throw new Error('Theme typography is required'); + } + + if (!theme.spacing) { + throw new Error('Theme spacing is required'); + } + + if (!theme.shape) { + throw new Error('Theme shape is required'); + } + const { themeId, includeThemeId = false } = options; const isDark = theme.palette.mode === 'dark'; @@ -211,36 +237,92 @@ function generateSurfaceColors( ): Record { const isDark = palette.mode === 'dark'; - // Helper function to get tint colors with proper fallbacks + // Get the default Backstage theme for fallback values + const defaultTheme = isDark ? themes.dark : themes.light; + const defaultMuiTheme = defaultTheme.getTheme('v5'); + + if (!defaultMuiTheme) { + throw new Error( + `Failed to get MUI v5 theme from Backstage ${ + isDark ? 'dark' : 'light' + } theme`, + ); + } + + const defaultPalette = defaultMuiTheme.palette; + if (!defaultPalette) { + throw new Error( + `Failed to get palette from Backstage ${isDark ? 'dark' : 'light'} theme`, + ); + } + + // Helper function to get tint colors const getTintColor = (opacity: string) => { - if (palette.primary?.main) { - return `${palette.primary.main}${opacity}`; + const primaryColor = palette.primary?.main || defaultPalette.primary?.main; + if (!primaryColor) { + throw new Error('Primary color not found in current or default theme'); } - return isDark - ? `rgba(156, 201, 255, ${opacity === '40' ? '0.12' : '0.16'})` - : `rgba(31, 84, 147, ${opacity === '40' ? '0.4' : '0.6'})`; + return `${primaryColor}${opacity}`; }; - // Helper function to get fallback colors based on theme mode - const getFallbackColor = (lightColor: string, darkColor: string) => { + // Helper function to get colors based on theme mode + const getThemeColor = (lightColor: string, darkColor: string) => { return isDark ? darkColor : lightColor; }; return { - 'surface-2': getFallbackColor('#ececec', '#242424'), - solid: palette.primary?.main || getFallbackColor('#1f5493', '#9cc9ff'), + 'surface-2': getThemeColor('#ececec', '#242424'), + solid: + palette.primary?.main || + defaultPalette.primary?.main || + (() => { + throw new Error('Primary color not found in current or default theme'); + })(), 'solid-hover': - palette.primary?.dark || getFallbackColor('#163a66', '#83b9fd'), + palette.primary?.dark || + defaultPalette.primary?.dark || + (() => { + throw new Error( + 'Primary dark color not found in current or default theme', + ); + })(), 'solid-pressed': - palette.primary?.dark || getFallbackColor('#0f2b4e', '#83b9fd'), - 'solid-disabled': getFallbackColor('#ebebeb', '#222222'), + palette.primary?.dark || + defaultPalette.primary?.dark || + (() => { + throw new Error( + 'Primary dark color not found in current or default theme', + ); + })(), + 'solid-disabled': getThemeColor('#ebebeb', '#222222'), tint: 'transparent', 'tint-hover': getTintColor('40'), 'tint-pressed': getTintColor('60'), - 'tint-disabled': getFallbackColor('#ebebeb', 'transparent'), - danger: palette.error?.light || getFallbackColor('#feebe7', '#3b1219'), - warning: palette.warning?.light || getFallbackColor('#fff2b2', '#302008'), - success: palette.success?.light || getFallbackColor('#e6f6eb', '#132d21'), + 'tint-disabled': getThemeColor('#ebebeb', 'transparent'), + danger: + palette.error?.light || + defaultPalette.error?.light || + (() => { + throw new Error( + 'Error light color not found in current or default theme', + ); + })(), + warning: + palette.warning?.light || + defaultPalette.warning?.light || + (() => { + throw new Error( + 'Warning light color not found in current or default theme', + ); + })(), + success: + palette.success?.light || + defaultPalette.success?.light || + (() => { + throw new Error( + 'Success light color not found in current or default theme', + ); + })(), }; } @@ -252,17 +334,75 @@ function generateForegroundColors( ): Record { const isDark = palette.mode === 'dark'; + // Get the default Backstage theme for fallback values + const defaultTheme = isDark ? themes.dark : themes.light; + const defaultMuiTheme = defaultTheme.getTheme('v5'); + + if (!defaultMuiTheme) { + throw new Error( + `Failed to get MUI v5 theme from Backstage ${ + isDark ? 'dark' : 'light' + } theme`, + ); + } + + const defaultPalette = defaultMuiTheme.palette; + if (!defaultPalette) { + throw new Error( + `Failed to get palette from Backstage ${isDark ? 'dark' : 'light'} theme`, + ); + } + return { - link: palette.primary?.main || (isDark ? '#9cc9ff' : '#1f5493'), - 'link-hover': palette.primary?.dark || (isDark ? '#7eb5f7' : '#1f2d5c'), - disabled: palette.text?.disabled || (isDark ? '#9e9e9e' : '#9e9e9e'), + link: + palette.primary?.main || + defaultPalette.primary?.main || + (() => { + throw new Error('Primary color not found in current or default theme'); + })(), + 'link-hover': + palette.primary?.dark || + defaultPalette.primary?.dark || + (() => { + throw new Error( + 'Primary dark color not found in current or default theme', + ); + })(), + disabled: + palette.text?.disabled || + defaultPalette.text?.disabled || + (() => { + throw new Error( + 'Text disabled color not found in current or default theme', + ); + })(), solid: isDark ? '#101821' : '#ffffff', 'solid-disabled': isDark ? '#575757' : '#9c9c9c', - tint: palette.primary?.main || (isDark ? '#9cc9ff' : '#1f5493'), + tint: + palette.primary?.main || + defaultPalette.primary?.main || + (() => { + throw new Error('Primary color not found in current or default theme'); + })(), 'tint-disabled': isDark ? '#575757' : '#9e9e9e', - danger: palette.error?.main || '#e22b2b', - warning: palette.warning?.main || '#e36d05', - success: palette.success?.main || '#1db954', + danger: + palette.error?.main || + defaultPalette.error?.main || + (() => { + throw new Error('Error color not found in current or default theme'); + })(), + warning: + palette.warning?.main || + defaultPalette.warning?.main || + (() => { + throw new Error('Warning color not found in current or default theme'); + })(), + success: + palette.success?.main || + defaultPalette.success?.main || + (() => { + throw new Error('Success color not found in current or default theme'); + })(), }; } @@ -274,13 +414,47 @@ function generateBorderColors( ): Record { const isDark = palette.mode === 'dark'; + // Get the default Backstage theme for fallback values + const defaultTheme = isDark ? themes.dark : themes.light; + const defaultMuiTheme = defaultTheme.getTheme('v5'); + + if (!defaultMuiTheme) { + throw new Error( + `Failed to get MUI v5 theme from Backstage ${ + isDark ? 'dark' : 'light' + } theme`, + ); + } + + const defaultPalette = defaultMuiTheme.palette; + if (!defaultPalette) { + throw new Error( + `Failed to get palette from Backstage ${isDark ? 'dark' : 'light'} theme`, + ); + } + return { '': isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.1)', hover: isDark ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.2)', pressed: isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.4)', disabled: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)', - danger: palette.error?.main || '#f87a7a', - warning: palette.warning?.main || '#e36d05', - success: palette.success?.main || '#53db83', + danger: + palette.error?.main || + defaultPalette.error?.main || + (() => { + throw new Error('Error color not found in current or default theme'); + })(), + warning: + palette.warning?.main || + defaultPalette.warning?.main || + (() => { + throw new Error('Warning color not found in current or default theme'); + })(), + success: + palette.success?.main || + defaultPalette.success?.main || + (() => { + throw new Error('Success color not found in current or default theme'); + })(), }; } From 71cb5327c54aae23936e404e81bb99234df684d4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Sep 2025 12:53:08 +0200 Subject: [PATCH 07/31] bui-themer: skip gray scale Signed-off-by: Patrik Oldsberg --- .../BuiThemerPage/convertMuiToBuiTheme.ts | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.ts b/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.ts index dee1c1fd72..dbd60c6ea6 100644 --- a/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.ts +++ b/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.ts @@ -152,12 +152,6 @@ function generateBuiVariables(theme: Mui5Theme): string { variables.push(` --bui-white: ${palette.common.white};`); } - // Gray scale - generate from primary color or use defaults - const grayScale = generateGrayScale(palette.mode === 'dark'); - Object.entries(grayScale).forEach(([key, value]) => { - variables.push(` --bui-gray-${key}: ${value};`); - }); - // Background colors if (palette.background?.default) { variables.push(` --bui-bg: ${palette.background.default};`); @@ -200,35 +194,6 @@ function generateBuiVariables(theme: Mui5Theme): string { return variables.join('\n'); } -/** - * Generates gray scale colors - */ -function generateGrayScale(isDark: boolean): Record { - if (isDark) { - return { - '1': '#191919', - '2': '#242424', - '3': '#373737', - '4': '#464646', - '5': '#575757', - '6': '#7b7b7b', - '7': '#9e9e9e', - '8': '#b4b4b4', - }; - } - - return { - '1': '#f8f8f8', - '2': '#ececec', - '3': '#d9d9d9', - '4': '#c1c1c1', - '5': '#9e9e9e', - '6': '#8c8c8c', - '7': '#757575', - '8': '#595959', - }; -} - /** * Generates surface background colors */ From 6c0a51b3f7606d42bf1d27eb38767233132fbd97 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Sep 2025 15:07:43 +0200 Subject: [PATCH 08/31] bui-themer: simplify theme conversion Signed-off-by: Patrik Oldsberg --- plugins/bui-themer/package.json | 1 + .../convertMuiToBuiTheme.test.ts | 104 +----- .../BuiThemerPage/convertMuiToBuiTheme.ts | 306 +++--------------- yarn.lock | 1 + 4 files changed, 45 insertions(+), 367 deletions(-) diff --git a/plugins/bui-themer/package.json b/plugins/bui-themer/package.json index ff97c92ecb..64cad56775 100644 --- a/plugins/bui-themer/package.json +++ b/plugins/bui-themer/package.json @@ -35,6 +35,7 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.61", "@mui/material": "^7.3.2", + "@mui/system": "^7.3.2", "react-use": "^17.2.4" }, "peerDependencies": { diff --git a/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.test.ts b/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.test.ts index ffbd4fc392..7c11dd7d25 100644 --- a/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.test.ts +++ b/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.test.ts @@ -54,7 +54,8 @@ describe('convertMuiToBuiTheme', () => { expect(result).toContain(':root {'); expect(result).toContain('--bui-font-regular: Roboto, sans-serif;'); - expect(result).toContain('--bui-space: 8px;'); + // Spacing is skipped for default 8px + expect(result).not.toContain('--bui-space:'); expect(result).toContain('--bui-radius-3: 4px;'); expect(result).toContain('--bui-bg: #f5f5f5;'); expect(result).toContain('--bui-bg-surface-1: #ffffff;'); @@ -122,38 +123,6 @@ describe('convertMuiToBuiTheme', () => { expect(result).toContain('--bui-white: #fff;'); }); - it('should generate proper gray scale for light theme', () => { - const theme = createTheme({ - palette: { - mode: 'light', - primary: { - main: '#1976d2', - }, - }, - }); - - const result = convertMuiToBuiTheme(theme); - - expect(result).toContain('--bui-gray-1: #f8f8f8;'); - expect(result).toContain('--bui-gray-8: #595959;'); - }); - - it('should generate proper gray scale for dark theme', () => { - const theme = createTheme({ - palette: { - mode: 'dark', - primary: { - main: '#90caf9', - }, - }, - }); - - const result = convertMuiToBuiTheme(theme); - - expect(result).toContain('--bui-gray-1: #191919;'); - expect(result).toContain('--bui-gray-8: #b4b4b4;'); - }); - it('should generate surface colors correctly', () => { const theme = createTheme({ palette: { @@ -180,7 +149,7 @@ describe('convertMuiToBuiTheme', () => { const result = convertMuiToBuiTheme(theme); expect(result).toContain('--bui-bg-solid: #1976d2;'); - expect(result).toContain('--bui-bg-solid-hover: #115293;'); + expect(result).toContain('--bui-bg-solid-hover: rgb(21, 100, 179);'); expect(result).toContain('--bui-bg-danger: #ffcdd2;'); expect(result).toContain('--bui-bg-warning: #ffe0b2;'); expect(result).toContain('--bui-bg-success: #c8e6c9;'); @@ -237,8 +206,7 @@ describe('convertMuiToBuiTheme', () => { const result = convertMuiToBuiTheme(theme); - expect(result).toContain('--bui-border: rgba(0, 0, 0, 0.1);'); - expect(result).toContain('--bui-border-hover: rgba(0, 0, 0, 0.2);'); + // Base border colors are no longer generated expect(result).toContain('--bui-border-danger: #f44336;'); expect(result).toContain('--bui-border-warning: #ff9800;'); expect(result).toContain('--bui-border-success: #4caf50;'); @@ -251,7 +219,7 @@ describe('convertMuiToBuiTheme', () => { const result = convertMuiToBuiTheme(theme); - expect(result).toContain('--bui-space: 4px;'); + expect(result).toContain('--bui-space: calc(4px * 0.5);'); }); it('should handle string-based spacing', () => { @@ -261,7 +229,7 @@ describe('convertMuiToBuiTheme', () => { const result = convertMuiToBuiTheme(theme); - expect(result).toContain('--bui-space: calc(1 * 8px);'); + expect(result).toContain('--bui-space: calc(calc(1 * 8px) * 0.5);'); }); it('should handle string-based border radius', () => { @@ -275,64 +243,4 @@ describe('convertMuiToBuiTheme', () => { expect(result).toContain('--bui-radius-3: 8px;'); }); - - describe('early validation', () => { - it('should throw error when theme is undefined', () => { - expect(() => convertMuiToBuiTheme(undefined as any)).toThrow( - 'Theme is required', - ); - }); - - it('should throw error when theme palette is missing', () => { - const theme = { typography: {}, spacing: () => 8, shape: {} } as any; - expect(() => convertMuiToBuiTheme(theme)).toThrow( - 'Theme palette is required', - ); - }); - - it('should throw error when theme palette mode is missing', () => { - const theme = { - palette: {}, - typography: {}, - spacing: () => 8, - shape: {}, - } as any; - expect(() => convertMuiToBuiTheme(theme)).toThrow( - 'Theme palette mode is required', - ); - }); - - it('should throw error when theme typography is missing', () => { - const theme = { - palette: { mode: 'light' }, - spacing: () => 8, - shape: {}, - } as any; - expect(() => convertMuiToBuiTheme(theme)).toThrow( - 'Theme typography is required', - ); - }); - - it('should throw error when theme spacing is missing', () => { - const theme = { - palette: { mode: 'light' }, - typography: {}, - shape: {}, - } as any; - expect(() => convertMuiToBuiTheme(theme)).toThrow( - 'Theme spacing is required', - ); - }); - - it('should throw error when theme shape is missing', () => { - const theme = { - palette: { mode: 'light' }, - typography: {}, - spacing: () => 8, - } as any; - expect(() => convertMuiToBuiTheme(theme)).toThrow( - 'Theme shape is required', - ); - }); - }); }); diff --git a/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.ts b/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.ts index dbd60c6ea6..1d6e581b57 100644 --- a/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.ts +++ b/plugins/bui-themer/src/components/BuiThemerPage/convertMuiToBuiTheme.ts @@ -15,7 +15,7 @@ */ import { Theme as Mui5Theme } from '@mui/material/styles'; -import { themes } from '@backstage/theme'; +import { blend, alpha } from '@mui/system/colorManipulator'; export interface ConvertMuiToBuiThemeOptions { /** @@ -38,31 +38,6 @@ export function convertMuiToBuiTheme( theme: Mui5Theme, options: ConvertMuiToBuiThemeOptions = {}, ): string { - // Early validation of required theme properties - if (!theme) { - throw new Error('Theme is required'); - } - - if (!theme.palette) { - throw new Error('Theme palette is required'); - } - - if (!theme.palette.mode) { - throw new Error('Theme palette mode is required'); - } - - if (!theme.typography) { - throw new Error('Theme typography is required'); - } - - if (!theme.spacing) { - throw new Error('Theme spacing is required'); - } - - if (!theme.shape) { - throw new Error('Theme shape is required'); - } - const { themeId, includeThemeId = false } = options; const isDark = theme.palette.mode === 'dark'; @@ -125,13 +100,10 @@ function generateBuiVariables(theme: Mui5Theme): string { } }); - // Spacing - map MUI spacing to BUI spacing scale - if (theme.spacing) { - const spacingUnit = - typeof theme.spacing === 'function' ? theme.spacing(1) : theme.spacing; - const spacingValue = - typeof spacingUnit === 'number' ? `${spacingUnit}px` : spacingUnit; - variables.push(` --bui-space: ${spacingValue};`); + const spacing = theme.spacing(1); + // Skip spacing if the theme is using the default + if (spacing !== '8px') { + variables.push(` --bui-space: calc(${spacing} * 0.5);`); } // Border radius @@ -161,8 +133,21 @@ function generateBuiVariables(theme: Mui5Theme): string { } // Generate surface colors - const surfaceColors = generateSurfaceColors(palette); - Object.entries(surfaceColors).forEach(([key, value]) => { + Object.entries({ + 'surface-1': palette.background.default, + 'surface-2': palette.background.paper, + 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]) => { variables.push(` --bui-bg-${key}: ${value};`); }); @@ -175,14 +160,27 @@ function generateBuiVariables(theme: Mui5Theme): string { } // Generate foreground colors - const foregroundColors = generateForegroundColors(palette); - Object.entries(foregroundColors).forEach(([key, value]) => { + Object.entries({ + link: palette.primary.main, + 'link-hover': palette.primary.dark, + disabled: palette.text.disabled, + solid: palette.text.primary, + 'solid-disabled': palette.action.disabled, + tint: palette.primary.main, + 'tint-disabled': palette.action.disabled, + danger: palette.error.main, + warning: palette.warning.main, + success: palette.success.main, + }).forEach(([key, value]) => { variables.push(` --bui-fg-${key}: ${value};`); }); // Border colors - const borderColors = generateBorderColors(palette); - Object.entries(borderColors).forEach(([key, value]) => { + Object.entries({ + danger: palette.error.main, + warning: palette.warning.main, + success: palette.success.main, + }).forEach(([key, value]) => { variables.push(` --bui-border${key ? `-${key}` : ''}: ${value};`); }); @@ -193,233 +191,3 @@ function generateBuiVariables(theme: Mui5Theme): string { return variables.join('\n'); } - -/** - * Generates surface background colors - */ -function generateSurfaceColors( - palette: Mui5Theme['palette'], -): Record { - const isDark = palette.mode === 'dark'; - - // Get the default Backstage theme for fallback values - const defaultTheme = isDark ? themes.dark : themes.light; - const defaultMuiTheme = defaultTheme.getTheme('v5'); - - if (!defaultMuiTheme) { - throw new Error( - `Failed to get MUI v5 theme from Backstage ${ - isDark ? 'dark' : 'light' - } theme`, - ); - } - - const defaultPalette = defaultMuiTheme.palette; - if (!defaultPalette) { - throw new Error( - `Failed to get palette from Backstage ${isDark ? 'dark' : 'light'} theme`, - ); - } - - // Helper function to get tint colors - const getTintColor = (opacity: string) => { - const primaryColor = palette.primary?.main || defaultPalette.primary?.main; - if (!primaryColor) { - throw new Error('Primary color not found in current or default theme'); - } - return `${primaryColor}${opacity}`; - }; - - // Helper function to get colors based on theme mode - const getThemeColor = (lightColor: string, darkColor: string) => { - return isDark ? darkColor : lightColor; - }; - - return { - 'surface-2': getThemeColor('#ececec', '#242424'), - solid: - palette.primary?.main || - defaultPalette.primary?.main || - (() => { - throw new Error('Primary color not found in current or default theme'); - })(), - 'solid-hover': - palette.primary?.dark || - defaultPalette.primary?.dark || - (() => { - throw new Error( - 'Primary dark color not found in current or default theme', - ); - })(), - 'solid-pressed': - palette.primary?.dark || - defaultPalette.primary?.dark || - (() => { - throw new Error( - 'Primary dark color not found in current or default theme', - ); - })(), - 'solid-disabled': getThemeColor('#ebebeb', '#222222'), - tint: 'transparent', - 'tint-hover': getTintColor('40'), - 'tint-pressed': getTintColor('60'), - 'tint-disabled': getThemeColor('#ebebeb', 'transparent'), - danger: - palette.error?.light || - defaultPalette.error?.light || - (() => { - throw new Error( - 'Error light color not found in current or default theme', - ); - })(), - warning: - palette.warning?.light || - defaultPalette.warning?.light || - (() => { - throw new Error( - 'Warning light color not found in current or default theme', - ); - })(), - success: - palette.success?.light || - defaultPalette.success?.light || - (() => { - throw new Error( - 'Success light color not found in current or default theme', - ); - })(), - }; -} - -/** - * Generates foreground colors - */ -function generateForegroundColors( - palette: Mui5Theme['palette'], -): Record { - const isDark = palette.mode === 'dark'; - - // Get the default Backstage theme for fallback values - const defaultTheme = isDark ? themes.dark : themes.light; - const defaultMuiTheme = defaultTheme.getTheme('v5'); - - if (!defaultMuiTheme) { - throw new Error( - `Failed to get MUI v5 theme from Backstage ${ - isDark ? 'dark' : 'light' - } theme`, - ); - } - - const defaultPalette = defaultMuiTheme.palette; - if (!defaultPalette) { - throw new Error( - `Failed to get palette from Backstage ${isDark ? 'dark' : 'light'} theme`, - ); - } - - return { - link: - palette.primary?.main || - defaultPalette.primary?.main || - (() => { - throw new Error('Primary color not found in current or default theme'); - })(), - 'link-hover': - palette.primary?.dark || - defaultPalette.primary?.dark || - (() => { - throw new Error( - 'Primary dark color not found in current or default theme', - ); - })(), - disabled: - palette.text?.disabled || - defaultPalette.text?.disabled || - (() => { - throw new Error( - 'Text disabled color not found in current or default theme', - ); - })(), - solid: isDark ? '#101821' : '#ffffff', - 'solid-disabled': isDark ? '#575757' : '#9c9c9c', - tint: - palette.primary?.main || - defaultPalette.primary?.main || - (() => { - throw new Error('Primary color not found in current or default theme'); - })(), - 'tint-disabled': isDark ? '#575757' : '#9e9e9e', - danger: - palette.error?.main || - defaultPalette.error?.main || - (() => { - throw new Error('Error color not found in current or default theme'); - })(), - warning: - palette.warning?.main || - defaultPalette.warning?.main || - (() => { - throw new Error('Warning color not found in current or default theme'); - })(), - success: - palette.success?.main || - defaultPalette.success?.main || - (() => { - throw new Error('Success color not found in current or default theme'); - })(), - }; -} - -/** - * Generates border colors - */ -function generateBorderColors( - palette: Mui5Theme['palette'], -): Record { - const isDark = palette.mode === 'dark'; - - // Get the default Backstage theme for fallback values - const defaultTheme = isDark ? themes.dark : themes.light; - const defaultMuiTheme = defaultTheme.getTheme('v5'); - - if (!defaultMuiTheme) { - throw new Error( - `Failed to get MUI v5 theme from Backstage ${ - isDark ? 'dark' : 'light' - } theme`, - ); - } - - const defaultPalette = defaultMuiTheme.palette; - if (!defaultPalette) { - throw new Error( - `Failed to get palette from Backstage ${isDark ? 'dark' : 'light'} theme`, - ); - } - - return { - '': isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.1)', - hover: isDark ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.2)', - pressed: isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.4)', - disabled: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)', - danger: - palette.error?.main || - defaultPalette.error?.main || - (() => { - throw new Error('Error color not found in current or default theme'); - })(), - warning: - palette.warning?.main || - defaultPalette.warning?.main || - (() => { - throw new Error('Warning color not found in current or default theme'); - })(), - success: - palette.success?.main || - defaultPalette.success?.main || - (() => { - throw new Error('Success color not found in current or default theme'); - })(), - }; -} diff --git a/yarn.lock b/yarn.lock index 41f7dc6d64..9dd2bd9e16 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4348,6 +4348,7 @@ __metadata: "@material-ui/icons": "npm:^4.9.1" "@material-ui/lab": "npm:^4.0.0-alpha.61" "@mui/material": "npm:^7.3.2" + "@mui/system": "npm:^7.3.2" "@testing-library/jest-dom": "npm:^6.0.0" "@testing-library/react": "npm:^14.0.0" "@testing-library/user-event": "npm:^14.0.0" From 09e65c6266b4458bb40f303e919de87dbe44f01f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Sep 2025 15:43:15 +0200 Subject: [PATCH 09/31] bui-themer: switch to horizontal layout Signed-off-by: Patrik Oldsberg --- .../BuiThemerPage/BuiThemerPage.tsx | 116 ++++++++++-------- 1 file changed, 65 insertions(+), 51 deletions(-) diff --git a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx index b36c550081..4ae25b60ce 100644 --- a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx +++ b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx @@ -24,10 +24,8 @@ import { Card, Container, Flex, - Grid, HeaderPage, Text, - TextField, Switch, } from '@backstage/ui'; import { @@ -136,16 +134,16 @@ function ThemeContent({ themeId, themeTitle, variant }: ThemeContentProps) { return ( - - + + - {themeTitle} + {themeTitle} {variant} theme - + - + @@ -172,53 +170,70 @@ function ThemeContent({ themeId, themeTitle, variant }: ThemeContentProps) { Generated CSS: - - - - {isPreviewMode && ( - + {generatedCss} + + + + {isPreviewMode && ( + + Live Preview: - Solid Button - - - Tint Button - - - Surface Card + + + Solid Button + + + Tint Button + + + Surface Card + + )} @@ -270,18 +285,17 @@ export function BuiThemerPage() { ) : ( - + {installedThemes.map(theme => ( - - - + ))} - + )} From d5c170eaf48e916ec95c4ef71ce30d332ee40458 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Sep 2025 16:32:07 +0200 Subject: [PATCH 10/31] bui-themer: extract mui theme and then unmount theme provider Signed-off-by: Patrik Oldsberg --- .../src/unified/UnifiedThemeProvider.tsx | 14 +++- .../BuiThemerPage/BuiThemerPage.tsx | 66 +++++++++++-------- 2 files changed, 49 insertions(+), 31 deletions(-) diff --git a/packages/theme/src/unified/UnifiedThemeProvider.tsx b/packages/theme/src/unified/UnifiedThemeProvider.tsx index a80e36cf35..006c45605e 100644 --- a/packages/theme/src/unified/UnifiedThemeProvider.tsx +++ b/packages/theme/src/unified/UnifiedThemeProvider.tsx @@ -74,12 +74,22 @@ export function UnifiedThemeProvider( const themeName = 'backstage'; useEffect(() => { + const oldMode = document.body.getAttribute('data-theme-mode'); + const oldName = document.body.getAttribute('data-theme-name'); 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'); + if (oldMode) { + document.body.setAttribute('data-theme-mode', oldMode); + } else { + document.body.removeAttribute('data-theme-mode'); + } + if (oldName) { + document.body.setAttribute('data-theme-name', oldName); + } else { + document.body.removeAttribute('data-theme-name'); + } }; }, [themeMode, themeName]); diff --git a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx index 4ae25b60ce..4df3aae006 100644 --- a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx +++ b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx @@ -15,9 +15,9 @@ */ import { useMemo, useState, useCallback, useEffect } from 'react'; -import { useApi } from '@backstage/core-plugin-api'; +import { AppTheme, useApi } from '@backstage/core-plugin-api'; import { appThemeApiRef } from '@backstage/core-plugin-api'; -import { useTheme } from '@mui/material/styles'; +import { Theme, useTheme } from '@mui/material/styles'; import { Box, Button, @@ -33,13 +33,6 @@ import { ConvertMuiToBuiThemeOptions, } from './convertMuiToBuiTheme'; -interface ThemePreviewProps { - themeId: string; - themeTitle: string; - variant: 'light' | 'dark'; - Provider: React.ComponentType<{ children: React.ReactNode }>; -} - // Memoization cache for generated CSS const cssCache = new Map(); @@ -47,13 +40,18 @@ interface ThemeContentProps { themeId: string; themeTitle: string; variant: 'light' | 'dark'; + muiTheme: Theme; } -function ThemeContent({ themeId, themeTitle, variant }: ThemeContentProps) { +function ThemeContent({ + themeId, + themeTitle, + variant, + muiTheme, +}: ThemeContentProps) { const [generatedCss, setGeneratedCss] = useState(''); const [isPreviewMode, setIsPreviewMode] = useState(false); const [includeThemeId, setIncludeThemeId] = useState(false); - const muiTheme = useTheme(); const css = useMemo(() => { // Create cache key based on theme properties and options @@ -243,23 +241,29 @@ function ThemeContent({ themeId, themeTitle, variant }: ThemeContentProps) { ); } -function ThemePreview({ - themeId, - themeTitle, - variant, - Provider, -}: ThemePreviewProps) { +function MuiThemeExtractor(props: { + appTheme: AppTheme; + children: (theme: Theme) => JSX.Element; +}): JSX.Element { + const { appTheme, children } = props; + const [theme, setTheme] = useState(null); + const { Provider } = appTheme; + if (theme) { + return children(theme); + } + return ( - + ); } +function MuiThemeExtractorInner(props: { setTheme: (theme: Theme) => void }) { + props.setTheme(useTheme()); + return null; +} + export function BuiThemerPage() { const appThemeApi = useApi(appThemeApiRef); const installedThemes = appThemeApi.getInstalledThemes(); @@ -287,13 +291,17 @@ export function BuiThemerPage() { ) : ( {installedThemes.map(theme => ( - + + {muiTheme => ( + + )} + ))} )} From 5161dbc757b6ef3c59f9420cef27321d74b9dab2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Sep 2025 16:37:44 +0200 Subject: [PATCH 11/31] bui-themer: remove unnecessary css cache Signed-off-by: Patrik Oldsberg --- .../BuiThemerPage/BuiThemerPage.tsx | 39 ++----------------- 1 file changed, 3 insertions(+), 36 deletions(-) diff --git a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx index 4df3aae006..061ea379cd 100644 --- a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx +++ b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx @@ -28,13 +28,7 @@ import { Text, Switch, } from '@backstage/ui'; -import { - convertMuiToBuiTheme, - ConvertMuiToBuiThemeOptions, -} from './convertMuiToBuiTheme'; - -// Memoization cache for generated CSS -const cssCache = new Map(); +import { convertMuiToBuiTheme } from './convertMuiToBuiTheme'; interface ThemeContentProps { themeId: string; @@ -54,37 +48,10 @@ function ThemeContent({ const [includeThemeId, setIncludeThemeId] = useState(false); const css = useMemo(() => { - // Create cache key based on theme properties and options - const cacheKey = `${themeId}-${includeThemeId}-${JSON.stringify({ - palette: muiTheme.palette, - typography: muiTheme.typography, - spacing: muiTheme.spacing, - shape: muiTheme.shape, - })}`; - - // Check cache first - if (cssCache.has(cacheKey)) { - return cssCache.get(cacheKey)!; - } - - const options: ConvertMuiToBuiThemeOptions = { + return convertMuiToBuiTheme(muiTheme, { themeId, includeThemeId, - }; - const result = convertMuiToBuiTheme(muiTheme, options); - - // Cache the result - cssCache.set(cacheKey, result); - - // Clean up old cache entries (keep only last 50) - if (cssCache.size > 50) { - const firstKey = cssCache.keys().next().value; - if (firstKey) { - cssCache.delete(firstKey); - } - } - - return result; + }); }, [muiTheme, themeId, includeThemeId]); useEffect(() => { From 8ceba35b280d7daac1ad66ae9fab1b954f175998 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Sep 2025 16:42:36 +0200 Subject: [PATCH 12/31] bui-themer: switch theme cards to use tabs Signed-off-by: Patrik Oldsberg --- .../BuiThemerPage/BuiThemerPage.tsx | 184 +++++++++++------- 1 file changed, 109 insertions(+), 75 deletions(-) diff --git a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx index 061ea379cd..90e7d21902 100644 --- a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx +++ b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx @@ -27,6 +27,10 @@ import { HeaderPage, Text, Switch, + Tabs, + TabList, + Tab, + TabPanel, } from '@backstage/ui'; import { convertMuiToBuiTheme } from './convertMuiToBuiTheme'; @@ -46,6 +50,7 @@ function ThemeContent({ const [generatedCss, setGeneratedCss] = useState(''); const [isPreviewMode, setIsPreviewMode] = useState(false); const [includeThemeId, setIncludeThemeId] = useState(false); + const [activeTab, setActiveTab] = useState('css'); const css = useMemo(() => { return convertMuiToBuiTheme(muiTheme, { @@ -123,85 +128,114 @@ function ThemeContent({ - - - - Generated CSS: - - - {generatedCss} - - + setActiveTab(key as string)} + > + + Generated CSS + Live Preview + - {isPreviewMode && ( - - - Live Preview: - - - - - Solid Button - - - Tint Button - - - Surface Card - - + + + + Generated CSS: + + + {generatedCss} + - - )} + + + + + + + + + {isPreviewMode ? ( + + + Live Preview: + + + + + Solid Button + + + Tint Button + + + Surface Card + + + + + ) : ( + + + Click "Start Preview" to see the theme applied to sample + components. + + + )} + + + From 3cb8a3aa88f004975f75d6f5a808425860dd92c3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 13 Sep 2025 17:19:13 +0200 Subject: [PATCH 13/31] bui-themer: new and improved preview Signed-off-by: Patrik Oldsberg --- .../BuiThemerPage/BuiThemerPage.tsx | 227 ++++++++++-------- 1 file changed, 133 insertions(+), 94 deletions(-) diff --git a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx index 90e7d21902..a65c35d665 100644 --- a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx +++ b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx @@ -31,6 +31,13 @@ import { TabList, Tab, TabPanel, + TextField, + Select, + Checkbox, + Radio, + RadioGroup, + CardHeader, + CardBody, } from '@backstage/ui'; import { convertMuiToBuiTheme } from './convertMuiToBuiTheme'; @@ -41,6 +48,128 @@ interface ThemeContentProps { muiTheme: Theme; } +interface IsolatedPreviewProps { + mode: 'light' | 'dark'; + css: string; +} + +function BuiThemePreview({ mode, css }: IsolatedPreviewProps) { + return ( +
line.trim().startsWith('--bui-')) + .map(line => { + const [key, value] = line.trim().split(':'); + return [key?.trim(), value?.replace(';', '').trim()]; + }) + .filter(([key, value]) => key && value), + ), + width: '100%', + backgroundColor: 'var(--bui-bg-surface-2)', + padding: 'var(--bui-space-3)', + borderRadius: 'var(--bui-radius-2)', + }} + > + + + Theme Preview + + + This preview shows how your theme will look with various Backstage + UI components + + + + + + Button Variants + + + + + + + + + + + Form Inputs + + + + + + + + Option 1 + + Option 2 + + Option 3 + + + + + + + Surface Variations + + + + Surface 1 + + + Surface 2 + + + Solid + + + + + +
+ ); +} diff --git a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx index 856790161b..bb3e797bb1 100644 --- a/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx +++ b/plugins/bui-themer/src/components/BuiThemerPage/BuiThemerPage.tsx @@ -14,264 +14,11 @@ * limitations under the License. */ -import { useMemo, useState, useCallback } from 'react'; -import { AppTheme, useApi } from '@backstage/core-plugin-api'; +import { useApi } from '@backstage/core-plugin-api'; import { appThemeApiRef } from '@backstage/core-plugin-api'; -import { Theme, useTheme } from '@mui/material/styles'; -import { - Box, - Button, - Card, - Container, - Flex, - HeaderPage, - Text, - Tabs, - TabList, - Tab, - TabPanel, - TextField, - Select, - Checkbox, - Radio, - RadioGroup, - CardHeader, - CardBody, -} from '@backstage/ui'; -import { convertMuiToBuiTheme } from './convertMuiToBuiTheme'; - -interface ThemeContentProps { - themeId: string; - themeTitle: string; - variant: 'light' | 'dark'; - muiTheme: Theme; -} - -interface IsolatedPreviewProps { - mode: 'light' | 'dark'; - styleObject: Record; -} - -function BuiThemePreview({ mode, styleObject }: IsolatedPreviewProps) { - return ( -
- - - Theme Preview - - - This preview shows how your theme will look with various Backstage - UI components - - - - - - Button Variants - - - - - - - - - - - Form Inputs - - - -