Merge branch 'master' of github.com:spotify/backstage into sebastianq/component-navigation

* 'master' of github.com:spotify/backstage:
  packages/cli: avoid duplicate dependency warning in E2E test
  build(deps): [security] bump websocket-extensions from 0.1.3 to 0.1.4
  fix: import from package
  fix: type checking
  fix: tests
  Ran yarn install
  fix: show list of components in removal dialog
  fix: github actions
  fix: make locations fetching work
  feature: extract getLocationById method, wrap location fetching in useAsync
This commit is contained in:
blam
2020-06-06 14:54:24 +02:00
18 changed files with 160 additions and 85 deletions
@@ -0,0 +1,16 @@
/*
* 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.
*/
export const LOCATION_ANNOTATION = 'backstage.io/managed-by-location';
@@ -16,3 +16,4 @@
export type { Location, LocationSpec } from './types';
export { locationSchema, locationSpecSchema } from './validation';
export { LOCATION_ANNOTATION } from './annotation';
+1
View File
@@ -143,6 +143,7 @@ export async function installWithLocalDeps(dir: string) {
// Add to both resolutions and dependencies, or transitive dependencies will still be fetched from the registry.
pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`;
pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`;
delete pkgJson.devDependencies[`@backstage/${name}`];
await fs
.writeJSON(pkgJsonPath, pkgJson, { encoding: 'utf8', spaces: 2 })
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env sh
curl \
--location \
--request POST 'localhost:3003/locations' \
--request POST 'localhost:7000/catalog/locations' \
--header 'Content-Type: application/json' \
--data-raw '{
"type": "github",
@@ -19,7 +19,12 @@ import {
InputError,
NotFoundError,
} from '@backstage/backend-common';
import type { Entity, EntityMeta, Location } from '@backstage/catalog-model';
import {
Entity,
EntityMeta,
Location,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import Knex from 'knex';
import lodash from 'lodash';
import { v4 as uuidv4 } from 'uuid';
@@ -167,7 +172,7 @@ export class CommonDatabase implements Database {
annotations: {
...(newEntity.metadata?.annotations ?? {}),
...(request.locationId
? { 'backstage.io/managed-by-location': request.locationId }
? { [LOCATION_ANNOTATION]: request.locationId }
: {}),
},
};
@@ -15,7 +15,12 @@
*/
import { InputError } from '@backstage/backend-common';
import { Entity, Location, LocationSpec } from '@backstage/catalog-model';
import {
Entity,
Location,
LocationSpec,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import lodash from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
@@ -23,8 +28,6 @@ import { IngestionModel } from '../ingestion';
import { AddLocationResult, HigherOrderOperation } from './types';
import { Logger } from 'winston';
const LOCATION_ANNOTATION = 'backstage.io/managed-by-location';
/**
* Placeholder for operations that span several catalogs and/or stretches out
* in time.
@@ -81,6 +81,8 @@ describe('Integration: GitHubLocationSource', () => {
});
it('fetches the fixture from backstage repo', async () => {
(fetch as any).mockResolvedValueOnce(new Response('component3'));
const PERMANENT_LINK =
'https://github.com/spotify/backstage/blob/ee84a874f8e37f87940cbe515a86c07a2db29541/plugins/catalog-backend/fixtures/one_component.yaml';
+1 -1
View File
@@ -18,7 +18,7 @@ import { getRootLogger } from '@backstage/backend-common';
import yn from 'yn';
import { startStandaloneServer } from './service/standaloneServer';
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003;
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
@@ -56,7 +56,7 @@ export async function createStandaloneApplication(
app.use(express.json());
app.use(requestLoggingHandler());
app.use(
'/',
'/catalog',
await createRouter({
entitiesCatalog,
locationsCatalog,
+24 -13
View File
@@ -16,7 +16,11 @@
import { CatalogApi } from './types';
import { DescriptorEnvelope } from '../types';
import { Entity, Location } from '@backstage/catalog-model';
import {
Entity,
Location,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
export class CatalogClient implements CatalogApi {
private apiOrigin: string;
@@ -31,6 +35,22 @@ export class CatalogClient implements CatalogApi {
this.apiOrigin = apiOrigin;
this.basePath = basePath;
}
async getLocationById(id: String): Promise<Location | undefined> {
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 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`);
return await response.json();
@@ -72,20 +92,11 @@ export class CatalogClient implements CatalogApi {
}
async getLocationByEntity(entity: Entity): Promise<Location | undefined> {
const findLocationIdInEntity = (e: Entity): string | undefined =>
e.metadata.annotations?.['backstage.io/managed-by-location'];
const locationId = findLocationIdInEntity(entity);
const locationId = entity.metadata.annotations?.[LOCATION_ANNOTATION];
if (!locationId) return undefined;
const response = await fetch(
`${this.apiOrigin}${this.basePath}/locations/${locationId}`,
);
if (response.ok) {
const location = await response.json();
if (location) return location.data;
}
const location = this.getLocationById(locationId);
return undefined;
return location;
}
}
+2
View File
@@ -23,8 +23,10 @@ export const catalogApiRef = createApiRef<CatalogApi>({
});
export interface CatalogApi {
getLocationById(id: String): Promise<Location | undefined>;
getEntities(): 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,7 +14,7 @@
* limitations under the License.
*/
import React, { FC, useCallback, useState, useEffect } from 'react';
import React, { FC, useCallback, useState } from 'react';
import {
Content,
ContentHeader,
@@ -27,7 +27,7 @@ import {
useApi,
HeaderTabs,
} from '@backstage/core';
import { useAsync, useMountedState } from 'react-use';
import { useAsync } from 'react-use';
import CatalogTable from '../CatalogTable/CatalogTable';
import {
CatalogFilter,
@@ -38,7 +38,11 @@ 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 } from '@backstage/catalog-model';
import {
Entity,
Location,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
const useStyles = makeStyles(theme => ({
contentWrapper: {
@@ -50,17 +54,15 @@ const useStyles = makeStyles(theme => ({
}));
import { catalogApiRef } from '../..';
import { envelopeToComponent } from '../../data/utils';
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());
const [locations, setLocations] = useState<Location[]>([]);
const [selectedFilter, setSelectedFilter] = useState<CatalogFilterItem>(
defaultFilter,
);
const isMounted = useMountedState();
const onFilterSelected = useCallback(
selected => setSelectedFilter(selected),
@@ -68,25 +70,26 @@ const CatalogPage: FC<{}> = () => {
);
const styles = useStyles();
useEffect(() => {
const { value: locations } = useAsync(async () => {
const getLocationDataForEntities = async (entities: Entity[]) => {
return Promise.all(
entities.map(entity => catalogApi.getLocationByEntity(entity)),
entities.map(entity => {
const locationId = entity.metadata.annotations?.[LOCATION_ANNOTATION];
if (!locationId) return undefined;
return catalogApi.getLocationById(locationId);
}),
);
};
if (value) {
getLocationDataForEntities(value)
.then(
(location): Location[] =>
location.filter(l => !!l) as Array<Location>,
)
.then(location => {
if (isMounted()) setLocations(location);
});
return getLocationDataForEntities(value).then(
(location): Location[] =>
location.filter(loc => !!loc) as Array<Location>,
);
}
}, [value, catalogApi, isMounted]);
return [];
}, [value, catalogApi, catalogApi]);
const actions = [
(rowData: Component) => ({
icon: GitHub,
@@ -100,15 +103,6 @@ const CatalogPage: FC<{}> = () => {
}),
];
const findLocationForEntity = (
entity: Entity,
l: Location[],
): Location | undefined => {
const entityLocationId =
entity.metadata.annotations?.['backstage.io/managed-by-location'];
return l.find(location => location.id === entityLocationId);
};
// TODO: replace me with the proper tabs implemntation
const tabs = [
{
@@ -172,22 +166,24 @@ const CatalogPage: FC<{}> = () => {
onSelectedChange={onFilterSelected}
/>
</div>
<CatalogTable
titlePreamble={selectedFilter.label}
components={
(value &&
value.map(val =>
envelopeToComponent(
val,
findLocationForEntity(val, locations),
),
)) ||
[]
}
loading={loading}
error={error}
actions={actions}
/>
{locations && (
<CatalogTable
titlePreamble={selectedFilter.label}
components={
(value &&
value.map(val => {
return {
...entityToComponent(val),
location: findLocationForEntity(val, locations),
};
})) ||
[]
}
loading={loading}
error={error}
actions={actions}
/>
)}
</div>
</Content>
</Page>
@@ -32,7 +32,8 @@ import ComponentRemovalDialog from '../ComponentRemovalDialog/ComponentRemovalDi
import { SentryIssuesWidget } from '@backstage/plugin-sentry';
import { Grid } from '@material-ui/core';
import { catalogApiRef } from '../..';
import { envelopeToComponent } from '../../data/utils';
import { entityToComponent } from '../../data/utils';
import { Component } from '../../data/component';
const REDIRECT_DELAY = 1000;
@@ -56,18 +57,20 @@ const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
const errorApi = useApi<ErrorApi>(errorApiRef);
const catalogApi = useApi(catalogApiRef);
const catalogRequest = useAsync(() =>
catalogApi.getEntityByName(match.params.name),
);
const { value: component, error, loading } = useAsync<Component>(async () => {
const entity = await catalogApi.getEntityByName(match.params.name);
const location = await catalogApi.getLocationByEntity(entity);
return { ...entityToComponent(entity), location };
});
useEffect(() => {
if (catalogRequest.error) {
if (error) {
errorApi.post(new Error('Component not found!'));
setTimeout(() => {
history.push('/');
}, REDIRECT_DELAY);
}
}, [catalogRequest.error, errorApi, history]);
}, [error, errorApi, history]);
if (componentName === '') {
history.push('/catalog');
@@ -78,12 +81,12 @@ const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
setConfirmationDialogOpen(false);
setRemovingPending(true);
// await componentFactory.removeComponentByName(componentName);
await catalogApi;
history.push('/');
};
const component = envelopeToComponent(catalogRequest.value! ?? {});
// TODO: replace me with the proper tabs implemntation
// TODO - Replace with proper tabs implementation
const tabs = [
{
id: 'overview',
@@ -110,13 +113,15 @@ const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
label: 'Quality',
},
];
return (
<Page theme={pageTheme.home}>
<Header title={component.name || 'Catalog'}>
<Header title={component?.name || 'Catalog'}>
<ComponentContextMenu onUnregisterComponent={showRemovalDialog} />
</Header>
<HeaderTabs tabs={tabs} />
{confirmationDialogOpen && catalogRequest.value && (
{confirmationDialogOpen && component && (
<ComponentRemovalDialog
component={component}
onClose={hideRemovalDialog}
@@ -128,7 +133,7 @@ const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
<Grid container spacing={3} direction="column">
<Grid item>
<ComponentMetadataCard
loading={catalogRequest.loading || removingPending}
loading={loading || removingPending}
component={component}
/>
</Grid>
@@ -25,6 +25,10 @@ import {
useTheme,
} from '@material-ui/core';
import { Component } from '../../data/component';
import { useAsync } from 'react-use';
import { useApi } from '@backstage/core';
import { catalogApiRef } from '../../api/types';
import { Entity } from '@backstage/catalog-model';
type ComponentRemovalDialogProps = {
onConfirm: () => any;
@@ -38,18 +42,28 @@ const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
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 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>
This action will unregister {component.name}. To undo, just
re-register the component in Backstage.
</DialogContentText>
<DialogContentText>{infoMessage}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} color="primary">
+1
View File
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Location } from '@backstage/catalog-model';
+18 -2
View File
@@ -15,7 +15,11 @@
*/
import React from 'react';
import { Component } from './component';
import { Entity, Location } from '@backstage/catalog-model';
import {
Entity,
Location,
LOCATION_ANNOTATION,
} 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';
@@ -27,7 +31,7 @@ const DescriptionWrapper = styled('span')({
const createEditLink = (url: string): string => url.replace('blob', 'edit');
export function envelopeToComponent(
export function entityToComponent(
envelope: Entity,
location?: Location,
): Component {
@@ -49,3 +53,15 @@ export function envelopeToComponent(
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;
}
}
return undefined;
}
@@ -30,6 +30,8 @@ const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
getEntities: jest.fn(),
getEntityByName: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
getEntitiesByLocationId: jest.fn(),
};
const setup = () => ({
+3 -3
View File
@@ -18999,9 +18999,9 @@ websocket-driver@>=0.5.1:
websocket-extensions ">=0.1.1"
websocket-extensions@>=0.1.1:
version "0.1.3"
resolved "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.3.tgz#5d2ff22977003ec687a4b87073dfbbac146ccf29"
integrity sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg==
version "0.1.4"
resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42"
integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==
whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3, whatwg-encoding@^1.0.5:
version "1.0.5"