Add component page with unregister button (#866)

* Add component page with unregister button

* Use errorApi and redirect on ComponentPage

Co-authored-by: Wojciech Adaszynski <wojciecha@spotify.com>
This commit is contained in:
Wojciech Adaszyński
2020-05-15 15:40:15 +02:00
committed by GitHub
parent e7c8369c32
commit 9fc9f972fc
15 changed files with 509 additions and 12 deletions
+1
View File
@@ -28,6 +28,7 @@
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.5",
"@backstage/dev-utils": "^0.1.1-alpha.5",
"@backstage/test-utils": "^0.1.1-alpha.5",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
@@ -19,12 +19,19 @@ import { render } from '@testing-library/react';
import CatalogPage from './CatalogPage';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { ComponentFactory } from '../../data/component';
const testComponentFactory: ComponentFactory = {
getAllComponents: jest.fn(() => Promise.resolve([{ name: 'test' }])),
getComponentByName: jest.fn(() => Promise.resolve({ name: 'test' })),
removeComponentByName: jest.fn(() => Promise.resolve(true)),
};
describe('CatalogPage', () => {
it('should render', async () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<CatalogPage />
<CatalogPage componentFactory={testComponentFactory} />
</ThemeProvider>,
);
expect(await rendered.findByText('Your components')).toBeInTheDocument();
@@ -18,12 +18,12 @@ import React, { FC } from 'react';
import { Content, Header, Page, pageTheme } from '@backstage/core';
import { useAsync } from 'react-use';
import { ComponentFactory } from '../../data/component';
import { MockComponentFactory } from '../../data/mock-factory';
import CatalogTable from '../CatalogTable/CatalogTable';
const componentFactory: ComponentFactory = MockComponentFactory;
const CatalogPage: FC<{}> = () => {
type CatalogPageProps = {
componentFactory: ComponentFactory;
};
const CatalogPage: FC<CatalogPageProps> = ({ componentFactory }) => {
const { value, error, loading } = useAsync(componentFactory.getAllComponents);
return (
<Page theme={pageTheme.home}>
@@ -16,13 +16,16 @@
import React, { FC } from 'react';
import { Component } from '../../data/component';
import { InfoCard, Progress, Table, TableColumn } from '@backstage/core';
import { Typography } from '@material-ui/core';
import { Typography, Link } from '@material-ui/core';
const columns: TableColumn[] = [
{
title: 'Name',
field: 'name',
highlight: true,
render: (componentData: any) => (
<Link href={`/catalog/${componentData.name}`}>{componentData.name}</Link>
),
},
];
@@ -0,0 +1,34 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ComponentContextMenu from './ComponentContextMenu';
import { render } from '@testing-library/react';
import * as React from 'react';
import { act } from 'react-dom/test-utils';
describe('ComponentContextMenu', () => {
it('should call onUnregisterComponent on button click', async () => {
await act(async () => {
const mockCallback = jest.fn();
const menu = await render(
<ComponentContextMenu onUnregisterComponent={mockCallback} />,
);
const button = await menu.findByTestId('menu-button');
button.click();
const unregister = await menu.findByText('Unregister component');
expect(unregister).toBeInTheDOM();
});
});
});
@@ -0,0 +1,88 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC, useEffect, useRef, useState } from 'react';
import {
IconButton,
ListItemIcon,
Menu,
MenuItem,
Typography,
} from '@material-ui/core';
import { Cancel, MoreVert, SwapHoriz } from '@material-ui/icons';
import { makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles({
menu: {
marginTop: 52,
},
});
type ComponentContextMenuProps = {
onUnregisterComponent: () => void;
};
const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
onUnregisterComponent,
}) => {
const [menuOpen, setMenuOpen] = useState(false);
const menuAnchor = useRef<HTMLDivElement>(null);
const classes = useStyles();
useEffect(() => {
const globalCloseHandler = (event: any) => {
const menu = menuAnchor.current;
if (menu !== null && !menu.contains(event.target)) {
setMenuOpen(false);
}
};
window.addEventListener('click', globalCloseHandler);
return () => window.removeEventListener('click', globalCloseHandler);
}, [menuOpen]);
return (
<div ref={menuAnchor}>
<IconButton
aria-label="more"
aria-controls="long-menu"
aria-haspopup="true"
onClick={() => setMenuOpen(!menuOpen)}
data-testid="menu-button"
>
<MoreVert />
</IconButton>
<Menu
open={menuOpen}
anchorEl={menuAnchor.current}
className={classes.menu}
>
<MenuItem onClick={onUnregisterComponent}>
<ListItemIcon>
<Cancel fontSize="small" />
</ListItemIcon>
<Typography variant="inherit">Unregister component</Typography>
</MenuItem>
<MenuItem>
<ListItemIcon>
<SwapHoriz fontSize="small" />
</ListItemIcon>
<Typography variant="inherit">More repository</Typography>
</MenuItem>
</Menu>
</div>
);
};
export default ComponentContextMenu;
@@ -0,0 +1,37 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import ComponentMetadataCard from './ComponentMetadataCard';
import { Component } from '../../data/component';
import { render } from '@testing-library/react';
describe('ComponentMetadataCard component', () => {
it('should display component name if provided', async () => {
const testComponent: Component = {
name: 'test',
};
const rendered = await render(
<ComponentMetadataCard loading={false} component={testComponent} />,
);
expect(await rendered.findByText('test')).toBeInTheDOM();
});
it('should display loader when loading is set to true', async () => {
const rendered = await render(
<ComponentMetadataCard loading component={undefined} />,
);
expect(await rendered.findByRole('progressbar')).toBeInTheDOM();
});
});
@@ -0,0 +1,40 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
import { Component } from '../../data/component';
import { Progress, InfoCard, StructuredMetadataTable } from '@backstage/core';
type ComponentMetadataCardProps = {
loading: boolean;
component: Component | undefined;
};
const ComponentMetadataCard: FC<ComponentMetadataCardProps> = ({
loading,
component,
}) => {
if (loading) {
return <Progress />;
}
if (!component) {
return null;
}
return (
<InfoCard title="Metadata">
<StructuredMetadataTable metadata={component} />
</InfoCard>
);
};
export default ComponentMetadataCard;
@@ -0,0 +1,70 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ComponentPage from './ComponentPage';
import { render } from '@testing-library/react';
import * as React from 'react';
import { wrapInTheme } from '@backstage/test-utils';
import { act } from 'react-dom/test-utils';
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
const getTestProps = (componentName: string) => {
return {
match: {
params: {
name: componentName,
},
},
history: {
push: jest.fn(),
},
componentFactory: {
getAllComponents: jest.fn(() => Promise.resolve([{ name: 'test' }])),
getComponentByName: jest.fn(() => Promise.resolve({ name: 'test' })),
removeComponentByName: jest.fn(() => Promise.resolve(true)),
},
};
};
const errorApi = { post: () => {} };
describe('ComponentPage', () => {
it('should redirect to component table page when name is not provided', async () => {
const props = getTestProps('');
await render(
wrapInTheme(
<ApiProvider apis={ApiRegistry.from([[errorApiRef, errorApi]])}>
<ComponentPage {...props} />
</ApiProvider>,
),
);
expect(props.history.push).toHaveBeenCalledWith('/catalog');
});
it('should use factory to fetch component by name and display it', async () => {
await act(async () => {
const props = getTestProps('test');
await render(
wrapInTheme(
<ApiProvider apis={ApiRegistry.from([[errorApiRef, errorApi]])}>
<ComponentPage {...props} />
</ApiProvider>,
),
);
expect(props.componentFactory.getComponentByName).toHaveBeenCalledWith(
'test',
);
});
});
});
@@ -0,0 +1,105 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC, useEffect, useState } from 'react';
import { useAsync } from 'react-use';
import { ComponentFactory } from '../../data/component';
import ComponentMetadataCard from '../ComponentMetadataCard/ComponentMetadataCard';
import {
Content,
Header,
pageTheme,
Page,
useApi,
ErrorApi,
errorApiRef,
} from '@backstage/core';
import ComponentContextMenu from '../ComponentContextMenu/ComponentContextMenu';
import ComponentRemovalDialog from '../ComponentRemovalDialog/ComponentRemovalDialog';
const REDIRECT_DELAY = 1000;
type ComponentPageProps = {
componentFactory: ComponentFactory;
match: {
params: {
name: string;
};
};
history: {
push: (url: string) => void;
};
};
const ComponentPage: FC<ComponentPageProps> = ({
match,
history,
componentFactory,
}) => {
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const [removingPending, setRemovingPending] = useState(false);
const showRemovalDialog = () => setConfirmationDialogOpen(true);
const hideRemovalDialog = () => setConfirmationDialogOpen(false);
const componentName = match.params.name;
const errorApi = useApi<ErrorApi>(errorApiRef);
if (componentName === '') {
history.push('/catalog');
return null;
}
const catalogRequest = useAsync(() =>
componentFactory.getComponentByName(match.params.name),
);
useEffect(() => {
if (catalogRequest.error) {
errorApi.post(new Error('Component not found!'));
setTimeout(() => {
history.push('/catalog');
}, REDIRECT_DELAY);
}
}, [catalogRequest.error]);
const removeComponent = async () => {
setConfirmationDialogOpen(false);
setRemovingPending(true);
await componentFactory.removeComponentByName(componentName);
history.push('/catalog');
};
return (
<Page theme={pageTheme.home}>
<Header title={catalogRequest?.value?.name || 'Catalog'}>
<ComponentContextMenu onUnregisterComponent={showRemovalDialog} />
</Header>
{confirmationDialogOpen && catalogRequest.value && (
<ComponentRemovalDialog
component={catalogRequest.value}
onClose={hideRemovalDialog}
onConfirm={removeComponent}
onCancel={hideRemovalDialog}
/>
)}
<Content>
<ComponentMetadataCard
loading={catalogRequest.loading || removingPending}
component={catalogRequest.value}
/>
</Content>
</Page>
);
};
export default ComponentPage;
@@ -0,0 +1,65 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
useMediaQuery,
useTheme,
} from '@material-ui/core';
import { Component } from '../../data/component';
type ComponentRemovalDialogProps = {
onConfirm: () => any;
onCancel: () => any;
onClose: () => any;
component: Component;
};
const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
onConfirm,
onCancel,
onClose,
component,
}) => {
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
return (
<Dialog fullScreen={fullScreen} open onClose={onClose}>
<DialogTitle id="responsive-dialog-title">
Are you sure you want to unregister this component?
</DialogTitle>
<DialogContent>
<DialogContentText>
This action will unregister {component.name}. To undo, just
re-register the component in Backstage.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} color="primary">
Cancel
</Button>
<Button onClick={onConfirm} color="primary">
Unregister
</Button>
</DialogActions>
</Dialog>
);
};
export default ComponentRemovalDialog;
+1
View File
@@ -20,4 +20,5 @@ export type Component = {
export interface ComponentFactory {
getAllComponents(): Promise<Component[]>;
getComponentByName(name: string): Promise<Component | undefined>;
removeComponentByName(name: string): Promise<boolean>;
}
+22 -5
View File
@@ -16,16 +16,33 @@
import { Component, ComponentFactory } from './component';
import mock from './mock-factory-data.json';
const ARTIFICIAL_TIMEOUT = 800;
let inMemoryStore = [...mock];
export const MockComponentFactory: ComponentFactory = {
getAllComponents(): Promise<Component[]> {
return new Promise((resolve) => setTimeout(() => resolve(mock), 2000));
return new Promise((resolve) =>
setTimeout(() => resolve(inMemoryStore), ARTIFICIAL_TIMEOUT),
);
},
getComponentByName(name: string): Promise<Component | undefined> {
return new Promise((resolve, reject) =>
setTimeout(() => {
const mockComponent = inMemoryStore.find(
(component) => component.name === name,
);
if (mockComponent) return resolve(mockComponent);
return reject({ code: 'Component not found!' });
}, ARTIFICIAL_TIMEOUT),
);
},
removeComponentByName(name: string): Promise<boolean> {
return new Promise((resolve) =>
setTimeout(
() => resolve(mock.find((component) => component.name === name)),
2000,
),
setTimeout(() => {
inMemoryStore = inMemoryStore.filter(
(component) => component.name !== name,
);
resolve(true);
}, ARTIFICIAL_TIMEOUT),
);
},
};
@@ -0,0 +1,26 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as React from 'react';
import { ComponentFactory } from './component';
import { MockComponentFactory } from './mock-factory';
const componentFactory: ComponentFactory = MockComponentFactory;
export const withMockStore = (Component: React.ElementType) => {
return (props: any) => (
<Component {...props} componentFactory={componentFactory} />
);
};
+4 -1
View File
@@ -16,10 +16,13 @@
import { createPlugin } from '@backstage/core';
import CatalogPage from './components/CatalogPage';
import ComponentPage from './components/ComponentPage/ComponentPage';
import { withMockStore } from './data/with-mock-store';
export const plugin = createPlugin({
id: 'catalog',
register({ router }) {
router.registerRoute('/catalog', CatalogPage);
router.registerRoute('/catalog', withMockStore(CatalogPage));
router.registerRoute('/catalog/:name/', withMockStore(ComponentPage));
},
});