(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"
+ >
+
+
+
+
+ );
+};
+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 (
+
+ );
+};
+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));
},
});
diff --git a/yarn.lock b/yarn.lock
index adf2cb670f..3e60308ab5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4358,9 +4358,9 @@
immutable ">=3.8.2"
"@types/react-router-dom@^5.1.3":
- version "5.1.3"
- resolved "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.3.tgz#b5d28e7850bd274d944c0fbbe5d57e6b30d71196"
- integrity sha512-pCq7AkOvjE65jkGS5fQwQhvUp4+4PVD9g39gXLZViP2UqFiFzsEpB3PKf0O6mdbKsewSK8N14/eegisa/0CwnA==
+ version "5.1.5"
+ resolved "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.5.tgz#7c334a2ea785dbad2b2dcdd83d2cf3d9973da090"
+ integrity sha512-ArBM4B1g3BWLGbaGvwBGO75GNFbLDUthrDojV2vHLih/Tq8M+tgvY1DSwkuNrPSwdp/GUL93WSEpTZs8nVyJLw==
dependencies:
"@types/history" "*"
"@types/react" "*"