feat(core): add support config & button/error components

This adds a new app.support top level config that can be used to
configure various support links and other information. This can later be
extended for plugins to append their own specific support items.
This commit is contained in:
Andrew Thauer
2021-02-23 15:47:55 -05:00
parent 51e7fef788
commit 8a15667196
18 changed files with 301 additions and 191 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core': patch
---
Added a new useSupportConfig hook that reads a new `app.support` config key. Also updated the SupportButton and ErrorPage components to use the new config.
+13
View File
@@ -2,6 +2,19 @@ app:
title: Backstage Example App
baseUrl: http://localhost:3000
googleAnalyticsTrackingId: # UA-000000-0
support:
url: https://github.com/backstage/backstage/issues # Used by common ErrorPage
items: # Used by common SupportButton component
- title: Issues
icon: github
links:
- url: https://github.com/backstage/backstage/issues
title: GitHub Issues
- title: Discord Chatroom
icon: chat
links:
- url: https://discord.gg/MUpMjP2
title: '#backstage'
backend:
baseUrl: http://localhost:7000
+2 -2
View File
@@ -149,7 +149,7 @@ class AppContextImpl implements AppContext {
return this.app.getPlugins();
}
getSystemIcon(key: string): IconComponent {
getSystemIcon(key: IconKey): IconComponent | undefined {
return this.app.getSystemIcon(key);
}
@@ -206,7 +206,7 @@ export class PrivateAppImpl implements BackstageApp {
return this.plugins;
}
getSystemIcon(key: IconKey): IconComponent {
getSystemIcon(key: IconKey): IconComponent | undefined {
return this.icons[key];
}
+2 -2
View File
@@ -171,7 +171,7 @@ export type BackstageApp = {
/**
* Get a common or custom icon for this app.
*/
getSystemIcon(key: IconKey): IconComponent;
getSystemIcon(key: IconKey): IconComponent | undefined;
/**
* Provider component that should wrap the Router created with getRouter()
@@ -202,7 +202,7 @@ export type AppContext = {
/**
* Get a common or custom icon for this app.
*/
getSystemIcon(key: IconKey): IconComponent;
getSystemIcon(key: IconKey): IconComponent | undefined;
/**
* Get the components registered for various purposes in the app.
+21 -6
View File
@@ -15,31 +15,46 @@
*/
import { SvgIconProps } from '@material-ui/core';
import MuiBrokenImageIcon from '@material-ui/icons/BrokenImage';
import MuiChatIcon from '@material-ui/icons/Chat';
import MuiDashboardIcon from '@material-ui/icons/Dashboard';
import MuiEmailIcon from '@material-ui/icons/Email';
import MuiGitHubIcon from '@material-ui/icons/GitHub';
import MuiHelpIcon from '@material-ui/icons/Help';
import PeopleIcon from '@material-ui/icons/People';
import PersonIcon from '@material-ui/icons/Person';
import MuiPeopleIcon from '@material-ui/icons/People';
import MuiPersonIcon from '@material-ui/icons/Person';
import MuiWarningIcon from '@material-ui/icons/Warning';
import React from 'react';
import { useApp } from '../app/AppContext';
import { IconComponent, SystemIconKey, IconComponentMap } from './types';
import { IconComponent, IconComponentMap, SystemIconKey } from './types';
export const defaultSystemIcons: IconComponentMap = {
user: PersonIcon,
group: PeopleIcon,
brokenImage: MuiBrokenImageIcon,
chat: MuiChatIcon,
dashboard: MuiDashboardIcon,
email: MuiEmailIcon,
github: MuiGitHubIcon,
group: MuiPeopleIcon,
help: MuiHelpIcon,
user: MuiPersonIcon,
warning: MuiWarningIcon,
};
const overridableSystemIcon = (key: SystemIconKey): IconComponent => {
const Component = (props: SvgIconProps) => {
const app = useApp();
const Icon = app.getSystemIcon(key);
return <Icon {...props} />;
return Icon ? <Icon {...props} /> : <MuiBrokenImageIcon {...props} />;
};
return Component;
};
export const BrokenImageIcon = overridableSystemIcon('brokenImage');
export const ChatIcon = overridableSystemIcon('chat');
export const DashboardIcon = overridableSystemIcon('dashboard');
export const EmailIcon = overridableSystemIcon('email');
export const GitHubIcon = overridableSystemIcon('github');
export const GroupIcon = overridableSystemIcon('group');
export const HelpIcon = overridableSystemIcon('help');
export const UserIcon = overridableSystemIcon('user');
export const WarningIcon = overridableSystemIcon('warning');
+10 -1
View File
@@ -17,7 +17,16 @@
import { ComponentType } from 'react';
import { SvgIconProps } from '@material-ui/core';
export type SystemIconKey = 'user' | 'group' | 'dashboard' | 'help';
export type SystemIconKey =
| 'brokenImage'
| 'chat'
| 'dashboard'
| 'email'
| 'github'
| 'group'
| 'help'
| 'user'
| 'warning';
export type IconComponent = ComponentType<SvgIconProps>;
export type IconKey = SystemIconKey | string;
+35
View File
@@ -30,6 +30,41 @@ export interface Config {
* @visibility frontend
*/
title?: string;
/**
* Information about support of this Backstage instance and how to contact the integrator team.
*/
support?: {
/**
* The primary support url.
* @visibility frontend
*/
url: string;
/**
* A list of categorized support item groupings.
*/
items: {
/**
* The title of the support item grouping.
* @visibility frontend
*/
title: string;
/**
* An optional icon for the support item grouping.
* @visibility frontend
*/
icon?: string;
/**
* A list of support links for the Backstage instance.
*/
links?: {
/** @visibility frontend */
url: string;
/** @visibility frontend */
title?: string;
}[];
}[];
};
};
/**
@@ -14,35 +14,26 @@
* limitations under the License.
*/
import React, {
Fragment,
useState,
MouseEventHandler,
PropsWithChildren,
} from 'react';
import { HelpIcon, useApp } from '@backstage/core-api';
import {
Button,
Link,
List,
ListItem,
ListItemIcon,
Popover,
Typography,
makeStyles,
ListItemText,
makeStyles,
Popover,
} from '@material-ui/core';
import GroupIcon from '@material-ui/icons/Group';
import HelpIcon from '@material-ui/icons/Help';
import React, {
Fragment,
MouseEventHandler,
PropsWithChildren,
useState,
} from 'react';
import { SupportItem, SupportItemLink, useSupportConfig } from '../../hooks';
import { Link } from '../Link';
// import { EmailIcon, SlackIcon, SupportIcon } from 'shared/icons';
// import { Button, Link } from 'shared/components';
// import { StackOverflow, StackOverflowTag } from 'shared/components/layout';
type Props = {
slackChannel?: string | string[];
email?: string | string[];
plugin?: any;
};
type Props = {};
const useStyles = makeStyles(theme => ({
leftIcon: {
@@ -50,17 +41,45 @@ const useStyles = makeStyles(theme => ({
},
popoverList: {
minWidth: 260,
maxWidth: 320,
maxWidth: 400,
},
}));
export const SupportButton = ({
slackChannel = '#backstage',
email = [],
children,
}: // plugin,
PropsWithChildren<Props>) => {
// TODO: get plugin manifest with hook
const SupportIcon = ({ icon }: { icon: string | undefined }) => {
const app = useApp();
const Icon = icon ? app.getSystemIcon(icon) ?? HelpIcon : HelpIcon;
return <Icon />;
};
const SupportLink = ({ link }: { link: SupportItemLink }) => (
<Link to={link.url} target="_blank" rel="noreferrer noopener">
{link.title ?? link.url}
</Link>
);
const SupportListItem = ({ item }: { item: SupportItem }) => {
return (
<ListItem>
<ListItemIcon>
<SupportIcon icon={item.icon} />
</ListItemIcon>
<ListItemText
primary={item.title}
secondary={
<>
{item.links &&
item.links.map(link => (
<SupportLink link={link} key={link.url} />
))}
</>
}
/>
</ListItem>
);
};
export const SupportButton = ({ children }: PropsWithChildren<Props>) => {
const { items } = useSupportConfig();
const [popoverOpen, setPopoverOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState<Element | null>(null);
@@ -75,12 +94,6 @@ PropsWithChildren<Props>) => {
setPopoverOpen(false);
};
// const tags = plugin ? plugin.stackoverflowTags : undefined;
const slackChannels = Array.isArray(slackChannel)
? slackChannel
: [slackChannel];
const contactEmails = Array.isArray(email) ? email : [email];
return (
<Fragment>
<Button
@@ -111,53 +124,8 @@ PropsWithChildren<Props>) => {
{child}
</ListItem>
))}
{/* {tags && tags.length > 0 && (
<ListItem alignItems="flex-start">
<StackOverflow>
{tags.map((tag, i) => (
<StackOverflowTag key={i} tag={tag} />
))}
</StackOverflow>
</ListItem>
)} */}
{slackChannels && (
<ListItem>
<ListItemIcon>
<GroupIcon />
</ListItemIcon>
<ListItemText
disableTypography
primary={<Typography>Support</Typography>}
secondary={
<div>
{slackChannels.map((channel, i) => (
<Link key={i}>{channel}</Link>
))}
</div>
}
/>
</ListItem>
)}
{contactEmails.length > 0 && (
<ListItem>
<ListItemIcon>
<GroupIcon />
</ListItemIcon>
<ListItemText
disableTypography
primary={<Typography>Contact</Typography>}
secondary={
<div>
{contactEmails.map((em, index) => (
<Typography key={index}>
<Link>{em}</Link>
</Typography>
))}
</div>
}
/>
</ListItem>
)}
{items &&
items.map((item, i) => <SupportListItem item={item} key={i} />)}
</List>
</Popover>
</Fragment>
+6
View File
@@ -15,3 +15,9 @@
*/
export { useQueryParamState } from './useQueryParamState';
export { useSupportConfig } from './useSupportConfig';
export type {
SupportConfig,
SupportItem,
SupportItemLink,
} from './useSupportConfig';
@@ -0,0 +1,56 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useApi, configApiRef } from '@backstage/core-api';
export type SupportItemLink = {
url: string;
title: string;
};
export type SupportItem = {
title: string;
icon?: string;
links?: SupportItemLink[];
};
export type SupportConfig = {
url: string;
items: SupportItem[];
};
export function useSupportConfig(): SupportConfig {
const config = useApi(configApiRef);
const supportConfig = config.getOptional('app.support') as SupportConfig;
return {
url: supportConfig?.url ?? 'https://github.com/backstage/backstage/issues',
items: supportConfig?.items ?? [
{
title: 'Support Not Configured',
icon: 'warning',
links: [
{
// TODO: Update to dedicated support page on backstage.io/docs
title: 'Add `app.support` config key',
url:
'https://github.com/andrewthauer/backstage/blob/master/app-config.yaml',
},
],
},
],
};
}
@@ -20,6 +20,7 @@ import { makeStyles } from '@material-ui/core/styles';
import { BackstageTheme } from '@backstage/theme';
import { MicDrop } from './MicDrop';
import { useNavigate } from 'react-router';
import { useSupportConfig } from '../../hooks';
interface IErrorPageProps {
status: string;
@@ -53,6 +54,7 @@ export const ErrorPage = ({
}: IErrorPageProps) => {
const classes = useStyles();
const navigate = useNavigate();
const support = useSupportConfig();
return (
<Grid container spacing={0} className={classes.container}>
@@ -71,13 +73,11 @@ export const ErrorPage = ({
<Link data-testid="go-back-link" onClick={() => navigate(-1)}>
Go back
</Link>
... or if you think this is a bug, please file an{' '}
<Link
href="https://github.com/backstage/backstage/issues"
rel="noopener noreferrer"
>
issue.
</Link>
... or please{' '}
<Link href={support.url} rel="noopener noreferrer">
contact support
</Link>{' '}
if you think this is a bug.
</Typography>
</Grid>
</Grid>
@@ -33,9 +33,8 @@ export const EntityLinksCard = ({ cols = undefined }: Props) => {
const { entity } = useEntity();
const app = useApp();
const iconResolver = (key: IconKey | undefined): IconComponent => {
return app.getSystemIcon(key ?? '') ?? LanguageIcon;
};
const iconResolver = (key: IconKey | undefined): IconComponent =>
key ? app.getSystemIcon(key) ?? LanguageIcon : LanguageIcon;
const links = entity?.metadata?.links;
@@ -17,9 +17,9 @@
import { DomainEntity } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { render, waitFor } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import { waitFor } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { DomainExplorerContent } from './DomainExplorerContent';
describe('<DomainExplorerContent />', () => {
@@ -33,11 +33,9 @@ describe('<DomainExplorerContent />', () => {
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<MemoryRouter>
<ApiProvider apis={ApiRegistry.with(catalogApiRef, catalogApi)}>
{children}
</ApiProvider>
</MemoryRouter>
<ApiProvider apis={ApiRegistry.with(catalogApiRef, catalogApi)}>
{children}
</ApiProvider>
);
beforeEach(() => {
@@ -69,9 +67,11 @@ describe('<DomainExplorerContent />', () => {
];
catalogApi.getEntities.mockResolvedValue({ items: entities });
const { getByText } = render(<DomainExplorerContent />, {
wrapper: Wrapper,
});
const { getByText } = await renderInTestApp(
<Wrapper>
<DomainExplorerContent />
</Wrapper>,
);
await waitFor(() => {
expect(getByText('artists')).toBeInTheDocument();
@@ -82,9 +82,11 @@ describe('<DomainExplorerContent />', () => {
it('renders empty state', async () => {
catalogApi.getEntities.mockResolvedValue({ items: [] });
const { getByText } = render(<DomainExplorerContent />, {
wrapper: Wrapper,
});
const { getByText } = await renderInTestApp(
<Wrapper>
<DomainExplorerContent />
</Wrapper>,
);
await waitFor(() =>
expect(getByText('No domains to display')).toBeInTheDocument(),
@@ -95,9 +97,11 @@ describe('<DomainExplorerContent />', () => {
const catalogError = new Error('Network timeout');
catalogApi.getEntities.mockRejectedValueOnce(catalogError);
const { getByText } = render(<DomainExplorerContent />, {
wrapper: Wrapper,
});
const { getByText } = await renderInTestApp(
<Wrapper>
<DomainExplorerContent />
</Wrapper>,
);
await waitFor(() =>
expect(getByText(/Could not load domains/)).toBeInTheDocument(),
@@ -19,11 +19,11 @@ import {
ExploreTool,
exploreToolsConfigRef,
} from '@backstage/plugin-explore-react';
import { renderInTestApp } from '@backstage/test-utils';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import { render, waitFor } from '@testing-library/react';
import { waitFor } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { ToolExplorerContent } from './ToolExplorerContent';
describe('<ToolExplorerContent />', () => {
@@ -33,13 +33,11 @@ describe('<ToolExplorerContent />', () => {
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ThemeProvider theme={lightTheme}>
<MemoryRouter>
<ApiProvider
apis={ApiRegistry.with(exploreToolsConfigRef, exploreToolsConfigApi)}
>
{children}
</ApiProvider>
</MemoryRouter>
<ApiProvider
apis={ApiRegistry.with(exploreToolsConfigRef, exploreToolsConfigApi)}
>
{children}
</ApiProvider>
</ThemeProvider>
);
@@ -70,9 +68,11 @@ describe('<ToolExplorerContent />', () => {
];
exploreToolsConfigApi.getTools.mockResolvedValue(tools);
const { getByText } = render(<ToolExplorerContent />, {
wrapper: Wrapper,
});
const { getByText } = await renderInTestApp(
<Wrapper>
<ToolExplorerContent />
</Wrapper>,
);
await waitFor(() => {
expect(getByText('Lighthouse')).toBeInTheDocument();
@@ -83,9 +83,11 @@ describe('<ToolExplorerContent />', () => {
it('renders empty state', async () => {
exploreToolsConfigApi.getTools.mockResolvedValue([]);
const { getByText } = render(<ToolExplorerContent />, {
wrapper: Wrapper,
});
const { getByText } = await renderInTestApp(
<Wrapper>
<ToolExplorerContent />
</Wrapper>,
);
await waitFor(() =>
expect(getByText('No tools to display')).toBeInTheDocument(),
@@ -14,23 +14,23 @@
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import ProfileCatalog from './ProfileCatalog';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import {
ApiProvider,
ApiRegistry,
githubAuthApiRef,
GithubAuth,
githubAuthApiRef,
OAuthRequestManager,
UrlPatternDiscovery,
} from '@backstage/core';
import { renderInTestApp } from '@backstage/test-utils';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import React from 'react';
import { gitOpsApiRef, GitOpsRestApi } from '../../api';
import ProfileCatalog from './ProfileCatalog';
describe('ProfileCatalog', () => {
it('should render', () => {
it('should render', async () => {
const oauthRequestApi = new OAuthRequestManager();
const apis = ApiRegistry.from([
[gitOpsApiRef, new GitOpsRestApi('http://localhost:3008')],
@@ -44,15 +44,15 @@ describe('ProfileCatalog', () => {
}),
],
]);
const rendered = render(
const { getByText } = await renderInTestApp(
<ThemeProvider theme={lightTheme}>
<ApiProvider apis={apis}>
<ProfileCatalog />
</ApiProvider>
</ThemeProvider>,
);
expect(
rendered.getByText('Create GitOps-managed Cluster'),
).toBeInTheDocument();
expect(getByText('Create GitOps-managed Cluster')).toBeInTheDocument();
});
});
@@ -21,11 +21,10 @@ import {
errorApiRef,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { RegisterComponentPage } from './RegisterComponentPage';
const errorApi: jest.Mocked<typeof errorApiRef.T> = {
@@ -44,30 +43,29 @@ const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<MemoryRouter>
<ApiProvider
apis={ApiRegistry.with(errorApiRef, errorApi).with(
catalogApiRef,
catalogApi,
)}
>
<ThemeProvider theme={lightTheme}>{children}</ThemeProvider>
</ApiProvider>
</MemoryRouter>
<ApiProvider
apis={ApiRegistry.with(errorApiRef, errorApi).with(
catalogApiRef,
catalogApi,
)}
>
<ThemeProvider theme={lightTheme}>{children}</ThemeProvider>
</ApiProvider>
);
describe('RegisterComponentPage', () => {
it('should render', () => {
render(
<RegisterComponentPage
catalogRouteRef={createRouteRef({
path: '/catalog',
title: 'Service Catalog',
})}
/>,
{ wrapper: Wrapper },
it('should render', async () => {
const { getByText } = await renderInTestApp(
<Wrapper>
<RegisterComponentPage
catalogRouteRef={createRouteRef({
path: '/catalog',
title: 'Service Catalog',
})}
/>
</Wrapper>,
);
expect(screen.getByText('Register existing component')).toBeInTheDocument();
expect(getByText('Register existing component')).toBeInTheDocument();
});
});
@@ -14,16 +14,19 @@
* limitations under the License.
*/
import React from 'react';
import { render, waitForElement } from '@testing-library/react';
import { ThemeProvider } from '@material-ui/core';
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import {
MockErrorApi,
renderInTestApp,
wrapInTestApp,
} from '@backstage/test-utils';
import { lightTheme } from '@backstage/theme';
import { ApiRegistry, ApiProvider, errorApiRef } from '@backstage/core';
import { ThemeProvider } from '@material-ui/core';
import { render, waitForElement } from '@testing-library/react';
import React from 'react';
import { act } from 'react-dom/test-utils';
import GetBBoxPolyfill from '../utils/polyfills/getBBox';
import { RadarPage } from './RadarPage';
import { act } from 'react-dom/test-utils';
import { MockErrorApi, wrapInTestApp } from '@backstage/test-utils';
describe('RadarPage', () => {
beforeAll(() => {
@@ -67,12 +70,10 @@ describe('RadarPage', () => {
svgProps: { 'data-testid': 'tech-radar-svg' },
};
const { getByText, getByTestId } = render(
wrapInTestApp(
<ThemeProvider theme={lightTheme}>
<RadarPage {...techRadarProps} />
</ThemeProvider>,
),
const { getByText, getByTestId } = await renderInTestApp(
<ThemeProvider theme={lightTheme}>
<RadarPage {...techRadarProps} />
</ThemeProvider>,
);
await waitForElement(() => getByTestId('tech-radar-svg'));
@@ -94,7 +95,7 @@ describe('RadarPage', () => {
svgProps: { 'data-testid': 'tech-radar-svg' },
};
const { queryByTestId } = render(
const { queryByTestId } = await renderInTestApp(
<ThemeProvider theme={lightTheme}>
<ApiProvider apis={ApiRegistry.with(errorApiRef, errorApi)}>
<RadarPage {...techRadarProps} />
@@ -14,23 +14,22 @@
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import WelcomePage from './WelcomePage';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import {
ApiProvider,
ApiRegistry,
errorApiRef,
configApiRef,
ConfigReader,
errorApiRef,
} from '@backstage/core';
import { renderInTestApp } from '@backstage/test-utils';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import React from 'react';
import WelcomePage from './WelcomePage';
describe('WelcomePage', () => {
it('should render', () => {
// TODO: use common test app with mock implementations of all core APIs
const rendered = render(
it('should render', async () => {
const { baseElement } = await renderInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[errorApiRef, { post: jest.fn() }],
@@ -42,6 +41,6 @@ describe('WelcomePage', () => {
</ThemeProvider>
</ApiProvider>,
);
expect(rendered.baseElement).toBeInTheDocument();
expect(baseElement).toBeInTheDocument();
});
});