diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 610cd7f159..579b88053f 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -57,11 +57,14 @@ const SidebarLogo: FC<{}> = () => { return (
- - - {isOpen ? : } - - + + {isOpen ? : } +
); }; diff --git a/plugins/catalog/src/api/CatalogClient.ts b/plugins/catalog/src/api/CatalogClient.ts index 2f9ffe302c..9ea59aab48 100644 --- a/plugins/catalog/src/api/CatalogClient.ts +++ b/plugins/catalog/src/api/CatalogClient.ts @@ -45,14 +45,27 @@ export class CatalogClient implements CatalogApi { } return undefined; } - async getEntitiesByLocationId(id: string): Promise { - const response = await fetch( - `${this.apiOrigin}${this.basePath}/entities?${LOCATION_ANNOTATION}=${id}`, - ); - return await response.json(); - } - async getEntities(): Promise { - const response = await fetch(`${this.apiOrigin}${this.basePath}/entities`); + async getEntities( + filter?: Record, + ): Promise { + let url = `${this.apiOrigin}${this.basePath}/entities`; + if (filter) { + url += '?'; + url += 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}`, + ); + } + return await response.json(); } async getEntityByName(name: string): Promise { diff --git a/plugins/catalog/src/api/types.ts b/plugins/catalog/src/api/types.ts index 7d6c1d2b47..1cfe5736fb 100644 --- a/plugins/catalog/src/api/types.ts +++ b/plugins/catalog/src/api/types.ts @@ -24,9 +24,8 @@ export const catalogApiRef = createApiRef({ export interface CatalogApi { getLocationById(id: String): Promise; - getEntities(): Promise; + getEntities(filter?: Record): Promise; getEntityByName(name: string): Promise; - getEntitiesByLocationId(id: string): 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 ac8de59bd9..452254a3cf 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -14,35 +14,33 @@ * limitations under the License. */ -import React, { FC, useCallback, useState } from 'react'; import { Content, ContentHeader, DismissableBanner, Header, + HeaderTabs, HomepageTimer, - SupportButton, Page, pageTheme, + SupportButton, useApi, - HeaderTabs, } from '@backstage/core'; +import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder'; +import { Button, Link, makeStyles, Typography } from '@material-ui/core'; +import GitHub from '@material-ui/icons/GitHub'; +import React, { FC, useCallback, useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; import { useAsync } from 'react-use'; -import CatalogTable from '../CatalogTable/CatalogTable'; +import { catalogApiRef } from '../..'; +import { Component } from '../../data/component'; +import { defaultFilter, filterGroups } from '../../data/filters'; +import { entityToComponent, findLocationForEntityMeta } from '../../data/utils'; import { CatalogFilter, CatalogFilterItem, } from '../CatalogFilter/CatalogFilter'; -import { Button, makeStyles, Typography, Link } from '@material-ui/core'; -import { filterGroups, defaultFilter } from '../../data/filters'; -import { Link as RouterLink } from 'react-router-dom'; -import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder'; -import GitHub from '@material-ui/icons/GitHub'; -import { - Entity, - Location, - LOCATION_ANNOTATION, -} from '@backstage/catalog-model'; +import CatalogTable from '../CatalogTable/CatalogTable'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -57,10 +55,6 @@ const useStyles = makeStyles(theme => ({ }, })); -import { catalogApiRef } from '../..'; -import { entityToComponent, findLocationForEntity } from '../../data/utils'; -import { Component } from '../../data/component'; - const CatalogPage: FC<{}> = () => { const catalogApi = useApi(catalogApiRef); const { value, error, loading } = useAsync(() => catalogApi.getEntities()); @@ -74,37 +68,19 @@ const CatalogPage: FC<{}> = () => { ); const styles = useStyles(); - const { value: locations } = useAsync(async () => { - const getLocationDataForEntities = async (entities: Entity[]) => { - return Promise.all( - entities.map(entity => { - const locationId = entity.metadata.annotations?.[LOCATION_ANNOTATION]; - if (!locationId) return undefined; - - return catalogApi.getLocationById(locationId); - }), - ); - }; - - if (value) { - return getLocationDataForEntities(value).then( - (location): Location[] => - location.filter(loc => !!loc) as Array, - ); - } - return []; - }, [value, catalogApi, catalogApi]); const actions = [ - (rowData: Component) => ({ - icon: GitHub, - tooltip: 'View on GitHub', - onClick: () => { - if (!rowData || !rowData.location) return; - window.open(rowData.location.target, '_blank'); - }, - hidden: - rowData && rowData.location ? rowData.location.type !== 'github' : true, - }), + (rowData: Component) => { + const location = findLocationForEntityMeta(rowData.metadata); + return { + icon: GitHub, + tooltip: 'View on GitHub', + onClick: () => { + if (!location) return; + window.open(location.target, '_blank'); + }, + hidden: location ? location?.type !== 'github' : true, + }; + }, ]; // TODO: replace me with the proper tabs implemntation @@ -171,24 +147,22 @@ const CatalogPage: FC<{}> = () => { onSelectedChange={onFilterSelected} /> - {locations && ( - { - return { - ...entityToComponent(val), - location: findLocationForEntity(val, locations), - }; - })) || - [] - } - loading={loading} - error={error} - actions={actions} - /> - )} + { + return { + ...entityToComponent(val), + locationSpec: findLocationForEntityMeta(val.metadata), + }; + })) || + [] + } + 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 029fad5474..ac7abe235a 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -20,9 +20,24 @@ import CatalogTable from './CatalogTable'; import { Component } from '../../data/component'; const components: Component[] = [ - { name: 'component1', kind: 'Component', description: 'Placeholder' }, - { name: 'component2', kind: 'Component', description: 'Placeholder' }, - { name: 'component3', kind: 'Component', description: 'Placeholder' }, + { + name: 'component1', + kind: 'Component', + metadata: { name: 'component1' }, + description: 'Placeholder', + }, + { + name: 'component2', + kind: 'Component', + metadata: { name: 'component2' }, + description: 'Placeholder', + }, + { + name: 'component3', + kind: 'Component', + metadata: { name: 'component3' }, + description: 'Placeholder', + }, ]; describe('CatalogTable component', () => { @@ -48,7 +63,7 @@ describe('CatalogTable component', () => { ), ); const errorMessage = await rendered.findByText( - 'Error encountered while fetching components.', + /Error encountered while fetching components./, ); expect(errorMessage).toBeInTheDocument(); }); diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 7ff521ee67..e918cc13aa 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -13,11 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { Progress, 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 { InfoCard, Progress, Table, TableColumn } from '@backstage/core'; -import { Typography, Link } from '@material-ui/core'; -import { Link as RouterLink, generatePath } from 'react-router-dom'; import { entityRoute } from '../../routes'; const columns: TableColumn[] = [ @@ -51,6 +52,7 @@ type CatalogTableProps = { error?: any; actions?: any; }; + const CatalogTable: FC = ({ components, loading, @@ -60,16 +62,16 @@ const CatalogTable: FC = ({ }) => { if (loading) { return ; - } - if (error) { + } else if (error) { return ( - - - Error encountered while fetching components. - - +
+ + Error encountered while fetching components. {error.toString()} + +
); } + return ( = ({ /> ); }; + export default CatalogTable; diff --git a/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.tsx b/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.tsx index 674d979f83..66c234e55e 100644 --- a/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.tsx +++ b/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.tsx @@ -13,22 +13,24 @@ * 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, + MenuList, + Popover, Typography, } from '@material-ui/core'; import Cancel from '@material-ui/icons/Cancel'; import MoreVert from '@material-ui/icons/MoreVert'; import SwapHoriz from '@material-ui/icons/SwapHoriz'; +import React, { FC, useState } from 'react'; import { makeStyles } from '@material-ui/core/styles'; +// TODO(freben): It should probably instead be the case that Header sets the theme text color to white inside itself unconditionally instead const useStyles = makeStyles({ - menu: { - marginTop: 52, + button: { + color: 'white', }, }); @@ -39,52 +41,58 @@ type ComponentContextMenuProps = { const ComponentContextMenu: FC = ({ onUnregisterComponent, }) => { - const [menuOpen, setMenuOpen] = useState(false); - const menuAnchor = useRef(null); + const [anchorEl, setAnchorEl] = useState(); const classes = useStyles(); - useEffect(() => { - const globalCloseHandler = (event: any) => { - const menu = menuAnchor.current; - if (menu !== null && !menu.contains(event.target)) { - setMenuOpen(false); - } - }; + const onOpen = (event: React.SyntheticEvent) => { + setAnchorEl(event.currentTarget); + }; - window.addEventListener('click', globalCloseHandler); - return () => window.removeEventListener('click', globalCloseHandler); - }, [menuOpen]); + const onClose = () => { + setAnchorEl(undefined); + }; return ( -
+
setMenuOpen(!menuOpen)} + onClick={onOpen} data-testid="menu-button" + className={classes.button} > - - - - - - Unregister component - - - - - - More repository - - + + { + onClose(); + onUnregisterComponent(); + }} + > + + + + Unregister component + + + + + + Move repository + + +
); }; + export default ComponentContextMenu; diff --git a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx b/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx index 62e3d52c29..5b6e7dfe07 100644 --- a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx +++ b/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx @@ -23,6 +23,7 @@ describe('ComponentMetadataCard component', () => { const testComponent: Component = { name: 'test', kind: 'Component', + metadata: { name: 'test' }, description: 'Placeholder', }; const rendered = await render( diff --git a/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx b/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx index 7dde95c8f0..59c79cddc6 100644 --- a/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx +++ b/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; + +import { Entity, LOCATION_ANNOTATION } from '@backstage/catalog-model'; +import { Progress, useApi } from '@backstage/core'; import { Button, Dialog, @@ -21,14 +23,16 @@ import { DialogContent, DialogContentText, DialogTitle, + Typography, useMediaQuery, useTheme, } from '@material-ui/core'; -import { Component } from '../../data/component'; +import Alert from '@material-ui/lab/Alert'; +import React, { FC } from 'react'; import { useAsync } from 'react-use'; -import { useApi } from '@backstage/core'; +import { AsyncState } from 'react-use/lib/useAsync'; import { catalogApiRef } from '../../api/types'; -import { Entity } from '@backstage/catalog-model'; +import { Component } from '../../data/component'; type ComponentRemovalDialogProps = { onConfirm: () => any; @@ -36,44 +40,81 @@ type ComponentRemovalDialogProps = { onClose: () => any; component: Component; }; + +function useColocatedEntities(component: Component): AsyncState { + const catalogApi = useApi(catalogApiRef); + return useAsync(async () => { + const myLocation = component.metadata.annotations?.[LOCATION_ANNOTATION]; + return myLocation + ? await catalogApi.getEntities({ [LOCATION_ANNOTATION]: myLocation }) + : []; + }, [catalogApi, component]); +} + const ComponentRemovalDialog: FC = ({ onConfirm, onCancel, onClose, component, }) => { - const catalogApi = useApi(catalogApiRef); - const { value } = useAsync(async () => { - let colocatedEntities: Array = []; - const locationId = component.location?.id; - if (locationId) { - colocatedEntities = await catalogApi.getEntitiesByLocationId(locationId); - } - return colocatedEntities; - }); + const { value: entities, loading, error } = useColocatedEntities(component); const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); - const infoMessage = `This action will unregister ${ - value ? value.map(e => e.metadata.name).join(', ') : '' - } from location with target ${component.location?.target}. To undo, - just re-register the component in Backstage.`; + return ( Are you sure you want to unregister this component? - {infoMessage} + {loading ? : null} + {error ? ( + + + {error.toString()} + + + ) : null} + {entities ? ( + <> + + This action will unregister the following entities: + + +
    + {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. + + + ) : null}
- - +
); }; + export default ComponentRemovalDialog; diff --git a/plugins/catalog/src/data/component.ts b/plugins/catalog/src/data/component.ts index 73b349391b..86749c6faa 100644 --- a/plugins/catalog/src/data/component.ts +++ b/plugins/catalog/src/data/component.ts @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { EntityMeta } from '@backstage/catalog-model'; import { ReactNode } from 'react'; -import { Location } from '@backstage/catalog-model'; export type Component = { name: string; kind: string; + metadata: EntityMeta; description: ReactNode; - location?: Location; }; diff --git a/plugins/catalog/src/data/utils.tsx b/plugins/catalog/src/data/utils.tsx index 93d28f5ca2..3e8e0458d6 100644 --- a/plugins/catalog/src/data/utils.tsx +++ b/plugins/catalog/src/data/utils.tsx @@ -13,23 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { Component } from './component'; import { Entity, - Location, + LocationSpec, LOCATION_ANNOTATION, + EntityMeta, } from '@backstage/catalog-model'; -import Edit from '@material-ui/icons/Edit'; import IconButton from '@material-ui/core/IconButton'; import { styled } from '@material-ui/core/styles'; +import Edit from '@material-ui/icons/Edit'; +import React from 'react'; +import { Component } from './component'; const DescriptionWrapper = styled('span')({ display: 'flex', alignItems: 'center', }); -const createEditLink = (location: Location): string => { +const createEditLink = (location: LocationSpec): string => { switch (location.type) { case 'github': return location.target.replace('/blob/', '/edit/'); @@ -38,13 +39,12 @@ const createEditLink = (location: Location): string => { } }; -export function entityToComponent( - envelope: Entity, - location?: Location, -): Component { +export function entityToComponent(envelope: Entity): Component { + const location = findLocationForEntityMeta(envelope.metadata); return { name: envelope.metadata?.name ?? '', kind: envelope.kind ?? 'unknown', + metadata: envelope.metadata, description: ( {envelope.metadata?.annotations?.description ?? 'placeholder'} @@ -57,18 +57,28 @@ export function entityToComponent( ) : null} ), - location, }; } -export function findLocationForEntity( - entity: Entity, - locations: Location[], -): Location | undefined { - for (const loc of locations) { - if (loc.id === entity.metadata.annotations?.[LOCATION_ANNOTATION]) { - return loc; - } +export function findLocationForEntityMeta( + meta: EntityMeta, +): LocationSpec | undefined { + if (!meta) { + return undefined; } - return undefined; + + const annotation = meta.annotations?.[LOCATION_ANNOTATION]; + if (!annotation) { + return undefined; + } + + const separatorIndex = annotation.indexOf(':'); + if (separatorIndex === -1) { + return undefined; + } + + return { + type: annotation.substring(0, separatorIndex), + target: annotation.substring(separatorIndex + 1), + }; } diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx index ca463550d1..48130124b7 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx @@ -31,7 +31,6 @@ const catalogApi: jest.Mocked = { getEntityByName: jest.fn(), getLocationByEntity: jest.fn(), getLocationById: jest.fn(), - getEntitiesByLocationId: jest.fn(), }; const setup = () => ({