Merge pull request #4517 from andrewthauer/feat/support-button

Add extended support button config
This commit is contained in:
Andrew Thauer
2021-02-23 20:18:33 -05:00
committed by GitHub
18 changed files with 319 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,74 @@
/*
* 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[];
};
const DEFAULT_SUPPORT_CONFIG: SupportConfig = {
url: 'https://github.com/backstage/backstage/issues',
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',
},
],
},
],
};
export function useSupportConfig(): SupportConfig {
const config = useApi(configApiRef);
const supportConfig = config.getOptionalConfig('app.support');
if (!supportConfig) {
return DEFAULT_SUPPORT_CONFIG;
}
return {
url: supportConfig.getString('url'),
items: supportConfig.getConfigArray('items').flatMap(itemConf => ({
title: itemConf.getString('title'),
icon: itemConf.getOptionalString('icon'),
links: (itemConf.getOptionalConfigArray('links') ?? []).flatMap(
linkConf => ({
url: linkConf.getString('url'),
title: linkConf.getString('title'),
}),
),
})),
};
}
@@ -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();
});
});