Merge pull request #5392 from backstage/eide/shortcut-plugin

[plugin]: Shortcuts plugin for the Sidebar
This commit is contained in:
Ben Lambert
2021-05-10 10:47:31 +02:00
committed by GitHub
29 changed files with 1578 additions and 1 deletions
+1
View File
@@ -32,6 +32,7 @@
"@backstage/plugin-scaffolder": "^0.9.3",
"@backstage/plugin-search": "^0.3.5",
"@backstage/plugin-sentry": "^0.3.9",
"@backstage/plugin-shortcuts": "^0.1.1",
"@backstage/plugin-tech-radar": "^0.3.10",
"@backstage/plugin-techdocs": "^0.9.1",
"@backstage/plugin-todo": "^0.1.0",
@@ -39,6 +39,7 @@ import { NavLink } from 'react-router-dom';
import { GraphiQLIcon } from '@backstage/plugin-graphiql';
import { Settings as SidebarSettings } from '@backstage/plugin-user-settings';
import { SidebarSearch } from '@backstage/plugin-search';
import { Shortcuts } from '@backstage/plugin-shortcuts';
const useSidebarLogoStyles = makeStyles({
root: {
@@ -91,6 +92,8 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
<SidebarItem icon={RuleIcon} to="lighthouse" text="Lighthouse" />
<SidebarItem icon={MoneyIcon} to="cost-insights" text="Cost Insights" />
<SidebarItem icon={GraphiQLIcon} to="graphiql" text="GraphiQL" />
<SidebarDivider />
<Shortcuts />
<SidebarSpace />
<SidebarDivider />
<SidebarSettings />
+1
View File
@@ -17,3 +17,4 @@
// TODO(Rugvip): This plugin is currently not part of the app element tree,
// ideally we have an API for the context menu that permits that.
export { badgesPlugin } from '@backstage/plugin-badges';
export { shortcutsPlugin } from '@backstage/plugin-shortcuts';
@@ -18,10 +18,13 @@ import {
storageApiRef,
errorApiRef,
createApiFactory,
AlertApiForwarder,
alertApiRef,
} from '@backstage/core-api';
import { MockErrorApi, MockStorageApi } from './apis';
export const mockApis = [
createApiFactory(errorApiRef, new MockErrorApi()),
createApiFactory(storageApiRef, MockStorageApi.create()),
createApiFactory(alertApiRef, new AlertApiForwarder()),
];
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+52
View File
@@ -0,0 +1,52 @@
# shortcuts
The shortcuts plugin allows a user to have easy access to pages within a Backstage app by storing them as "shortcuts" in the Sidebar.
## Usage
Install the package:
```bash
yarn add @backstage/plugin-shortcuts
```
Add it to your App's `plugins.ts` file:
```ts
// ...
export { shortcutsPlugin } from '@backstage/plugin-shortcuts';
```
Add the `<Shortcuts />` component within your `<Sidebar>`:
```tsx
import { Sidebar, SidebarDivider, SidebarSpace } from '@backstage/core';
import { Shortcuts } from '@backstage/plugin-shortcuts';
export const SidebarComponent = () => (
<Sidebar>
{/* ... */}
<SidebarDivider />
<Shortcuts />
<SidebarSpace />
</Sidebar>
);
```
The plugin exports a `shortcutApiRef` but the plugin includes a default implementation of the `ShortcutApi` that uses `localStorage` to store each user's shortcuts.
To overwrite the default implementation add it to the App's `apis.ts`:
```ts
import { shortcutsApiRef } from '@backstage/plugin-shortcuts';
import { CustomShortcutsImpl } from '...';
export const apis = [
// ...
createApiFactory({
api: shortcutsApiRef,
deps: {},
factory: () => new CustomShortcutsImpl(),
}),
];
```
+26
View File
@@ -0,0 +1,26 @@
/*
* Copyright 2021 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 React from 'react';
import { createDevApp } from '@backstage/dev-utils';
import { shortcutsPlugin, Shortcuts } from '../src/plugin';
createDevApp()
.registerPlugin(shortcutsPlugin)
.addPage({
element: <Shortcuts />,
title: 'Root Page',
})
.render();
+52
View File
@@ -0,0 +1,52 @@
{
"name": "@backstage/plugin-shortcuts",
"version": "0.1.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.7.8",
"@backstage/theme": "^0.2.7",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/zen-observable": "^0.8.2",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-hook-form": "^7.1.1",
"react-router": "6.0.0-beta.0",
"react-use": "^17.2.4",
"uuid": "^8.3.2",
"zen-observable": "^0.8.15"
},
"devDependencies": {
"@backstage/cli": "^0.6.10",
"@backstage/dev-utils": "^0.1.13",
"@backstage/test-utils": "^0.1.10",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"cross-fetch": "^3.0.6",
"msw": "^0.21.2"
},
"files": [
"dist"
]
}
+105
View File
@@ -0,0 +1,105 @@
/*
* Copyright 2021 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 React from 'react';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { AddShortcut } from './AddShortcut';
import { LocalStoredShortcuts } from './api';
import { MockStorageApi, renderInTestApp } from '@backstage/test-utils';
import { AlertDisplay } from '@backstage/core';
describe('AddShortcut', () => {
const api = new LocalStoredShortcuts(MockStorageApi.create());
const props = {
onClose: jest.fn(),
anchorEl: document.createElement('div'),
api,
};
beforeEach(() => {
jest.clearAllMocks();
document.title = 'some document title';
});
it('displays the title', async () => {
await renderInTestApp(<AddShortcut {...props} />);
expect(screen.getByText('Add Shortcut')).toBeInTheDocument();
});
it('closes the popup', async () => {
await renderInTestApp(<AddShortcut {...props} />);
fireEvent.click(screen.getByText('Cancel'));
expect(props.onClose).toHaveBeenCalledTimes(1);
});
it('saves the input', async () => {
const spy = jest.spyOn(api, 'add');
await renderInTestApp(<AddShortcut {...props} />);
const urlInput = screen.getByPlaceholderText('Enter a URL');
const titleInput = screen.getByPlaceholderText('Enter a display name');
fireEvent.change(urlInput, { target: { value: '/some-url' } });
fireEvent.change(titleInput, { target: { value: 'some title' } });
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(spy).toBeCalledWith({
title: 'some title',
url: '/some-url',
});
});
});
it('pastes the values', async () => {
const spy = jest.spyOn(api, 'add');
await renderInTestApp(<AddShortcut {...props} />, {
routeEntries: ['/some-initial-url'],
});
fireEvent.click(screen.getByText('Use current page'));
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(spy).toBeCalledWith({
title: 'some document title',
url: '/some-initial-url',
});
});
});
it('displays errors', async () => {
jest.spyOn(api, 'add').mockRejectedValueOnce(new Error('some add error'));
await renderInTestApp(
<>
<AlertDisplay />
<AddShortcut {...props} />
</>,
);
fireEvent.click(screen.getByText('Use current page'));
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(
screen.getByText('Could not add shortcut: some add error'),
).toBeInTheDocument();
});
});
});
+121
View File
@@ -0,0 +1,121 @@
/*
* Copyright 2021 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 React, { useState } from 'react';
import { useLocation } from 'react-router';
import { SubmitHandler } from 'react-hook-form';
import { alertApiRef, useApi } from '@backstage/core';
import {
Button,
Card,
CardHeader,
makeStyles,
Popover,
} from '@material-ui/core';
import { ShortcutForm } from './ShortcutForm';
import { FormValues, Shortcut } from './types';
import { ShortcutApi } from './api';
const useStyles = makeStyles(theme => ({
card: {
width: 400,
},
header: {
marginBottom: theme.spacing(1),
},
button: {
marginTop: theme.spacing(1),
},
}));
type Props = {
onClose: () => void;
anchorEl?: Element;
api: ShortcutApi;
};
export const AddShortcut = ({ onClose, anchorEl, api }: Props) => {
const classes = useStyles();
const alertApi = useApi(alertApiRef);
const { pathname } = useLocation();
const [formValues, setFormValues] = useState<FormValues>();
const open = Boolean(anchorEl);
const handleSave: SubmitHandler<FormValues> = async ({ url, title }) => {
const shortcut: Omit<Shortcut, 'id'> = { url, title };
try {
await api.add(shortcut);
alertApi.post({
message: `Added shortcut '${title}' to your sidebar`,
severity: 'success',
});
} catch (error) {
alertApi.post({
message: `Could not add shortcut: ${error.message}`,
severity: 'error',
});
}
onClose();
};
const handlePaste = () => {
setFormValues({ url: pathname, title: document.title });
};
const handleClose = () => {
setFormValues(undefined);
onClose();
};
return (
<Popover
open={open}
anchorEl={anchorEl}
onExit={handleClose}
onClose={onClose}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Card className={classes.card}>
<CardHeader
className={classes.header}
title="Add Shortcut"
titleTypographyProps={{ variant: 'subtitle2' }}
action={
<Button
className={classes.button}
variant="text"
size="small"
color="primary"
onClick={handlePaste}
>
Use current page
</Button>
}
/>
<ShortcutForm
onClose={handleClose}
onSave={handleSave}
formValues={formValues}
/>
</Card>
</Popover>
);
};
+118
View File
@@ -0,0 +1,118 @@
/*
* Copyright 2021 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 React from 'react';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { EditShortcut } from './EditShortcut';
import { Shortcut } from './types';
import { LocalStoredShortcuts } from './api';
import { MockStorageApi, renderInTestApp } from '@backstage/test-utils';
import { AlertDisplay } from '@backstage/core';
describe('EditShortcut', () => {
const shortcut: Shortcut = {
id: 'id',
url: '/some-url',
title: 'some title',
};
const api = new LocalStoredShortcuts(MockStorageApi.create());
const props = {
onClose: jest.fn(),
anchorEl: document.createElement('div'),
shortcut,
api,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('displays the title', async () => {
await renderInTestApp(<EditShortcut {...props} />);
expect(screen.getByText('Edit Shortcut')).toBeInTheDocument();
});
it('closes the popup', async () => {
await renderInTestApp(<EditShortcut {...props} />);
fireEvent.click(screen.getByText('Cancel'));
expect(props.onClose).toHaveBeenCalledTimes(1);
});
it('updates the shortcut', async () => {
const spy = jest.spyOn(api, 'update');
await renderInTestApp(<EditShortcut {...props} />);
const urlInput = screen.getByPlaceholderText('Enter a URL');
const titleInput = screen.getByPlaceholderText('Enter a display name');
fireEvent.change(urlInput, { target: { value: '/some-new-url' } });
fireEvent.change(titleInput, { target: { value: 'some new title' } });
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(spy).toBeCalledWith({
id: 'id',
title: 'some new title',
url: '/some-new-url',
});
expect(props.onClose).toHaveBeenCalledTimes(1);
});
});
it('removes the shortcut', async () => {
const spy = jest.spyOn(api, 'remove');
await renderInTestApp(<EditShortcut {...props} />);
fireEvent.click(screen.getByText('Remove'));
expect(spy).toBeCalledWith('id');
});
it('displays errors', async () => {
jest
.spyOn(api, 'update')
.mockRejectedValueOnce(new Error('some update error'));
jest
.spyOn(api, 'remove')
.mockRejectedValueOnce(new Error('some remove error'));
await renderInTestApp(
<>
<AlertDisplay />
<EditShortcut {...props} />
</>,
);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(
screen.getByText('Could not update shortcut: some update error'),
).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('error-button-close'));
fireEvent.click(screen.getByText('Remove'));
await waitFor(() => {
expect(
screen.getByText('Could not delete shortcut: some remove error'),
).toBeInTheDocument();
});
});
});
+134
View File
@@ -0,0 +1,134 @@
/*
* Copyright 2021 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 React from 'react';
import { SubmitHandler } from 'react-hook-form';
import { alertApiRef, useApi } from '@backstage/core';
import {
Button,
Card,
CardHeader,
makeStyles,
Popover,
} from '@material-ui/core';
import { ShortcutForm } from './ShortcutForm';
import { FormValues, Shortcut } from './types';
import DeleteIcon from '@material-ui/icons/Delete';
import { ShortcutApi } from './api';
const useStyles = makeStyles(theme => ({
card: {
width: 400,
},
header: {
marginBottom: theme.spacing(1),
},
button: {
marginTop: theme.spacing(1),
},
}));
type Props = {
shortcut: Shortcut;
onClose: () => void;
anchorEl?: Element;
api: ShortcutApi;
};
export const EditShortcut = ({ shortcut, onClose, anchorEl, api }: Props) => {
const classes = useStyles();
const alertApi = useApi(alertApiRef);
const open = Boolean(anchorEl);
const handleSave: SubmitHandler<FormValues> = async ({ url, title }) => {
const newShortcut: Shortcut = {
...shortcut,
url,
title,
};
try {
await api.update(newShortcut);
alertApi.post({
message: `Updated shortcut '${title}'`,
severity: 'success',
});
} catch (error) {
alertApi.post({
message: `Could not update shortcut: ${error.message}`,
severity: 'error',
});
}
onClose();
};
const handleRemove = async () => {
try {
await api.remove(shortcut.id);
alertApi.post({
message: `Removed shortcut '${shortcut.title}' from your sidebar`,
severity: 'success',
});
} catch (error) {
alertApi.post({
message: `Could not delete shortcut: ${error.message}`,
severity: 'error',
});
}
};
const handleClose = () => {
onClose();
};
return (
<Popover
open={open}
anchorEl={anchorEl}
onClose={onClose}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Card className={classes.card}>
<CardHeader
className={classes.header}
title="Edit Shortcut"
titleTypographyProps={{ variant: 'subtitle2' }}
action={
<Button
className={classes.button}
variant="text"
size="small"
color="secondary"
startIcon={<DeleteIcon />}
onClick={handleRemove}
>
Remove
</Button>
}
/>
<ShortcutForm
formValues={{ url: shortcut.url, title: shortcut.title }}
onClose={handleClose}
onSave={handleSave}
/>
</Card>
</Popover>
);
};
@@ -0,0 +1,71 @@
/*
* Copyright 2021 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 React from 'react';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { ShortcutForm } from './ShortcutForm';
import { renderInTestApp } from '@backstage/test-utils';
describe('ShortcutForm', () => {
const props = {
onSave: jest.fn(),
onClose: jest.fn(),
};
it('displays validation messages', async () => {
await renderInTestApp(<ShortcutForm {...props} />);
const urlInput = screen.getByPlaceholderText('Enter a URL');
const titleInput = screen.getByPlaceholderText('Enter a display name');
fireEvent.change(urlInput, { target: { value: 'url' } });
fireEvent.change(titleInput, { target: { value: 't' } });
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(
screen.getByText('Must be a relative URL (starts with a /)'),
).toBeInTheDocument();
expect(
screen.getByText('Must be at least 2 characters'),
).toBeInTheDocument();
});
});
it('calls the save handler', async () => {
await renderInTestApp(
<ShortcutForm
{...props}
formValues={{ url: '/some-url', title: 'some title' }}
/>,
);
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(props.onSave).toHaveBeenCalledWith(
expect.objectContaining({ title: 'some title', url: '/some-url' }),
expect.anything(),
);
});
});
it('calls the close handler', async () => {
await renderInTestApp(<ShortcutForm {...props} />);
fireEvent.click(screen.getByText('Cancel'));
await waitFor(() => {
expect(props.onClose).toHaveBeenCalled();
});
});
});
+140
View File
@@ -0,0 +1,140 @@
/*
* Copyright 2021 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 React, { useEffect } from 'react';
import { useForm, SubmitHandler, Controller } from 'react-hook-form';
import {
Button,
CardActions,
CardContent,
makeStyles,
TextField,
} from '@material-ui/core';
import { FormValues } from './types';
const useStyles = makeStyles(theme => ({
field: {
marginBottom: theme.spacing(2),
},
actionRoot: {
paddingLeft: theme.spacing(2),
paddingBottom: theme.spacing(3),
justifyContent: 'flex-start',
},
}));
type Props = {
formValues?: FormValues;
onSave: SubmitHandler<FormValues>;
onClose: () => void;
};
export const ShortcutForm = ({ formValues, onSave, onClose }: Props) => {
const classes = useStyles();
const {
handleSubmit,
reset,
control,
formState: { errors },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues: {
url: formValues?.url ?? '',
title: formValues?.title ?? '',
},
});
useEffect(() => {
reset(formValues);
}, [reset, formValues]);
return (
<>
<CardContent>
<Controller
name="url"
control={control}
rules={{
required: true,
pattern: {
value: /^\//,
message: 'Must be a relative URL (starts with a /)',
},
}}
render={({ field }) => (
<TextField
{...field}
error={!!errors.url}
helperText={errors.url?.message}
type="text"
placeholder="Enter a URL"
InputLabelProps={{
shrink: true,
}}
className={classes.field}
fullWidth
label="Shortcut URL"
variant="outlined"
autoComplete="off"
/>
)}
/>
<Controller
name="title"
control={control}
rules={{
required: true,
minLength: {
value: 2,
message: 'Must be at least 2 characters',
},
}}
render={({ field }) => (
<TextField
{...field}
error={!!errors.title}
helperText={errors.title?.message}
type="text"
placeholder="Enter a display name"
InputLabelProps={{
shrink: true,
}}
className={classes.field}
fullWidth
label="Display Name"
variant="outlined"
autoComplete="off"
/>
)}
/>
</CardContent>
<CardActions classes={{ root: classes.actionRoot }}>
<Button
variant="contained"
color="primary"
size="large"
onClick={handleSubmit(onSave)}
>
Save
</Button>
<Button variant="outlined" size="large" onClick={onClose}>
Cancel
</Button>
</CardActions>
</>
);
};
+47
View File
@@ -0,0 +1,47 @@
/*
* Copyright 2021 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 React from 'react';
type Props = {
text: string;
color: string;
};
export const ShortcutIcon = ({ text, color }: Props) => {
const size = 28;
return (
<svg
viewBox={`0 0 ${size} ${size}`}
height={size}
width={size}
style={{ filter: 'contrast(150%) brightness(1.4)' }}
>
<circle r={size / 2} cx={size / 2} cy={size / 2} fill={color} />
<text
dy={2 + size / 2}
dx={size / 2}
fontWeight="bold"
fontSize="13"
textAnchor="middle"
alignmentBaseline="middle"
fill="black"
>
{text}
</text>
</svg>
);
};
+103
View File
@@ -0,0 +1,103 @@
/*
* Copyright 2021 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 React from 'react';
import { screen, waitFor } from '@testing-library/react';
import { ShortcutItem } from './ShortcutItem';
import { Shortcut } from './types';
import { SidebarContext } from '@backstage/core';
import { LocalStoredShortcuts } from './api';
import {
MockStorageApi,
renderInTestApp,
wrapInTestApp,
} from '@backstage/test-utils';
import { pageTheme } from '@backstage/theme';
describe('ShortcutItem', () => {
const shortcut: Shortcut = {
id: 'id',
url: '/some-url',
title: 'some title',
};
const api = new LocalStoredShortcuts(MockStorageApi.create());
it('displays the shortcut', async () => {
await renderInTestApp(
<SidebarContext.Provider value={{ isOpen: true }}>
<ShortcutItem api={api} shortcut={shortcut} />
</SidebarContext.Provider>,
);
expect(screen.getByText('ST')).toBeInTheDocument();
expect(screen.getByText('some title')).toBeInTheDocument();
});
it('calculates the shortcut text correctly', async () => {
const shortcut1: Shortcut = {
id: 'id1',
url: '/some-url',
title: 'onetitle',
};
const shortcut2: Shortcut = {
id: 'id2',
url: '/some-url',
title: 'two title',
};
const shortcut3: Shortcut = {
id: 'id3',
url: '/some-url',
title: 'more | title words',
};
const { rerender } = await renderInTestApp(
<ShortcutItem api={api} shortcut={shortcut1} />,
);
expect(screen.getByText('On')).toBeInTheDocument();
rerender(wrapInTestApp(<ShortcutItem api={api} shortcut={shortcut2} />));
await waitFor(() => {
expect(screen.getByText('TT')).toBeInTheDocument();
});
rerender(wrapInTestApp(<ShortcutItem api={api} shortcut={shortcut3} />));
await waitFor(() => {
expect(screen.getByText('MT')).toBeInTheDocument();
});
});
it('gets the color based on the theme', async () => {
const { rerender } = await renderInTestApp(
<ShortcutItem api={api} shortcut={shortcut} />,
);
expect(document.querySelector('circle')?.getAttribute('fill')).toEqual(
pageTheme.tool.colors[0],
);
const newShortcut: Shortcut = {
id: 'id',
url: '/catalog',
title: 'some title',
};
rerender(wrapInTestApp(<ShortcutItem api={api} shortcut={newShortcut} />));
await waitFor(() => {
expect(document.querySelector('circle')?.getAttribute('fill')).toEqual(
pageTheme.home.colors[0],
);
});
});
});
+100
View File
@@ -0,0 +1,100 @@
/*
* Copyright 2021 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 React from 'react';
import { SidebarItem } from '@backstage/core';
import { IconButton, makeStyles } from '@material-ui/core';
import EditIcon from '@material-ui/icons/Edit';
import { ShortcutIcon } from './ShortcutIcon';
import { EditShortcut } from './EditShortcut';
import { ShortcutApi } from './api';
import { Shortcut } from './types';
const useStyles = makeStyles({
root: {
'&:hover #edit': {
visibility: 'visible',
},
},
button: {
visibility: 'hidden',
},
icon: {
color: 'white',
fontSize: 16,
},
});
const getIconText = (title: string) =>
title.split(' ').length === 1
? // If there's only one word, keep the first two characters
title[0].toUpperCase() + title[1].toLowerCase()
: // If there's more than one word, take the first character of the first two words
title
.replace(/\B\W/g, '')
.split(' ')
.map(s => s[0])
.join('')
.slice(0, 2)
.toUpperCase();
type Props = {
shortcut: Shortcut;
api: ShortcutApi;
};
export const ShortcutItem = ({ shortcut, api }: Props) => {
const classes = useStyles();
const [anchorEl, setAnchorEl] = React.useState<Element | undefined>();
const handleClick = (event: React.MouseEvent<Element>) => {
event.preventDefault();
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(undefined);
};
const text = getIconText(shortcut.title);
const color = api.getColor(shortcut.url);
return (
<>
<SidebarItem
className={classes.root}
to={shortcut.url}
text={shortcut.title}
icon={() => <ShortcutIcon text={text} color={color} />}
>
<IconButton
id="edit"
data-testid="edit"
onClick={handleClick}
className={classes.button}
>
<EditIcon className={classes.icon} />
</IconButton>
</SidebarItem>
<EditShortcut
onClose={handleClose}
anchorEl={anchorEl}
api={api}
shortcut={shortcut}
/>
</>
);
};
+40
View File
@@ -0,0 +1,40 @@
/*
* Copyright 2021 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 React from 'react';
import { SidebarContext, ApiProvider, ApiRegistry } from '@backstage/core';
import { MockStorageApi, renderInTestApp } from '@backstage/test-utils';
import { screen, waitFor } from '@testing-library/react';
import { Shortcuts } from './Shortcuts';
import { LocalStoredShortcuts, shortcutsApiRef } from './api';
const apis = ApiRegistry.from([
[shortcutsApiRef, new LocalStoredShortcuts(MockStorageApi.create())],
]);
describe('Shortcuts', () => {
it('displays an add button', async () => {
await renderInTestApp(
<SidebarContext.Provider value={{ isOpen: true }}>
<ApiProvider apis={apis}>
<Shortcuts />
</ApiProvider>
</SidebarContext.Provider>,
);
await waitFor(() => !screen.queryByTestId('progress'));
expect(screen.getByText('Add Shortcuts')).toBeInTheDocument();
});
});
+75
View File
@@ -0,0 +1,75 @@
/*
* Copyright 2021 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 React, { useMemo } from 'react';
import { useObservable } from 'react-use';
import { Progress, SidebarItem, useApi } from '@backstage/core';
import { makeStyles } from '@material-ui/core';
import PlayListAddIcon from '@material-ui/icons/PlaylistAdd';
import { ShortcutItem } from './ShortcutItem';
import { AddShortcut } from './AddShortcut';
import { shortcutsApiRef } from './api';
const useStyles = makeStyles({
root: {
flex: '1 1 auto',
overflow: 'scroll',
},
});
export const Shortcuts = () => {
const classes = useStyles();
const shortcutApi = useApi(shortcutsApiRef);
const shortcuts = useObservable(
useMemo(() => shortcutApi.shortcut$(), [shortcutApi]),
);
const [anchorEl, setAnchorEl] = React.useState<Element | undefined>();
const loading = Boolean(!shortcuts);
const handleClick = (event: React.MouseEvent<Element>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(undefined);
};
return (
<div className={classes.root}>
<SidebarItem
icon={PlayListAddIcon}
text="Add Shortcuts"
onClick={handleClick}
/>
<AddShortcut
onClose={handleClose}
anchorEl={anchorEl}
api={shortcutApi}
/>
{loading ? (
<Progress />
) : (
shortcuts?.map(shortcut => (
<ShortcutItem
key={shortcut.id}
shortcut={shortcut}
api={shortcutApi}
/>
))
)}
</div>
);
};
@@ -0,0 +1,82 @@
/*
* Copyright 2021 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 { MockStorageApi } from '@backstage/test-utils';
import { pageTheme } from '@backstage/theme';
import { Shortcut } from '../types';
import { LocalStoredShortcuts } from './LocalStoredShortcuts';
import { ShortcutApi } from './ShortcutApi';
describe('LocalStoredShortcuts', () => {
// eslint-disable-next-line jest/no-done-callback
it('should observe shortcuts', async done => {
const shortcutApi: ShortcutApi = new LocalStoredShortcuts(
MockStorageApi.create(),
);
const shortcut: Shortcut = { id: 'id', title: 'title', url: '/url' };
await shortcutApi.add(shortcut);
shortcutApi.shortcut$().subscribe(data => {
expect(data).toEqual(
expect.arrayContaining([{ ...shortcut, id: expect.anything() }]),
);
done();
});
});
it('should add shortcuts with ids', async () => {
const storageApi = MockStorageApi.create();
const shortcutApi: ShortcutApi = new LocalStoredShortcuts(storageApi);
const shortcut: Omit<Shortcut, 'id'> = { title: 'title', url: '/url' };
const spy = jest.spyOn(storageApi, 'set');
await shortcutApi.add(shortcut);
expect(spy).toHaveBeenCalledWith(
'items',
expect.objectContaining([{ ...shortcut, id: expect.anything() }]),
);
});
it('should update shortcuts', async () => {
const storageApi = MockStorageApi.create();
const shortcutApi: ShortcutApi = new LocalStoredShortcuts(storageApi);
const shortcut: Shortcut = { id: 'someid', title: 'title', url: '/url' };
const spy = jest.spyOn(storageApi, 'set');
await shortcutApi.update(shortcut);
expect(spy).toHaveBeenCalledWith(
'items',
expect.objectContaining([shortcut]),
);
});
it('should remove shortcuts', async () => {
const storageApi = MockStorageApi.create();
const shortcutApi: ShortcutApi = new LocalStoredShortcuts(storageApi);
const shortcut: Shortcut = { id: 'someid', title: 'title', url: '/url' };
const spy = jest.spyOn(storageApi, 'set');
await shortcutApi.remove(shortcut.id);
expect(spy).toHaveBeenCalledWith('items', []);
});
it('should get a color', () => {
const storageApi = MockStorageApi.create();
const shortcutApi: ShortcutApi = new LocalStoredShortcuts(storageApi);
expect(shortcutApi.getColor('/catalog')).toEqual(pageTheme.home.colors[0]);
});
});
@@ -0,0 +1,97 @@
/*
* Copyright 2021 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 { StorageApi } from '@backstage/core';
import { pageTheme } from '@backstage/theme';
import ObservableImpl from 'zen-observable';
import { v4 as uuid } from 'uuid';
import { ShortcutApi } from './ShortcutApi';
import { Shortcut } from '../types';
/**
* Implementation of the ShortcutApi that uses the StorageApi to store shortcuts.
*/
export class LocalStoredShortcuts implements ShortcutApi {
constructor(private readonly storageApi: StorageApi) {}
shortcut$() {
return this.observable;
}
async add(shortcut: Omit<Shortcut, 'id'>) {
const shortcuts = this.get();
shortcuts.push({ ...shortcut, id: uuid() });
await this.storageApi.set('items', shortcuts);
this.notify();
}
async remove(id: string) {
const shortcuts = this.get().filter(s => s.id !== id);
await this.storageApi.set('items', shortcuts);
this.notify();
}
async update(shortcut: Shortcut) {
const shortcuts = this.get().filter(s => s.id !== shortcut.id);
shortcuts.push(shortcut);
await this.storageApi.set('items', shortcuts);
this.notify();
}
getColor(url: string) {
const type = url.split('/')[1];
const theme =
this.THEME_MAP[type] ??
(Object.keys(pageTheme).includes(type) ? type : 'tool');
return pageTheme[theme].colors[0];
}
private subscribers = new Set<
ZenObservable.SubscriptionObserver<Shortcut[]>
>();
private readonly observable = new ObservableImpl<Shortcut[]>(subscriber => {
subscriber.next(this.get());
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
});
private readonly THEME_MAP: Record<string, keyof typeof pageTheme> = {
catalog: 'home',
docs: 'documentation',
};
private get() {
return (
(this.storageApi.get('items') as Shortcut[])?.sort((a, b) =>
a.title >= b.title ? 1 : -1,
) ?? []
);
}
private notify() {
for (const subscription of this.subscribers) {
subscription.next(this.get());
}
}
}
+54
View File
@@ -0,0 +1,54 @@
/*
* Copyright 2021 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 { createApiRef, Observable } from '@backstage/core';
import { Shortcut } from '../types';
export const shortcutsApiRef = createApiRef<ShortcutApi>({
id: 'plugin.shortcuts.api',
description: 'API to handle shortcuts in a Backstage Sidebar',
});
export interface ShortcutApi {
/**
* Returns an Observable that will subscribe to changes.
*/
shortcut$(): Observable<Shortcut[]>;
/**
* Generates a unique id for the shortcut and then saves it.
*/
add(shortcut: Omit<Shortcut, 'id'>): Promise<void>;
/**
* Removes the shortcut.
*/
remove(id: string): Promise<void>;
/**
* Finds an existing shortcut that matches the ID of the
* supplied shortcut and updates its values.
*/
update(shortcut: Shortcut): Promise<void>;
/**
* Each shortcut should get a color for its icon based on the url.
*
* Preferably using some abstraction between the url and the actual
* color value.
*/
getColor(url: string): string;
}
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2021 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.
*/
export { LocalStoredShortcuts } from './LocalStoredShortcuts';
export { shortcutsApiRef } from './ShortcutApi';
export type { ShortcutApi } from './ShortcutApi';
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2021 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.
*/
export { shortcutsPlugin, Shortcuts } from './plugin';
export * from './api';
export type { Shortcut } from './types';
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2021 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 { shortcutsPlugin } from './plugin';
describe('shortcuts', () => {
it('should export plugin', () => {
expect(shortcutsPlugin).toBeDefined();
});
});
+41
View File
@@ -0,0 +1,41 @@
/*
* Copyright 2021 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 {
createApiFactory,
createComponentExtension,
createPlugin,
storageApiRef,
} from '@backstage/core';
import { LocalStoredShortcuts, shortcutsApiRef } from './api';
export const shortcutsPlugin = createPlugin({
id: 'shortcuts',
apis: [
createApiFactory({
api: shortcutsApiRef,
deps: { storageApi: storageApiRef },
factory: ({ storageApi }) =>
new LocalStoredShortcuts(storageApi.forBucket('shortcuts')),
}),
],
});
export const Shortcuts = shortcutsPlugin.provide(
createComponentExtension({
component: { lazy: () => import('./Shortcuts').then(m => m.Shortcuts) },
}),
);
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2021 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 '@testing-library/jest-dom';
import 'cross-fetch/polyfill';
+26
View File
@@ -0,0 +1,26 @@
/*
* Copyright 2021 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.
*/
export type Shortcut = {
id: string;
url: string;
title: string;
};
export type FormValues = {
url: string;
title: string;
};
+7 -1
View File
@@ -7029,7 +7029,7 @@
resolved "https://registry.npmjs.org/@types/yup/-/yup-0.29.11.tgz#d654a112973f5e004bf8438122bd7e56a8e5cd7e"
integrity sha512-9cwk3c87qQKZrT251EDoibiYRILjCmxBvvcb4meofCmx1vdnNcR9gyildy5vOHASpOKMsn42CugxUvcwK5eu1g==
"@types/zen-observable@^0.8.0":
"@types/zen-observable@^0.8.0", "@types/zen-observable@^0.8.2":
version "0.8.2"
resolved "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.2.tgz#808c9fa7e4517274ed555fa158f2de4b4f468e71"
integrity sha512-HrCIVMLjE1MOozVoD86622S7aunluLb2PJdPfb3nYiEtohm8mIB/vyv0Fd37AdeMFrTUQXEunw78YloMA3Qilg==
@@ -18668,6 +18668,7 @@ minipass-fetch@^1.3.0, minipass-fetch@^1.3.2:
resolved "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.3.3.tgz#34c7cea038c817a8658461bf35174551dce17a0a"
integrity sha512-akCrLDWfbdAWkMLBxJEeWTdNsjML+dt5YgOI4gJ53vuO0vrmYQkUPxa6j6V65s9CcePIr2SSWqjT2EcrNseryQ==
dependencies:
encoding "^0.1.12"
minipass "^3.1.0"
minipass-sized "^1.0.3"
minizlib "^2.0.0"
@@ -22027,6 +22028,11 @@ react-hook-form@^6.15.4:
resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-6.15.4.tgz#328003e1ccc096cd158899ffe7e3b33735a9b024"
integrity sha512-K+Sw33DtTMengs8OdqFJI3glzNl1wBzSefD/ksQw/hJf9CnOHQAU6qy82eOrh0IRNt2G53sjr7qnnw1JDjvx1w==
react-hook-form@^7.1.1:
version "7.2.1"
resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.2.1.tgz#99b3540dd2314499df12e9a53c70587ad63a806c"
integrity sha512-QopAubhVofqQrwlWLr9aK0DF8tNU8fnU8sJIlw1Tb3tGkEvP9yeaA+cx1hlxYni8xBswtHruL1WcDEa6CYQDow==
react-hot-loader@^4.12.21:
version "4.13.0"
resolved "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.13.0.tgz#c27e9408581c2a678f5316e69c061b226dc6a202"