Merge branch 'master' of github.com:spotify/backstage into shmidt-i/scaffolder-flow-frontend
This commit is contained in:
@@ -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!,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
|
||||
export {};
|
||||
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
|
||||
export {};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
|
||||
@@ -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>]>(
|
||||
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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';
|
||||
+20
-13
@@ -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">
|
||||
<
|
||||
</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';
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
|
||||
export {};
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)!),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+3
-3
@@ -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,
|
||||
}),
|
||||
],
|
||||
+5
-7
@@ -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/',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
|
||||
@@ -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: '' };
|
||||
|
||||
Reference in New Issue
Block a user