diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 1987dea1f5..5cdb6b715e 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -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", diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index 01d821e175..d018d59837 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -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( - + , ); expect(await rendered.findByText('Your components')).toBeInTheDocument(); diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index 9972b9d71f..7889c5e0c6 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -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 = ({ componentFactory }) => { const { value, error, loading } = useAsync(componentFactory.getAllComponents); return ( diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 31f15fe5ca..ea23281262 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -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) => ( + {componentData.name} + ), }, ]; diff --git a/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.test.tsx b/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.test.tsx new file mode 100644 index 0000000000..cd9bb95b23 --- /dev/null +++ b/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.test.tsx @@ -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( + , + ); + const button = await menu.findByTestId('menu-button'); + button.click(); + const unregister = await menu.findByText('Unregister component'); + expect(unregister).toBeInTheDOM(); + }); + }); +}); diff --git a/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.tsx b/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.tsx new file mode 100644 index 0000000000..2895ebd0b0 --- /dev/null +++ b/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.tsx @@ -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 = ({ + onUnregisterComponent, +}) => { + const [menuOpen, setMenuOpen] = useState(false); + const menuAnchor = useRef(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 ( +
+ setMenuOpen(!menuOpen)} + data-testid="menu-button" + > + + + + + + + + Unregister component + + + + + + More repository + + +
+ ); +}; +export default ComponentContextMenu; diff --git a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx b/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx new file mode 100644 index 0000000000..e190ee6994 --- /dev/null +++ b/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx @@ -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( + , + ); + expect(await rendered.findByText('test')).toBeInTheDOM(); + }); + it('should display loader when loading is set to true', async () => { + const rendered = await render( + , + ); + expect(await rendered.findByRole('progressbar')).toBeInTheDOM(); + }); +}); diff --git a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx b/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx new file mode 100644 index 0000000000..9d606c5839 --- /dev/null +++ b/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx @@ -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 = ({ + loading, + component, +}) => { + if (loading) { + return ; + } + if (!component) { + return null; + } + return ( + + + + ); +}; +export default ComponentMetadataCard; diff --git a/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx b/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx new file mode 100644 index 0000000000..369b80de80 --- /dev/null +++ b/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx @@ -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( + + + , + ), + ); + 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( + + + , + ), + ); + expect(props.componentFactory.getComponentByName).toHaveBeenCalledWith( + 'test', + ); + }); + }); +}); diff --git a/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx b/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx new file mode 100644 index 0000000000..6348e88c4f --- /dev/null +++ b/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx @@ -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 = ({ + 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(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 ( + +
+ +
+ {confirmationDialogOpen && catalogRequest.value && ( + + )} + + + +
+ ); +}; +export default ComponentPage; diff --git a/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx b/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx new file mode 100644 index 0000000000..b5a863cef8 --- /dev/null +++ b/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx @@ -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 = ({ + onConfirm, + onCancel, + onClose, + component, +}) => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + return ( + + + Are you sure you want to unregister this component? + + + + This action will unregister {component.name}. To undo, just + re-register the component in Backstage. + + + + + + + + ); +}; +export default ComponentRemovalDialog; diff --git a/plugins/catalog/src/data/component.ts b/plugins/catalog/src/data/component.ts index 5b67b647e4..ec6e2368b8 100644 --- a/plugins/catalog/src/data/component.ts +++ b/plugins/catalog/src/data/component.ts @@ -20,4 +20,5 @@ export type Component = { export interface ComponentFactory { getAllComponents(): Promise; getComponentByName(name: string): Promise; + removeComponentByName(name: string): Promise; } diff --git a/plugins/catalog/src/data/mock-factory.ts b/plugins/catalog/src/data/mock-factory.ts index 010036d732..bf39fa436c 100644 --- a/plugins/catalog/src/data/mock-factory.ts +++ b/plugins/catalog/src/data/mock-factory.ts @@ -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 { - return new Promise((resolve) => setTimeout(() => resolve(mock), 2000)); + return new Promise((resolve) => + setTimeout(() => resolve(inMemoryStore), ARTIFICIAL_TIMEOUT), + ); }, getComponentByName(name: string): Promise { + 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 { 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), ); }, }; diff --git a/plugins/catalog/src/data/with-mock-store.tsx b/plugins/catalog/src/data/with-mock-store.tsx new file mode 100644 index 0000000000..2e5425d03e --- /dev/null +++ b/plugins/catalog/src/data/with-mock-store.tsx @@ -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) => ( + + ); +}; diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index ff3a53ce5b..456a6d2446 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -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)); }, });