diff --git a/plugins/catalog/src/api/CatalogClient.ts b/plugins/catalog/src/api/CatalogClient.ts index 659ca9bd16..5717735d9f 100644 --- a/plugins/catalog/src/api/CatalogClient.ts +++ b/plugins/catalog/src/api/CatalogClient.ts @@ -14,14 +14,14 @@ * limitations under the License. */ -import { CatalogApi } from './types'; -import { DescriptorEnvelope } from '../types'; import { Entity, Location, LOCATION_ANNOTATION, } from '@backstage/catalog-model'; import Cache from 'node-cache'; +import { DescriptorEnvelope } from '../types'; +import { CatalogApi, EntityCompoundName } from './types'; export class CatalogClient implements CatalogApi { // TODO(blam): This cache is just temporary until we have GraphQL. @@ -30,6 +30,7 @@ export class CatalogClient implements CatalogApi { private cache: Cache; private apiOrigin: string; private basePath: string; + constructor({ apiOrigin, basePath, @@ -41,46 +42,41 @@ export class CatalogClient implements CatalogApi { this.basePath = basePath; this.cache = new Cache({ stdTTL: 10 }); } + + private async getRequired(path: string): Promise { + const url = `${this.apiOrigin}${this.basePath}${path}`; + const response = await fetch(url); + + if (!response.ok) { + const payload = await response.text(); + const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`; + throw new Error(message); + } + + return await response.json(); + } + + private async getOptional(path: string): Promise { + const url = `${this.apiOrigin}${this.basePath}${path}`; + const response = await fetch(url); + + if (!response.ok) { + if (response.status === 404) { + return undefined; + } + + const payload = await response.text(); + const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`; + throw new Error(message); + } + + return await response.json(); + } + async getLocationById(id: String): Promise { - const response = await fetch( - `${this.apiOrigin}${this.basePath}/locations/${id}`, - ); - if (response.ok) { - const location = await response.json(); - if (location) return location.data; - } - return undefined; - } - async removeEntityByUid(uid: string): Promise { - const response = await fetch( - `${this.apiOrigin}${this.basePath}/entities/by-uid/${uid}`, - { - method: 'DELETE', - }, - ); - if (!response.ok) { - const payload = await response.text(); - throw new Error( - `Request failed with ${response.status} ${response.statusText}, ${payload}`, - ); - } - return undefined; - } - async removeLocationById(id: string): Promise { - const response = await fetch( - `${this.apiOrigin}${this.basePath}/locations/${id}`, - { - method: 'DELETE', - }, - ); - if (!response.ok) { - const payload = await response.text(); - throw new Error( - `Request failed with ${response.status} ${response.statusText}, ${payload}`, - ); - } - return undefined; + return await this.getOptional(`/locations/${id}`); } + async getEntities( filter?: Record, ): Promise { @@ -89,49 +85,25 @@ export class CatalogClient implements CatalogApi { ); if (cachedValue) return cachedValue; - let url = `${this.apiOrigin}${this.basePath}/entities`; + let path = `/entities`; if (filter) { - url += '?'; - url += Object.entries(filter) + path += '?'; + path += Object.entries(filter) .map( ([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`, ) .join('&'); } - const response = await fetch(url); - if (!response.ok) { - const payload = await response.text(); - throw new Error( - `Request failed with ${response.status} ${response.statusText}, ${payload}`, - ); - } - const value = await response.json(); - if (value?.length) { - this.cache.set(`get:${JSON.stringify(filter)}`, value); - } - - return value; + return await this.getRequired(path); } - async getEntity({ - name, - namespace, - kind, - }: { - name: string; - namespace?: string; - kind: string; - }): Promise { - const response = await fetch( - `${this.apiOrigin}${this.basePath}/entities/by-name/${kind}/${ - namespace ?? 'default' - }/${name}`, - ); - const entity = await response.json(); - if (entity) return entity; - throw new Error(`'Entity not found: ${name}`); + async getEntityByName( + compoundName: EntityCompoundName, + ): Promise { + const { kind, namespace = 'default', name } = compoundName; + return this.getOptional(`/entities/by-name/${kind}/${namespace}/${name}`); } async addLocation(type: string, target: string) { @@ -162,11 +134,10 @@ export class CatalogClient implements CatalogApi { } async getLocationByEntity(entity: Entity): Promise { - const locationId = entity.metadata.annotations?.[LOCATION_ANNOTATION]; - if (!locationId) return undefined; - - const location = this.getLocationById(locationId); - - return location; + const locationCompound = entity.metadata.annotations?.[LOCATION_ANNOTATION]; + const all: { data: Location }[] = await this.getRequired('/locations'); + return all + .map(r => r.data) + .find(l => locationCompound === `${l.type}:${l.target}`); } } diff --git a/plugins/catalog/src/api/types.ts b/plugins/catalog/src/api/types.ts index 8c71a5a113..3bd2c6de97 100644 --- a/plugins/catalog/src/api/types.ts +++ b/plugins/catalog/src/api/types.ts @@ -22,13 +22,17 @@ export const catalogApiRef = createApiRef({ 'Used by the Catalog plugin to make requests to accompanying backend', }); +export type EntityCompoundName = { + kind: string; + namespace?: string; + name: string; +}; + export interface CatalogApi { - getEntity(params: { - name: string; - namespace?: string; - kind: string; - }): Promise; getLocationById(id: String): Promise; + getEntityByName( + compoundName: EntityCompoundName, + ): Promise; getEntities(filter?: Record): Promise; addLocation(type: string, target: string): Promise; getLocationByEntity(entity: Entity): Promise; diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index 3d59ac5b9a..c5af3220c0 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { LocationSpec, Entity } from '@backstage/catalog-model'; +import { Entity, LocationSpec } from '@backstage/catalog-model'; import { Content, ContentHeader, @@ -27,21 +27,18 @@ import { SupportButton, useApi, } from '@backstage/core'; - import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder'; -import { Button, makeStyles, Typography, Link } from '@material-ui/core'; -import GitHub from '@material-ui/icons/GitHub'; -import StarOutline from '@material-ui/icons/StarBorder'; -import Star from '@material-ui/icons/Star'; - +import { Button, Link, makeStyles, Typography } from '@material-ui/core'; import Edit from '@material-ui/icons/Edit'; - +import GitHub from '@material-ui/icons/GitHub'; +import Star from '@material-ui/icons/Star'; +import StarOutline from '@material-ui/icons/StarBorder'; import React, { FC, useCallback, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { useAsync } from 'react-use'; import { catalogApiRef } from '../..'; import { defaultFilter, filterGroups, entityFilters } from '../../data/filters'; -import { entityToComponent, findLocationForEntityMeta } from '../../data/utils'; +import { findLocationForEntityMeta } from '../../data/utils'; import { useStarredEntities } from '../../hooks/useStarredEntites'; import { CatalogFilter, @@ -102,7 +99,7 @@ export const CatalogPage: FC<{}> = () => { if (!location) return; window.open(location.target, '_blank'); }, - hidden: location ? location?.type !== 'github' : true, + hidden: location?.type !== 'github', }; }, (rowData: Entity) => { @@ -125,7 +122,7 @@ export const CatalogPage: FC<{}> = () => { if (!location) return; window.open(createEditLink(location), '_blank'); }, - hidden: location ? location?.type !== 'github' : true, + hidden: location?.type !== 'github', }; }, (rowData: Entity) => { @@ -204,16 +201,7 @@ export const CatalogPage: FC<{}> = () => { { - return { - ...entityToComponent(val), - locationSpec: findLocationForEntityMeta(val.metadata), - }; - })) || - [] - } + entities={value || []} loading={loading} error={error} actions={actions} diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index ae6f919505..6d38319fe9 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -13,30 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import * as React from 'react'; -import { render } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; import { wrapInTestApp } from '@backstage/test-utils'; +import { render } from '@testing-library/react'; +import * as React from 'react'; import { CatalogTable } from './CatalogTable'; -import { Component } from '../../data/component'; -const components: Component[] = [ +const entites: Entity[] = [ { - name: 'component1', + apiVersion: 'backstage.io/v1beta1', kind: 'Component', metadata: { name: 'component1' }, - description: 'Placeholder', }, { - name: 'component2', + apiVersion: 'backstage.io/v1beta1', kind: 'Component', metadata: { name: 'component2' }, - description: 'Placeholder', }, { - name: 'component3', + apiVersion: 'backstage.io/v1beta1', kind: 'Component', metadata: { name: 'component3' }, - description: 'Placeholder', }, ]; @@ -46,7 +43,7 @@ describe('CatalogTable component', () => { wrapInTestApp( , @@ -63,13 +60,13 @@ describe('CatalogTable component', () => { wrapInTestApp( , ), ); expect( - await rendered.findByText(`Owned (${components.length})`), + await rendered.findByText(`Owned (${entites.length})`), ).toBeInTheDocument(); expect(await rendered.findByText('component1')).toBeInTheDocument(); expect(await rendered.findByText('component2')).toBeInTheDocument(); diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 483125e7f2..1e53cd4241 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -13,33 +13,33 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; import { Table, TableColumn } from '@backstage/core'; import { Link } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import React, { FC } from 'react'; import { generatePath, Link as RouterLink } from 'react-router-dom'; -import { Component } from '../../data/component'; import { entityRoute } from '../../routes'; const columns: TableColumn[] = [ { title: 'Name', - field: 'name', + field: 'metadata.name', highlight: true, - render: (componentData: any) => ( + render: (entity: any) => ( - {componentData.name} + {entity.metadata.name} ), }, @@ -49,12 +49,12 @@ const columns: TableColumn[] = [ }, { title: 'Description', - field: 'description', + field: 'metadata.description', }, ]; type CatalogTableProps = { - components: Component[]; + entities: Entity[]; titlePreamble: string; loading: boolean; error?: any; @@ -62,7 +62,7 @@ type CatalogTableProps = { }; export const CatalogTable: FC = ({ - components, + entities, loading, error, titlePreamble, @@ -88,8 +88,8 @@ export const CatalogTable: FC = ({ loadingType: 'linear', showEmptyDataSourceMessage: !loading, }} - title={`${titlePreamble} (${(components && components.length) || 0})`} - data={components} + title={`${titlePreamble} (${(entities && entities.length) || 0})`} + data={entities} actions={actions} /> ); diff --git a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx b/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx index 06b768d55f..ef12e52363 100644 --- a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx +++ b/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx @@ -13,29 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import ComponentMetadataCard from './ComponentMetadataCard'; +import { Entity } from '@backstage/catalog-model'; import { render } from '@testing-library/react'; -import { Entity } from '../../../../../packages/catalog-model/src/entity/Entity'; +import React from 'react'; +import { ComponentMetadataCard } from './ComponentMetadataCard'; describe('ComponentMetadataCard component', () => { it('should display component name if provided', async () => { const testEntity: Entity = { - apiVersion: '', + apiVersion: 'backstage.io/v1beta1', kind: 'Component', - metadata: { - name: 'test', - }, + metadata: { name: 'test' }, }; const rendered = await render( - , + , ); expect(await rendered.findByText('test')).toBeInTheDocument(); }); - it('should display loader when loading is set to true', async () => { - const rendered = await render( - , - ); - expect(await rendered.findByRole('progressbar')).toBeInTheDocument(); - }); }); diff --git a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx b/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx index 8f504545e3..60c9284121 100644 --- a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx +++ b/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx @@ -13,32 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; -import { Progress, InfoCard, StructuredMetadataTable } from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; +import { InfoCard, StructuredMetadataTable } from '@backstage/core'; +import React, { FC } from 'react'; -type ComponentMetadataCardProps = { - loading: boolean; - entity: Entity | undefined; +type Props = { + entity: Entity; }; -const ComponentMetadataCard: FC = ({ - loading, - entity, -}) => { - if (loading) { - return ( - - - - ); - } - if (!entity) { - return null; - } - return ( - - - - ); -}; -export default ComponentMetadataCard; + +export const ComponentMetadataCard: FC = ({ entity }) => ( + + + +); diff --git a/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx b/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx index e0d5c99e4d..75393bd10e 100644 --- a/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx +++ b/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx @@ -47,8 +47,8 @@ describe('ComponentPage', () => { [ catalogApiRef, ({ - async getEntity() {}, - } as unknown) as CatalogApi, + async getEntityByName() {}, + } as Partial) as CatalogApi, ], ])} > diff --git a/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx b/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx index 4274c3df07..247a13247f 100644 --- a/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx +++ b/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx @@ -13,26 +13,27 @@ * 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 ComponentMetadataCard from '../ComponentMetadataCard/ComponentMetadataCard'; + +import { Entity } from '@backstage/catalog-model'; import { Content, - Header, - pageTheme, - Page, - useApi, - ErrorApi, errorApiRef, + Header, HeaderTabs, + Page, + pageTheme, + Progress, + useApi, } from '@backstage/core'; -import ComponentContextMenu from '../ComponentContextMenu/ComponentContextMenu'; -import ComponentRemovalDialog from '../ComponentRemovalDialog/ComponentRemovalDialog'; - import { SentryIssuesWidget } from '@backstage/plugin-sentry'; import { Grid } from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import React, { FC, useEffect, useState } from 'react'; +import { useAsync } from 'react-use'; import { catalogApiRef } from '../..'; -import { Entity } from '@backstage/catalog-model'; +import { ComponentContextMenu } from '../ComponentContextMenu/ComponentContextMenu'; +import { ComponentMetadataCard } from '../ComponentMetadataCard/ComponentMetadataCard'; +import { ComponentRemovalDialog } from '../ComponentRemovalDialog/ComponentRemovalDialog'; const REDIRECT_DELAY = 1000; @@ -48,42 +49,59 @@ type ComponentPageProps = { }; }; -const ComponentPage: FC = ({ match, history }) => { - const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); - const [removingPending, setRemovingPending] = useState(false); - const showRemovalDialog = () => setConfirmationDialogOpen(true); - const hideRemovalDialog = () => setConfirmationDialogOpen(false); +function headerProps( + kind: string, + namespace: string | undefined, + name: string, + entity: Entity | undefined, +): { headerTitle: string; headerType: string } { + return { + headerTitle: `${name}${namespace ? ` in ${namespace}` : ''}`, + headerType: (() => { + let t = kind.toLowerCase(); + if (entity && entity.spec && 'type' in entity.spec) { + t += ' — '; + t += (entity.spec as { type: string }).type.toLowerCase(); + } + return t; + })(), + }; +} + +export const ComponentPage: FC = ({ match, history }) => { const { optionalNamespaceAndName, kind } = match.params; const [name, namespace] = optionalNamespaceAndName.split(':').reverse(); - const errorApi = useApi(errorApiRef); + const errorApi = useApi(errorApiRef); const catalogApi = useApi(catalogApiRef); - const { value: component, error, loading } = useAsync(async () => { - const entity = await catalogApi.getEntity({ name, namespace, kind }); - const location = await catalogApi.getLocationByEntity(entity); - return { ...entityToComponent(entity), location }; - }); + + const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); + const { value: entity, error, loading } = useAsync( + () => catalogApi.getEntityByName({ kind, namespace, name }), + [catalogApi, kind, namespace, name], + ); useEffect(() => { - if (error) { + if (!error && !loading && !entity) { errorApi.post(new Error('Component not found!')); setTimeout(() => { history.push('/'); }, REDIRECT_DELAY); } - }, [error, errorApi, history]); + }, [errorApi, history, error, loading, entity]); - if (name === '') { + if (!name) { history.push('/catalog'); return null; } const cleanUpAfterRemoval = async () => { setConfirmationDialogOpen(false); - setRemovingPending(true); history.push('/'); }; + const showRemovalDialog = () => setConfirmationDialogOpen(true); + // TODO - Replace with proper tabs implementation const tabs = [ { @@ -112,39 +130,56 @@ const ComponentPage: FC = ({ match, history }) => { }, ]; + const { headerTitle, headerType } = headerProps( + kind, + namespace, + name, + entity, + ); + return ( // TODO: Switch theme and type props based on component type (website, library, ...) -
- +
+ {entity && ( + + )}
- - {confirmationDialogOpen && entity && ( - + {loading && } + + {error && ( + + {error.toString()} + + )} + + {entity && ( + <> + + + + + + + + + + + + + + setConfirmationDialogOpen(false)} + /> + )} - - - - - - - - - - ); }; -export default ComponentPage; diff --git a/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx b/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx index 828965638e..0c2bd2d8dd 100644 --- a/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx +++ b/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx @@ -26,19 +26,17 @@ import { Typography, useMediaQuery, useTheme, - List, - ListItem, - ListItemText, } from '@material-ui/core'; import Alert from '@material-ui/lab/Alert'; import React, { FC } from 'react'; import { useAsync } from 'react-use'; import { AsyncState } from 'react-use/lib/useAsync'; import { catalogApiRef } from '../../api/types'; +import { alertApiRef } from '../../../../../packages/core-api/src/apis/definitions/AlertApi'; type ComponentRemovalDialogProps = { + open: boolean; onConfirm: () => any; - onCancel: () => any; onClose: () => any; entity: Entity; }; @@ -54,51 +52,51 @@ function useColocatedEntities(entity: Entity): AsyncState { } export const ComponentRemovalDialog: FC = ({ + open, onConfirm, - onCancel, onClose, entity, }) => { - const catalogApi = useApi(catalogApiRef); - const alertApi = useApi(alertApiRef); const { value: entities, loading, error } = useColocatedEntities(entity); const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const catalogApi = useApi(catalogApiRef); + const alertApi = useApi(alertApiRef); return ( - + Are you sure you want to unregister this component? {loading ? : null} {error ? ( - {error.toString()} + + {error.toString()} + ) : null} {entities ? ( <> This action will unregister the following entities: - - {entities.map(e => ( - - - - ))} - + +
    + {entities.map(e => ( +
  • {e.metadata.name}
  • + ))} +
+
That are located at the following location: - - - - - + +
    +
  • + {entities[0]?.metadata?.annotations?.[LOCATION_ANNOTATION]} +
  • +
+
To undo, just re-register the component in Backstage. @@ -106,7 +104,7 @@ export const ComponentRemovalDialog: FC = ({ ) : null}
- diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx index 4da68fe343..361b98d788 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx @@ -24,6 +24,7 @@ import { catalogApiRef } from '@backstage/plugin-catalog'; import { MemoryRouter } from 'react-router-dom'; const errorApi = { post: () => {} }; + const catalogApi: jest.Mocked = { /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ addLocation: jest.fn((_a, _b) => new Promise(() => {})), @@ -49,6 +50,7 @@ const setup = () => ({ , ), }); + describe('RegisterComponentPage', () => { afterEach(() => cleanup());