Merge branch 'master' of github.com:spotify/backstage into feat/star-components

* 'master' of github.com:spotify/backstage:
  chore(catalog): consistent use of named exports
  fix(core): Tabs useEffect dependency list
  Optional namespace and name as one part of URL
  Remove deleted UserBadge component from Sidebar story
  remove LoggedUserBadge
  make the sidebar pin button show up again
  feat(backend-common): add common code for service shell
  await promise.all when setting isSignedIn
  PinButton wip
  List auth providers in UserSettings
  Collapsible sidebar item for auth providers
  fix(core): lint error
  refactor(core): update tabs
  Fix tests
  /catalog/:namespace?/:kind/:name/
  feat(core): add Tabs component
This commit is contained in:
blam
2020-06-10 12:32:38 +02:00
49 changed files with 1102 additions and 571 deletions
-3
View File
@@ -18,13 +18,10 @@
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.7",
"@backstage/catalog-model": "^0.1.1-alpha.7",
"compression": "^1.7.4",
"cors": "^2.8.5",
"esm": "^3.2.25",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
"helmet": "^3.22.0",
"knex": "^0.21.1",
"lodash": "^4.17.15",
"morgan": "^1.10.0",
@@ -1,71 +0,0 @@
/*
* 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 {
errorHandler,
notFoundHandler,
requestLoggingHandler,
} from '@backstage/backend-common';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import { Logger } from 'winston';
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
import { HigherOrderOperation } from '../ingestion';
import { createRouter } from './router';
export interface ApplicationOptions {
enableCors: boolean;
entitiesCatalog: EntitiesCatalog;
locationsCatalog?: LocationsCatalog;
higherOrderOperation?: HigherOrderOperation;
logger: Logger;
}
export async function createStandaloneApplication(
options: ApplicationOptions,
): Promise<express.Application> {
const {
enableCors,
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
logger,
} = options;
const app = express();
app.use(helmet());
if (enableCors) {
app.use(cors());
}
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
app.use(
'/catalog',
await createRouter({
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
logger,
}),
);
app.use(notFoundHandler());
app.use(errorHandler());
return app;
}
@@ -14,13 +14,15 @@
* limitations under the License.
*/
import { createServiceBuilder } from '@backstage/backend-common';
import { Server } from 'http';
import { Logger } from 'winston';
import { HigherOrderOperations } from '..';
import { DatabaseEntitiesCatalog } from '../catalog/DatabaseEntitiesCatalog';
import { DatabaseLocationsCatalog } from '../catalog/DatabaseLocationsCatalog';
import { DatabaseManager } from '../database/DatabaseManager';
import { HigherOrderOperations, LocationReaders } from '../ingestion';
import { createStandaloneApplication } from './standaloneApplication';
import { createRouter } from './router';
import { LocationReaders } from '../ingestion';
export interface ServerOptions {
port: number;
@@ -33,11 +35,11 @@ export async function startStandaloneServer(
): Promise<Server> {
const logger = options.logger.child({ service: 'catalog-backend' });
logger.debug('Creating application...');
const db = await DatabaseManager.createInMemoryDatabase(logger);
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
const locationsCatalog = new DatabaseLocationsCatalog(db);
const locationReader = new LocationReaders(options.logger);
const locationReader = new LocationReaders();
const higherOrderOperation = new HigherOrderOperations(
entitiesCatalog,
locationsCatalog,
@@ -45,25 +47,18 @@ export async function startStandaloneServer(
logger,
);
logger.debug('Creating application...');
const app = await createStandaloneApplication({
enableCors: options.enableCors,
logger.debug('Starting application server...');
const router = await createRouter({
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
logger,
});
logger.debug('Starting application server...');
return await new Promise((resolve, reject) => {
const server = app.listen(options.port, (err?: Error) => {
if (err) {
reject(err);
return;
}
logger.info(`Listening on port ${options.port}`);
resolve(server);
});
const service = createServiceBuilder()
.enableCors({ origin: 'http://localhost:3000' })
.addRouter('/catalog', router);
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
+13 -2
View File
@@ -77,9 +77,20 @@ export class CatalogClient implements CatalogApi {
this.cache.set(`get:${JSON.stringify(filter)}`, value);
return value;
}
async getEntityByName(name: string): Promise<DescriptorEnvelope> {
async getEntity({
name,
namespace,
kind,
}: {
name: string;
namespace?: string;
kind: string;
}): Promise<DescriptorEnvelope> {
const response = await fetch(
`${this.apiOrigin}${this.basePath}/entities/by-name/Component/default/${name}`,
`${this.apiOrigin}${this.basePath}/entities/by-name/${kind}/${
namespace ?? 'default'
}/${name}`,
);
const entity = await response.json();
if (entity) return entity;
+5 -1
View File
@@ -23,9 +23,13 @@ export const catalogApiRef = createApiRef<CatalogApi>({
});
export interface CatalogApi {
getEntity(params: {
name: string;
namespace?: string;
kind: string;
}): Promise<Entity>;
getLocationById(id: String): Promise<Location | undefined>;
getEntities(filter?: Record<string, string>): Promise<Entity[]>;
getEntityByName(name: string): Promise<Entity>;
addLocation(type: string, target: string): Promise<AddLocationResponse>;
getLocationByEntity(entity: Entity): Promise<Location | undefined>;
}
@@ -14,20 +14,20 @@
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import CatalogPage from './CatalogPage';
import { Entity } from '@backstage/catalog-model';
import {
ApiRegistry,
ApiProvider,
ApiRegistry,
errorApiRef,
storageApiRef,
WebStorage,
} from '@backstage/core';
import { wrapInTestApp, MockErrorApi } from '@backstage/test-utils';
import { MockErrorApi, wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import React from 'react';
import { catalogApiRef } from '../..';
import { CatalogApi } from '../../api/types';
import { Entity } from '@backstage/catalog-model';
import { CatalogPage } from './CatalogPage';
describe('CatalogPage', () => {
const mockErrorApi = new MockErrorApi();
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { LocationSpec, Entity } from '@backstage/catalog-model';
import {
Content,
ContentHeader,
@@ -26,27 +27,27 @@ import {
SupportButton,
useApi,
} from '@backstage/core';
import { LocationSpec, Entity } from '@backstage/catalog-model';
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 Edit from '@material-ui/icons/Edit';
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, dataResolvers } from '../../data/filters';
import { entityToComponent, findLocationForEntityMeta } from '../../data/utils';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import {
CatalogFilter,
CatalogFilterItem,
} from '../CatalogFilter/CatalogFilter';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import CatalogTable from '../CatalogTable/CatalogTable';
import { CatalogTable } from '../CatalogTable/CatalogTable';
const useStyles = makeStyles(theme => ({
contentWrapper: {
@@ -61,7 +62,7 @@ const useStyles = makeStyles(theme => ({
},
}));
const CatalogPage: FC<{}> = () => {
export const CatalogPage: FC<{}> = () => {
const catalogApi = useApi(catalogApiRef);
const {
starredEntities,
@@ -215,5 +216,3 @@ const CatalogPage: FC<{}> = () => {
</Page>
);
};
export default CatalogPage;
@@ -1,17 +0,0 @@
/*
* 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 { default } from './CatalogPage';
@@ -16,7 +16,7 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import CatalogTable from './CatalogTable';
import { CatalogTable } from './CatalogTable';
import { Component } from '../../data/component';
const components: Component[] = [
@@ -17,9 +17,8 @@ import { Table, TableColumn } from '@backstage/core';
import { Link } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { FC } from 'react';
import { Link as RouterLink, generatePath } from 'react-router-dom';
import { generatePath, Link as RouterLink } from 'react-router-dom';
import { Component } from '../../data/component';
import { entityRoute } from '../../routes';
const columns: TableColumn[] = [
@@ -30,7 +29,15 @@ const columns: TableColumn[] = [
render: (componentData: any) => (
<Link
component={RouterLink}
to={generatePath(entityRoute.path, { name: componentData.name })}
to={generatePath(entityRoute.path, {
optionalNamespaceAndName: [
componentData.namespace,
componentData.name,
]
.filter(Boolean)
.join(':'),
kind: componentData.kind,
})}
>
{componentData.name}
</Link>
@@ -54,7 +61,7 @@ type CatalogTableProps = {
actions?: any;
};
const CatalogTable: FC<CatalogTableProps> = ({
export const CatalogTable: FC<CatalogTableProps> = ({
components,
loading,
error,
@@ -87,5 +94,3 @@ const CatalogTable: FC<CatalogTableProps> = ({
/>
);
};
export default CatalogTable;
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ComponentContextMenu from './ComponentContextMenu';
import { ComponentContextMenu } from './ComponentContextMenu';
import { render } from '@testing-library/react';
import * as React from 'react';
import { act } from 'react-dom/test-utils';
@@ -21,11 +21,11 @@ import {
Popover,
Typography,
} from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
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({
@@ -38,7 +38,7 @@ type ComponentContextMenuProps = {
onUnregisterComponent: () => void;
};
const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
export const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
onUnregisterComponent,
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
@@ -94,5 +94,3 @@ const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
</div>
);
};
export default ComponentContextMenu;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import ComponentMetadataCard from './ComponentMetadataCard';
import { ComponentMetadataCard } from './ComponentMetadataCard';
import { Component } from '../../data/component';
import { render } from '@testing-library/react';
@@ -13,18 +13,16 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InfoCard, Progress, StructuredMetadataTable } from '@backstage/core';
import React, { FC } from 'react';
import { Component } from '../../data/component';
import { Progress, InfoCard, StructuredMetadataTable } from '@backstage/core';
type ComponentMetadataCardProps = {
type Props = {
loading: boolean;
component: Component | undefined;
};
const ComponentMetadataCard: FC<ComponentMetadataCardProps> = ({
loading,
component,
}) => {
export const ComponentMetadataCard: FC<Props> = ({ loading, component }) => {
if (loading) {
return (
<InfoCard title="Metadata">
@@ -41,4 +39,3 @@ const ComponentMetadataCard: FC<ComponentMetadataCardProps> = ({
</InfoCard>
);
};
export default ComponentMetadataCard;
@@ -13,18 +13,19 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ComponentPage from './ComponentPage';
import { ComponentPage } from './ComponentPage';
import { render, wait } from '@testing-library/react';
import * as React from 'react';
import { wrapInTestApp } from '@backstage/test-utils';
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { catalogApiRef, CatalogApi } from '../../api/types';
const getTestProps = (componentName: string) => {
const getTestProps = (name: string) => {
return {
match: {
params: {
name: componentName,
optionalNamespaceAndName: name,
kind: 'Component',
},
},
history: {
@@ -46,7 +47,7 @@ describe('ComponentPage', () => {
[
catalogApiRef,
({
async getEntityByName() {},
async getEntity() {},
} as unknown) as CatalogApi,
],
])}
@@ -13,34 +13,34 @@
* 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 {
Content,
Header,
pageTheme,
Page,
useApi,
ErrorApi,
errorApiRef,
Header,
HeaderTabs,
Page,
pageTheme,
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 React, { FC, useEffect, useState } from 'react';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../..';
import { entityToComponent } from '../../data/utils';
import { Component } from '../../data/component';
import { entityToComponent } from '../../data/utils';
import { ComponentContextMenu } from '../ComponentContextMenu/ComponentContextMenu';
import { ComponentMetadataCard } from '../ComponentMetadataCard/ComponentMetadataCard';
import { ComponentRemovalDialog } from '../ComponentRemovalDialog/ComponentRemovalDialog';
const REDIRECT_DELAY = 1000;
type ComponentPageProps = {
match: {
params: {
name: string;
optionalNamespaceAndName: string;
kind: string;
};
};
history: {
@@ -48,17 +48,18 @@ type ComponentPageProps = {
};
};
const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const [removingPending, setRemovingPending] = useState(false);
const showRemovalDialog = () => setConfirmationDialogOpen(true);
const hideRemovalDialog = () => setConfirmationDialogOpen(false);
const componentName = match.params.name;
const { optionalNamespaceAndName, kind } = match.params;
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const errorApi = useApi<ErrorApi>(errorApiRef);
const catalogApi = useApi(catalogApiRef);
const { value: component, error, loading } = useAsync<Component>(async () => {
const entity = await catalogApi.getEntityByName(match.params.name);
const entity = await catalogApi.getEntity({ name, namespace, kind });
const location = await catalogApi.getLocationByEntity(entity);
return { ...entityToComponent(entity), location };
});
@@ -72,7 +73,7 @@ const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
}
}, [error, errorApi, history]);
if (componentName === '') {
if (name === '') {
history.push('/catalog');
return null;
}
@@ -149,4 +150,3 @@ const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
</Page>
);
};
export default ComponentPage;
@@ -51,7 +51,7 @@ function useColocatedEntities(component: Component): AsyncState<Entity[]> {
}, [catalogApi, component]);
}
const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
export const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
onConfirm,
onCancel,
onClose,
@@ -114,5 +114,3 @@ const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
</Dialog>
);
};
export default ComponentRemovalDialog;
+1
View File
@@ -18,6 +18,7 @@ import { ReactNode } from 'react';
export type Component = {
name: string;
namespace?: string;
kind: string;
metadata: EntityMeta;
description: ReactNode;
+1
View File
@@ -24,6 +24,7 @@ import { Component } from './component';
export function entityToComponent(envelope: Entity): Component {
return {
name: envelope.metadata.name,
namespace: envelope.metadata.namespace,
kind: envelope.kind,
metadata: envelope.metadata,
description: envelope.metadata.annotations?.description ?? 'placeholder',
+3 -3
View File
@@ -15,9 +15,9 @@
*/
import { createPlugin } from '@backstage/core';
import CatalogPage from './components/CatalogPage';
import ComponentPage from './components/ComponentPage/ComponentPage';
import { rootRoute, entityRoute } from './routes';
import { CatalogPage } from './components/CatalogPage/CatalogPage';
import { ComponentPage } from './components/ComponentPage/ComponentPage';
import { entityRoute, rootRoute } from './routes';
export const plugin = createPlugin({
id: 'catalog',
+1 -1
View File
@@ -25,6 +25,6 @@ export const rootRoute = createRouteRef({
});
export const entityRoute = createRouteRef({
icon: NoIcon,
path: '/catalog/:name/',
path: '/catalog/:kind/:optionalNamespaceAndName/',
title: 'Entity',
});
@@ -28,7 +28,7 @@ const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
addLocation: jest.fn((_a, _b) => new Promise(() => {})),
getEntities: jest.fn(),
getEntityByName: jest.fn(),
getEntity: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
};
@@ -54,34 +54,39 @@ export const RegisterComponentResultDialog: FC<Props> = ({
The following components have been succefully created:
</DialogContentText>
<List>
{entities.map((entity: any, index: number) => (
<React.Fragment
key={`${entity.metadata.namespace}-${entity.metadata.name}`}
>
<ListItem>
<StructuredMetadataTable
dense
metadata={{
name: entity.metadata.name,
type: entity.spec.type,
link: (
<Link
component={RouterLink}
to={generatePath(entityRoute.path, {
name: entity.metadata.name,
})}
>
{generatePath(entityRoute.path, {
name: entity.metadata.name,
})}
</Link>
),
}}
/>
</ListItem>
{index < entities.length - 1 && <Divider component="li" />}
</React.Fragment>
))}
{entities.map((entity: any, index: number) => {
const entityPath = generatePath(entityRoute.path, {
optionalNamespaceAndName: [
entity.metadata.namespace,
entity.metadata.name,
]
.filter(Boolean)
.join(':'),
kind: entity.kind,
});
return (
<React.Fragment
key={`${entity.metadata.namespace}-${entity.metadata.name}`}
>
<ListItem>
<StructuredMetadataTable
dense
metadata={{
name: entity.metadata.name,
type: entity.spec.type,
link: (
<Link component={RouterLink} to={entityPath}>
{entityPath}
</Link>
),
}}
/>
</ListItem>
{index < entities.length - 1 && <Divider component="li" />}
</React.Fragment>
);
})}
</List>
</DialogContent>
<DialogActions>