Merge branch 'master' into 1471-starring-entity-pages

This commit is contained in:
tudi2d
2020-06-30 12:19:41 +02:00
103 changed files with 3140 additions and 1074 deletions
@@ -33,6 +33,7 @@ export type Options = {
providerId: string;
secure: boolean;
disableRefresh?: boolean;
persistScopes?: boolean;
baseUrl: string;
appOrigin: string;
tokenIssuer: TokenIssuer;
@@ -105,6 +106,10 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
throw new InputError('missing scope parameter');
}
if (this.options.persistScopes) {
this.setScopesCookie(res, scope);
}
const nonce = crypto.randomBytes(16).toString('base64');
// set a nonce cookie before redirecting to oauth provider
this.setNonceCookie(res, nonce);
@@ -137,6 +142,14 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
req,
);
if (this.options.persistScopes) {
const grantedScopes = this.getScopesFromCookie(
req,
this.options.providerId,
);
response.providerInfo.scope = grantedScopes;
}
if (!this.options.disableRefresh) {
// throw error if missing refresh token
if (!refreshToken) {
@@ -241,6 +254,21 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
});
};
private setScopesCookie = (res: express.Response, scope: string) => {
res.cookie(`${this.options.providerId}-scope`, scope, {
maxAge: TEN_MINUTES_MS,
secure: this.options.secure,
sameSite: 'none',
domain: this.domain,
path: `${this.basePath}/${this.options.providerId}/handler`,
httpOnly: true,
});
};
private getScopesFromCookie = (req: express.Request, providerId: string) => {
return req.cookies[`${providerId}-scope`];
};
private setRefreshTokenCookie = (
res: express.Response,
refreshToken: string,
@@ -115,6 +115,7 @@ export function createGithubProvider(
envProviders[env] = new OAuthProvider(new GithubAuthProvider(opts), {
disableRefresh: true,
persistScopes: true,
providerId: 'github',
secure,
baseUrl,
@@ -15,4 +15,5 @@ for URL in \
--request POST 'localhost:7000/catalog/locations' \
--header 'Content-Type: application/json' \
--data-raw "{\"type\": \"github\", \"target\": \"https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/${URL}\"}"
echo
done
@@ -14,195 +14,248 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import {
ApiProvider,
ApiRegistry,
IdentityApi,
identityApiRef,
storageApiRef,
} from '@backstage/core';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { fireEvent, render, waitFor } from '@testing-library/react';
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter';
import { EntityGroup } from '../../data/filters';
import { CatalogApi, catalogApiRef } from '../../api/types';
import { EntityFilterGroupsProvider } from '../../filter';
import { ButtonGroup, CatalogFilter } from './CatalogFilter';
describe('Catalog Filter', () => {
const comp1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component-1',
},
spec: {
owner: 'team',
},
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
] as Entity[]),
};
const comp2 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component-2',
},
spec: {
owner: 'team',
},
const indentityApi: Partial<IdentityApi> = {
getUserId: () => 'tools@example.com',
};
const comp3 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component-3',
},
spec: {
owner: '',
},
};
const defaultFilterProps = {
selectedFilter: EntityGroup.ALL,
onFilterChange: (type: EntityGroup) => type,
entitiesByFilter: {
[EntityGroup.ALL]: [comp1, comp2, comp3],
[EntityGroup.STARRED]: [comp1],
[EntityGroup.OWNED]: [comp1],
},
};
it('should render the different groups', async () => {
const mockGroups: CatalogFilterGroup[] = [
{ name: 'Test Group 1', items: [] },
{ name: 'Test Group 2', items: [] },
];
const { findByText } = render(
const renderWrapped = (children: React.ReactNode) =>
render(
wrapInTestApp(
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
<ApiProvider
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[identityApiRef, indentityApi],
[storageApiRef, MockStorageApi.create()],
])}
>
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>,
</ApiProvider>,
),
);
it('should render the different groups', async () => {
const mockGroups: ButtonGroup[] = [
{ name: 'Test Group 1', items: [] },
{ name: 'Test Group 2', items: [] },
];
const { findByText } = renderWrapped(
<CatalogFilter buttonGroups={mockGroups} initiallySelected="" />,
);
for (const group of mockGroups) {
expect(await findByText(group.name)).toBeInTheDocument();
}
});
it('should render the different items and their names', async () => {
const mockGroups: CatalogFilterGroup[] = [
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: EntityGroup.ALL,
id: 'all',
label: 'First Label',
filterFn: () => true,
},
{
id: EntityGroup.STARRED,
id: 'starred',
label: 'Second Label',
filterFn: () => false,
},
],
},
];
const { findByText } = render(
wrapInTestApp(
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
),
const { findByText } = renderWrapped(
<CatalogFilter buttonGroups={mockGroups} initiallySelected="all" />,
);
const [group] = mockGroups;
for (const item of group.items) {
for (const item of mockGroups[0].items) {
expect(await findByText(item.label)).toBeInTheDocument();
}
});
it('should render the count in each item', async () => {
const mockGroups: CatalogFilterGroup[] = [
it('selects the first item if no desired initial one is set', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: EntityGroup.ALL,
id: 'all',
label: 'First Label',
count: 3,
filterFn: () => true,
},
{
id: EntityGroup.STARRED,
id: 'starred',
label: 'Second Label',
count: 1,
filterFn: () => false,
},
],
},
];
const { getAllByText } = render(
wrapInTestApp(
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
),
const onChange = jest.fn();
renderWrapped(
<CatalogFilter
buttonGroups={mockGroups}
initiallySelected="all"
onChange={onChange}
/>,
);
for (const key of Object.keys(defaultFilterProps.entitiesByFilter)) {
const matcher = new RegExp(
`(${defaultFilterProps.entitiesByFilter[key as EntityGroup].length})`,
);
const items = await getAllByText(matcher);
items.forEach(el => expect(el).toBeInTheDocument());
}
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'all',
label: 'First Label',
});
});
});
it('should fire the callback when an item is clicked', async () => {
const mockGroups: CatalogFilterGroup[] = [
it('selects the initial item', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: EntityGroup.ALL,
id: 'all',
label: 'First Label',
count: 100,
filterFn: () => true,
},
{
id: EntityGroup.STARRED,
id: 'starred',
label: 'Second Label',
count: 400,
filterFn: () => false,
},
],
},
];
const onSelectedChangeHandler = jest.fn();
const onChange = jest.fn();
const { findByText } = render(
wrapInTestApp(
<CatalogFilter
{...defaultFilterProps}
groups={mockGroups}
onFilterChange={onSelectedChangeHandler}
/>,
),
renderWrapped(
<CatalogFilter
buttonGroups={mockGroups}
onChange={onChange}
initiallySelected="starred"
/>,
);
const item = mockGroups[0].items[0];
const element = await findByText(item.label);
fireEvent.click(element);
expect(onSelectedChangeHandler).toHaveBeenCalledWith(item.id);
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'starred',
label: 'Second Label',
});
});
});
it('should render a component when a function is passed to the count component', async () => {
const mockGroups: CatalogFilterGroup[] = [
it('can change the selected item', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: EntityGroup.ALL,
id: 'all',
label: 'First Label',
count: () => <b>BACKSTAGE!</b>,
filterFn: () => true,
},
{
id: EntityGroup.STARRED,
id: 'starred',
label: 'Second Label',
count: 400,
filterFn: () => false,
},
],
},
];
const { findByText } = render(
wrapInTestApp(
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
),
const onChange = jest.fn();
const { findByText } = renderWrapped(
<CatalogFilter
buttonGroups={mockGroups}
initiallySelected="all"
onChange={onChange}
/>,
);
expect(await findByText('Test Group 1')).toBeInTheDocument();
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'all',
label: 'First Label',
});
});
fireEvent.click(await findByText('Second Label'));
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'starred',
label: 'Second Label',
});
});
});
it('displays match counts properly', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'owned',
label: 'First Label',
filterFn: entity => entity.spec?.owner === 'tools@example.com',
},
],
},
];
const { findByText } = renderWrapped(
<CatalogFilter buttonGroups={mockGroups} initiallySelected="owned" />,
);
expect(await findByText('1')).toBeInTheDocument();
});
});
@@ -14,31 +14,36 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import { Entity } from '@backstage/catalog-model';
import { IconComponent } from '@backstage/core';
import {
Card,
List,
ListItemIcon,
ListItemSecondaryAction,
ListItemText,
MenuItem,
Typography,
Theme,
makeStyles,
MenuItem,
Theme,
Typography,
} from '@material-ui/core';
import type { IconComponent } from '@backstage/core';
import { EntityGroup } from '../../data/filters';
import { EntitiesByFilter } from '../../hooks/useEntities';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { FilterGroup, useEntityFilterGroup } from '../../filter';
export type CatalogFilterItem = {
id: EntityGroup;
label: string;
icon?: IconComponent;
count?: number | FC;
};
export type CatalogFilterGroup = {
export type ButtonGroup = {
name: string;
items: CatalogFilterItem[];
items: {
id: string;
label: string;
icon?: IconComponent;
filterFn: (entity: Entity) => boolean;
}[];
};
const useStyles = makeStyles<Theme>(theme => ({
@@ -67,21 +72,56 @@ const useStyles = makeStyles<Theme>(theme => ({
},
}));
export const CatalogFilter: FC<{
selectedFilter: EntityGroup;
onFilterChange: (type: EntityGroup) => void;
entitiesByFilter: EntitiesByFilter;
groups: CatalogFilterGroup[];
}> = ({
selectedFilter: selectedId,
onFilterChange: setSelectedFilter,
entitiesByFilter,
groups,
}) => {
type OnChangeCallback = (item: { id: string; label: string }) => void;
type Props = {
buttonGroups: ButtonGroup[];
initiallySelected: string;
onChange?: OnChangeCallback;
};
/**
* The main filter group in the sidebar, toggling owned/starred/all.
*/
export const CatalogFilter = ({
buttonGroups,
onChange,
initiallySelected,
}: Props) => {
const classes = useStyles();
const { currentFilter, setCurrentFilter, getFilterCount } = useFilter(
buttonGroups,
initiallySelected,
);
const onChangeRef = useRef<OnChangeCallback>();
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
const setCurrent = useCallback(
(item: { id: string; label: string }) => {
setCurrentFilter(item.id);
onChangeRef.current?.({ id: item.id, label: item.label });
},
[setCurrentFilter],
);
// Make one initial onChange to inform the surroundings about the selected
// item
useEffect(() => {
const items = buttonGroups.flatMap(g => g.items);
const item = items.find(i => i.id === initiallySelected) || items[0];
if (item) {
onChangeRef.current?.({ id: item.id, label: item.label });
}
// intentionally only happens on startup
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Card className={classes.root}>
{groups.map(group => (
{buttonGroups.map(group => (
<React.Fragment key={group.name}>
<Typography variant="subtitle2" className={classes.title}>
{group.name}
@@ -93,10 +133,8 @@ export const CatalogFilter: FC<{
key={item.id}
button
divider
onClick={() => {
setSelectedFilter(item.id);
}}
selected={item.id === selectedId}
onClick={() => setCurrent(item)}
selected={item.id === currentFilter}
className={classes.menuItem}
>
{item.icon && (
@@ -109,7 +147,9 @@ export const CatalogFilter: FC<{
{item.label}
</Typography>
</ListItemText>
{entitiesByFilter[item.id]?.length ?? '-'}
<ListItemSecondaryAction>
{getFilterCount(item.id) ?? '-'}
</ListItemSecondaryAction>
</MenuItem>
))}
</List>
@@ -119,3 +159,53 @@ export const CatalogFilter: FC<{
</Card>
);
};
function useFilter(
buttonGroups: ButtonGroup[],
initiallySelected: string,
): {
currentFilter: string;
setCurrentFilter: (filterId: string) => void;
getFilterCount: (filterId: string) => number | undefined;
} {
const [currentFilter, setCurrentFilter] = useState(initiallySelected);
const filterGroup = useMemo<FilterGroup>(
() => ({
filters: Object.fromEntries(
buttonGroups.flatMap(g => g.items).map(i => [i.id, i.filterFn]),
),
}),
[buttonGroups],
);
const { setSelectedFilters, state } = useEntityFilterGroup(
'primary-sidebar',
filterGroup,
[initiallySelected],
);
const setCurrent = useCallback(
(filterId: string) => {
setCurrentFilter(filterId);
setSelectedFilters([filterId]);
},
[setCurrentFilter, setSelectedFilters],
);
const getFilterCount = useCallback(
(filterId: string) => {
if (state.type !== 'ready') {
return undefined;
}
return state.state.filters[filterId].matchCount;
},
[state],
);
return {
currentFilter,
setCurrentFilter: setCurrent,
getFilterCount,
};
}
@@ -14,26 +14,29 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import {
Header,
HomepageTimer,
identityApiRef,
Page,
pageTheme,
identityApiRef,
useApi,
} from '@backstage/core';
import React from 'react';
import { getTimeBasedGreeting } from './utils/timeUtil';
const CatalogLayout: FC<{}> = props => {
const { children } = props;
type Props = {
children?: React.ReactNode;
};
const CatalogLayout = ({ children }: Props) => {
const greeting = getTimeBasedGreeting();
const identityApi = useApi(identityApiRef);
const userId = useApi(identityApiRef).getUserId();
return (
<Page theme={pageTheme.home}>
<Header
title={`${greeting.greeting}, ${identityApi.getUserId()}!`}
title={`${greeting.greeting}, ${userId}!`}
subtitle="Backstage Service Catalog"
tooltip={greeting.language}
pageTitleOverride="Home"
@@ -14,45 +14,43 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import {
ApiProvider,
ApiRegistry,
errorApiRef,
storageApiRef,
WebStorage,
IdentityApi,
identityApiRef,
storageApiRef,
} from '@backstage/core';
import { MockErrorApi, wrapInTestApp } from '@backstage/test-utils';
import { render, fireEvent } from '@testing-library/react';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { fireEvent, render } from '@testing-library/react';
import React from 'react';
import { catalogApiRef } from '../..';
import { CatalogApi } from '../../api/types';
import { EntityFilterGroupsProvider } from '../../filter';
import { CatalogPage } from './CatalogPage';
import { Entity } from '@backstage/catalog-model';
describe('CatalogPage', () => {
const mockErrorApi = new MockErrorApi();
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
},
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
spec: {
owner: 'not-tools@example.com',
type: 'service',
@@ -62,49 +60,32 @@ describe('CatalogPage', () => {
getLocationByEntity: () =>
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
};
const mockIndentityApi: Partial<IdentityApi> = {
const indentityApi: Partial<IdentityApi> = {
getUserId: () => 'tools@example.com',
};
const renderWrapped = (children: React.ReactNode) =>
render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[identityApiRef, indentityApi],
[storageApiRef, MockStorageApi.create()],
])}
>
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>,
</ApiProvider>,
),
);
// this test right now causes some red lines in the log output when running tests
// related to some theme issues in mui-table
// https://github.com/mbrn/material-table/issues/1293
it('should render', async () => {
const { findByText } = render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[errorApiRef, mockErrorApi],
[catalogApiRef, catalogApi],
[storageApiRef, new WebStorage('@mock', mockErrorApi)],
[identityApiRef, mockIndentityApi],
])}
>
<CatalogPage />
</ApiProvider>,
),
);
const items = await findByText(/All Services \(2\)/);
expect(items).toBeInTheDocument();
});
it('should filter by owner', async () => {
const { findByText, getByText } = render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[errorApiRef, mockErrorApi],
[catalogApiRef, catalogApi],
[storageApiRef, new WebStorage('@mock', mockErrorApi)],
[identityApiRef, mockIndentityApi],
])}
>
<CatalogPage />
</ApiProvider>,
),
);
fireEvent.click(getByText(/Owned/));
const items = await findByText(/Owned \(1\)/);
expect(items).toBeInTheDocument();
const { findByText, getByText } = renderWrapped(<CatalogPage />);
expect(await findByText(/Owned \(1\)/)).toBeInTheDocument();
fireEvent.click(getByText(/All/));
expect(await findByText(/All \(2\)/)).toBeInTheDocument();
});
});
@@ -14,35 +14,26 @@
* limitations under the License.
*/
import { Entity, LocationSpec } from '@backstage/catalog-model';
import {
Content,
ContentHeader,
DismissableBanner,
HeaderTabs,
identityApiRef,
SupportButton,
useApi,
} from '@backstage/core';
import CatalogLayout from './CatalogLayout';
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
import { Button, Link, makeStyles, Typography } from '@material-ui/core';
import Edit from '@material-ui/icons/Edit';
import GitHub from '@material-ui/icons/GitHub';
import React, { FC } from 'react';
import { Button, makeStyles } from '@material-ui/core';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
import React, { useMemo, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { CatalogFilter } from '../CatalogFilter/CatalogFilter';
import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import { CatalogFilter, ButtonGroup } from '../CatalogFilter/CatalogFilter';
import { CatalogTable } from '../CatalogTable/CatalogTable';
import { useEntities } from '../../hooks/useEntities';
import { findLocationForEntityMeta } from '../../data/utils';
import {
getCatalogFilterItemByType,
EntityGroup,
filterGroups,
labeledEntityTypes,
} from '../../data/filters';
import {
favouriteEntityIcon,
favouriteEntityTooltip,
} from '../FavouriteEntity/FavouriteEntity';
import CatalogLayout from './CatalogLayout';
import { CatalogTabs, LabeledComponentType } from './CatalogTabs';
import { WelcomeBanner } from './WelcomeBanner';
const useStyles = makeStyles(theme => ({
contentWrapper: {
@@ -51,132 +42,116 @@ const useStyles = makeStyles(theme => ({
gridTemplateColumns: '250px 1fr',
gridColumnGap: theme.spacing(2),
},
emoji: {
fontSize: '125%',
marginRight: theme.spacing(2),
},
}));
export const CatalogPage: FC<{}> = () => {
const {
entitiesByFilter,
error,
loading,
selectedFilter,
setSelectedFilter,
toggleStarredEntity,
isStarredEntity,
selectTypeFilter,
} = useEntities();
const filteredEntities = entitiesByFilter[selectedFilter ?? EntityGroup.ALL];
const CatalogPageContents = () => {
const styles = useStyles();
const { loading, error, matchingEntities } = useFilteredEntities();
const { isStarredEntity } = useStarredEntities();
const userId = useApi(identityApiRef).getUserId();
const [selectedTab, setSelectedTab] = useState<string>();
const [selectedSidebarItem, setSelectedSidebarItem] = useState<string>();
const actions = [
(rowData: Entity) => {
const location = findLocationForEntityMeta(rowData.metadata);
return {
icon: GitHub,
tooltip: 'View on GitHub',
onClick: () => {
if (!location) return;
window.open(location.target, '_blank');
},
hidden: location?.type !== 'github',
};
},
(rowData: Entity) => {
const createEditLink = (location: LocationSpec): string => {
switch (location.type) {
case 'github':
return location.target.replace('/blob/', '/edit/');
default:
return location.target;
}
};
const tabs = useMemo<LabeledComponentType[]>(
() => [
{
id: 'service',
label: 'Services',
},
{
id: 'website',
label: 'Websites',
},
{
id: 'library',
label: 'Libraries',
},
{
id: 'documentation',
label: 'Documentation',
},
{
id: 'other',
label: 'Other',
},
],
[],
);
const location = findLocationForEntityMeta(rowData.metadata);
return {
icon: Edit,
tooltip: 'Edit',
iconProps: { size: 'small' },
onClick: () => {
if (!location) return;
window.open(createEditLink(location), '_blank');
},
hidden: location?.type !== 'github',
};
},
(rowData: Entity) => {
const isStarred = isStarredEntity(rowData);
return {
icon: favouriteEntityIcon(isStarred),
tooltip: favouriteEntityTooltip(isStarred),
onClick: () => toggleStarredEntity(rowData),
};
},
];
const filterGroups = useMemo<ButtonGroup[]>(
() => [
{
name: 'Personal',
items: [
{
id: 'owned',
label: 'Owned',
icon: SettingsIcon,
filterFn: entity => entity.spec?.owner === userId,
},
{
id: 'starred',
label: 'Starred',
icon: StarIcon,
filterFn: isStarredEntity,
},
],
},
{
name: 'Company', // TODO: Replace with Company name, read from app config.
items: [
{
id: 'all',
label: 'All',
filterFn: () => true,
},
],
},
],
[isStarredEntity, userId],
);
return (
<CatalogLayout>
<HeaderTabs
tabs={labeledEntityTypes}
onChange={(index: Number) => {
selectTypeFilter(labeledEntityTypes[index as number].id);
}}
<CatalogTabs
tabs={tabs}
onChange={({ label }) => setSelectedTab(label)}
/>
<Content>
<DismissableBanner
variant="info"
message={
<Typography>
<span role="img" aria-label="wave" className={styles.emoji}>
👋🏼
</span>
Welcome to Backstage, we are happy to have you. Start by checking
out our{' '}
<Link href="/welcome" color="textSecondary">
getting started
</Link>{' '}
page.
</Typography>
}
id="catalog_page_welcome_banner"
/>
<ContentHeader title="Services">
<WelcomeBanner />
<ContentHeader title={selectedTab ?? ''}>
<Button
component={RouterLink}
variant="contained"
color="primary"
to={scaffolderRootRoute.path}
>
Create Service
Create Component
</Button>
<SupportButton>All your software catalog entities</SupportButton>
</ContentHeader>
<div className={styles.contentWrapper}>
<div>
<CatalogFilter
groups={filterGroups}
selectedFilter={selectedFilter ?? EntityGroup.ALL}
onFilterChange={setSelectedFilter}
entitiesByFilter={entitiesByFilter}
buttonGroups={filterGroups}
onChange={({ label }) => setSelectedSidebarItem(label)}
initiallySelected="owned"
/>
</div>
<CatalogTable
titlePreamble={
getCatalogFilterItemByType(selectedFilter ?? EntityGroup.ALL)
?.label ?? ''
}
entities={filteredEntities || []}
loading={loading && !error}
titlePreamble={selectedSidebarItem ?? ''}
entities={matchingEntities}
loading={loading}
error={error}
actions={actions}
/>
</div>
</Content>
</CatalogLayout>
);
};
export const CatalogPage = () => (
<EntityFilterGroupsProvider>
<CatalogPageContents />
</EntityFilterGroupsProvider>
);
@@ -0,0 +1,85 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { HeaderTabs } from '@backstage/core';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { FilterGroup, useEntityFilterGroup } from '../../filter';
/**
* A component type, and a human readable label for it.
*/
export type LabeledComponentType = {
id: string;
label: string;
};
/**
* Called on mount, and when the selected tab changes.
*/
export type OnChangeCallback = (tab: LabeledComponentType) => void;
type Props = {
tabs: LabeledComponentType[];
onChange?: OnChangeCallback;
};
/**
* The tabs at the top of the catalog list page, for component type filtering.
*/
export const CatalogTabs = ({ tabs, onChange }: Props) => {
const filterGroup = useMemo<FilterGroup>(() => {
return {
filters: Object.fromEntries(
tabs.map(t => [t.id, (entity: Entity) => entity.spec?.type === t.id]),
),
};
}, [tabs]);
const { setSelectedFilters } = useEntityFilterGroup('type', filterGroup, [
tabs[0].id,
]);
const [currentTabIndex, setCurrentTabIndex] = useState<number>(0);
// Hold a reference to the callback
const onChangeRef = useRef<OnChangeCallback>();
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
onChangeRef.current?.(tabs[currentTabIndex]);
}, [tabs, currentTabIndex]);
const switchTab = useCallback(
(index: number) => {
const tab = tabs[index];
setSelectedFilters([tab.id]);
setCurrentTabIndex(index);
onChangeRef.current?.(tab);
},
[tabs, setSelectedFilters],
);
return <HeaderTabs tabs={tabs} onChange={switchTab} />;
};
@@ -0,0 +1,55 @@
/*
* 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 { DismissableBanner } from '@backstage/core';
import { Link, makeStyles, Typography } from '@material-ui/core';
import React from 'react';
const useStyles = makeStyles(theme => ({
contentWrapper: {
display: 'grid',
gridTemplateAreas: "'filters' 'table'",
gridTemplateColumns: '250px 1fr',
gridColumnGap: theme.spacing(2),
},
emoji: {
fontSize: '125%',
marginRight: theme.spacing(2),
},
}));
export const WelcomeBanner = () => {
const classes = useStyles();
return (
<DismissableBanner
variant="info"
message={
<Typography>
<span role="img" aria-label="wave" className={classes.emoji}>
👋🏼
</span>
Welcome to Backstage, we are happy to have you. Start by checking out
our{' '}
<Link href="/welcome" color="textSecondary">
getting started
</Link>{' '}
page.
</Typography>
}
id="catalog_page_welcome_banner"
/>
);
};
@@ -66,11 +66,9 @@ describe('CatalogTable component', () => {
/>,
),
);
expect(
await rendered.findByText(`Owned (${entites.length})`),
).toBeInTheDocument();
expect(await rendered.findByText('component1')).toBeInTheDocument();
expect(await rendered.findByText('component2')).toBeInTheDocument();
expect(await rendered.findByText('component3')).toBeInTheDocument();
expect(rendered.getByText(/Owned \(3\)/)).toBeInTheDocument();
expect(rendered.getByText(/component1/)).toBeInTheDocument();
expect(rendered.getByText(/component2/)).toBeInTheDocument();
expect(rendered.getByText(/component3/)).toBeInTheDocument();
});
});
@@ -13,16 +13,21 @@
* 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 { Entity, LocationSpec } from '@backstage/catalog-model';
import { Table, TableColumn, TableProps } from '@backstage/core';
import { Link } 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 { Alert } from '@material-ui/lab';
import React, { FC } from 'react';
import React from 'react';
import { generatePath, Link as RouterLink } from 'react-router-dom';
import { findLocationForEntityMeta } from '../../data/utils';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import { entityRoute } from '../../routes';
const columns: TableColumn[] = [
const columns: TableColumn<Entity>[] = [
{
title: 'Name',
field: 'metadata.name',
@@ -63,16 +68,16 @@ type CatalogTableProps = {
titlePreamble: string;
loading: boolean;
error?: any;
actions?: any;
};
export const CatalogTable: FC<CatalogTableProps> = ({
export const CatalogTable = ({
entities,
loading,
error,
titlePreamble,
actions,
}) => {
}: CatalogTableProps) => {
const { isStarredEntity, toggleStarredEntity } = useStarredEntities();
if (error) {
return (
<div>
@@ -83,8 +88,57 @@ export const CatalogTable: FC<CatalogTableProps> = ({
);
}
const actions: TableProps<Entity>['actions'] = [
(rowData: Entity) => {
const location = findLocationForEntityMeta(rowData.metadata);
return {
icon: () => <GitHub fontSize="small" />,
tooltip: 'View on GitHub',
onClick: () => {
if (!location) return;
window.open(location.target, '_blank');
},
hidden: location?.type !== 'github',
};
},
(rowData: Entity) => {
const createEditLink = (location: LocationSpec): string => {
switch (location.type) {
case 'github':
return location.target.replace('/blob/', '/edit/');
default:
return location.target;
}
};
const location = findLocationForEntityMeta(rowData.metadata);
return {
icon: () => <Edit fontSize="small" />,
tooltip: 'Edit',
onClick: () => {
if (!location) return;
window.open(createEditLink(location), '_blank');
},
hidden: location?.type !== 'github',
};
},
(rowData: Entity) => {
const isStarred = isStarredEntity(rowData);
return {
cellStyle: { paddingLeft: '1em' },
icon: () =>
isStarred ? (
<Star htmlColor="#f3ba37" fontSize="small" />
) : (
<StarOutline fontSize="small" />
),
tooltip: isStarred ? 'Remove from favorites' : 'Add to favorites',
onClick: () => toggleStarredEntity(rowData),
};
},
];
return (
<Table
<Table<Entity>
isLoading={loading}
columns={columns}
options={{
@@ -27,7 +27,7 @@ jest.mock('react-router-dom', () => {
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { wrapInTestApp } from '@backstage/test-utils';
import { render, wait } from '@testing-library/react';
import { render, waitFor } from '@testing-library/react';
import * as React from 'react';
import { CatalogApi, catalogApiRef } from '../../api/types';
import { EntityPage, getPageTheme } from './EntityPage';
@@ -66,7 +66,7 @@ describe('EntityPage', () => {
),
);
await wait(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog'));
await waitFor(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog'));
});
});
-123
View File
@@ -1,123 +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 { Entity } from '@backstage/catalog-model';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
import {
CatalogFilterGroup,
CatalogFilterItem,
} from '../components/CatalogFilter/CatalogFilter';
export enum EntityGroup {
ALL = 'ALL',
STARRED = 'STARRED',
OWNED = 'OWNED',
}
export const filterGroups: CatalogFilterGroup[] = [
{
name: 'Personal',
items: [
{
id: EntityGroup.OWNED,
label: 'Owned',
icon: SettingsIcon,
},
{
id: EntityGroup.STARRED,
label: 'Starred',
icon: StarIcon,
},
],
},
{
// TODO: Replace with Company name, read from app config.
name: 'Company',
items: [
{
id: EntityGroup.ALL,
label: 'All Services',
},
],
},
];
export const getCatalogFilterItemByType = (filterType: EntityGroup) => {
for (const group of filterGroups) {
for (const filter of group.items) {
if (filter.id === filterType) {
return filter;
}
}
}
return null;
};
type EntityFilter = (entity: Entity, options: EntityFilterOptions) => boolean;
type EntityFilterOptions = Partial<{
isStarred: boolean;
userId: string;
}>;
type Owned = {
owner: string;
};
export const entityFilters: Record<string, EntityFilter> = {
[EntityGroup.OWNED]: (e, { userId }) => {
const owner = (e.spec! as Owned).owner;
return owner === userId;
},
[EntityGroup.ALL]: () => true,
[EntityGroup.STARRED]: (_, { isStarred }) => !!isStarred,
};
export const entityTypeFilter = (e: Entity, type: string) =>
(e.spec as any)?.type === type;
type EntityType = 'service' | 'website' | 'library' | 'documentation' | 'other';
type LabeledEntityType = {
id: EntityType;
label: string;
};
export const labeledEntityTypes: LabeledEntityType[] = [
{
id: 'service',
label: 'Services',
},
{
id: 'website',
label: 'Websites',
},
{
id: 'library',
label: 'Libraries',
},
{
id: 'documentation',
label: 'Documentation',
},
{
id: 'other',
label: 'Other',
},
];
export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0];
@@ -0,0 +1,207 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { useApi } from '@backstage/core';
import React, { useCallback, useRef, useState } from 'react';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../api/types';
import { filterGroupsContext, FilterGroupsContext } from './context';
import {
EntityFilterFn,
FilterGroup,
FilterGroupState,
FilterGroupStates,
} from './types';
/**
* Implementation of the shared filter groups state.
*/
export const EntityFilterGroupsProvider = ({
children,
}: {
children?: React.ReactNode;
}) => {
const state = useProvideEntityFilters();
return (
<filterGroupsContext.Provider value={state}>
{children}
</filterGroupsContext.Provider>
);
};
// The hook that implements the actual context building
function useProvideEntityFilters(): FilterGroupsContext {
const catalogApi = useApi(catalogApiRef);
const { value: entities, error } = useAsync(() => catalogApi.getEntities());
const filterGroups = useRef<{
[filterGroupId: string]: FilterGroup;
}>({});
const selectedFilterKeys = useRef<{
[filterGroupId: string]: Set<string>;
}>({});
const [filterGroupStates, setFilterGroupStates] = useState<{
[filterGroupId: string]: FilterGroupStates;
}>({});
const [matchingEntities, setMatchingEntities] = useState<Entity[]>([]);
const rebuild = useCallback(() => {
setFilterGroupStates(
buildStates(
filterGroups.current,
selectedFilterKeys.current,
entities,
error,
),
);
setMatchingEntities(
buildMatchingEntities(
filterGroups.current,
selectedFilterKeys.current,
entities,
),
);
}, [entities, error]);
const register = useCallback(
(
filterGroupId: string,
filterGroup: FilterGroup,
initialSelectedFilterIds?: string[],
) => {
filterGroups.current[filterGroupId] = filterGroup;
selectedFilterKeys.current[filterGroupId] = new Set(
initialSelectedFilterIds ?? [],
);
rebuild();
},
[rebuild],
);
const unregister = useCallback(
(filterGroupId: string) => {
delete filterGroups.current[filterGroupId];
delete selectedFilterKeys.current[filterGroupId];
rebuild();
},
[rebuild],
);
const setGroupSelectedFilters = useCallback(
(filterGroupId: string, filters: string[]) => {
selectedFilterKeys.current[filterGroupId] = new Set(filters);
rebuild();
},
[rebuild],
);
return {
register,
unregister,
setGroupSelectedFilters,
loading: !error && !entities,
error,
filterGroupStates,
matchingEntities,
};
}
// Given all filter groups and what filters are actually selected, along with
// the loading state for entities, generate the state of each individual filter
function buildStates(
filterGroups: { [filterGroupId: string]: FilterGroup },
selectedFilterKeys: { [filterGroupId: string]: Set<string> },
entities?: Entity[],
error?: Error,
): { [filterGroupId: string]: FilterGroupStates } {
// On error - all entries are an error state
if (error) {
return Object.fromEntries(
Object.keys(filterGroups).map(filterGroupId => [
filterGroupId,
{ type: 'error', error },
]),
);
}
// On startup - all entries are a loading state
if (!entities) {
return Object.fromEntries(
Object.keys(filterGroups).map(filterGroupId => [
filterGroupId,
{ type: 'loading' },
]),
);
}
const result: { [filterGroupId: string]: FilterGroupStates } = {};
for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) {
const otherMatchingEntities = buildMatchingEntities(
filterGroups,
selectedFilterKeys,
entities,
filterGroupId,
);
const groupState: FilterGroupState = { filters: {} };
for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) {
const isSelected = !!selectedFilterKeys[filterGroupId]?.has(filterId);
const matchCount = otherMatchingEntities.filter(entity =>
filterFn(entity),
).length;
groupState.filters[filterId] = { isSelected, matchCount };
}
result[filterGroupId] = { type: 'ready', state: groupState };
}
return result;
}
// Given all filter groups and what filters are actually selected, extract all
// entities that match all those filter groups.
function buildMatchingEntities(
filterGroups: { [filterGroupId: string]: FilterGroup },
selectedFilterKeys: { [filterGroupId: string]: Set<string> },
entities?: Entity[],
excludeFilterGroupId?: string,
): Entity[] {
// Build one filter fn per filter group
const allFilters: EntityFilterFn[] = [];
for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) {
if (excludeFilterGroupId === filterGroupId) {
continue;
}
// Pick out all of the filter functions in the group that are actually
// selected
const groupFilters: EntityFilterFn[] = [];
for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) {
if (!!selectedFilterKeys[filterGroupId]?.has(filterId)) {
groupFilters.push(filterFn);
}
}
// Need to match any of the selected filters in the group - if there is
// any at all
if (groupFilters.length) {
allFilters.push(entity => groupFilters.some(fn => fn(entity)));
}
}
// All filter groups that had any checked filters need to match. Note that
// every() always returns true for an empty array.
return entities?.filter(entity => allFilters.every(fn => fn(entity))) ?? [];
}
+40
View File
@@ -0,0 +1,40 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { createContext } from 'react';
import { FilterGroup, FilterGroupStates } from './types';
export type FilterGroupsContext = {
register: (
filterGroupId: string,
filterGroup: FilterGroup,
initialSelectedFilterIds?: string[],
) => void;
unregister: (filterGroupId: string) => void;
setGroupSelectedFilters: (filterGroupId: string, filterIds: string[]) => void;
loading: boolean;
error?: Error;
filterGroupStates: { [filterGroupId: string]: FilterGroupStates };
matchingEntities: Entity[];
};
/**
* The context that maintains shared state for all visible filter groups.
*/
export const filterGroupsContext = createContext<
FilterGroupsContext | undefined
>(undefined);
+28
View File
@@ -0,0 +1,28 @@
/*
* 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 { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider';
export type {
EntityFilterFn,
FilterGroup,
FilterGroupState,
FilterGroupStates,
FilterGroupStatesError,
FilterGroupStatesLoading,
FilterGroupStatesReady,
} from './types';
export { useEntityFilterGroup } from './useEntityFilterGroup';
export { useFilteredEntities } from './useFilteredEntities';
+53
View File
@@ -0,0 +1,53 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
export type EntityFilterFn = (entity: Entity) => boolean;
export type FilterGroup = {
filters: {
[filterId: string]: EntityFilterFn;
};
};
export type FilterGroupState = {
filters: {
[filterId: string]: {
isSelected: boolean;
matchCount: number;
};
};
};
export type FilterGroupStatesReady = {
type: 'ready';
state: FilterGroupState;
};
export type FilterGroupStatesError = {
type: 'error';
error: Error;
};
export type FilterGroupStatesLoading = {
type: 'loading';
};
export type FilterGroupStates =
| FilterGroupStatesReady
| FilterGroupStatesError
| FilterGroupStatesLoading;
@@ -0,0 +1,118 @@
/*
* 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 { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core';
import { act, renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { catalogApiRef } from '../api/types';
import { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider';
import { FilterGroupStatesReady, FilterGroup } from './types';
import { useEntityFilterGroup } from './useEntityFilterGroup';
import { MockStorageApi } from '@backstage/test-utils';
describe('useEntityFilterGroup', () => {
let catalogApi: jest.Mocked<typeof catalogApiRef.T>;
let wrapper: ({ children }: { children?: React.ReactNode }) => JSX.Element;
beforeEach(() => {
catalogApi = {
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
addLocation: jest.fn((_a, _b) => new Promise(() => {})),
getEntities: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
};
const apis = ApiRegistry.with(catalogApiRef, catalogApi).with(
storageApiRef,
MockStorageApi.create(),
);
wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={apis}>
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>
</ApiProvider>
);
});
it('works for an empty set of filters', async () => {
catalogApi.getEntities.mockResolvedValue([]);
const group: FilterGroup = { filters: {} };
const { result, wait } = renderHook(
() => useEntityFilterGroup('g1', group),
{ wrapper },
);
await wait(() => expect(result.current.state.type).toBe('ready'));
});
it('works for a single group', async () => {
catalogApi.getEntities.mockResolvedValue([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'n' },
},
]);
const group: FilterGroup = {
filters: {
f1: e => e.metadata.name === 'n',
f2: e => e.metadata.name !== 'n',
},
};
const { result, wait } = renderHook(
() => useEntityFilterGroup('g1', group),
{ wrapper },
);
await wait(() => expect(result.current.state.type).toEqual('ready'));
let state = result.current.state as FilterGroupStatesReady;
expect(state.state.filters.f1).toEqual({
isSelected: false,
matchCount: 1,
});
expect(state.state.filters.f2).toEqual({
isSelected: false,
matchCount: 0,
});
act(() => result.current.setSelectedFilters(['f1']));
await wait(() => expect(result.current.state.type).toEqual('ready'));
state = result.current.state as FilterGroupStatesReady;
expect(state.state.filters.f1).toEqual({
isSelected: true,
matchCount: 1,
});
expect(state.state.filters.f2).toEqual({
isSelected: false,
matchCount: 0,
});
act(() => result.current.setSelectedFilters(['f2']));
await wait(() => expect(result.current.state.type).toEqual('ready'));
state = result.current.state as FilterGroupStatesReady;
expect(state.state.filters.f1).toEqual({
isSelected: false,
matchCount: 1,
});
expect(state.state.filters.f2).toEqual({
isSelected: true,
matchCount: 0,
});
});
});
@@ -0,0 +1,69 @@
/*
* 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 { useCallback, useContext, useEffect, useMemo } from 'react';
import { filterGroupsContext } from './context';
import { FilterGroup, FilterGroupStates } from './types';
export type EntityFilterGroupOutput = {
state: FilterGroupStates;
setSelectedFilters: (filterIds: string[]) => void;
};
/**
* Hook that exposes the relevant data and operations for a single filter
* group.
*/
export const useEntityFilterGroup = (
filterGroupId: string,
filterGroup: FilterGroup,
initialSelectedFilters?: string[],
): EntityFilterGroupOutput => {
const context = useContext(filterGroupsContext);
if (!context) {
throw new Error(`Must be used inside an EntityFilterGroupsProvider`);
}
const {
register,
unregister,
setGroupSelectedFilters,
filterGroupStates,
} = context;
// Intentionally consider initial set only at mount time
// eslint-disable-next-line react-hooks/exhaustive-deps
const initialMemo = useMemo(() => initialSelectedFilters?.slice(), []);
// Register the group on mount, and unregister on unmount
useEffect(() => {
register(filterGroupId, filterGroup, initialMemo);
return () => unregister(filterGroupId);
}, [register, unregister, filterGroupId, filterGroup, initialMemo]);
const setSelectedFilters = useCallback(
(filters: string[]) => {
setGroupSelectedFilters(filterGroupId, filters);
},
[setGroupSelectedFilters, filterGroupId],
);
let state = filterGroupStates[filterGroupId];
if (!state) {
state = { type: 'loading' };
}
return { state, setSelectedFilters };
};
@@ -0,0 +1,34 @@
/*
* 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 { useContext } from 'react';
import { filterGroupsContext } from './context';
/**
* Hook that exposes the result of applying a set of filter groups.
*/
export function useFilteredEntities() {
const context = useContext(filterGroupsContext);
if (!context) {
throw new Error(`Must be used inside an EntityFilterGroupsProvider`);
}
return {
loading: context.loading,
error: context.error,
matchingEntities: context.matchingEntities,
};
}
-103
View File
@@ -1,103 +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 { useState, useMemo } from 'react';
import {
EntityGroup,
entityFilters,
entityTypeFilter,
labeledEntityTypes,
} from '../data/filters';
import { useApi, identityApiRef } from '@backstage/core';
import { catalogApiRef } from '..';
import { useStarredEntities } from './useStarredEntites';
import { Entity } from '@backstage/catalog-model';
import useStaleWhileRevalidate from 'swr';
export type EntitiesByFilter = Record<EntityGroup, Entity[] | undefined>;
type UseEntities = {
selectedFilter: EntityGroup | undefined;
setSelectedFilter: (f: EntityGroup) => void;
error: Error | null;
toggleStarredEntity: any;
isStarredEntity: (e: Entity) => boolean;
entitiesByFilter: EntitiesByFilter;
loading: boolean;
selectedTypeFilter: string;
selectTypeFilter: (id: string) => void;
};
export const useEntities = (): UseEntities => {
const [selectedFilter, setSelectedFilter] = useState<
EntityGroup | undefined
>();
const catalogApi = useApi(catalogApiRef);
const { toggleStarredEntity, isStarredEntity } = useStarredEntities();
const { data: entities, error } = useStaleWhileRevalidate(
['catalog/all', entityFilters[selectedFilter ?? EntityGroup.ALL]],
async () => catalogApi.getEntities(),
);
const indentityApi = useApi(identityApiRef);
const userId = indentityApi.getUserId();
const [selectedTypeFilter, selectTypeFilter] = useState<string>(
labeledEntityTypes[0].id,
);
const entitiesByFilter = useMemo(() => {
const filterEntities = (
ents: Entity[] | undefined,
filterId: EntityGroup,
isStarred: (e: Entity) => boolean,
user: string,
) => {
return ents
?.filter((e: Entity) =>
entityFilters[filterId](e, {
isStarred: isStarred(e),
userId: user,
}),
)
.filter(e => entityTypeFilter(e, selectedTypeFilter));
};
const data = Object.keys(EntityGroup).reduce(
(res, key) => ({
...res,
[key]: filterEntities(
entities,
key as EntityGroup,
isStarredEntity,
userId,
),
}),
{} as EntitiesByFilter,
);
return data;
}, [entities, isStarredEntity, userId, selectedTypeFilter]);
return {
selectedFilter,
setSelectedFilter,
error,
toggleStarredEntity,
isStarredEntity,
entitiesByFilter,
loading: entities === undefined,
selectedTypeFilter,
selectTypeFilter,
};
};
+1 -1
View File
@@ -28,7 +28,7 @@
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0"
},
"devDependencies": {
+22
View File
@@ -79,6 +79,14 @@ export interface ListClusterRequest {
gitHubToken: string;
}
export interface GithubUserInfoRequest {
accessToken: string;
}
export interface GithubUserInfoResponse {
login: string;
}
export class FetchError extends Error {
get name(): string {
return this.constructor.name;
@@ -100,6 +108,7 @@ export type GitOpsApi = {
cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise<any>;
applyProfiles(req: ApplyProfileRequest): Promise<any>;
listClusters(req: ListClusterRequest): Promise<ListClusterStatusesResponse>;
fetchUserInfo(req: GithubUserInfoRequest): Promise<GithubUserInfoResponse>;
};
export const gitOpsApiRef = createApiRef<GitOpsApi>({
@@ -116,6 +125,19 @@ export class GitOpsRestApi implements GitOpsApi {
return await resp.json();
}
async fetchUserInfo(
req: GithubUserInfoRequest,
): Promise<GithubUserInfoResponse> {
const resp = await fetch(`https://api.github.com/user`, {
method: 'get',
headers: new Headers({
Authorization: `token ${req.accessToken}`,
}),
});
if (!resp.ok) throw await FetchError.forResponse(resp);
return await resp.json();
}
async fetchLog(req: PollLogRequest): Promise<StatusResponse> {
return await this.fetch<StatusResponse>(`/api/cluster/run-status`, {
method: 'post',
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import React, { FC, useState } from 'react';
import {
Content,
ContentHeader,
@@ -25,32 +25,30 @@ import {
Progress,
HeaderLabel,
useApi,
githubAuthApiRef,
} from '@backstage/core';
import ClusterTable from '../ClusterTable/ClusterTable';
import { Button } from '@material-ui/core';
import { useAsync, useLocalStorage } from 'react-use';
import { useAsync } from 'react-use';
import { gitOpsApiRef, ListClusterStatusesResponse } from '../../api';
import { Alert } from '@material-ui/lab';
const ClusterList: FC<{}> = () => {
const [loginInfo] = useLocalStorage<{
token: string;
username: string;
name: string;
}>('githubLoginDetails', {
token: '',
username: '',
name: 'Guest',
});
const api = useApi(gitOpsApiRef);
const githubAuth = useApi(githubAuthApiRef);
const [githubUsername, setGithubUsername] = useState(String);
const { loading, error, value } = useAsync<ListClusterStatusesResponse>(
() => {
async () => {
const accessToken = await githubAuth.getAccessToken(['repo', 'user']);
if (!githubUsername) {
const userInfo = await api.fetchUserInfo({ accessToken });
setGithubUsername(userInfo.login);
}
return api.listClusters({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
gitHubToken: accessToken,
gitHubUser: githubUsername,
});
},
);
@@ -73,9 +71,6 @@ const ClusterList: FC<{}> = () => {
Please make sure that you start GitOps-API backend on localhost port
3008 before using this plugin.
</Alert>
<Alert severity="info">
If you're Guest, please login via GitHub first.
</Alert>
</div>
</Content>
);
@@ -100,7 +95,7 @@ const ClusterList: FC<{}> = () => {
return (
<Page theme={pageTheme.home}>
<Header title="GitOps-managed Clusters">
<HeaderLabel label="Welcome" value={loginInfo.name} />
<HeaderLabel label="Welcome" value={githubUsername} />
</Header>
{content}
</Page>
@@ -24,21 +24,16 @@ import {
Progress,
HeaderLabel,
useApi,
githubAuthApiRef,
} from '@backstage/core';
import { Link } from '@material-ui/core';
import { useParams } from 'react-router-dom';
import { useLocalStorage } from 'react-use';
import { gitOpsApiRef, Status } from '../../api';
import { transformRunStatus } from '../ProfileCatalog';
const ClusterPage: FC<{}> = () => {
const params = useParams() as { owner: string; repo: string };
const [loginInfo] = useLocalStorage<{
token: string;
username: string;
name: string;
}>('githubLoginDetails');
const [pollingLog, setPollingLog] = useState(true);
const [runStatus, setRunStatus] = useState<Status[]>([]);
@@ -46,6 +41,9 @@ const ClusterPage: FC<{}> = () => {
const [showProgress, setShowProgress] = useState(true);
const api = useApi(gitOpsApiRef);
const githubAuth = useApi(githubAuthApiRef);
const [githubAccessToken, setGithubAccessToken] = useState(String);
const [githubUsername, setGithubUsername] = useState(String);
const columns = [
{ field: 'status', title: 'Status' },
@@ -53,31 +51,43 @@ const ClusterPage: FC<{}> = () => {
];
useEffect(() => {
if (pollingLog) {
const interval = setInterval(async () => {
const resp = await api.fetchLog({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
targetOrg: params.owner,
targetRepo: params.repo,
});
const fetchGithubUserInfo = async () => {
const accessToken = await githubAuth.getAccessToken(['repo', 'user']);
const userInfo = await api.fetchUserInfo({ accessToken });
setGithubAccessToken(accessToken);
setGithubUsername(userInfo.login);
};
setRunStatus(resp.result);
setRunLink(resp.link);
if (resp.status === 'completed') {
setPollingLog(false);
setShowProgress(false);
}
}, 10000);
return () => clearInterval(interval);
if (!githubAccessToken || !githubUsername) {
fetchGithubUserInfo();
} else {
if (pollingLog) {
const interval = setInterval(async () => {
const resp = await api.fetchLog({
gitHubToken: githubAccessToken,
gitHubUser: githubUsername,
targetOrg: params.owner,
targetRepo: params.repo,
});
setRunStatus(resp.result);
setRunLink(resp.link);
if (resp.status === 'completed') {
setPollingLog(false);
setShowProgress(false);
}
}, 10000);
return () => clearInterval(interval);
}
}
return () => {};
}, [pollingLog, api, loginInfo, params]);
}, [pollingLog, api, params, githubAuth, githubAccessToken, githubUsername]);
return (
<Page theme={pageTheme.home}>
<Header title={`Cluster ${params.owner}/${params.repo}`}>
<HeaderLabel label="Welcome" value={loginInfo.name} />
<HeaderLabel label="Welcome" value={githubUsername} />
</Header>
<Content>
<Progress hidden={!showProgress} />
@@ -20,13 +20,28 @@ import mockFetch from 'jest-fetch-mock';
import ProfileCatalog from './ProfileCatalog';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import {
ApiProvider,
ApiRegistry,
githubAuthApiRef,
GithubAuth,
OAuthRequestManager,
} from '@backstage/core';
import { gitOpsApiRef, GitOpsRestApi } from '../../api';
describe('ProfileCatalog', () => {
it('should render', () => {
const oauthRequestApi = new OAuthRequestManager();
const apis = ApiRegistry.from([
[gitOpsApiRef, new GitOpsRestApi('http://localhost:3008')],
[
githubAuthApiRef,
GithubAuth.create({
apiOrigin: 'http://localhost:7000',
basePath: '/auth/',
oauthRequestApi,
}),
],
]);
mockFetch.mockResponse(() => new Promise(() => {}));
const rendered = render(
@@ -35,6 +35,7 @@ import {
StatusPending,
StatusAborted,
useApi,
githubAuthApiRef,
} from '@backstage/core';
import { TextField, List, ListItem, Link } from '@material-ui/core';
@@ -111,17 +112,12 @@ const ProfileCatalog: FC<{}> = () => {
},
]);
const [loginInfo] = useLocalStorage('githubLoginDetails', {
name: 'Guest',
username: '',
token: '',
});
const [templateRepo] = useLocalStorage<string>('gitops-template-repo');
const [gitopsProfiles] = useLocalStorage<string[]>('gitops-profiles');
const [showProgress, setShowProgress] = useState(false);
const [pollingLog, setPollingLog] = useState(false);
const [gitHubOrg, setGitHubOrg] = useState(loginInfo.username);
const [gitHubOrg, setGitHubOrg] = useState(String);
const [gitHubRepo, setGitHubRepo] = useState('new-cluster');
const [awsAccessKeyId, setAwsAccessKeyId] = useState(String);
const [awsSecretAccessKey, setAwsSecretAccessKey] = useState(String);
@@ -129,28 +125,52 @@ const ProfileCatalog: FC<{}> = () => {
const [runLink, setRunLink] = useState<string>('');
const api = useApi(gitOpsApiRef);
const githubAuth = useApi(githubAuthApiRef);
const [githubAccessToken, setGithubAccessToken] = useState(String);
const [githubUsername, setGithubUsername] = useState(String);
useEffect(() => {
if (pollingLog) {
const interval = setInterval(async () => {
const resp = await api.fetchLog({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
});
const fetchGithubUserInfo = async () => {
const accessToken = await githubAuth.getAccessToken(['repo', 'user']);
const userInfo = await api.fetchUserInfo({ accessToken });
setGithubAccessToken(accessToken);
setGithubUsername(userInfo.login);
setGitHubOrg(userInfo.login);
};
setRunStatus(resp.result);
setRunLink(resp.link);
if (resp.status === 'completed') {
setPollingLog(false);
setShowProgress(false);
}
}, 10000);
return () => clearInterval(interval);
if (!githubAccessToken || !githubUsername) {
fetchGithubUserInfo();
} else {
if (pollingLog) {
const interval = setInterval(async () => {
const resp = await api.fetchLog({
gitHubToken: githubAccessToken,
gitHubUser: githubUsername,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
});
setRunStatus(resp.result);
setRunLink(resp.link);
if (resp.status === 'completed') {
setPollingLog(false);
setShowProgress(false);
}
}, 10000);
return () => clearInterval(interval);
}
}
return () => {};
}, [pollingLog, api, gitHubOrg, gitHubRepo, loginInfo]);
}, [
pollingLog,
api,
gitHubOrg,
gitHubRepo,
githubAuth,
githubAccessToken,
githubUsername,
]);
const showFailureMessage = (msg: string) => {
setRunStatus(
@@ -182,8 +202,8 @@ const ProfileCatalog: FC<{}> = () => {
const cloneResponse = await api.cloneClusterFromTemplate({
templateRepository: templateRepo,
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
gitHubToken: githubAccessToken,
gitHubUser: githubUsername,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
secrets: {
@@ -200,8 +220,8 @@ const ProfileCatalog: FC<{}> = () => {
}
const applyProfileResp = await api.applyProfiles({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
gitHubToken: githubAccessToken,
gitHubUser: githubUsername,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
profiles: gitopsProfiles,
@@ -215,8 +235,8 @@ const ProfileCatalog: FC<{}> = () => {
}
const clusterStateResp = await api.changeClusterState({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
gitHubToken: githubAccessToken,
gitHubUser: githubUsername,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
clusterState: 'present',
@@ -244,7 +264,7 @@ const ProfileCatalog: FC<{}> = () => {
title="Create GitOps-managed Cluster"
subtitle="Kubernetes cluster with ready-to-use profiles"
>
<HeaderLabel label="Welcome" value={loginInfo.name} />
<HeaderLabel label="Welcome" value={githubUsername} />
</Header>
<Content>
<ContentHeader title="Create Cluster">
+8 -3
View File
@@ -18,12 +18,17 @@ import { createPlugin } from '@backstage/core';
import ProfileCatalog from './components/ProfileCatalog';
import ClusterPage from './components/ClusterPage';
import ClusterList from './components/ClusterList';
import {
gitOpsClusterListRoute,
gitOpsClusterDetailsRoute,
gitOpsClusterCreateRoute,
} from './routes';
export const plugin = createPlugin({
id: 'gitops-profiles',
register({ router }) {
router.registerRoute('/gitops-clusters', ClusterList);
router.registerRoute('/gitops-cluster/:owner/:repo', ClusterPage);
router.registerRoute('/gitops-cluster-create', ProfileCatalog);
router.addRoute(gitOpsClusterListRoute, ClusterList);
router.addRoute(gitOpsClusterDetailsRoute, ClusterPage);
router.addRoute(gitOpsClusterCreateRoute, ProfileCatalog);
},
});
+37
View File
@@ -0,0 +1,37 @@
/*
* 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 { createRouteRef } from '@backstage/core';
const NoIcon = () => null;
export const gitOpsClusterListRoute = createRouteRef({
icon: NoIcon,
path: '/gitops-clusters',
title: 'GitOps Clusters',
});
export const gitOpsClusterDetailsRoute = createRouteRef({
icon: NoIcon,
path: '/gitops-cluster/:owner/:repo',
title: 'GitOps Cluster details',
});
export const gitOpsClusterCreateRoute = createRouteRef({
icon: NoIcon,
path: '/gitops-cluster-create',
title: 'GitOps Cluster create',
});
+1
View File
@@ -37,6 +37,7 @@
"helmet": "^3.22.0",
"morgan": "^1.10.0",
"nodegit": "0.26.5",
"uuid": "^8.2.0",
"winston": "^3.2.1"
},
"devDependencies": {
@@ -9,4 +9,5 @@ for URL in \
--request POST 'localhost:7000/catalog/locations' \
--header 'Content-Type: application/json' \
--data-raw "{\"type\": \"file\", \"target\": \"$(pwd)/sample-templates/${URL}/template.yaml\"}"
echo
done
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './templater';
export * from './prepare';
export * from './templater/cookiecutter';
export * from './stages/templater';
export * from './stages/templater/cookiecutter';
export * from './stages/prepare';
export * from './jobs';
@@ -0,0 +1,17 @@
/*
* 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 * from './processor';
export * from './types';
@@ -0,0 +1,45 @@
/*
* 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 { PassThrough } from 'stream';
import winston from 'winston';
import { JsonValue } from '@backstage/config';
export const useLogStream = (meta: Record<string, JsonValue>) => {
const log: string[] = [];
// Create an empty stream to collect all the log lines into
// one variable for the API.
const stream = new PassThrough();
stream.on('data', chunk => log.push(chunk.toString()));
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.colorize(),
winston.format.timestamp(),
winston.format.simple(),
),
defaultMeta: meta,
});
logger.add(new winston.transports.Stream({ stream }));
return {
log,
stream,
logger,
};
};
@@ -0,0 +1,278 @@
/*
* 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 { JobProcessor } from './processor';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { StageInput } from './types';
describe('JobProcessor', () => {
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
'backstage.io/managed-by-location':
'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
generation: 1,
},
spec: {
type: 'cookiecutter',
path: './template',
},
};
const mockValues = { component_id: 'bob' };
describe('create', () => {
it('creates should create a new job with a unique id', async () => {
const processor = new JobProcessor();
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages: [],
});
expect(job.id).toMatch(
/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i,
);
});
it('should setup the correct context for the job', async () => {
const processor = new JobProcessor();
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages: [],
});
expect(job.context.entity).toBe(mockEntity);
expect(job.context.values).toBe(mockValues);
});
it('should set the status as pending', async () => {
const processor = new JobProcessor();
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages: [],
});
expect(job.status).toBe('PENDING');
});
it('should create the correct stages', async () => {
const stages: StageInput[] = [
{
name: 'Do something cool step 1',
handler: jest.fn(),
},
{
name: 'Do something cool step 2',
handler: jest.fn(),
},
];
const processor = new JobProcessor();
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages,
});
expect(job.stages).toHaveLength(stages.length);
for (let i = 0; i < job.stages.length; i++) {
expect(job.stages[i].name).toBe(stages[i].name);
expect(job.stages[i].status).toBe('PENDING');
}
});
});
describe('get', () => {
it('return undefined for when the job does not exist', () => {
const processor = new JobProcessor();
expect(processor.get('123')).not.toBeDefined();
});
it('should return the exact same instance of the job when one is created', async () => {
const processor = new JobProcessor();
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages: [],
});
expect(processor.get(job.id)).toBe(job);
});
});
describe('process', () => {
it('throws an error when the status of the job is not in pending state', async () => {
const processor = new JobProcessor();
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages: [],
});
job.status = 'STARTED';
await expect(processor.run(job)).rejects.toThrow(
/Job is not in a 'PENDING' state/,
);
});
it('will call each of the handlers in the stages', async () => {
const stages: StageInput[] = [
{
name: 'c/o',
handler: jest.fn(),
},
{
name: 'g/p',
handler: jest.fn(),
},
];
const processor = new JobProcessor();
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages,
});
await processor.run(job);
for (const stage of stages) {
expect(stage.handler).toHaveBeenCalled();
}
});
it('should set all stages to complete and the job to complete when finishes without errors', async () => {
const stages: StageInput[] = [
{
name: 'c/o',
handler: jest.fn(),
},
{
name: 'g/p',
handler: jest.fn(),
},
];
const processor = new JobProcessor();
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages,
});
await processor.run(job);
for (const stage of job.stages) {
expect(stage.status).toBe('COMPLETED');
}
expect(job.status).toBe('COMPLETED');
});
it('should merge the return value from previous steps into the context of the next step', async () => {
const stages: StageInput[] = [
{
name: 'c/o',
handler: jest
.fn()
.mockResolvedValue({ first: 'ben', second: 'lambert' }),
},
{
name: 'g/p',
handler: jest
.fn()
.mockResolvedValue({ second: 'linus', third: 'lambert' }),
},
{
name: 'go',
handler: jest.fn(),
},
];
const processor = new JobProcessor();
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages,
});
await processor.run(job);
expect(stages[1].handler).toHaveBeenCalledWith(
expect.objectContaining({ first: 'ben', second: 'lambert' }),
);
expect(stages[2].handler).toHaveBeenCalledWith(
expect.objectContaining({
first: 'ben',
second: 'linus',
third: 'lambert',
}),
);
});
it('should fail the job and the step if one of them fails', async () => {
const fail = new Error('something went wrong here');
const stages: StageInput[] = [
{
name: 'c/o',
handler: jest.fn(),
},
{
name: 'g/p',
handler: jest.fn().mockRejectedValue(fail),
},
{
name: 'go',
handler: jest.fn(),
},
];
const processor = new JobProcessor();
const job = processor.create({
entity: mockEntity,
values: mockValues,
stages,
});
await processor.run(job);
expect(job.status).toBe('FAILED');
expect(job.stages[0].status).toBe('COMPLETED');
expect(job.stages[1].status).toBe('FAILED');
expect(job.stages[2].status).toBe('PENDING');
expect(job.error?.message).toBe('something went wrong here');
expect(job.stages[1].log.join()).toContain('something went wrong here');
});
});
});
@@ -0,0 +1,139 @@
/*
* 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 { Processor, Job, StageContext, StageInput } from './types';
import { JsonValue } from '@backstage/config';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import * as uuid from 'uuid';
import Docker from 'dockerode';
import { RequiredTemplateValues, TemplaterBase } from '../stages/templater';
import { PreparerBuilder } from '../stages/prepare';
import { useLogStream } from './logger';
export type JobProcessorArguments = {
preparers: PreparerBuilder;
templater: TemplaterBase;
dockerClient: Docker;
};
export type JobAndDirectoryTuple = {
job: Job;
directory: string;
};
export class JobProcessor implements Processor {
private jobs = new Map<string, Job>();
create({
entity,
values,
stages,
}: {
entity: TemplateEntityV1alpha1;
values: RequiredTemplateValues & Record<string, JsonValue>;
stages: StageInput[];
}): Job {
const id = uuid.v4();
const { logger, stream } = useLogStream({ id });
const context: StageContext = {
entity,
values,
logger,
logStream: stream,
};
const job: Job = {
id,
context,
stages: stages.map(stage => ({
handler: stage.handler,
log: [],
name: stage.name,
status: 'PENDING',
})),
status: 'PENDING',
};
this.jobs.set(job.id, job);
return job;
}
get(id: string): Job | undefined {
return this.jobs.get(id);
}
async run(job: Job): Promise<void> {
if (job.status !== 'PENDING') {
throw new Error("Job is not in a 'PENDING' state");
}
job.status = 'STARTED';
try {
for (const stage of job.stages) {
// Create a logger for each stage so we can create seperate
// Streams for each step.
const { logger, log, stream } = useLogStream({
id: job.id,
stage: stage.name,
});
// Attach the logger to the stage, and setup some timestamps.
stage.log = log;
stage.startedAt = Date.now();
try {
// Run the handler with the context created for the Job and some
// Additional logging helpers.
const handlerResponse = await stage.handler({
...job.context,
logger,
logStream: stream,
});
// If the handler returns something, then let's merge this onto the ontext
// For the next stage to use as it might be relevant.
if (handlerResponse) {
job.context = {
...job.context,
...handlerResponse,
};
}
// Complete the current stage
stage.status = 'COMPLETED';
} catch (error) {
// Log to the current stage the error that occured and fail the stage.
logger.error(`Stage failed with error: ${error.message}`);
stage.status = 'FAILED';
// Throw the error so the job can be failed too.
throw error;
} finally {
// Always set the stage end timestamp.
stage.endedAt = Date.now();
}
}
// If all went to plan, complete the job.
job.status = 'COMPLETED';
} catch (error) {
// If something went wrong, fail the job, and set the error property on the job.
job.error = { name: error.name, message: error.message };
job.status = 'FAILED';
}
}
}
@@ -0,0 +1,68 @@
/*
* 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 type { Writable } from 'stream';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { JsonValue } from '@backstage/config';
import { RequiredTemplateValues } from '../stages/templater';
import { Logger } from 'winston';
// Context will be a mutable object which is passed between stages
// To share data, but also thinking that we can pass in functions here too
// To maybe create sub steps or fail the entire thing, or skip stages down the line.
export type StageContext<T = {}> = {
values: RequiredTemplateValues & Record<string, JsonValue>;
entity: TemplateEntityV1alpha1;
logger: Logger;
logStream: Writable;
} & T;
export type ProcessorStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
export interface Stage extends StageInput {
log: string[];
status: ProcessorStatus;
startedAt?: number;
endedAt?: number;
}
export interface StageInput<T = {}> {
name: string;
handler(ctx: StageContext<T>): Promise<void | object>;
}
export type Job = {
id: string;
context: StageContext;
status: ProcessorStatus;
stages: Stage[];
error?: Error;
};
export type Processor = {
create({
entity,
values,
stages,
}: {
entity: TemplateEntityV1alpha1;
values: RequiredTemplateValues & Record<string, JsonValue>;
stages: StageInput[];
}): Job;
get(id: string): Job | undefined;
run(job: Job): Promise<void>;
};
@@ -60,7 +60,7 @@ describe('GitHubPreparer', () => {
1,
'https://github.com/benjdlambert/backstage-graphql-template',
expect.any(String),
{ checkoutOpts: { paths: ['template'] } },
{},
);
});
it('calls the clone command with the correct arguments for a repository when no path is provided', async () => {
@@ -71,7 +71,7 @@ describe('GitHubPreparer', () => {
1,
'https://github.com/benjdlambert/backstage-graphql-template',
expect.any(String),
{ checkoutOpts: {} },
{},
);
});
@@ -21,7 +21,7 @@ import { parseLocationAnnotation } from './helpers';
import { InputError } from '@backstage/backend-common';
import { PreparerBase } from './types';
import GitUriParser from 'git-url-parse';
import { Clone, CheckoutOptions } from 'nodegit';
import { Clone } from 'nodegit';
export class GithubPreparer implements PreparerBase {
async prepare(template: TemplateEntityV1alpha1): Promise<string> {
@@ -45,13 +45,7 @@ export class GithubPreparer implements PreparerBase {
template.spec.path ?? '.',
);
const checkoutOptions = new CheckoutOptions();
if (template.spec.path) {
checkoutOptions.paths = [templateDirectory];
}
await Clone.clone(repositoryCheckoutUrl, tempDir, {
checkoutOpts: checkoutOptions,
// TODO(blam): Maybe need some auth here?
});
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import type { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { Logger } from 'winston';
export type PreparerBase = {
/**
@@ -21,7 +22,10 @@ export type PreparerBase = {
* with contents from the remote location in temporary storage and return the path
* @param template The template entity from the Service Catalog
*/
prepare(template: TemplateEntityV1alpha1): Promise<string>;
prepare(
template: TemplateEntityV1alpha1,
opts: { logger: Logger },
): Promise<string>;
};
export type PreparerBuilder = {
@@ -18,6 +18,7 @@ jest.mock('./helpers', () => ({ runDockerContainer: jest.fn() }));
import { CookieCutter } from './cookiecutter';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { RunDockerContainerOptions } from './helpers';
import { PassThrough } from 'stream';
import Docker from 'dockerode';
@@ -33,12 +34,15 @@ describe('CookieCutter Templater', () => {
beforeEach(async () => {
jest.clearAllMocks();
await fs.remove(`${os.tmpdir()}/cookiecutter.json`);
});
const mkTemp = async () => {
const tempDir = os.tmpdir();
return await fs.promises.mkdtemp(path.join(tempDir, 'temp'));
};
it('should write a cookiecutter.json file with the values from the entitiy', async () => {
const tempdir = os.tmpdir();
const tempdir = await mkTemp();
const values = {
component_id: 'test',
@@ -53,10 +57,11 @@ describe('CookieCutter Templater', () => {
});
it('should merge any value that is in the cookiecutter.json path already', async () => {
const tempdir = os.tmpdir();
const tempdir = await mkTemp();
const existingJson = {
_copy_without_render: ['./github/workflows/*'],
};
await fs.writeJSON(`${tempdir}/cookiecutter.json`, existingJson);
const values = {
@@ -72,7 +77,7 @@ describe('CookieCutter Templater', () => {
});
it('should throw an error if the cookiecutter json is malformed and not missing', async () => {
const tempdir = os.tmpdir();
const tempdir = await mkTemp();
await fs.writeFile(`${tempdir}/cookiecutter.json`, "{'");
@@ -87,7 +92,7 @@ describe('CookieCutter Templater', () => {
});
it('should run the correct docker container with the correct bindings for the volumes', async () => {
const tempdir = os.tmpdir();
const tempdir = await mkTemp();
const values = {
component_id: 'test',
@@ -97,35 +102,43 @@ describe('CookieCutter Templater', () => {
await cookie.run({ directory: tempdir, values, dockerClient: mockDocker });
expect(runDockerContainer).toHaveBeenCalledWith({
imageName: 'backstage/cookiecutter',
args: ['cookiecutter', '--no-input', '-o', '/result', '/template'],
imageName: 'spotify/backstage-cookiecutter',
args: [
'cookiecutter',
'--no-input',
'-o',
'/result',
'/template',
'--verbose',
],
templateDir: tempdir,
resultDir: `${tempdir}/result`,
resultDir: expect.stringContaining(`${tempdir}-result`),
logStream: undefined,
dockerClient: mockDocker,
});
});
it('should return the result path to the end templated folder', async () => {
const tempdir = os.tmpdir();
const tempdir = await mkTemp();
const values = {
component_id: 'test',
description: 'description',
};
const path = await cookie.run({
const returnPath = await cookie.run({
directory: tempdir,
values,
dockerClient: mockDocker,
});
expect(path).toBe(`${tempdir}/result`);
expect(returnPath.startsWith(`${tempdir}-result`)).toBeTruthy();
});
it('should pass through the streamer to the run docker helper', async () => {
const stream = new PassThrough();
const tempdir = os.tmpdir();
const tempdir = await mkTemp();
const values = {
component_id: 'test',
@@ -140,10 +153,17 @@ describe('CookieCutter Templater', () => {
});
expect(runDockerContainer).toHaveBeenCalledWith({
imageName: 'backstage/cookiecutter',
args: ['cookiecutter', '--no-input', '-o', '/result', '/template'],
imageName: 'spotify/backstage-cookiecutter',
args: [
'cookiecutter',
'--no-input',
'-o',
'/result',
'/template',
'--verbose',
],
templateDir: tempdir,
resultDir: `${tempdir}/result`,
resultDir: expect.stringContaining(`${tempdir}-result`),
logStream: stream,
dockerClient: mockDocker,
});
@@ -48,14 +48,18 @@ export class CookieCutter implements TemplaterBase {
await fs.writeJSON(`${options.directory}/cookiecutter.json`, cookieInfo);
const templateDir = options.directory;
// TODO(blam): This should be an entirely different directory on the host machine
// not in the template directory
const resultDir = `${templateDir}/result`;
const resultDir = await fs.promises.mkdtemp(`${options.directory}-result`);
await runDockerContainer({
imageName: 'backstage/cookiecutter',
args: ['cookiecutter', '--no-input', '-o', '/result', '/template'],
imageName: 'spotify/backstage-cookiecutter',
args: [
'cookiecutter',
'--no-input',
'-o',
'/result',
'/template',
'--verbose',
],
templateDir,
resultDir,
logStream: options.logStream,
@@ -16,6 +16,7 @@
import type { Writable } from 'stream';
import Docker from 'dockerode';
import { JsonValue } from '@backstage/config';
export interface RequiredTemplateValues {
component_id: string;
@@ -23,7 +24,7 @@ export interface RequiredTemplateValues {
export interface TemplaterRunOptions {
directory: string;
values: RequiredTemplateValues & object;
values: RequiredTemplateValues & Record<string, JsonValue>;
logStream?: Writable;
dockerClient: Docker;
}
+106 -39
View File
@@ -17,10 +17,11 @@
import { Logger } from 'winston';
import Router from 'express-promise-router';
import express from 'express';
import { PreparerBuilder, TemplaterBase } from '../scaffolder';
import { PreparerBuilder, TemplaterBase, JobProcessor } from '../scaffolder';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import Docker from 'dockerode';
import {} from '@backstage/backend-common';
import { StageContext } from '../scaffolder/jobs/types';
export interface RouterOptions {
preparers: PreparerBuilder;
templater: TemplaterBase;
@@ -35,51 +36,117 @@ export async function createRouter(
const { preparers, templater, logger: parentLogger, dockerClient } = options;
const logger = parentLogger.child({ plugin: 'scaffolder' });
router.post('/v1/jobs', async (_, res) => {
// TODO(blam): Create a unique job here and return the ID so that
// The end user can poll for updates on the current job
res.status(201).json({ accepted: true });
const jobProcessor = new JobProcessor();
// TODO(blam): Take this entity from the post body sent from the frontend
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
'backstage.io/managed-by-location':
'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml',
router
.get('/v1/job/:jobId/stage/:index/log', ({ params }, res) => {
const job = jobProcessor.get(params.jobId);
if (!job) {
res.status(404).send({ error: 'job not found' });
return;
}
res.send(job.stages[Number(params.index)].log.join(''));
})
.get('/v1/job/:jobId', ({ params }, res) => {
const job = jobProcessor.get(params.jobId);
if (!job) {
res.status(404).send({ error: 'job not found' });
return;
}
res.send({
id: job.id,
metadata: {
...job.context,
logger: undefined,
logStream: undefined,
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
status: job.status,
stages: job.stages.map(stage => ({
...stage,
handler: undefined,
})),
error: job.error,
});
})
.post('/v1/jobs', async (_, res) => {
// TODO(blam): Create a unique job here and return the ID so that
// The end user can poll for updates on the current job
generation: 1,
},
spec: {
type: 'cookiecutter',
path: './template',
},
};
// TODO(blam): Take this entity from the post body sent from the frontend
const mockEntity: TemplateEntityV1alpha1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
'backstage.io/managed-by-location':
'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml',
},
name: 'graphql-starter',
title: 'GraphQL Service',
description:
'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n',
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
// Get the preparer for the mock entity
const preparer = preparers.get(mockEntity);
generation: 1,
},
spec: {
type: 'cookiecutter',
path: './template',
},
};
// Run the preparer for the mock entity to produce a temporary directory with template in
const skeletonPath = await preparer.prepare(mockEntity);
const job = jobProcessor.create({
entity: mockEntity,
values: { component_id: 'blob' },
stages: [
{
name: 'Prepare the skeleton',
handler: async ctx => {
const preparer = preparers.get(ctx.entity);
const skeletonDir = await preparer.prepare(ctx.entity, {
logger: ctx.logger,
});
return { skeletonDir };
},
},
{
name: 'Run the templater',
handler: async (ctx: StageContext<{ skeletonDir: string }>) => {
const resultDir = await templater.run({
directory: ctx.skeletonDir,
dockerClient,
logStream: ctx.logStream,
values: ctx.values,
});
// Run the templater on the mock directory with values from the post body
const templatedPath = await templater.run({
directory: skeletonPath,
values: { component_id: 'test' },
dockerClient,
return { resultDir };
},
},
{
name: 'Create VCS Repo',
handler: async (ctx: StageContext<{ resultDir: string }>) => {
ctx.logger.info('Should now create the VCS repo');
},
},
{
name: 'Push to remote',
handler: async ctx => {
ctx.logger.info('Should now push to the remote');
},
},
],
});
res.status(201).json({ id: job.id });
jobProcessor.run(job);
});
console.warn(templatedPath);
});
const app = express();
app.set('logger', logger);
app.use('/', router);
@@ -64,7 +64,7 @@ const SentryIssuesTable: FC<SentryIssuesTableProps> = ({ sentryIssues }) => {
return (
<Table
columns={columns}
options={{ paging: true, search: false, pageSize: 5 }}
options={{ padding: 'dense', paging: true, search: false, pageSize: 5 }}
title="Sentry issues"
data={sentryIssues}
/>
+1 -1
View File
@@ -27,7 +27,6 @@
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"color": "^3.1.2",
"d3-force": "^2.0.1",
"prop-types": "^15.7.2",
@@ -43,6 +42,7 @@
"@testing-library/user-event": "^12.0.7",
"@types/color": "^3.0.1",
"@types/d3-force": "^1.2.1",
"@types/react": "^16.9",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"jest-fetch-mock": "^3.0.3"
+2 -1
View File
@@ -15,6 +15,7 @@
*/
import { createApiRef } from '@backstage/core';
import { MovedState } from './utils/types';
/**
* Types related to the Radar's visualization.
@@ -34,7 +35,7 @@ export interface RadarQuadrant {
export interface RadarEntry {
key: string; // react key
id: string;
moved: number;
moved: MovedState;
quadrant: RadarQuadrant;
ring: RadarRing;
title: string;
@@ -20,9 +20,9 @@ import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import Radar from './Radar';
import Radar, { Props } from './Radar';
const minProps = {
const minProps: Props = {
width: 500,
height: 200,
quadrants: [{ id: 'languages', name: 'Languages' }],
@@ -14,12 +14,12 @@
* limitations under the License.
*/
import React, { FC, useState, useRef } from 'react';
import React, { useState, useRef } from 'react';
import RadarPlot from '../RadarPlot';
import { Ring, Quadrant, Entry } from '../../utils/types';
import type { Ring, Quadrant, Entry } from '../../utils/types';
import { adjustQuadrants, adjustRings, adjustEntries } from './utils';
type Props = {
export type Props = {
width: number;
height: number;
quadrants: Quadrant[];
@@ -28,17 +28,17 @@ type Props = {
svgProps?: object;
};
const Radar: FC<Props> = props => {
const Radar = (props: Props): JSX.Element => {
const { width, height, quadrants, rings, entries } = props;
const radius = Math.min(width, height) / 2;
const [activeEntry, setActiveEntry] = useState<Entry | null>();
const [activeEntry, setActiveEntry] = useState<Entry>();
const node = useRef<SVGSVGElement>(null);
// TODO(dflemstr): most of this can be heavily memoized if performance becomes a problem
adjustQuadrants(quadrants, radius, width, height);
adjustRings(rings, radius);
adjustEntries(entries, activeEntry, quadrants, rings, radius);
adjustEntries(entries, quadrants, rings, radius, activeEntry);
return (
<svg ref={node} width={width} height={height} {...props.svgProps}>
@@ -49,9 +49,9 @@ const Radar: FC<Props> = props => {
entries={entries}
quadrants={quadrants}
rings={rings}
activeEntry={activeEntry || undefined}
activeEntry={activeEntry}
onEntryMouseEnter={entry => setActiveEntry(entry)}
onEntryMouseLeave={() => setActiveEntry(null)}
onEntryMouseLeave={() => setActiveEntry(undefined)}
/>
</svg>
);
@@ -17,7 +17,7 @@
import color from 'color';
import { forceCollide, forceSimulation } from 'd3-force';
import Segment from '../../utils/segment';
import { Ring, Quadrant, Entry } from '../../utils/types';
import type { Ring, Quadrant, Entry } from '../../utils/types';
export const adjustQuadrants = (
quadrants: Quadrant[],
@@ -81,14 +81,14 @@ export const adjustQuadrants = (
},
];
quadrants.forEach((quadrant, idx) => {
const legendParam = legendParams[idx % 4];
quadrants.forEach((quadrant, index) => {
const legendParam = legendParams[index % 4];
quadrant.idx = idx;
quadrant.radialMin = (idx * Math.PI) / 2;
quadrant.radialMax = ((idx + 1) * Math.PI) / 2;
quadrant.offsetX = idx % 4 === 0 || idx % 4 === 3 ? 1 : -1;
quadrant.offsetY = idx % 4 === 0 || idx % 4 === 1 ? 1 : -1;
quadrant.index = index;
quadrant.radialMin = (index * Math.PI) / 2;
quadrant.radialMax = ((index + 1) * Math.PI) / 2;
quadrant.offsetX = index % 4 === 0 || index % 4 === 3 ? 1 : -1;
quadrant.offsetY = index % 4 === 0 || index % 4 === 1 ? 1 : -1;
quadrant.legendX = legendParam.x;
quadrant.legendY = legendParam.y;
quadrant.legendWidth = legendParam.width;
@@ -98,13 +98,13 @@ export const adjustQuadrants = (
export const adjustEntries = (
entries: Entry[],
activeEntry: Entry | null | undefined,
quadrants: Quadrant[],
rings: Ring[],
radius: number,
activeEntry?: Entry,
) => {
let seed = 42;
entries.forEach((entry, idx) => {
entries.forEach((entry, index) => {
const quadrant = quadrants.find(q => {
const match =
typeof entry.quadrant === 'object' ? entry.quadrant.id : entry.quadrant;
@@ -124,7 +124,7 @@ export const adjustEntries = (
throw new Error(`Unknown ring ${entry.ring} for entry ${entry.id}!`);
}
entry.idx = idx;
entry.index = index;
entry.quadrant = quadrant;
entry.ring = ring;
entry.segment = new Segment(quadrant, ring, radius, () => seed++);
@@ -163,10 +163,10 @@ export const adjustEntries = (
};
export const adjustRings = (rings: Ring[], radius: number) => {
rings.forEach((ring, idx) => {
ring.idx = idx;
ring.outerRadius = ((idx + 2) / (rings.length + 1)) * radius;
rings.forEach((ring, index) => {
ring.index = index;
ring.outerRadius = ((index + 2) / (rings.length + 1)) * radius;
ring.innerRadius =
((idx === 0 ? 0 : idx + 1) / (rings.length + 1)) * radius;
((index === 0 ? 0 : index + 1) / (rings.length + 1)) * radius;
});
};
@@ -0,0 +1,52 @@
/*
* 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 React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import RadarBubble, { Props } from './RadarBubble';
const minProps: Props = {
visible: true,
text: 'RadarBubble',
x: 2,
y: 2,
};
describe('RadarBubble', () => {
beforeAll(() => {
GetBBoxPolyfill.create(0, 0, 1000, 500);
});
afterAll(() => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<svg>
<RadarBubble {...minProps} />
</svg>
</ThemeProvider>,
);
expect(rendered.getByText(minProps.text)).toBeInTheDocument();
});
});
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import React, { FC, useRef, useLayoutEffect } from 'react';
import React, { useRef, useLayoutEffect } from 'react';
import { makeStyles, Theme } from '@material-ui/core';
type Props = {
export type Props = {
visible: boolean;
text: string;
x: number;
@@ -46,7 +46,7 @@ const useStyles = makeStyles<Theme>(() => ({
},
}));
const RadarBubble: FC<Props> = props => {
const RadarBubble = (props: Props): JSX.Element => {
const classes = useStyles(props);
const { visible, text } = props;
@@ -98,6 +98,7 @@ const RadarBubble: FC<Props> = props => {
x={0}
y={0}
className={visible ? classes.visibleBubble : classes.bubble}
data-testid="radar-bubble"
>
<rect ref={rectElem} rx={4} ry={4} className={classes.background} />
<text ref={textElem} className={classes.text}>
@@ -14,50 +14,38 @@
* limitations under the License.
*/
import React, { useEffect, useState, FC } from 'react';
import React, { useEffect } from 'react';
import { Progress, useApi, errorApiRef, ErrorApi } from '@backstage/core';
import { useAsync } from 'react-use';
import Radar from '../components/Radar';
import { TechRadarComponentProps, TechRadarLoaderResponse } from '../api';
import getSampleData from '../sampleData';
const useTechRadarLoader = (props: TechRadarComponentProps) => {
const errorApi = useApi<ErrorApi>(errorApiRef);
const [state, setState] = useState<{
loading: boolean;
error?: Error;
data?: TechRadarLoaderResponse;
}>({
loading: true,
error: undefined,
data: undefined,
});
const { getData } = props;
useEffect(() => {
if (!getData) {
return;
const state = useAsync(async () => {
if (getData) {
const response: TechRadarLoaderResponse = await getData();
return response;
}
getData()
.then((payload: TechRadarLoaderResponse) => {
setState({ loading: false, error: undefined, data: payload });
})
.catch((err: Error) => {
errorApi.post(err);
setState({
loading: false,
error: err,
data: undefined,
});
});
return undefined;
}, [getData, errorApi]);
useEffect(() => {
const { error } = state;
if (error) {
errorApi.post(error);
}
}, [errorApi, state]);
return state;
};
const RadarComponent: FC<TechRadarComponentProps> = props => {
const { loading, error, data } = useTechRadarLoader(props);
const RadarComponent = (props: TechRadarComponentProps): JSX.Element => {
const { loading, error, value: data } = useTechRadarLoader(props);
return (
<>
@@ -0,0 +1,56 @@
/*
* 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 React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import RadarEntry, { Props } from './RadarEntry';
const minProps: Props = {
x: 2,
y: 2,
value: 2,
color: 'red',
};
describe('RadarEntry', () => {
beforeAll(() => {
GetBBoxPolyfill.create(0, 0, 1000, 500);
});
afterAll(() => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<svg>
<RadarEntry {...minProps} />
</svg>
</ThemeProvider>,
);
const radarEntry = rendered.getByTestId('radar-entry');
const { x, y } = minProps;
expect(radarEntry).toBeInTheDocument();
expect(radarEntry.getAttribute('transform')).toBe(`translate(${x}, ${y})`);
expect(rendered.getByText(String(minProps.value))).toBeInTheDocument();
});
});
@@ -14,13 +14,14 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import React from 'react';
import { makeStyles, Theme } from '@material-ui/core';
import { WithLink } from '../../utils/components';
type Props = {
export type Props = {
x: number;
y: number;
number: number;
value: number;
color: string;
url?: string;
moved?: number;
@@ -43,14 +44,27 @@ const useStyles = makeStyles<Theme>(() => ({
},
}));
const RadarEntry: FC<Props> = props => {
const makeBlip = (color: string, moved?: number) => {
const style = { fill: color };
let blip = <circle r={9} style={style} />;
if (moved && moved > 0) {
blip = <path d="M -11,5 11,5 0,-13 z" style={style} />; // triangle pointing up
} else if (moved && moved < 0) {
blip = <path d="M -11,-5 11,-5 0,13 z" style={style} />; // triangle pointing down
}
return blip;
};
const RadarEntry = (props: Props): JSX.Element => {
const classes = useStyles(props);
const {
moved,
color,
url,
number,
value,
x,
y,
onMouseEnter,
@@ -58,24 +72,7 @@ const RadarEntry: FC<Props> = props => {
onClick,
} = props;
const style = { fill: color };
let blip;
if (moved && moved > 0) {
blip = <path d="M -11,5 11,5 0,-13 z" style={style} />; // triangle pointing up
} else if (moved && moved < 0) {
blip = <path d="M -11,-5 11,-5 0,13 z" style={style} />; // triangle pointing down
} else {
blip = <circle r={9} style={style} />;
}
if (url) {
blip = (
<a href={url} className={classes.link}>
{blip}
</a>
);
}
const blip = makeBlip(color, moved);
return (
<g
@@ -83,10 +80,13 @@ const RadarEntry: FC<Props> = props => {
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={onClick}
data-testid="radar-entry"
>
{blip}
<WithLink url={url} className={classes.link}>
{blip}
</WithLink>
<text y={3} className={classes.text}>
{number}
{value}
</text>
</g>
);
@@ -0,0 +1,52 @@
/*
* 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 React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import RadarFooter, { Props } from './RadarFooter';
const minProps: Props = {
x: 2,
y: 2,
};
describe('RadarFooter', () => {
beforeAll(() => {
GetBBoxPolyfill.create(0, 0, 1000, 500);
});
afterAll(() => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<svg>
<RadarFooter {...minProps} />
</svg>
</ThemeProvider>,
);
const radarFooter = rendered.getByTestId('radar-footer');
const { x, y } = minProps;
expect(radarFooter).toBeInTheDocument();
expect(radarFooter.getAttribute('transform')).toBe(`translate(${x}, ${y})`);
});
});
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import React from 'react';
import { makeStyles, Theme } from '@material-ui/core';
type Props = {
export type Props = {
x: number;
y: number;
};
@@ -31,12 +31,16 @@ const useStyles = makeStyles<Theme>(() => ({
},
}));
const RadarFooter: FC<Props> = props => {
const RadarFooter = (props: Props): JSX.Element => {
const { x, y } = props;
const classes = useStyles(props);
return (
<text transform={`translate(${x}, ${y})`} className={classes.text}>
<text
data-testid="radar-footer"
transform={`translate(${x}, ${y})`}
className={classes.text}
>
{'▲ moved up\u00a0\u00a0\u00a0\u00a0\u00a0▼ moved down'}
</text>
);
@@ -0,0 +1,51 @@
/*
* 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 React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import RadarGrid, { Props } from './RadarGrid';
const minProps: Props = {
radius: 5,
rings: [{ id: 'use', name: 'USE', color: '#93c47d' }],
};
describe('RadarGrid', () => {
beforeAll(() => {
GetBBoxPolyfill.create(0, 0, 1000, 500);
});
afterAll(() => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<svg>
<RadarGrid {...minProps} />
</svg>
</ThemeProvider>,
);
expect(rendered.getByTestId('radar-grid-x-line')).toBeInTheDocument();
expect(rendered.getByTestId('radar-grid-y-line')).toBeInTheDocument();
});
});
@@ -16,9 +16,9 @@
import React from 'react';
import { makeStyles, Theme } from '@material-ui/core';
import { Ring } from '../../utils/types';
import type { Ring } from '../../utils/types';
type Props = {
export type Props = {
radius: number;
rings: Ring[];
};
@@ -49,7 +49,7 @@ const RadarGrid = (props: Props) => {
const { radius, rings } = props;
const classes = useStyles(props);
const makeRingNode = (ringRadius: number | undefined, ringIndex: number) => [
const makeRingNode = (ringIndex: number, ringRadius?: number) => [
<circle
key={`c${ringIndex}`}
cx={0}
@@ -76,6 +76,7 @@ const RadarGrid = (props: Props) => {
x2={0}
y2={radius}
className={classes.axis}
data-testid="radar-grid-x-line"
/>,
// Y axis
<line
@@ -85,10 +86,13 @@ const RadarGrid = (props: Props) => {
x2={radius}
y2={0}
className={classes.axis}
data-testid="radar-grid-y-line"
/>,
];
const ringNodes = rings.map(r => r.outerRadius).map(makeRingNode);
const ringNodes = rings
.map(r => r.outerRadius)
.map((ringRadius, ringIndex) => makeRingNode(ringIndex, ringRadius));
return <>{axisNodes.concat(...ringNodes)}</>;
};
@@ -0,0 +1,62 @@
/*
* 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 React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import RadarLegend, { Props } from './RadarLegend';
const minProps: Props = {
quadrants: [{ id: 'languages', name: 'Languages' }],
rings: [{ id: 'use', name: 'USE', color: '#93c47d' }],
entries: [
{
id: 'typescript',
title: 'TypeScript',
quadrant: { id: 'languages', name: 'Languages' },
moved: 0,
ring: { id: 'use', name: 'USE', color: '#93c47d' },
url: '#',
},
],
};
describe('RadarLegend', () => {
beforeAll(() => {
GetBBoxPolyfill.create(0, 0, 1000, 500);
});
afterAll(() => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<svg>
<RadarLegend {...minProps} />
</svg>
</ThemeProvider>,
);
expect(rendered.getByTestId('radar-legend')).toBeInTheDocument();
expect(rendered.getAllByTestId('radar-quadrant')).toHaveLength(1);
expect(rendered.getAllByTestId('radar-ring')).toHaveLength(1);
});
});
@@ -13,15 +13,16 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
import React from 'react';
import { makeStyles, Theme } from '@material-ui/core';
import { Quadrant, Ring, Entry } from '../../utils/types';
import type { Quadrant, Ring, Entry } from '../../utils/types';
import { WithLink } from '../../utils/components';
type Segments = {
[k: number]: { [k: number]: Entry[] };
};
type Props = {
export type Props = {
quadrants: Quadrant[];
rings: Ring[];
entries: Entry[];
@@ -29,7 +30,7 @@ type Props = {
onEntryMouseLeave?: (entry: Entry) => void;
};
const useStyles = makeStyles<Theme>(() => ({
const useStyles = makeStyles<Theme>(theme => ({
quadrant: {
height: '100%',
width: '100%',
@@ -40,7 +41,7 @@ const useStyles = makeStyles<Theme>(() => ({
pointerEvents: 'none',
userSelect: 'none',
marginTop: 0,
marginBottom: 'calc(18px * 0.375)',
marginBottom: theme.spacing(8 / (18 * 0.375)),
fontSize: '18px',
},
rings: {
@@ -56,7 +57,7 @@ const useStyles = makeStyles<Theme>(() => ({
pointerEvents: 'none',
userSelect: 'none',
marginTop: 0,
marginBottom: 'calc(12px * 0.375)',
marginBottom: theme.spacing(8 / (12 * 0.375)),
fontSize: '12px',
fontWeight: 800,
},
@@ -79,73 +80,81 @@ const useStyles = makeStyles<Theme>(() => ({
},
}));
const RadarLegend: FC<Props> = props => {
const RadarLegend = (props: Props): JSX.Element => {
const classes = useStyles(props);
const _getSegment = (
const getSegment = (
segmented: Segments,
quadrant: Quadrant,
ring: Ring,
ringOffset = 0,
) => {
const qidx = quadrant.idx;
const ridx = ring.idx;
const segmentedData = qidx === undefined ? {} : segmented[qidx] || {};
return ridx === undefined ? [] : segmentedData[ridx + ringOffset] || [];
const quadrantIndex = quadrant.index;
const ringIndex = ring.index;
const segmentedData =
quadrantIndex === undefined ? {} : segmented[quadrantIndex] || {};
return ringIndex === undefined
? []
: segmentedData[ringIndex + ringOffset] || [];
};
const _renderRing = (
ring: Ring,
entries: Entry[],
onEntryMouseEnter?: Props['onEntryMouseEnter'],
onEntryMouseLeave?: Props['onEntryMouseEnter'],
) => {
type RadarLegendRingProps = {
ring: Ring;
entries: Entry[];
onEntryMouseEnter?: Props['onEntryMouseEnter'];
onEntryMouseLeave?: Props['onEntryMouseEnter'];
};
const RadarLegendRing = ({
ring,
entries,
onEntryMouseEnter,
onEntryMouseLeave,
}: RadarLegendRingProps) => {
return (
<div key={ring.id} className={classes.ring}>
<div data-testid="radar-ring" key={ring.id} className={classes.ring}>
<h3 className={classes.ringHeading}>{ring.name}</h3>
{entries.length === 0 ? (
<p>(empty)</p>
) : (
<ol className={classes.ringList}>
{entries.map(entry => {
let node = <span className={classes.entry}>{entry.title}</span>;
if (entry.url) {
node = (
<a className={classes.entryLink} href={entry.url}>
{node}
</a>
);
}
return (
<li
key={entry.id}
value={(entry.idx || 0) + 1}
onMouseEnter={
onEntryMouseEnter && (() => onEntryMouseEnter(entry))
}
onMouseLeave={
onEntryMouseLeave && (() => onEntryMouseLeave(entry))
}
>
{node}
</li>
);
})}
{entries.map(entry => (
<li
key={entry.id}
value={(entry.index || 0) + 1}
onMouseEnter={
onEntryMouseEnter && (() => onEntryMouseEnter(entry))
}
onMouseLeave={
onEntryMouseLeave && (() => onEntryMouseLeave(entry))
}
>
<WithLink url={entry.url} className={classes.entryLink}>
<span className={classes.entry}>{entry.title}</span>
</WithLink>
</li>
))}
</ol>
)}
</div>
);
};
const _renderQuadrant = (
segments: Segments,
quadrant: Quadrant,
rings: Ring[],
onEntryMouseEnter: Props['onEntryMouseEnter'],
onEntryMouseLeave: Props['onEntryMouseLeave'],
) => {
type RadarLegendQuadrantProps = {
segments: Segments;
quadrant: Quadrant;
rings: Ring[];
onEntryMouseEnter: Props['onEntryMouseEnter'];
onEntryMouseLeave: Props['onEntryMouseLeave'];
};
const RadarLegendQuadrant = ({
segments,
quadrant,
rings,
onEntryMouseEnter,
onEntryMouseLeave,
}: RadarLegendQuadrantProps) => {
return (
<foreignObject
key={quadrant.id}
@@ -153,46 +162,48 @@ const RadarLegend: FC<Props> = props => {
y={quadrant.legendY}
width={quadrant.legendWidth}
height={quadrant.legendHeight}
data-testid="radar-quadrant"
>
<div className={classes.quadrant}>
<h2 className={classes.quadrantHeading}>{quadrant.name}</h2>
<div className={classes.rings}>
{rings.map(ring =>
_renderRing(
ring,
_getSegment(segments, quadrant, ring),
onEntryMouseEnter,
onEntryMouseLeave,
),
)}
{rings.map(ring => (
<RadarLegendRing
key={ring.id}
ring={ring}
entries={getSegment(segments, quadrant, ring)}
onEntryMouseEnter={onEntryMouseEnter}
onEntryMouseLeave={onEntryMouseLeave}
/>
))}
</div>
</div>
</foreignObject>
);
};
const _setupSegments = (entries: Entry[]) => {
const setupSegments = (entries: Entry[]) => {
const segments: Segments = {};
for (const entry of entries) {
const qidx = entry.quadrant.idx;
const ridx = entry.ring.idx;
const quadrantIndex = entry.quadrant.index;
const ringIndex = entry.ring.index;
let quadrantData: { [k: number]: Entry[] } = {};
if (qidx !== undefined) {
if (segments[qidx] === undefined) {
segments[qidx] = {};
if (quadrantIndex !== undefined) {
if (segments[quadrantIndex] === undefined) {
segments[quadrantIndex] = {};
}
quadrantData = segments[qidx];
quadrantData = segments[quadrantIndex];
}
let ringData = [];
if (ridx !== undefined) {
if (quadrantData[ridx] === undefined) {
quadrantData[ridx] = [];
if (ringIndex !== undefined) {
if (quadrantData[ringIndex] === undefined) {
quadrantData[ringIndex] = [];
}
ringData = quadrantData[ridx];
ringData = quadrantData[ringIndex];
}
ringData.push(entry);
@@ -209,19 +220,20 @@ const RadarLegend: FC<Props> = props => {
onEntryMouseLeave,
} = props;
const segments: Segments = _setupSegments(entries);
const segments: Segments = setupSegments(entries);
return (
<g>
{quadrants.map(quadrant =>
_renderQuadrant(
segments,
quadrant,
rings,
onEntryMouseEnter,
onEntryMouseLeave,
),
)}
<g data-testid="radar-legend">
{quadrants.map(quadrant => (
<RadarLegendQuadrant
key={quadrant.id}
segments={segments}
quadrant={quadrant}
rings={rings}
onEntryMouseEnter={onEntryMouseEnter}
onEntryMouseLeave={onEntryMouseLeave}
/>
))}
</g>
);
};
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import React from 'react';
import { Grid } from '@material-ui/core';
import {
Content,
@@ -29,7 +29,7 @@ import {
import RadarComponent from '../components/RadarComponent';
import { techRadarApiRef, TechRadarApi } from '../api';
const RadarPage: FC<{}> = () => {
const RadarPage = (): JSX.Element => {
const techRadarApi = useApi<TechRadarApi>(techRadarApiRef);
return (
@@ -0,0 +1,67 @@
/*
* 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 React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import RadarPlot, { Props } from './RadarPlot';
const minProps: Props = {
width: 500,
height: 200,
radius: 50,
quadrants: [{ id: 'languages', name: 'Languages' }],
rings: [{ id: 'use', name: 'USE', color: '#93c47d' }],
entries: [
{
id: 'typescript',
title: 'TypeScript',
quadrant: { id: 'languages', name: 'Languages' },
moved: 0,
ring: { id: 'use', name: 'USE', color: '#93c47d' },
url: '#',
},
],
};
describe('RadarPlot', () => {
beforeAll(() => {
GetBBoxPolyfill.create(0, 0, 1000, 500);
});
afterAll(() => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<svg>
<RadarPlot {...minProps} />
</svg>
</ThemeProvider>,
);
expect(rendered.getByTestId('radar-plot')).toBeInTheDocument();
expect(rendered.getByTestId('radar-legend')).toBeInTheDocument();
expect(rendered.getByTestId('radar-footer')).toBeInTheDocument();
expect(rendered.getByTestId('radar-bubble')).toBeInTheDocument();
expect(rendered.getAllByTestId('radar-entry')).toHaveLength(1);
});
});
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import { Quadrant, Ring, Entry } from '../../utils/types';
import React from 'react';
import type { Quadrant, Ring, Entry } from '../../utils/types';
import RadarGrid from '../RadarGrid';
import RadarEntry from '../RadarEntry';
@@ -23,7 +23,7 @@ import RadarBubble from '../RadarBubble';
import RadarFooter from '../RadarFooter';
import RadarLegend from '../RadarLegend';
type Props = {
export type Props = {
width: number;
height: number;
radius: number;
@@ -36,7 +36,7 @@ type Props = {
};
// A component that draws the radar circle.
const RadarPlot: FC<Props> = props => {
const RadarPlot = (props: Props): JSX.Element => {
const {
width,
height,
@@ -50,7 +50,7 @@ const RadarPlot: FC<Props> = props => {
} = props;
return (
<g>
<g data-testid="radar-plot">
<RadarLegend
quadrants={quadrants}
rings={rings}
@@ -71,7 +71,7 @@ const RadarPlot: FC<Props> = props => {
x={entry.x || 0}
y={entry.y || 0}
color={entry.color || ''}
number={((entry && entry.idx) || 0) + 1}
value={(entry?.index || 0) + 1}
url={entry.url}
moved={entry.moved}
onMouseEnter={onEntryMouseEnter && (() => onEntryMouseEnter(entry))}
@@ -80,9 +80,9 @@ const RadarPlot: FC<Props> = props => {
))}
<RadarBubble
visible={!!activeEntry}
text={activeEntry ? activeEntry.title : ''}
x={activeEntry ? activeEntry.x || 0 : 0}
y={activeEntry ? activeEntry.y || 0 : 0}
text={activeEntry?.title || ''}
x={activeEntry?.x || 0}
y={activeEntry?.y || 0}
/>
</g>
</g>
@@ -0,0 +1,35 @@
/*
* 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 React from 'react';
type WithLinkProps = {
url?: string;
className: string;
children: React.ReactNode;
};
export const WithLink = ({
url,
className,
children,
}: WithLinkProps): JSX.Element =>
url ? (
<a href={url} className={className}>
{children}
</a>
) : (
<>{children}</>
);
+10 -4
View File
@@ -17,7 +17,7 @@
// Parameters for a ring; its index in an array determines how close to the center this ring is.
export type Ring = {
id: string;
idx?: number;
index?: number;
name: string;
color: string;
outerRadius?: number;
@@ -27,7 +27,7 @@ export type Ring = {
// Parameters for a quadrant (there should be exactly 4 of course)
export type Quadrant = {
id: string;
idx?: number;
index?: number;
name: string;
legendX?: number;
legendY?: number;
@@ -45,9 +45,15 @@ export type Segment = {
random: Function;
};
export enum MovedState {
Down = -1,
NoChange = 0,
Up = 1,
}
export type Entry = {
id: string;
idx?: number;
index?: number;
x?: number;
y?: number;
color?: string;
@@ -61,7 +67,7 @@ export type Entry = {
// An URL to a longer description as to why this entry is where it is
url?: string;
// How this entry has recently moved; -1 for "down", +1 for "up", 0 for not moved
moved?: number;
moved?: MovedState;
active?: boolean;
};
+1 -1
View File
@@ -10,7 +10,7 @@ Welcome to the TechDocs plugin - Spotify's docs-like-code approach built directl
## Getting started
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/techdocs](http://localhost:3000/techdocs).
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/docs](http://localhost:3000/docs).
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
+6
View File
@@ -33,6 +33,11 @@ import { createPlugin, createRouteRef } from '@backstage/core';
import { Reader } from './reader/components/Reader';
export const rootRouteRef = createRouteRef({
path: '/docs',
title: 'TechDocs Landing Page',
});
export const rootDocsRouteRef = createRouteRef({
path: '/docs/:componentId/*',
title: 'Docs',
});
@@ -41,5 +46,6 @@ export const plugin = createPlugin({
id: 'techdocs',
register({ router }) {
router.addRoute(rootRouteRef, Reader);
router.addRoute(rootDocsRouteRef, Reader);
},
});
@@ -17,14 +17,19 @@
import React from 'react';
import { useShadowDom } from '..';
import { useAsync } from 'react-use';
import { useLocation, useParams, useNavigate } from 'react-router-dom';
import { Grid } from '@material-ui/core';
import { Header, Content, ItemCard } from '@backstage/core';
import transformer, {
addBaseUrl,
rewriteDocLinks,
addEventListener,
removeMkdocsHeader,
modifyCss,
} from '../transformers';
import { docStorageURL } from '../../config';
import { Link } from '@backstage/core';
import { useLocation, useParams, useNavigate } from 'react-router-dom';
import URLParser from '../urlParser';
const useFetch = (url: string) => {
@@ -73,6 +78,16 @@ export const Reader = () => {
rewriteDocLinks({
componentId,
}),
modifyCss({
cssTransforms: {
'.md-main__inner': [{ 'margin-top': '0' }],
'.md-sidebar': [{ top: '0' }, { width: '20rem' }],
'.md-typeset': [{ 'font-size': '1rem' }],
'.md-nav': [{ 'font-size': '1rem' }],
'.md-grid': [{ 'max-width': '80vw' }],
},
}),
removeMkdocsHeader(),
]);
divElement.shadowRoot.innerHTML = '';
@@ -89,11 +104,37 @@ export const Reader = () => {
return (
<>
<nav>
<Link to="/docs/mkdocs/">mkdocs</Link>
<Link to="/docs/backstage-microsite/">Backstage docs</Link>
</nav>
<div ref={shadowDomRef} />
<Header
title={componentId ?? 'Documentation'}
subtitle={componentId ?? 'Documentation available in Backstage'}
/>
<Content>
{componentId ? (
<div ref={shadowDomRef} />
) : (
<Grid container>
<Grid item xs={12} sm={6} md={3}>
<ItemCard
onClick={() => navigate('/docs/mkdocs')}
tags={['Developer Tool']}
title="MkDocs"
label="Read Docs"
description="MkDocs is a fast, simple and downright gorgeous static site generator that's geared towards building project documentation. "
/>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<ItemCard
onClick={() => navigate('/docs/backstage-microsite')}
tags={['Service']}
title="Backstage"
label="Read Docs"
description="Getting started guides, API Overview, documentation around how to Create a Plugin and more. "
/>
</Grid>
</Grid>
)}
</Content>
</>
);
};
@@ -17,6 +17,8 @@
export * from './addBaseUrl';
export * from './rewriteDocLinks';
export * from './addEventListener';
export * from './removeMkdocsHeader';
export * from './modifyCss';
export type Transformer = (dom: Element) => Element;
@@ -0,0 +1,43 @@
/*
* 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 type { Transformer } from './index';
type ModifyCssOptions = {
// Example: { '.md-container': { 'marginTop': '10px' }}
cssTransforms: { [key: string]: { [key: string]: string }[] };
};
export const modifyCss = ({ cssTransforms }: ModifyCssOptions): Transformer => {
return dom => {
Object.entries(cssTransforms).forEach(([cssSelector, cssChanges]) => {
const elementsToChange = Array.from(
dom.querySelectorAll<HTMLElement>(cssSelector),
);
if (elementsToChange.length < 1) return;
cssChanges.forEach(changes => {
elementsToChange.forEach((element: HTMLElement) => {
Object.entries(changes).forEach(([cssProperty, cssValue]) => {
element.style.setProperty(cssProperty, cssValue);
});
});
});
});
return dom;
};
};
@@ -0,0 +1,26 @@
/*
* 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 type { Transformer } from './index';
export const removeMkdocsHeader = (): Transformer => {
return dom => {
// Remove the header
dom.querySelector('.md-header')?.remove();
return dom;
};
};