Merge pull request #5392 from backstage/eide/shortcut-plugin
[plugin]: Shortcuts plugin for the Sidebar
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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()),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -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(),
|
||||
}),
|
||||
];
|
||||
```
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) },
|
||||
}),
|
||||
);
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user