Add theme switcher to sidebar of dev app

Signed-off-by: Oliver Sand <oliver.sand@sda-se.com>
This commit is contained in:
Oliver Sand
2021-11-12 11:10:27 +01:00
parent 01a0a39521
commit 58a4e67ded
5 changed files with 256 additions and 26 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/dev-utils': patch
---
Add theme switcher to sidebar of dev app.
+3 -1
View File
@@ -45,10 +45,12 @@
"@testing-library/user-event": "^13.1.8",
"@types/react": "*",
"react": "^16.12.0",
"react-use": "^17.2.4",
"react-dom": "^16.12.0",
"react-hot-loader": "^4.12.21",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0"
"react-router-dom": "6.0.0-beta.0",
"zen-observable": "^0.8.15"
},
"devDependencies": {
"@backstage/cli": "^0.8.0",
@@ -0,0 +1,95 @@
/*
* Copyright 2021 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 { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { AppThemeApi, appThemeApiRef } from '@backstage/core-plugin-api';
import { renderInTestApp } from '@backstage/test-utils';
import { BackstageTheme } from '@backstage/theme';
import userEvent from '@testing-library/user-event';
import React from 'react';
import ObservableImpl from 'zen-observable';
import { SidebarThemeSwitcher } from './SidebarThemeSwitcher';
describe('SidebarThemeSwitcher', () => {
let appThemeApi: jest.Mocked<AppThemeApi>;
let apiRegistry: ApiRegistry;
beforeEach(() => {
appThemeApi = {
activeThemeId$: jest.fn(),
getActiveThemeId: jest.fn(),
getInstalledThemes: jest.fn(),
setActiveThemeId: jest.fn(),
};
appThemeApi.activeThemeId$.mockReturnValue(
ObservableImpl.of<string | undefined>('dark'),
);
appThemeApi.getInstalledThemes.mockReturnValue([
{
id: 'dark',
title: 'Dark Theme',
variant: 'dark',
theme: {} as unknown as BackstageTheme,
},
{
id: 'light',
title: 'Light Theme',
variant: 'light',
theme: {} as unknown as BackstageTheme,
},
]);
apiRegistry = ApiRegistry.with(appThemeApiRef, appThemeApi);
});
it('should display current theme', async () => {
const { getByLabelText, getByRole, getByText } = await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<SidebarThemeSwitcher />
</ApiProvider>,
);
const button = getByLabelText('Switch Theme');
expect(button).toBeInTheDocument();
userEvent.click(button);
expect(getByRole('listbox')).toBeInTheDocument();
expect(getByText('Dark Theme')).toBeInTheDocument();
expect(
getByText('Dark Theme').parentElement?.parentElement,
).toHaveAttribute('aria-selected', 'true');
});
it('should select different theme', async () => {
const { getByLabelText, getByRole, getByText } = await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<SidebarThemeSwitcher />
</ApiProvider>,
);
const button = getByLabelText('Switch Theme');
expect(button).toBeInTheDocument();
userEvent.click(button);
expect(getByRole('listbox')).toBeInTheDocument();
userEvent.click(getByText('Light Theme'));
expect(appThemeApi.setActiveThemeId).toHaveBeenCalledWith('light');
});
});
@@ -0,0 +1,125 @@
/*
* Copyright 2021 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 { SidebarItem } from '@backstage/core-components';
import { appThemeApiRef, useApi } from '@backstage/core-plugin-api';
import { ListItemIcon, ListItemText, Menu, MenuItem } from '@material-ui/core';
import AutoIcon from '@material-ui/icons/BrightnessAuto';
import React, { cloneElement, useCallback, useState } from 'react';
import { useObservable } from 'react-use';
type ThemeIconProps = {
active?: boolean;
icon: JSX.Element | undefined;
};
const ThemeIcon = ({ active, icon }: ThemeIconProps) =>
icon ? (
cloneElement(icon, {
color: active ? 'primary' : undefined,
})
) : (
<AutoIcon color={active ? 'primary' : undefined} />
);
export const SidebarThemeSwitcher = () => {
const appThemeApi = useApi(appThemeApiRef);
const themeId = useObservable(
appThemeApi.activeThemeId$(),
appThemeApi.getActiveThemeId(),
);
const themeIds = appThemeApi.getInstalledThemes();
const activeTheme = themeIds.find(t => t.id === themeId);
const [anchorEl, setAnchorEl] = useState<Element | undefined>();
const open = Boolean(anchorEl);
const handleOpen = (event: React.MouseEvent) => {
setAnchorEl(event.currentTarget);
};
const handleSelectTheme = (newThemeId: string | undefined) => {
if (themeIds.some(t => t.id === newThemeId)) {
appThemeApi.setActiveThemeId(newThemeId);
} else {
appThemeApi.setActiveThemeId(undefined);
}
setAnchorEl(undefined);
};
const handleClose = () => {
setAnchorEl(undefined);
};
const ActiveIcon = useCallback(
() => <ThemeIcon icon={activeTheme?.icon} />,
[activeTheme],
);
return (
<>
<SidebarItem
icon={ActiveIcon}
text="Switch Theme"
id="theme-button"
aria-haspopup="listbox"
aria-controls="theme-menu"
aria-label="switch theme"
aria-expanded={open ? 'true' : undefined}
onClick={handleOpen}
/>
<Menu
id="theme-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
MenuListProps={{
'aria-labelledby': 'theme-button',
role: 'listbox',
}}
>
<MenuItem disabled>Choose a theme</MenuItem>
<MenuItem
selected={themeId === undefined}
onClick={() => handleSelectTheme(undefined)}
>
<ListItemIcon>
<ThemeIcon icon={undefined} active={themeId === undefined} />
</ListItemIcon>
<ListItemText>Auto</ListItemText>
</MenuItem>
{themeIds.map(theme => {
const active = theme.id === themeId;
return (
<MenuItem
key={theme.id}
selected={active}
aria-selected={active}
onClick={() => handleSelectTheme(theme.id)}
>
<ListItemIcon>
<ThemeIcon icon={theme.icon} active={active} />
</ListItemIcon>
<ListItemText>{theme.title}</ListItemText>
</MenuItem>
);
})}
</Menu>
</>
);
};
+28 -25
View File
@@ -14,6 +14,30 @@
* limitations under the License.
*/
import { createApp } from '@backstage/app-defaults';
import { FlatRoutes } from '@backstage/core-app-api';
import {
AlertDisplay,
OAuthRequestDialog,
Sidebar,
SidebarDivider,
SidebarItem,
SidebarPage,
SidebarSpace,
SidebarSpacer,
} from '@backstage/core-components';
import {
AnyApiFactory,
ApiFactory,
AppTheme,
attachComponentData,
BackstagePlugin,
configApiRef,
createApiFactory,
createRouteRef,
IconComponent,
RouteRef,
} from '@backstage/core-plugin-api';
import {
ScmIntegrationsApi,
scmIntegrationsApiRef,
@@ -24,31 +48,7 @@ import React, { ComponentType, ReactNode } from 'react';
import ReactDOM from 'react-dom';
import { hot } from 'react-hot-loader';
import { Route } from 'react-router';
import {
AlertDisplay,
OAuthRequestDialog,
Sidebar,
SidebarItem,
SidebarPage,
SidebarSpacer,
} from '@backstage/core-components';
import {
AnyApiFactory,
ApiFactory,
AppTheme,
attachComponentData,
configApiRef,
createApiFactory,
createRouteRef,
IconComponent,
RouteRef,
BackstagePlugin,
} from '@backstage/core-plugin-api';
import { createApp } from '@backstage/app-defaults';
import { FlatRoutes } from '@backstage/core-app-api';
import { SidebarThemeSwitcher } from './SidebarThemeSwitcher';
const GatheringRoute: (props: {
path: string;
@@ -203,6 +203,9 @@ export class DevAppBuilder {
<Sidebar>
<SidebarSpacer />
{this.sidebarItems}
<SidebarSpace />
<SidebarDivider />
<SidebarThemeSwitcher />
</Sidebar>
<FlatRoutes>
{this.routes}