Add theme switcher to sidebar of dev app
Signed-off-by: Oliver Sand <oliver.sand@sda-se.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/dev-utils': patch
|
||||
---
|
||||
|
||||
Add theme switcher to sidebar of dev app.
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user