Merge branch 'master' of github.com:spotify/backstage into shmidt-i/scaffolder-flow-frontend

This commit is contained in:
Ivan Shmidt
2020-07-06 13:53:45 +02:00
151 changed files with 3135 additions and 683 deletions
@@ -15,14 +15,15 @@
*/
import Router from 'express-promise-router';
import { createGithubProvider } from './github';
import { createGoogleProvider } from './google';
import { createGitlabProvider } from './gitlab';
import { createSamlProvider } from './saml';
import { createOktaProvider } from './okta';
import { AuthProviderFactory, AuthProviderConfig } from './types';
import { Logger } from 'winston';
import { TokenIssuer } from '../identity';
import { createGithubProvider } from './github';
import { createGitlabProvider } from './gitlab';
import { createGoogleProvider } from './google';
import { createOAuth2Provider } from './oauth2';
import { createOktaProvider } from './okta';
import { createSamlProvider } from './saml';
import { AuthProviderConfig, AuthProviderFactory } from './types';
const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
@@ -30,6 +31,7 @@ const factories: { [providerId: string]: AuthProviderFactory } = {
gitlab: createGitlabProvider,
saml: createSamlProvider,
okta: createOktaProvider,
oauth2: createOAuth2Provider,
};
export const createAuthProviderRouter = (
@@ -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 { createOAuth2Provider } from './provider';
@@ -0,0 +1,198 @@
/*
* 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 express from 'express';
import passport from 'passport';
import { Strategy as OAuth2Strategy } from 'passport-oauth2';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
import {
EnvironmentHandler,
EnvironmentHandlers,
} from '../../lib/EnvironmentHandler';
import { OAuthProvider } from '../../lib/OAuthProvider';
import {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
} from '../../lib/PassportStrategyHelper';
import {
AuthProviderConfig,
EnvironmentProviderConfig,
GenericOAuth2ProviderConfig,
GenericOAuth2ProviderOptions,
OAuthProviderHandlers,
OAuthResponse,
PassportDoneCallback,
RedirectInfo,
} from '../types';
type PrivateInfo = {
refreshToken: string;
};
export class OAuth2AuthProvider implements OAuthProviderHandlers {
private readonly _strategy: OAuth2Strategy;
constructor(options: GenericOAuth2ProviderOptions) {
this._strategy = new OAuth2Strategy(
{ ...options, passReqToCallback: false as true },
(
accessToken: any,
refreshToken: any,
params: any,
rawProfile: passport.Profile,
done: PassportDoneCallback<OAuthResponse, PrivateInfo>,
) => {
const profile = makeProfileInfo(rawProfile, params.id_token);
done(
undefined,
{
providerInfo: {
idToken: params.id_token,
accessToken,
scope: params.scope,
expiresInSeconds: params.expires_in,
},
profile,
},
{
refreshToken,
},
);
},
);
}
async start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo> {
const providerOptions = {
...options,
accessType: 'offline',
prompt: 'consent',
};
return await executeRedirectStrategy(req, this._strategy, providerOptions);
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
const { response, privateInfo } = await executeFrameHandlerStrategy<
OAuthResponse,
PrivateInfo
>(req, this._strategy);
return {
response: await this.populateIdentity(response),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(refreshToken: string, scope: string): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
refreshToken,
scope,
);
const profile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
params.id_token,
);
return this.populateIdentity({
providerInfo: {
accessToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
},
profile,
});
}
// Use this function to grab the user profile info from the token
// Then populate the profile with it
private async populateIdentity(
response: OAuthResponse,
): Promise<OAuthResponse> {
const { profile } = response;
if (!profile.email) {
throw new Error('Profile does not contain a profile');
}
const id = profile.email.split('@')[0];
return { ...response, backstageIdentity: { id } };
}
}
export function createOAuth2Provider(
{ baseUrl }: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
tokenIssuer: TokenIssuer,
) {
const envProviders: EnvironmentHandlers = {};
for (const [env, envConfig] of Object.entries(providerConfig)) {
const config = (envConfig as unknown) as GenericOAuth2ProviderConfig;
const { secure, appOrigin } = config;
const callbackURLParam = `?env=${env}`;
const opts = {
clientID: config.clientId,
clientSecret: config.clientSecret,
callbackURL: `${baseUrl}/oauth2/handler/frame${callbackURLParam}`,
authorizationURL: config.authorizationURL,
tokenURL: config.tokenURL,
};
if (
!opts.clientID ||
!opts.clientSecret ||
!opts.authorizationURL ||
!opts.tokenURL
) {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'Failed to initialize OAuth2 auth provider, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars',
);
}
logger.warn(
'OAuth2 auth provider disabled, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars to enable',
);
continue;
}
envProviders[env] = new OAuthProvider(new OAuth2AuthProvider(opts), {
disableRefresh: false,
providerId: 'oauth2',
secure,
baseUrl,
appOrigin,
tokenIssuer,
});
}
return new EnvironmentHandler(envProviders);
}
@@ -33,6 +33,11 @@ export type OAuthProviderOptions = {
callbackURL: string;
};
export type GenericOAuth2ProviderOptions = OAuthProviderOptions & {
authorizationURL: string;
tokenURL: string;
};
export type OAuthProviderConfig = {
/**
* Cookies can be marked with a secure flag to send cookies only when the request
@@ -61,6 +66,11 @@ export type OAuthProviderConfig = {
audience?: string;
};
export type GenericOAuth2ProviderConfig = OAuthProviderConfig & {
authorizationURL: string;
tokenURL: string;
};
export type EnvironmentProviderConfig = {
/**
* key, values are environment names and OAuthProviderConfigs
@@ -100,6 +100,16 @@ export async function createRouter(
audience: process.env.AUTH_OKTA_AUDIENCE,
},
},
oauth2: {
development: {
appOrigin: 'http://localhost:3000',
secure: false,
clientId: process.env.AUTH_OAUTH2_CLIENT_ID!,
clientSecret: process.env.AUTH_OAUTH2_CLIENT_SECRET!,
authorizationURL: process.env.AUTH_OAUTH2_AUTH_URL!,
tokenURL: process.env.AUTH_OAUTH2_TOKEN_URL!,
},
},
},
},
};
+1
View File
@@ -15,4 +15,5 @@
*/
require('jest-fetch-mock').enableMocks();
export {};
@@ -15,4 +15,5 @@
*/
require('jest-fetch-mock').enableMocks();
export {};
+2 -2
View File
@@ -32,8 +32,8 @@
"moment": "^2.26.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0",
"swr": "^0.2.2"
},
@@ -18,6 +18,7 @@ import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { CatalogClient } from './CatalogClient';
import { Entity } from '@backstage/catalog-model';
const server = setupServer();
describe('CatalogClient', () => {
@@ -19,6 +19,7 @@ import {
ContentHeader,
identityApiRef,
SupportButton,
configApiRef,
useApi,
} from '@backstage/core';
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
@@ -51,6 +52,8 @@ const CatalogPageContents = () => {
const userId = useApi(identityApiRef).getUserId();
const [selectedTab, setSelectedTab] = useState<string>();
const [selectedSidebarItem, setSelectedSidebarItem] = useState<string>();
const orgName =
useApi(configApiRef).getOptionalString('organization.name') ?? 'Company';
const tabs = useMemo<LabeledComponentType[]>(
() => [
@@ -98,7 +101,7 @@ const CatalogPageContents = () => {
],
},
{
name: 'Company', // TODO: Replace with Company name, read from app config.
name: orgName,
items: [
{
id: 'all',
@@ -108,7 +111,7 @@ const CatalogPageContents = () => {
],
},
],
[isStarredEntity, userId],
[isStarredEntity, userId, orgName],
);
return (
@@ -18,14 +18,16 @@ 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 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';
import {
favouriteEntityIcon,
favouriteEntityTooltip,
} from '../FavouriteEntity/FavouriteEntity';
const columns: TableColumn<Entity>[] = [
{
@@ -125,13 +127,8 @@ export const CatalogTable = ({
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',
icon: () => favouriteEntityIcon(isStarred),
tooltip: favouriteEntityTooltip(isStarred),
onClick: () => toggleStarredEntity(rowData),
};
},
@@ -28,7 +28,7 @@ import {
useApi,
} from '@backstage/core';
import { SentryIssuesWidget } from '@backstage/plugin-sentry';
import { Grid } from '@material-ui/core';
import { Grid, Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { FC, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
@@ -37,6 +37,7 @@ import { catalogApiRef } from '../..';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { EntityMetadataCard } from '../EntityMetadataCard/EntityMetadataCard';
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
const REDIRECT_DELAY = 1000;
function headerProps(
@@ -63,6 +64,16 @@ export const getPageTheme = (entity?: Entity): PageTheme => {
return pageTheme[themeKey] ?? pageTheme.home;
};
const EntityPageTitle: FC<{ title: string; entity: Entity | undefined }> = ({
entity,
title,
}) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
{entity && <FavouriteEntity entity={entity} />}
</Box>
);
export const EntityPage: FC<{}> = () => {
const { optionalNamespaceAndName, kind } = useParams() as {
optionalNamespaceAndName: string;
@@ -138,7 +149,11 @@ export const EntityPage: FC<{}> = () => {
return (
<Page theme={getPageTheme(entity)}>
<Header title={headerTitle} type={headerType}>
<Header
title={<EntityPageTitle title={headerTitle} entity={entity} />}
pageTitleOverride={headerTitle}
type={headerType}
>
{entity && (
<>
<HeaderLabel
@@ -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, { ComponentProps } from 'react';
import { IconButton, Tooltip, withStyles } from '@material-ui/core';
import StarBorder from '@material-ui/icons/StarBorder';
import Star from '@material-ui/icons/Star';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import { Entity } from '@backstage/catalog-model';
type Props = ComponentProps<typeof IconButton> & { entity: Entity };
const YellowStar = withStyles({
root: {
color: '#f3ba37',
},
})(Star);
export const favouriteEntityTooltip = (isStarred: boolean) =>
isStarred ? 'Remove from favorites' : 'Add to favorites';
export const favouriteEntityIcon = (isStarred: boolean) =>
isStarred ? <YellowStar /> : <StarBorder />;
/**
* IconButton for showing if a current entity is starred and adding/removing it from the favourite entities
* @param props MaterialUI IconButton props extended by required `entity` prop
*/
export const FavouriteEntity: React.FC<Props> = props => {
const { toggleStarredEntity, isStarredEntity } = useStarredEntities();
const isStarred = isStarredEntity(props.entity);
return (
<IconButton
color="inherit"
{...props}
onClick={() => toggleStarredEntity(props.entity)}
>
<Tooltip title={favouriteEntityTooltip(isStarred)}>
{favouriteEntityIcon(isStarred)}
</Tooltip>
</IconButton>
);
};
+2 -2
View File
@@ -40,8 +40,8 @@
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-lazylog": "^4.5.2",
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0"
},
"devDependencies": {
+1
View File
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom/extend-expect';
require('jest-fetch-mock').enableMocks();
+1
View File
@@ -16,6 +16,7 @@
import React, { FC, useReducer, Dispatch, Reducer } from 'react';
import { circleCIApiRef } from '../api';
import type { State, Action, SettingsState } from './types';
export type { SettingsState };
export const AppContext = React.createContext<[State, Dispatch<Action>]>(
+1
View File
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+13
View File
@@ -0,0 +1,13 @@
# github-actions
Welcome to the github-actions plugin!
_This plugin was created through the Backstage CLI_
## 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 [/github-actions](http://localhost:3000/github-actions).
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.
It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory.
+20
View File
@@ -0,0 +1,20 @@
/*
* 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 { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
createDevApp().registerPlugin(plugin).render();
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@backstage/plugin-github-actions",
"version": "0.1.1-alpha.12",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.12",
"@backstage/theme": "^0.1.1-alpha.12",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.12",
"@backstage/dev-utils": "^0.1.1-alpha.12",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist"
]
}
@@ -0,0 +1,44 @@
/*
* 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 { Build, BuildDetails, BuildStatus } from './types';
export class BuildsClient {
static create(): BuildsClient {
return new BuildsClient();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async listBuilds(_entityUri: string): Promise<Build[]> {
return [];
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getBuild(_buildUri: string): Promise<BuildDetails> {
return {
build: {
commitId: 'TODO',
branch: 'TODO',
uri: 'TODO',
status: BuildStatus.Running,
message: 'TODO',
},
author: 'TODO',
logUrl: 'TODO',
overviewUrl: 'TODO',
};
}
}
@@ -0,0 +1,18 @@
/*
* 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 { BuildsClient } from './BuildsClient';
export * from './types';
@@ -14,18 +14,25 @@
* limitations under the License.
*/
const normalizeBaseURL = (baseURL: string): string => {
const url = new URL(baseURL);
url.pathname = url.pathname.replace(/([^/])$/, '$1/');
return url.toString();
export enum BuildStatus {
Null,
Success,
Failure,
Pending,
Running,
}
export type Build = {
commitId: string;
message: string;
branch: string;
status: BuildStatus;
uri: string;
};
export default class URLParser {
constructor(public baseURL: string, public pathname: string) {
this.baseURL = normalizeBaseURL(baseURL);
}
parse(): string {
return new URL(this.pathname, this.baseURL).toString();
}
}
export type BuildDetails = {
build: Build;
author: string;
logUrl: string;
overviewUrl: string;
};
@@ -0,0 +1,143 @@
/*
* 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 { Link } from '@backstage/core';
import {
Button,
ButtonGroup,
LinearProgress,
makeStyles,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableRow,
Theme,
Typography,
} from '@material-ui/core';
import React from 'react';
import { useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { BuildsClient } from '../../apis/builds';
import { BuildStatusIndicator } from '../BuildStatusIndicator';
const useStyles = makeStyles<Theme>(theme => ({
root: {
maxWidth: 720,
margin: theme.spacing(2),
},
title: {
padding: theme.spacing(1, 0, 2, 0),
},
table: {
padding: theme.spacing(1),
},
}));
const client = BuildsClient.create();
export const BuildDetailsPage = () => {
const classes = useStyles();
const { buildUri } = useParams();
const status = useAsync(() => client.getBuild(buildUri), [buildUri]);
if (status.loading) {
return <LinearProgress />;
} else if (status.error) {
return (
<Typography variant="h6" color="error">
Failed to load build, {status.error.message}
</Typography>
);
}
const details = status.value;
return (
<div className={classes.root}>
<Typography className={classes.title} variant="h3">
<Link to="/builds">
<Typography component="span" variant="h3" color="primary">
&lt;
</Typography>
</Link>
Build Details
</Typography>
<TableContainer component={Paper} className={classes.table}>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Typography noWrap>Branch</Typography>
</TableCell>
<TableCell>{details?.build.branch}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Message</Typography>
</TableCell>
<TableCell>{details?.build.message}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Commit ID</Typography>
</TableCell>
<TableCell>{details?.build.commitId}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Status</Typography>
</TableCell>
<TableCell>
<BuildStatusIndicator status={details?.build.status} />
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Author</Typography>
</TableCell>
<TableCell>{details?.author}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Links</Typography>
</TableCell>
<TableCell>
<ButtonGroup
variant="text"
color="primary"
aria-label="text primary button group"
>
{details?.overviewUrl && (
<Button>
<Link to={details.overviewUrl}>GitHub</Link>
</Button>
)}
{details?.logUrl && (
<Button>
<Link to={details.logUrl}>Logs</Link>
</Button>
)}
</ButtonGroup>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</div>
);
};
@@ -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 { BuildDetailsPage } from './BuildDetailsPage';
@@ -0,0 +1,102 @@
/*
* 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 { Link } from '@backstage/core';
import {
LinearProgress,
makeStyles,
Table,
TableBody,
TableCell,
TableRow,
Theme,
Typography,
} from '@material-ui/core';
import React from 'react';
import { useAsync } from 'react-use';
import { BuildsClient } from '../../apis/builds';
import { BuildStatusIndicator } from '../BuildStatusIndicator';
const client = BuildsClient.create();
const useStyles = makeStyles<Theme>(theme => ({
root: {
// height: 400,
},
title: {
paddingBottom: theme.spacing(1),
},
}));
export const BuildInfoCard = () => {
const classes = useStyles();
const status = useAsync(() => client.listBuilds('entity:spotify:backstage'));
let content: JSX.Element;
if (status.loading) {
content = <LinearProgress />;
} else if (status.error) {
content = (
<Typography variant="h2" color="error">
Failed to load builds, {status.error.message}
</Typography>
);
} else {
const [build] =
status.value?.filter(({ branch }) => branch === 'master') ?? [];
content = (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Typography noWrap>Message</Typography>
</TableCell>
<TableCell>
<Link to={`builds/${encodeURIComponent(build?.uri || '')}`}>
<Typography color="primary">{build?.message}</Typography>
</Link>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Commit ID</Typography>
</TableCell>
<TableCell>{build?.commitId}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Status</Typography>
</TableCell>
<TableCell>
<BuildStatusIndicator status={build?.status} />
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
return (
<div className={classes.root}>
<Typography variant="h2" className={classes.title}>
Master Build
</Typography>
{content}
</div>
);
};
@@ -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 { BuildInfoCard } from './BuildInfoCard';
@@ -0,0 +1,128 @@
/*
* 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 { Link } from '@backstage/core';
import {
LinearProgress,
makeStyles,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Theme,
Tooltip,
Typography,
} from '@material-ui/core';
import React from 'react';
import { useAsync } from 'react-use';
import { BuildsClient } from '../../apis/builds';
import { BuildStatusIndicator } from '../BuildStatusIndicator';
const client = BuildsClient.create();
const LongText = ({ text, max }: { text: string; max: number }) => {
if (text.length < max) {
return <span>{text}</span>;
}
return (
<Tooltip title={text}>
<span>{text.slice(0, max)}...</span>
</Tooltip>
);
};
const useStyles = makeStyles<Theme>(theme => ({
root: {
padding: theme.spacing(2),
},
title: {
padding: theme.spacing(1, 0, 2, 0),
},
}));
const PageContents = () => {
const { loading, error, value } = useAsync(() =>
client.listBuilds('entity:spotify:backstage'),
);
if (loading) {
return <LinearProgress />;
}
if (error) {
return (
<Typography variant="h2" color="error">
Failed to load builds, {error.message}{' '}
</Typography>
);
}
return (
<TableContainer component={Paper}>
<Table aria-label="CI/CD builds table">
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Branch</TableCell>
<TableCell>Message</TableCell>
<TableCell>Commit</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value!.map(build => (
<TableRow key={build.uri}>
<TableCell>
<BuildStatusIndicator status={build.status} />
</TableCell>
<TableCell>
<Typography>
<LongText text={build.branch} max={30} />
</Typography>
</TableCell>
<TableCell>
<Link to={`builds/${encodeURIComponent(build.uri)}`}>
<Typography color="primary">
<LongText text={build.message} max={60} />
</Typography>
</Link>
</TableCell>
<TableCell>
<Tooltip title={build.commitId}>
<Typography noWrap>{build.commitId.slice(0, 10)}</Typography>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
};
export const BuildListPage = () => {
const classes = useStyles();
return (
<div className={classes.root}>
<Typography variant="h3" className={classes.title}>
CI/CD Builds
</Typography>
<PageContents />
</div>
);
};
@@ -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 { BuildListPage } from './BuildListPage';
@@ -0,0 +1,74 @@
/*
* 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 { IconComponent } from '@backstage/core';
import { makeStyles, Theme } from '@material-ui/core';
import ProgressIcon from '@material-ui/icons/Autorenew';
import SuccessIcon from '@material-ui/icons/CheckCircle';
import FailureIcon from '@material-ui/icons/Error';
import UnknownIcon from '@material-ui/icons/Help';
import React from 'react';
import { BuildStatus } from '../../apis/builds';
type Props = {
status?: BuildStatus;
};
type StatusStyle = {
icon: IconComponent;
color: string;
};
const styles: { [key in BuildStatus]: StatusStyle } = {
[BuildStatus.Null]: {
icon: UnknownIcon,
color: '#f49b20',
},
[BuildStatus.Success]: {
icon: SuccessIcon,
color: '#1db855',
},
[BuildStatus.Failure]: {
icon: FailureIcon,
color: '#CA001B',
},
[BuildStatus.Pending]: {
icon: UnknownIcon,
color: '#5BC0DE',
},
[BuildStatus.Running]: {
icon: ProgressIcon,
color: '#BEBEBE',
},
};
const useStyles = makeStyles<Theme, StatusStyle>({
icon: style => ({
color: style.color,
}),
});
export const BuildStatusIndicator = ({ status }: Props) => {
const style = (status && styles[status]) || styles[BuildStatus.Null];
const classes = useStyles(style);
const Icon = style.icon;
return (
<div className={classes.icon}>
<Icon />
</div>
);
};
@@ -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 { BuildStatusIndicator } from './BuildStatusIndicator';
+17
View File
@@ -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 { plugin } from './plugin';
+23
View File
@@ -0,0 +1,23 @@
/*
* 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 { plugin } from './plugin';
describe('github-actions', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
});
});
+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 { createPlugin, createRouteRef } from '@backstage/core';
import { BuildDetailsPage } from './components/BuildDetailsPage';
import { BuildListPage } from './components/BuildListPage';
// TODO(freben): This is just a demo route for now
export const rootRouteRef = createRouteRef({
path: '/github-actions',
title: 'GitHub Actions',
});
export const buildRouteRef = createRouteRef({
path: '/github-actions/builds/:buildUri',
title: 'GitHub Actions Build',
});
export const plugin = createPlugin({
id: 'github-actions',
register({ router }) {
router.addRoute(rootRouteRef, BuildListPage);
router.addRoute(buildRouteRef, BuildDetailsPage);
},
});
+19
View File
@@ -0,0 +1,19 @@
/*
* 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 '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+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": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0"
},
"devDependencies": {
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+1 -1
View File
@@ -53,7 +53,7 @@
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"jest-fetch-mock": "^3.0.3",
"react-router-dom": "6.0.0-alpha.5"
"react-router-dom": "6.0.0-beta.0"
},
"files": [
"dist"
+1
View File
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
@@ -15,4 +15,5 @@
*/
require('jest-fetch-mock').enableMocks();
export {};
+1 -1
View File
@@ -29,7 +29,7 @@
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-markdown": "^4.3.1",
"react-router-dom": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0"
},
"devDependencies": {
+1
View File
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+3 -2
View File
@@ -15,6 +15,7 @@
*/
import { useLocation } from 'react-router-dom';
import { Website, Audit, LighthouseCategoryId, AuditCompleted } from './api';
export function useQuery(): URLSearchParams {
return new URLSearchParams(useLocation().search);
}
@@ -53,13 +54,13 @@ export function buildSparklinesDataForItem(
(audit: Audit): audit is AuditCompleted => audit.status === 'COMPLETED',
)
.reduce((scores, audit) => {
Object.values(audit.categories).forEach((category) => {
Object.values(audit.categories).forEach(category => {
scores[category.id] = scores[category.id] || [];
scores[category.id].unshift(category.score);
});
// edge case: if only one audit exists, force a "flat" sparkline
Object.values(scores).forEach((arr) => {
Object.values(scores).forEach(arr => {
if (arr.length === 1) arr.push(arr[0]);
});
+2 -2
View File
@@ -31,8 +31,8 @@
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-hook-form": "^5.7.2",
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0"
},
"devDependencies": {
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { makeLogStream } from './logger';
describe('Logger', () => {
const mockMeta = { test: 'blob' };
@@ -17,7 +17,17 @@ import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { RequiredTemplateValues } from '../templater';
import { JsonValue } from '@backstage/config';
/**
* Publisher is in charge of taking a folder created by
* the templater, and pushing it to a remote storage
*/
export type Publisher = {
/**
*
* @param opts object containing the template entity from the service
* catalog, plus the values from the form and the directory that has
* been templated
*/
publish(opts: {
entity: TemplateEntityV1alpha1;
values: RequiredTemplateValues & Record<string, JsonValue>;
@@ -131,13 +131,13 @@ describe('CookieCutter Templater', () => {
component_id: 'newthing',
};
const returnPath = await cookie.run({
const { resultDir } = await cookie.run({
directory: tempdir,
values,
dockerClient: mockDocker,
});
expect(returnPath.startsWith(`${tempdir}-result`)).toBeTruthy();
expect(resultDir.startsWith(`${tempdir}-result`)).toBeTruthy();
});
it('should pass through the streamer to the run docker helper', async () => {
@@ -18,6 +18,8 @@ import { JsonValue } from '@backstage/config';
import { runDockerContainer } from './helpers';
import { TemplaterBase, TemplaterRunOptions } from '.';
import path from 'path';
import { TemplaterRunResult } from './types';
export class CookieCutter implements TemplaterBase {
private async fetchTemplateCookieCutter(
directory: string,
@@ -33,7 +35,7 @@ export class CookieCutter implements TemplaterBase {
}
}
public async run(options: TemplaterRunOptions): Promise<string> {
public async run(options: TemplaterRunOptions): Promise<TemplaterRunResult> {
// First lets grab the default cookiecutter.json file
const cookieCutterJson = await this.fetchTemplateCookieCutter(
options.directory,
@@ -65,6 +67,8 @@ export class CookieCutter implements TemplaterBase {
dockerClient: options.dockerClient,
});
return path.resolve(resultDir, options.values.component_id as string);
return {
resultDir: path.resolve(resultDir, options.values.component_id as string),
};
}
}
@@ -26,6 +26,9 @@ describe('helpers', () => {
jest
.spyOn(mockDocker, 'run')
.mockResolvedValue([{ Error: null, StatusCode: 0 }]);
jest
.spyOn(mockDocker, 'pull')
.mockResolvedValue([{ Error: null, StatusCode: 0 }]);
});
describe('runDockerContainer', () => {
@@ -34,6 +37,17 @@ describe('helpers', () => {
const templateDir = os.tmpdir();
const resultDir = os.tmpdir();
it('will pull the docker container before running', async () => {
await runDockerContainer({
imageName,
args,
templateDir,
resultDir,
dockerClient: mockDocker,
});
expect(mockDocker.pull).toHaveBeenCalledWith(imageName, {});
});
it('should call the dockerClient run command with the correct arguments passed through', async () => {
await runDockerContainer({
imageName,
@@ -44,6 +44,7 @@ export const runDockerContainer = async ({
templateDir,
dockerClient,
}: RunDockerContainerOptions) => {
await dockerClient.pull(imageName, {});
const [{ Error: error, StatusCode: statusCode }] = await dockerClient.run(
imageName,
args,
@@ -18,11 +18,28 @@ import type { Writable } from 'stream';
import Docker from 'dockerode';
import { JsonValue } from '@backstage/config';
/**
* Currently the required template values. The owner
* and where to store the result from templating
*/
export type RequiredTemplateValues = {
owner: string;
storePath: string;
};
/**
* The returned directory from the templater which is ready
* to pass to the next stage of the scaffolder which is publishing
*/
export type TemplaterRunResult = {
resultDir: string;
};
/**
* The values that the templater will recieve. The directory of the
* skeleton, with the values from the frontend. A dedicated log stream and a docker
* client to run any templater on top of your directory.
*/
export type TemplaterRunOptions = {
directory: string;
values: RequiredTemplateValues & Record<string, JsonValue>;
@@ -32,7 +49,7 @@ export type TemplaterRunOptions = {
export type TemplaterBase = {
// runs the templating with the values and returns the directory to push the VCS
run(opts: TemplaterRunOptions): Promise<string>;
run(opts: TemplaterRunOptions): Promise<TemplaterRunResult>;
};
export type TemplaterConfig = {
+2 -2
View File
@@ -35,8 +35,8 @@
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-lazylog": "^4.5.2",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0",
"swr": "^0.2.2"
},
+1
View File
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import React from 'react';
import { Table, TableColumn } from '@backstage/core';
import { SentryIssue } from '../../data/sentry-issue';
import { format } from 'timeago.js';
@@ -60,7 +60,7 @@ type SentryIssuesTableProps = {
sentryIssues: SentryIssue[];
};
const SentryIssuesTable: FC<SentryIssuesTableProps> = ({ sentryIssues }) => {
const SentryIssuesTable = ({ sentryIssues }: SentryIssuesTableProps) => {
return (
<Table
columns={columns}
+1
View File
@@ -16,6 +16,7 @@
import { SentryIssue } from './sentry-issue';
import { SentryApi } from './sentry-api';
import mockData from './sentry-issue-mock.json';
function getMockIssue(): SentryIssue {
const randomizedStats = {
'12h': new Array(12)
+1
View File
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+1
View File
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+2 -2
View File
@@ -29,8 +29,8 @@
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0"
},
"devDependencies": {
+2 -1
View File
@@ -30,6 +30,7 @@
*/
import { createPlugin, createRouteRef } from '@backstage/core';
import { TechDocsHome } from './reader/components/TechDocsHome';
import { Reader } from './reader/components/Reader';
export const rootRouteRef = createRouteRef({
@@ -45,7 +46,7 @@ export const rootDocsRouteRef = createRouteRef({
export const plugin = createPlugin({
id: 'techdocs',
register({ router }) {
router.addRoute(rootRouteRef, Reader);
router.addRoute(rootRouteRef, TechDocsHome);
router.addRoute(rootDocsRouteRef, Reader);
},
});
@@ -17,35 +17,46 @@
import React from 'react';
import { useShadowDom } from '..';
import { useAsync } from 'react-use';
import { useLocation, useParams, useNavigate } from 'react-router-dom';
import { AsyncState } from 'react-use/lib/useAsync';
import { Grid } from '@material-ui/core';
import { Header, Content, ItemCard } from '@backstage/core';
import { useLocation, useParams, useNavigate } from 'react-router-dom';
import transformer, {
addBaseUrl,
rewriteDocLinks,
addEventListener,
addLinkClickListener,
removeMkdocsHeader,
modifyCss,
} from '../transformers';
import { docStorageURL } from '../../config';
import URLParser from '../urlParser';
import URLFormatter from '../urlFormatter';
import { TechDocsNotFound } from './TechDocsNotFound';
import { TechDocsPageWrapper } from './TechDocsPageWrapper';
const useFetch = (url: string) => {
const useFetch = (url: string): AsyncState<string | Error> => {
const state = useAsync(async () => {
const response = await fetch(url);
const raw = await response.text();
return raw;
const request = await fetch(url);
if (request.status === 404) {
return [request.url, new Error('Page not found')];
}
const response = await request.text();
return [request.url, response];
}, [url]);
return state;
const [fetchedUrl, fetchedValue] = state.value ?? [];
if (url !== fetchedUrl) {
// Fixes a race condition between two pages
return { loading: true };
}
return Object.assign(state, fetchedValue ? { value: fetchedValue } : {});
};
const useEnforcedTrailingSlash = (): void => {
React.useEffect(() => {
const actualUrl = window.location.href;
const expectedUrl = new URLParser(window.location.href, '.').parse();
const expectedUrl = new URLFormatter(window.location.href).formatBaseURL();
if (actualUrl !== expectedUrl) {
window.history.replaceState({}, document.title, expectedUrl);
@@ -54,85 +65,84 @@ const useEnforcedTrailingSlash = (): void => {
};
export const Reader = () => {
const location = useLocation();
const { componentId, '*': path } = useParams();
const shadowDomRef = useShadowDom();
const navigate = useNavigate();
const normalizedUrl = new URLParser(
`${docStorageURL}${location.pathname.replace('/docs', '')}`,
'.',
).parse();
const state = useFetch(`${normalizedUrl}index.html`);
useEnforcedTrailingSlash();
React.useEffect(() => {
const divElement = shadowDomRef.current;
if (divElement?.shadowRoot && state.value) {
const transformedElement = transformer(state.value, [
addBaseUrl({
docStorageURL,
componentId,
path,
}),
rewriteDocLinks(),
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(),
]);
const location = useLocation();
const { componentId, '*': path } = useParams();
const [shadowDomRef, shadowRoot] = useShadowDom();
const navigate = useNavigate();
const normalizedUrl = new URLFormatter(
`${docStorageURL}${location.pathname.replace('/docs', '')}`,
).formatBaseURL();
const state = useFetch(`${normalizedUrl}index.html`);
divElement.shadowRoot.innerHTML = '';
if (transformedElement) {
divElement.shadowRoot.appendChild(transformedElement);
transformer(divElement.shadowRoot.children[0], [
addEventListener({
onClick: navigate,
}),
]);
}
React.useEffect(() => {
if (!shadowRoot) {
return; // Shadow DOM isn't ready
}
}, [shadowDomRef, state, componentId, path, navigate]);
if (state.loading) {
return; // Page isn't ready
}
// Pre-render
const transformedElement = transformer(state.value as string, [
addBaseUrl({
docStorageURL,
componentId,
path,
}),
rewriteDocLinks(),
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(),
]);
if (!transformedElement) {
return; // An unexpected error occurred
}
Array.from(shadowRoot.children).forEach(child =>
shadowRoot.removeChild(child),
);
shadowRoot.appendChild(transformedElement);
// Post-render
transformer(shadowRoot.children[0], [
dom => {
setTimeout(() => {
if (window.location.hash) {
const hash = window.location.hash.slice(1);
shadowRoot?.getElementById(hash)?.scrollIntoView();
}
}, 200);
return dom;
},
addLinkClickListener({
onClick: (_: MouseEvent, url: string) => {
const parsedUrl = new URL(url);
navigate(`${parsedUrl.pathname}${parsedUrl.hash}`);
shadowRoot?.querySelector(parsedUrl.hash)?.scrollIntoView();
},
}),
]);
}, [componentId, path, shadowRoot, state]); // eslint-disable-line react-hooks/exhaustive-deps
if (state.value instanceof Error) return <TechDocsNotFound />;
return (
<>
<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>
<TechDocsPageWrapper title={componentId} subtitle={componentId}>
<div ref={shadowDomRef} />
</TechDocsPageWrapper>
</>
);
};
@@ -0,0 +1,36 @@
/*
* 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 { TechDocsHome } from './TechDocsHome';
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
describe('TechDocs Home', () => {
it('should render a TechDocs home page', () => {
const { getByTestId, queryByText } = render(
wrapInTestApp(<TechDocsHome />),
);
// Header
expect(queryByText('Documentation')).toBeInTheDocument();
expect(
queryByText(/Documentation available in Backstage/i),
).toBeInTheDocument();
// Explore Content
expect(getByTestId('docs-explore')).toBeDefined();
});
});
@@ -0,0 +1,74 @@
/*
* 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 { useNavigate } from 'react-router-dom';
import { Grid } from '@material-ui/core';
import { ItemCard } from '@backstage/core';
import { TechDocsPageWrapper } from './TechDocsPageWrapper';
type DocumentationSite = {
title: string;
description: string;
tags: Array<string>;
path: string;
btnLabel: string;
};
const documentationSites: Array<DocumentationSite> = [
{
title: 'MkDocs',
description:
"MkDocs is a fast, simple and downright gorgeous static site generator that's geared towards building project documentation. ",
tags: ['Developer Tool'],
path: '/docs/mkdocs',
btnLabel: 'Read Docs',
},
{
title: 'Backstage Docs',
description:
'Getting started guides, API Overview, documentation around how to Create a Plugin and more. ',
tags: ['Service'],
path: '/docs/backstage-microsite',
btnLabel: 'Read Docs',
},
];
export const TechDocsHome = () => {
const navigate = useNavigate();
return (
<>
<TechDocsPageWrapper
title="Documentation"
subtitle="Documentation available in Backstage"
>
<Grid container data-testid="docs-explore">
{documentationSites.map((site: DocumentationSite, index: number) => (
<Grid key={index} item xs={12} sm={6} md={3}>
<ItemCard
onClick={() => navigate(site.path)}
tags={site.tags}
title={site.title}
label={site.btnLabel}
description={site.description}
/>
</Grid>
))}
</Grid>
</TechDocsPageWrapper>
</>
);
};
@@ -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 { TechDocsNotFound } from './TechDocsNotFound';
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
describe('TechDocs Not Found', () => {
it('should render a Documentation not found page', async () => {
const { queryByText } = render(wrapInTestApp(<TechDocsNotFound />));
expect(queryByText(/error: documentation not found/i)).toBeInTheDocument();
});
});
@@ -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 React from 'react';
import { Typography, Button } from '@material-ui/core';
import { TechDocsPageWrapper } from './TechDocsPageWrapper';
export const TechDocsNotFound = () => {
return (
<TechDocsPageWrapper
title="Documentation"
subtitle="Documentation available in Backstage"
>
<Typography>Error: Documentation not found</Typography>
<Typography>Path: {window.location.pathname}</Typography>
<Button color="primary" onClick={() => window.history.back()}>
Go back
</Button>
</TechDocsPageWrapper>
);
};
@@ -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 { TechDocsPageWrapper } from './TechDocsPageWrapper';
import { TechDocsHome } from './TechDocsHome';
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
describe('TechDocs Page Wrapper', () => {
it('should render a TechDocs Page Wrapper', async () => {
const { queryByText } = render(
wrapInTestApp(
<TechDocsPageWrapper title="test-title" subtitle="test-subtitle">
<TechDocsHome />
</TechDocsPageWrapper>,
),
);
expect(queryByText(/test-title/i)).toBeInTheDocument();
expect(queryByText(/test-subtitle/i)).toBeInTheDocument();
});
});
@@ -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 React from 'react';
import { Header, Content } from '@backstage/core';
type TechDocsPageWrapperProps = {
title: string;
subtitle: string;
children: any;
};
export const TechDocsPageWrapper = ({
children,
title,
subtitle,
}: TechDocsPageWrapperProps) => {
return (
<>
<Header title={title} subtitle={subtitle} />
<Content>{children}</Content>
</>
);
};
@@ -23,7 +23,7 @@ const ComponentWithoutHook = () => {
};
const ComponentWithHook = () => {
const ref = useShadowDom();
const [ref] = useShadowDom();
return <div data-testid="shadow-dom" ref={ref} />;
};
@@ -17,14 +17,15 @@
import { useEffect, useRef } from 'react';
import type { RefObject } from 'react';
type IShadowDOMRefObject = RefObject<HTMLDivElement>;
export const useShadowDom: () => IShadowDOMRefObject = () => {
const ref: IShadowDOMRefObject = useRef(null);
type IUseShadowDOM = () => [RefObject<HTMLDivElement>, ShadowRoot?];
export const useShadowDom: IUseShadowDOM = () => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const divElement = ref.current;
divElement?.attachShadow({ mode: 'open' });
}, [ref]);
}, []);
return ref;
return [ref, ref.current?.shadowRoot || undefined];
};
@@ -62,28 +62,73 @@ describe('addBaseUrl', () => {
]);
});
it('includes path option', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
transformers: [
addBaseUrl({
docStorageURL: DOC_STORAGE_URL,
componentId: 'example-docs',
path: 'examplepath',
}),
],
});
it('includes path option without slash', () => {
const shadowDom = createTestShadowDom(
`
<img src="../img/win-py-install.png" />
<img src="../img/initial-layout.png" />
<link href="https://www.mkdocs.org/" />
<link href="../assets/images/favicon.png" />
<script src="https://www.google-analytics.com/analytics.js"></script>
<script src="../assets/javascripts/vendor.d710d30a.min.js"></script>
`,
{
transformers: [
addBaseUrl({
docStorageURL: DOC_STORAGE_URL,
componentId: 'example-docs',
path: 'examplepath',
}),
],
},
);
expect(getSample(shadowDom, 'img', 'src')).toEqual([
'https://example-host.storage.googleapis.com/example-docs/examplepath/img/win-py-install.png',
'https://example-host.storage.googleapis.com/example-docs/examplepath/img/initial-layout.png',
'https://example-host.storage.googleapis.com/example-docs/img/win-py-install.png',
'https://example-host.storage.googleapis.com/example-docs/img/initial-layout.png',
]);
expect(getSample(shadowDom, 'link', 'href')).toEqual([
'https://www.mkdocs.org/',
'https://example-host.storage.googleapis.com/example-docs/examplepath/assets/images/favicon.png',
'https://example-host.storage.googleapis.com/example-docs/assets/images/favicon.png',
]);
expect(getSample(shadowDom, 'script', 'src')).toEqual([
'https://www.google-analytics.com/analytics.js',
'https://example-host.storage.googleapis.com/example-docs/examplepath/assets/javascripts/vendor.d710d30a.min.js',
'https://example-host.storage.googleapis.com/example-docs/assets/javascripts/vendor.d710d30a.min.js',
]);
});
it('includes path option with slash', () => {
const shadowDom = createTestShadowDom(
`
<img src="../img/win-py-install.png" />
<img src="../img/initial-layout.png" />
<link href="https://www.mkdocs.org/" />
<link href="../assets/images/favicon.png" />
<script src="https://www.google-analytics.com/analytics.js"></script>
<script src="../assets/javascripts/vendor.d710d30a.min.js"></script>
`,
{
transformers: [
addBaseUrl({
docStorageURL: DOC_STORAGE_URL,
componentId: 'example-docs',
path: 'examplepath/',
}),
],
},
);
expect(getSample(shadowDom, 'img', 'src')).toEqual([
'https://example-host.storage.googleapis.com/example-docs/img/win-py-install.png',
'https://example-host.storage.googleapis.com/example-docs/img/initial-layout.png',
]);
expect(getSample(shadowDom, 'link', 'href')).toEqual([
'https://www.mkdocs.org/',
'https://example-host.storage.googleapis.com/example-docs/assets/images/favicon.png',
]);
expect(getSample(shadowDom, 'script', 'src')).toEqual([
'https://www.google-analytics.com/analytics.js',
'https://example-host.storage.googleapis.com/example-docs/assets/javascripts/vendor.d710d30a.min.js',
]);
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import URLParser from '../urlParser';
import URLFormatter from '../urlFormatter';
import type { Transformer } from './index';
type AddBaseUrlOptions = {
@@ -36,11 +36,16 @@ export const addBaseUrl = ({
Array.from(list)
.filter(elem => !!elem.getAttribute(attributeName))
.forEach((elem: T) => {
const newUrl = new URLParser(
`${docStorageURL}/${componentId}/${path}`,
elem.getAttribute(attributeName)!,
).parse();
elem.setAttribute(attributeName, newUrl);
const urlFormatter = new URLFormatter(
path.length < 1 || path.endsWith('/')
? `${docStorageURL}/${componentId}/${path}`
: `${docStorageURL}/${componentId}/${path}/`,
);
elem.setAttribute(
attributeName,
urlFormatter.formatURL(elem.getAttribute(attributeName)!),
);
});
};
@@ -15,14 +15,14 @@
*/
import { createTestShadowDom, FIXTURES } from '../../test-utils';
import { addEventListener } from '../transformers';
import { addLinkClickListener } from '.';
describe('addEventListener', () => {
describe('addLinkClickListener', () => {
it('calls onClick when a link has been clicked', () => {
const fn = jest.fn();
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
transformers: [
addEventListener({
addLinkClickListener({
onClick: fn,
}),
],
@@ -16,22 +16,20 @@
import type { Transformer } from './index';
type AddEventListenerOptions = {
onClick: (newUrl: string) => void;
type AddLinkClickListenerOptions = {
onClick: (e: MouseEvent, newUrl: string) => void;
};
export const addEventListener = ({
export const addLinkClickListener = ({
onClick,
}: AddEventListenerOptions): Transformer => {
}: AddLinkClickListenerOptions): Transformer => {
return dom => {
Array.from(dom.getElementsByTagName('a')).forEach(elem => {
elem.addEventListener('click', (e: MouseEvent) => {
e.preventDefault();
const target = e.target as HTMLAnchorElement;
if (target?.getAttribute('href')) {
onClick(
target.getAttribute('href')!.replace(window.location.origin, ''),
);
onClick(e, target.getAttribute('href')!);
}
});
});
@@ -16,7 +16,7 @@
export * from './addBaseUrl';
export * from './rewriteDocLinks';
export * from './addEventListener';
export * from './addLinkClickListener';
export * from './removeMkdocsHeader';
export * from './modifyCss';
@@ -34,7 +34,7 @@ describe('rewriteDocLinks', () => {
]);
});
it('should transform a href with licalhost as baseUrl', () => {
it('should transform a href with localhost as baseUrl', () => {
const shadowDom = createTestShadowDom(
`
<a href="http://example.org/">Test</a>
@@ -49,9 +49,9 @@ describe('rewriteDocLinks', () => {
expect(getSample(shadowDom, 'a', 'href', 6)).toEqual([
'http://example.org/',
'http://localhost/example',
'http://localhost/example-docs',
'http://localhost/example-docs/example-page',
'http://localhost/example/',
'http://localhost/example-docs/',
'http://localhost/example-docs/example-page/',
]);
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import URLParser from '../urlParser';
import URLFormatter from '../urlFormatter';
import type { Transformer } from './index';
export const rewriteDocLinks = (): Transformer => {
@@ -26,12 +26,10 @@ export const rewriteDocLinks = (): Transformer => {
Array.from(list)
.filter(elem => elem.hasAttribute(attributeName))
.forEach((elem: T) => {
const urlFormatter = new URLFormatter(window.location.href);
elem.setAttribute(
attributeName,
new URLParser(
window.location.href,
elem.getAttribute(attributeName)!,
).parse(),
urlFormatter.formatURL(elem.getAttribute(attributeName)!),
);
});
};
@@ -0,0 +1,84 @@
/*
* 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 URLFormatter from './urlFormatter';
describe('URLFormatter', () => {
describe('formatURL', () => {
it('should not change an absolute url', () => {
const formatter = new URLFormatter('https://www.google.com/');
expect(formatter.formatURL('https://www.mkdocs.org/')).toEqual(
'https://www.mkdocs.org/',
);
});
it('should convert a relative url to an absolute url', () => {
const formatter = new URLFormatter(
'https://www.mkdocs.org/user-guide/getting-started/',
);
expect(formatter.formatURL('../../support/installing/')).toEqual(
'https://www.mkdocs.org/support/installing/',
);
});
it('should add a trailing slash', () => {
const formatter = new URLFormatter(
'https://www.mkdocs.org/user-guide/getting-started',
);
expect(formatter.formatURL('./getting-started')).toEqual(
'https://www.mkdocs.org/user-guide/getting-started/',
);
});
it('should not add a trailing slash', () => {
const formatter = new URLFormatter(
'https://www.mkdocs.org/user-guide/getting-started/',
);
expect(formatter.formatURL('.')).toEqual(
'https://www.mkdocs.org/user-guide/getting-started/',
);
});
it('should not add multiple hashes', () => {
const formatter = new URLFormatter(
'https://www.mkdocs.org/user-guide/getting-started/#hash1',
);
expect(formatter.formatURL('./#hash2')).toEqual(
'https://www.mkdocs.org/user-guide/getting-started/#hash2',
);
});
});
describe('formatBaseURL', () => {
it('should keep query params in URL', () => {
const formatter = new URLFormatter(
'https://www.mkdocs.org/user-guide/getting-started/?query=hello+world',
);
expect(formatter.formatBaseURL()).toEqual(
'https://www.mkdocs.org/user-guide/getting-started/?query=hello+world',
);
});
it('should keep hash in URL', () => {
const formatter = new URLFormatter(
'https://www.mkdocs.org/user-guide/getting-started/#hash',
);
expect(formatter.formatBaseURL()).toEqual(
'https://www.mkdocs.org/user-guide/getting-started/#hash',
);
});
});
});
@@ -0,0 +1,39 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export default class URLFormatter {
constructor(public baseURL: string) {}
formatBaseURL(): string {
return this.normalizeURL(this.baseURL);
}
formatURL(pathname: string): string {
return this.normalizeURL(new URL(pathname, this.baseURL).toString());
}
private normalizeURL(urlString: string): string {
const url = new URL(urlString);
const filename: string = url.pathname.split('/').pop() ?? url.pathname;
const isDir: boolean = filename.includes('.') === false;
if (isDir) {
url.pathname = url.pathname.replace(/([^/])$/, '$1/');
}
return url.toString();
}
}
@@ -1,61 +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 URLParser from './urlParser';
describe('URLParser', () => {
it('should not change an absolute url', () => {
const urlParser = new URLParser(
'https://www.google.com/',
'https://www.mkdocs.org/',
);
expect(urlParser.parse()).toEqual('https://www.mkdocs.org/');
});
it('should convert a relative url to an absolute url', () => {
const urlParser = new URLParser(
'https://www.mkdocs.org/user-guide/getting-started/',
'../../support/installing/',
);
expect(urlParser.parse()).toEqual(
'https://www.mkdocs.org/support/installing/',
);
});
it('should add a trailing slash', () => {
const urlParser = new URLParser(
'https://www.mkdocs.org/user-guide/getting-started',
'.',
);
expect(urlParser.parse()).toEqual(
'https://www.mkdocs.org/user-guide/getting-started/',
);
});
it('should not add a trailing slash', () => {
const urlParser = new URLParser(
'https://www.mkdocs.org/user-guide/getting-started/',
'.',
);
expect(urlParser.parse()).toEqual(
'https://www.mkdocs.org/user-guide/getting-started/',
);
});
});
+1
View File
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+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": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0"
},
"devDependencies": {
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import React from 'react';
import { Link as RouterLink } from 'react-router-dom';
import {
Typography,
@@ -38,7 +38,7 @@ import {
configApiRef,
} from '@backstage/core';
const WelcomePage: FC<{}> = () => {
const WelcomePage = () => {
const appTitle =
useApi(configApiRef).getOptionalString('app.title') ?? 'Backstage';
const profile = { givenName: '' };