Merge pull request #1199 from spotify/freben/unregister

Unbreak the unregister dialog after catalog changes
This commit is contained in:
Fredrik Adelöw
2020-06-09 10:33:15 +02:00
committed by GitHub
12 changed files with 238 additions and 174 deletions
+8 -5
View File
@@ -57,11 +57,14 @@ const SidebarLogo: FC<{}> = () => {
return (
<div className={classes.root}>
<NavLink to="/">
<Link underline="none" className={classes.link}>
{isOpen ? <LogoFull /> : <LogoIcon />}
</Link>
</NavLink>
<Link
component={NavLink}
to="/"
underline="none"
className={classes.link}
>
{isOpen ? <LogoFull /> : <LogoIcon />}
</Link>
</div>
);
};
+21 -8
View File
@@ -45,14 +45,27 @@ export class CatalogClient implements CatalogApi {
}
return undefined;
}
async getEntitiesByLocationId(id: string): Promise<Entity[]> {
const response = await fetch(
`${this.apiOrigin}${this.basePath}/entities?${LOCATION_ANNOTATION}=${id}`,
);
return await response.json();
}
async getEntities(): Promise<DescriptorEnvelope[]> {
const response = await fetch(`${this.apiOrigin}${this.basePath}/entities`);
async getEntities(
filter?: Record<string, string>,
): Promise<DescriptorEnvelope[]> {
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<DescriptorEnvelope> {
+1 -2
View File
@@ -24,9 +24,8 @@ export const catalogApiRef = createApiRef<CatalogApi>({
export interface CatalogApi {
getLocationById(id: String): Promise<Location | undefined>;
getEntities(): Promise<Entity[]>;
getEntities(filter?: Record<string, string>): Promise<Entity[]>;
getEntityByName(name: string): Promise<Entity>;
getEntitiesByLocationId(id: string): Promise<Entity[]>;
addLocation(type: string, target: string): Promise<AddLocationResponse>;
getLocationByEntity(entity: Entity): Promise<Location | undefined>;
}
@@ -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<Location>,
);
}
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}
/>
</div>
{locations && (
<CatalogTable
titlePreamble={selectedFilter.label}
components={
(value &&
value.map(val => {
return {
...entityToComponent(val),
location: findLocationForEntity(val, locations),
};
})) ||
[]
}
loading={loading}
error={error}
actions={actions}
/>
)}
<CatalogTable
titlePreamble={selectedFilter.label}
components={
(value &&
value.map(val => {
return {
...entityToComponent(val),
locationSpec: findLocationForEntityMeta(val.metadata),
};
})) ||
[]
}
loading={loading}
error={error}
actions={actions}
/>
</div>
</Content>
</Page>
@@ -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();
});
@@ -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<CatalogTableProps> = ({
components,
loading,
@@ -60,16 +62,16 @@ const CatalogTable: FC<CatalogTableProps> = ({
}) => {
if (loading) {
return <Progress />;
}
if (error) {
} else if (error) {
return (
<InfoCard>
<Typography variant="subtitle1" paragraph>
Error encountered while fetching components.
</Typography>
</InfoCard>
<div>
<Alert severity="error">
Error encountered while fetching components. {error.toString()}
</Alert>
</div>
);
}
return (
<Table
columns={columns}
@@ -80,4 +82,5 @@ const CatalogTable: FC<CatalogTableProps> = ({
/>
);
};
export default CatalogTable;
@@ -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<ComponentContextMenuProps> = ({
onUnregisterComponent,
}) => {
const [menuOpen, setMenuOpen] = useState(false);
const menuAnchor = useRef<HTMLDivElement>(null);
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
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<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
window.addEventListener('click', globalCloseHandler);
return () => window.removeEventListener('click', globalCloseHandler);
}, [menuOpen]);
const onClose = () => {
setAnchorEl(undefined);
};
return (
<div ref={menuAnchor}>
<div>
<IconButton
aria-label="more"
aria-controls="long-menu"
aria-haspopup="true"
onClick={() => setMenuOpen(!menuOpen)}
onClick={onOpen}
data-testid="menu-button"
className={classes.button}
>
<MoreVert />
</IconButton>
<Menu
open={menuOpen}
anchorEl={menuAnchor.current}
className={classes.menu}
<Popover
open={Boolean(anchorEl)}
onClose={onClose}
anchorEl={anchorEl}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<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>
<MenuList>
<MenuItem
onClick={() => {
onClose();
onUnregisterComponent();
}}
>
<ListItemIcon>
<Cancel fontSize="small" />
</ListItemIcon>
<Typography variant="inherit">Unregister component</Typography>
</MenuItem>
<MenuItem>
<ListItemIcon>
<SwapHoriz fontSize="small" />
</ListItemIcon>
<Typography variant="inherit">Move repository</Typography>
</MenuItem>
</MenuList>
</Popover>
</div>
);
};
export default ComponentContextMenu;
@@ -23,6 +23,7 @@ describe('ComponentMetadataCard component', () => {
const testComponent: Component = {
name: 'test',
kind: 'Component',
metadata: { name: 'test' },
description: 'Placeholder',
};
const rendered = await render(
@@ -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,79 @@ type ComponentRemovalDialogProps = {
onClose: () => any;
component: Component;
};
function useColocatedEntities(component: Component): AsyncState<Entity[]> {
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<ComponentRemovalDialogProps> = ({
onConfirm,
onCancel,
onClose,
component,
}) => {
const catalogApi = useApi(catalogApiRef);
const { value } = useAsync(async () => {
let colocatedEntities: Array<Entity> = [];
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 (
<Dialog fullScreen={fullScreen} open onClose={onClose}>
<DialogTitle id="responsive-dialog-title">
Are you sure you want to unregister this component?
</DialogTitle>
<DialogContent>
<DialogContentText>{infoMessage}</DialogContentText>
{loading ? <Progress /> : null}
{error ? (
<Alert severity="error" style={{ wordBreak: 'break-word' }}>
{error.toString()}
</Alert>
) : null}
{entities ? (
<>
<DialogContentText>
This action will unregister the following entities:
</DialogContentText>
<Typography component="div">
<ul>
{entities.map(e => (
<li key={e.metadata.name}>{e.metadata.name}</li>
))}
</ul>
</Typography>
<DialogContentText>
That are located at the following location:
</DialogContentText>
<Typography component="div">
<ul>
<li>
{entities[0]?.metadata?.annotations?.[LOCATION_ANNOTATION]}
</li>
</ul>
</Typography>
<DialogContentText>
To undo, just re-register the component in Backstage.
</DialogContentText>
</>
) : null}
</DialogContent>
<DialogActions>
<Button onClick={onCancel} color="primary">
Cancel
</Button>
<Button onClick={onConfirm} color="primary">
<Button onClick={onCancel}>Cancel</Button>
<Button
disabled={!!(loading || error)}
onClick={onConfirm}
color="secondary"
>
Unregister
</Button>
</DialogActions>
</Dialog>
);
};
export default ComponentRemovalDialog;
+2 -2
View File
@@ -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;
};
+29 -19
View File
@@ -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: (
<DescriptionWrapper>
{envelope.metadata?.annotations?.description ?? 'placeholder'}
@@ -57,18 +57,28 @@ export function entityToComponent(
) : null}
</DescriptionWrapper>
),
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),
};
}
@@ -31,7 +31,6 @@ const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
getEntityByName: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
getEntitiesByLocationId: jest.fn(),
};
const setup = () => ({