Merge branch 'master' of github.com:spotify/backstage into mob/create-vcs-step

* 'master' of github.com:spotify/backstage: (86 commits)
  Removed argument to rewriteDocLinks
  Removed unused JSDOM dependency
  Updated test descriptions
  Added rewriteDocLinks test
  Added tests for transformers
  chore(app): use backend.baseUrl
  Remove unused argument to removeMkdocsHeader
  Rename modifyCssTransformer to modifyCss
  Updated stories for ItemCard
  Persist scopes in Github provider and add it to the auth response. Fix serialization for Set type in locaStorage.
  Fix tests
  Github Auth to use AuthSessionStore
  Use new GithubAuth flow in GitOps plugin
  Added DenseTable to Storybook.
  Changes to material theme css
  nitpick: newlines between mock entity outputs
  fix(techdocs): move card component into techdocs plugin from backstage core
  yarn.lock again
  Enabled 'dense' mode for Table cells.
  core: pin material-table to 1.62.x
  ...
This commit is contained in:
blam
2020-06-30 14:54:50 +02:00
149 changed files with 6880 additions and 2307 deletions
+38 -9
View File
@@ -7,17 +7,46 @@ This is the backend part of the auth plugin.
It responds to auth requests from the frontend, and fulfills them by delegating
to the appropriate provider in the backend.
## Requirements
Needs AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET set in the environment for the backend to startup
## Local development
export AUTH_GOOGLE_CLIENT_ID=<INSERT_CLIENT_ID_HERE>
read -r AUTH_GOOGLE_CLIENT_SECRET
<COPY_PASTE_CLIENT_SECRET_HERE>
export AUTH_GOOGLE_CLIENT_SECRET
run `yarn start` in packages/backend folder
Choose your OAuth Providers, replace `x` with actual value and then start backend:
Example for Google Oauth Provider at root directory:
```bash
export AUTH_GOOGLE_CLIENT_ID=x
export AUTH_GOOGLE_CLIENT_SECRET=x
yarn --cwd packages/backend start
```
### Google
```bash
export AUTH_GOOGLE_CLIENT_ID=x
export AUTH_GOOGLE_CLIENT_SECRET=x
```
### Github
```bash
export AUTH_GITHUB_CLIENT_ID=x
export AUTH_GITHUB_CLIENT_SECRET=x
```
### Gitlab
```bash
export GITLAB_BASE_URL=x # default is https://gitlab.com
export AUTH_GITLAB_CLIENT_ID=x
export AUTH_GITLAB_CLIENT_SECRET=x
```
### Okta
```bash
export AUTH_OKTA_AUDIENCE=x
export AUTH_OKTA_CLIENT_ID=x
export AUTH_OKTA_CLIENT_SECRET=x
```
### SAML
+5 -1
View File
@@ -39,7 +39,10 @@
"morgan": "^1.10.0",
"passport": "^0.4.1",
"passport-github2": "^0.1.12",
"passport-gitlab2": "^5.0.0",
"passport-google-oauth20": "^2.0.0",
"passport-oauth2": "^1.5.0",
"passport-okta-oauth": "^0.0.1",
"passport-saml": "^1.3.3",
"uuid": "^8.0.0",
"winston": "^3.2.1",
@@ -57,6 +60,7 @@
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist"
"dist",
"migrations"
]
}
@@ -33,6 +33,7 @@ export type Options = {
providerId: string;
secure: boolean;
disableRefresh?: boolean;
persistScopes?: boolean;
baseUrl: string;
appOrigin: string;
tokenIssuer: TokenIssuer;
@@ -105,6 +106,10 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
throw new InputError('missing scope parameter');
}
if (this.options.persistScopes) {
this.setScopesCookie(res, scope);
}
const nonce = crypto.randomBytes(16).toString('base64');
// set a nonce cookie before redirecting to oauth provider
this.setNonceCookie(res, nonce);
@@ -137,6 +142,14 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
req,
);
if (this.options.persistScopes) {
const grantedScopes = this.getScopesFromCookie(
req,
this.options.providerId,
);
response.providerInfo.scope = grantedScopes;
}
if (!this.options.disableRefresh) {
// throw error if missing refresh token
if (!refreshToken) {
@@ -241,6 +254,21 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
});
};
private setScopesCookie = (res: express.Response, scope: string) => {
res.cookie(`${this.options.providerId}-scope`, scope, {
maxAge: TEN_MINUTES_MS,
secure: this.options.secure,
sameSite: 'none',
domain: this.domain,
path: `${this.basePath}/${this.options.providerId}/handler`,
httpOnly: true,
});
};
private getScopesFromCookie = (req: express.Request, providerId: string) => {
return req.cookies[`${providerId}-scope`];
};
private setRefreshTokenCookie = (
res: express.Response,
refreshToken: string,
@@ -137,7 +137,7 @@ export const executeRefreshTokenStrategy = async (
params: any,
) => {
if (err) {
reject(new Error(`Failed to refresh access token ${err}`));
reject(new Error(`Failed to refresh access token ${err.toString()}`));
}
if (!accessToken) {
reject(
@@ -17,7 +17,9 @@
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';
@@ -25,7 +27,9 @@ import { TokenIssuer } from '../identity';
const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
github: createGithubProvider,
gitlab: createGitlabProvider,
saml: createSamlProvider,
okta: createOktaProvider,
};
export const createAuthProviderRouter = (
@@ -115,6 +115,7 @@ export function createGithubProvider(
envProviders[env] = new OAuthProvider(new GithubAuthProvider(opts), {
disableRefresh: true,
persistScopes: true,
providerId: 'github',
secure,
baseUrl,
@@ -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 { createGitlabProvider } from './provider';
@@ -0,0 +1,100 @@
/*
* 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 { GitlabAuthProvider } from './provider';
describe('GitlabAuthProvider', () => {
it('should transform to type OAuthResponse', () => {
const tests = [
{
arguments: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
rawProfile: {
id: 'uid-123',
username: 'jimmymarkum',
provider: 'gitlab',
displayName: 'Jimmy Markum',
emails: [
{
value: 'jimmymarkum@gmail.com',
},
],
avatarUrl:
'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg',
},
params: {
scope: 'user_read write_repository',
expires_in: 100,
},
},
expect: {
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
expiresInSeconds: 100,
scope: 'user_read write_repository',
},
profile: {
email: 'jimmymarkum@gmail.com',
displayName: 'Jimmy Markum',
picture:
'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg',
},
},
},
{
arguments: {
accessToken:
'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe',
rawProfile: {
id: 'ipd12039',
username: 'daveboyle',
provider: 'gitlab',
displayName: 'Dave Boyle',
emails: [
{
value: 'daveboyle@gitlab.org',
},
],
},
params: {
scope: 'read_repository',
},
},
expect: {
providerInfo: {
accessToken:
'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe',
scope: 'read_repository',
},
profile: {
displayName: 'Dave Boyle',
email: 'daveboyle@gitlab.org',
},
},
},
];
for (const test of tests) {
expect(
GitlabAuthProvider.transformOAuthResponse(
test.arguments.accessToken,
test.arguments.rawProfile,
test.arguments.params,
),
).toEqual(test.expect);
}
});
});
@@ -0,0 +1,176 @@
/*
* 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 { Strategy as GitlabStrategy } from 'passport-gitlab2';
import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
makeProfileInfo,
} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
AuthProviderConfig,
RedirectInfo,
EnvironmentProviderConfig,
OAuthProviderOptions,
OAuthProviderConfig,
OAuthResponse,
PassportDoneCallback,
} from '../types';
import { OAuthProvider } from '../../lib/OAuthProvider';
import {
EnvironmentHandlers,
EnvironmentHandler,
} from '../../lib/EnvironmentHandler';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
import passport from 'passport';
export class GitlabAuthProvider implements OAuthProviderHandlers {
private readonly _strategy: GitlabStrategy;
static transformPassportProfile(rawProfile: any): passport.Profile {
const profile: passport.Profile = {
id: rawProfile.id,
username: rawProfile.username,
provider: rawProfile.provider,
displayName: rawProfile.displayName,
};
if (rawProfile.emails && rawProfile.emails.length > 0) {
profile.emails = rawProfile.emails;
}
if (rawProfile.avatarUrl) {
profile.photos = [{ value: rawProfile.avatarUrl }];
}
return profile;
}
static transformOAuthResponse(
accessToken: string,
rawProfile: any,
params: any = {},
): OAuthResponse {
const passportProfile = GitlabAuthProvider.transformPassportProfile(
rawProfile,
);
const profile = makeProfileInfo(passportProfile, params.id_token);
const providerInfo = {
accessToken,
scope: params.scope,
expiresInSeconds: params.expires_in,
idToken: params.id_token,
};
if (params.expires_in) {
providerInfo.expiresInSeconds = params.expires_in;
}
if (params.id_token) {
providerInfo.idToken = params.id_token;
}
return {
providerInfo,
profile,
};
}
constructor(options: OAuthProviderOptions) {
this._strategy = new GitlabStrategy(
{ ...options },
(
accessToken: any,
_: any,
params: any,
rawProfile: any,
done: PassportDoneCallback<OAuthResponse>,
) => {
const oauthResponse = GitlabAuthProvider.transformOAuthResponse(
accessToken,
rawProfile,
params,
);
done(undefined, oauthResponse);
},
);
}
async start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo> {
return await executeRedirectStrategy(req, this._strategy, options);
}
async handler(req: express.Request): Promise<{ response: OAuthResponse }> {
return await executeFrameHandlerStrategy<OAuthResponse>(
req,
this._strategy,
);
}
}
export function createGitlabProvider(
{ baseUrl }: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
tokenIssuer: TokenIssuer,
) {
const envProviders: EnvironmentHandlers = {};
for (const [env, envConfig] of Object.entries(providerConfig)) {
const {
secure,
appOrigin,
clientId,
clientSecret,
audience,
} = (envConfig as unknown) as OAuthProviderConfig;
const callbackURLParam = `?env=${env}`;
const opts = {
clientID: clientId,
clientSecret: clientSecret,
callbackURL: `${baseUrl}/gitlab/handler/frame${callbackURLParam}`,
baseURL: audience,
};
if (!opts.clientID || !opts.clientSecret) {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'Failed to initialize Gitlab auth provider, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars',
);
}
logger.warn(
'Gitlab auth provider disabled, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars to enable',
);
continue;
}
envProviders[env] = new OAuthProvider(new GitlabAuthProvider(opts), {
disableRefresh: true,
providerId: 'gitlab',
secure,
baseUrl,
appOrigin,
tokenIssuer,
});
}
return new EnvironmentHandler(envProviders);
}
+25
View File
@@ -0,0 +1,25 @@
/*
* 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.
*/
declare module 'passport-gitlab2' {
import { Request } from 'express';
import { StrategyCreated } from 'passport';
export class Strategy {
constructor(options: any, verify: any);
authenticate(this: StrategyCreated<this>, req: Request, options?: any): any;
}
}
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { createOktaProvider } from './provider';
@@ -0,0 +1,213 @@
/*
* 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 { OAuthProvider } from '../../lib/OAuthProvider';
import { Strategy as OktaStrategy } from 'passport-okta-oauth';
import passport from 'passport';
import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
executeFetchUserProfileStrategy,
} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
RedirectInfo,
AuthProviderConfig,
EnvironmentProviderConfig,
OAuthProviderOptions,
OAuthProviderConfig,
OAuthResponse,
PassportDoneCallback,
} from '../types';
import {
EnvironmentHandler,
EnvironmentHandlers,
} from '../../lib/EnvironmentHandler';
import { Logger } from 'winston';
import { StateStore } from 'passport-oauth2';
import { TokenIssuer } from '../../identity';
type PrivateInfo = {
refreshToken: string;
};
export class OktaAuthProvider implements OAuthProviderHandlers {
private readonly _strategy: any;
/**
* Due to passport-okta-oauth forcing options.state = true,
* passport-oauth2 requires express-session to be installed
* so that the 'state' parameter of the oauth2 flow can be stored.
* This implementation of StateStore matches the NullStore found within
* passport-oauth2, which is the StateStore implementation used when options.state = false,
* allowing us to avoid using express-session in order to integrate with Okta.
*/
private _store: StateStore = {
store(_req: express.Request, cb: any) {
cb(null, null);
},
verify(_req: express.Request, _state: string, cb: any) {
cb(null, true);
},
}
constructor(options: OAuthProviderOptions) {
this._strategy = new OktaStrategy({
passReqToCallback: false as true,
...options,
store: this._store,
response_type: 'code',
}, (
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,
});
}
private async populateIdentity(
response: OAuthResponse,
): Promise<OAuthResponse> {
const { profile } = response;
if (!profile.email) {
throw new Error('Okta profile contained no email');
}
// TODO(Rugvip): Hardcoded to the local part of the email for now
const id = profile.email.split('@')[0];
return { ...response, backstageIdentity: { id } };
}
}
export function createOktaProvider(
{ 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 OAuthProviderConfig;
const { secure, appOrigin } = config;
const callbackURLParam = `?env=${env}`;
const opts = {
audience: config.audience,
clientID: config.clientId,
clientSecret: config.clientSecret,
callbackURL: `${baseUrl}/okta/handler/frame${callbackURLParam}`,
};
if (!opts.clientID || !opts.clientSecret || !opts.audience) {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'Failed to initialize Okta auth provider, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars',
);
}
logger.warn(
'Okta auth provider disabled, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars to enable',
);
continue;
}
envProviders[env] = new OAuthProvider(new OktaAuthProvider(opts), {
disableRefresh: false,
providerId: 'okta',
secure,
baseUrl,
appOrigin,
tokenIssuer,
});
}
return new EnvironmentHandler(envProviders);
}
+22
View File
@@ -0,0 +1,22 @@
/*
* 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.
*/
declare module 'passport-okta-oauth' {
export class Strategy {
constructor(options: any, verify: any)
}
}
@@ -55,6 +55,10 @@ export type OAuthProviderConfig = {
* Client Secret of the auth provider.
*/
clientSecret: string;
/**
* The location of the OAuth Authorization Server
*/
audience?: string;
};
export type EnvironmentProviderConfig = {
@@ -76,12 +76,30 @@ export async function createRouter(
clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!,
},
},
gitlab: {
development: {
appOrigin: 'http://localhost:3000',
secure: false,
clientId: process.env.AUTH_GITLAB_CLIENT_ID!,
clientSecret: process.env.AUTH_GITLAB_CLIENT_SECRET!,
audience: process.env.GITLAB_BASE_URL! || 'https://gitlab.com',
},
},
saml: {
development: {
entryPoint: 'http://localhost:7001/',
issuer: 'passport-saml',
},
},
okta: {
development: {
appOrigin: 'http://localhost:3000',
secure: false,
clientId: process.env.AUTH_OKTA_CLIENT_ID!,
clientSecret: process.env.AUTH_OKTA_CLIENT_SECRET!,
audience: process.env.AUTH_OKTA_AUDIENCE,
},
},
},
},
};
@@ -15,4 +15,5 @@ for URL in \
--request POST 'localhost:7000/catalog/locations' \
--header 'Content-Type: application/json' \
--data-raw "{\"type\": \"github\", \"target\": \"https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/${URL}\"}"
echo
done
@@ -14,195 +14,248 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import {
ApiProvider,
ApiRegistry,
IdentityApi,
identityApiRef,
storageApiRef,
} from '@backstage/core';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { fireEvent, render, waitFor } from '@testing-library/react';
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter';
import { EntityGroup } from '../../data/filters';
import { CatalogApi, catalogApiRef } from '../../api/types';
import { EntityFilterGroupsProvider } from '../../filter';
import { ButtonGroup, CatalogFilter } from './CatalogFilter';
describe('Catalog Filter', () => {
const comp1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component-1',
},
spec: {
owner: 'team',
},
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
] as Entity[]),
};
const comp2 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component-2',
},
spec: {
owner: 'team',
},
const indentityApi: Partial<IdentityApi> = {
getUserId: () => 'tools@example.com',
};
const comp3 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component-3',
},
spec: {
owner: '',
},
};
const defaultFilterProps = {
selectedFilter: EntityGroup.ALL,
onFilterChange: (type: EntityGroup) => type,
entitiesByFilter: {
[EntityGroup.ALL]: [comp1, comp2, comp3],
[EntityGroup.STARRED]: [comp1],
[EntityGroup.OWNED]: [comp1],
},
};
it('should render the different groups', async () => {
const mockGroups: CatalogFilterGroup[] = [
{ name: 'Test Group 1', items: [] },
{ name: 'Test Group 2', items: [] },
];
const { findByText } = render(
const renderWrapped = (children: React.ReactNode) =>
render(
wrapInTestApp(
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
<ApiProvider
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[identityApiRef, indentityApi],
[storageApiRef, MockStorageApi.create()],
])}
>
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>,
</ApiProvider>,
),
);
it('should render the different groups', async () => {
const mockGroups: ButtonGroup[] = [
{ name: 'Test Group 1', items: [] },
{ name: 'Test Group 2', items: [] },
];
const { findByText } = renderWrapped(
<CatalogFilter buttonGroups={mockGroups} initiallySelected="" />,
);
for (const group of mockGroups) {
expect(await findByText(group.name)).toBeInTheDocument();
}
});
it('should render the different items and their names', async () => {
const mockGroups: CatalogFilterGroup[] = [
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: EntityGroup.ALL,
id: 'all',
label: 'First Label',
filterFn: () => true,
},
{
id: EntityGroup.STARRED,
id: 'starred',
label: 'Second Label',
filterFn: () => false,
},
],
},
];
const { findByText } = render(
wrapInTestApp(
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
),
const { findByText } = renderWrapped(
<CatalogFilter buttonGroups={mockGroups} initiallySelected="all" />,
);
const [group] = mockGroups;
for (const item of group.items) {
for (const item of mockGroups[0].items) {
expect(await findByText(item.label)).toBeInTheDocument();
}
});
it('should render the count in each item', async () => {
const mockGroups: CatalogFilterGroup[] = [
it('selects the first item if no desired initial one is set', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: EntityGroup.ALL,
id: 'all',
label: 'First Label',
count: 3,
filterFn: () => true,
},
{
id: EntityGroup.STARRED,
id: 'starred',
label: 'Second Label',
count: 1,
filterFn: () => false,
},
],
},
];
const { getAllByText } = render(
wrapInTestApp(
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
),
const onChange = jest.fn();
renderWrapped(
<CatalogFilter
buttonGroups={mockGroups}
initiallySelected="all"
onChange={onChange}
/>,
);
for (const key of Object.keys(defaultFilterProps.entitiesByFilter)) {
const matcher = new RegExp(
`(${defaultFilterProps.entitiesByFilter[key as EntityGroup].length})`,
);
const items = await getAllByText(matcher);
items.forEach(el => expect(el).toBeInTheDocument());
}
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'all',
label: 'First Label',
});
});
});
it('should fire the callback when an item is clicked', async () => {
const mockGroups: CatalogFilterGroup[] = [
it('selects the initial item', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: EntityGroup.ALL,
id: 'all',
label: 'First Label',
count: 100,
filterFn: () => true,
},
{
id: EntityGroup.STARRED,
id: 'starred',
label: 'Second Label',
count: 400,
filterFn: () => false,
},
],
},
];
const onSelectedChangeHandler = jest.fn();
const onChange = jest.fn();
const { findByText } = render(
wrapInTestApp(
<CatalogFilter
{...defaultFilterProps}
groups={mockGroups}
onFilterChange={onSelectedChangeHandler}
/>,
),
renderWrapped(
<CatalogFilter
buttonGroups={mockGroups}
onChange={onChange}
initiallySelected="starred"
/>,
);
const item = mockGroups[0].items[0];
const element = await findByText(item.label);
fireEvent.click(element);
expect(onSelectedChangeHandler).toHaveBeenCalledWith(item.id);
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'starred',
label: 'Second Label',
});
});
});
it('should render a component when a function is passed to the count component', async () => {
const mockGroups: CatalogFilterGroup[] = [
it('can change the selected item', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: EntityGroup.ALL,
id: 'all',
label: 'First Label',
count: () => <b>BACKSTAGE!</b>,
filterFn: () => true,
},
{
id: EntityGroup.STARRED,
id: 'starred',
label: 'Second Label',
count: 400,
filterFn: () => false,
},
],
},
];
const { findByText } = render(
wrapInTestApp(
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
),
const onChange = jest.fn();
const { findByText } = renderWrapped(
<CatalogFilter
buttonGroups={mockGroups}
initiallySelected="all"
onChange={onChange}
/>,
);
expect(await findByText('Test Group 1')).toBeInTheDocument();
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'all',
label: 'First Label',
});
});
fireEvent.click(await findByText('Second Label'));
await waitFor(() => {
expect(onChange).toHaveBeenLastCalledWith({
id: 'starred',
label: 'Second Label',
});
});
});
it('displays match counts properly', async () => {
const mockGroups: ButtonGroup[] = [
{
name: 'Test Group 1',
items: [
{
id: 'owned',
label: 'First Label',
filterFn: entity => entity.spec?.owner === 'tools@example.com',
},
],
},
];
const { findByText } = renderWrapped(
<CatalogFilter buttonGroups={mockGroups} initiallySelected="owned" />,
);
expect(await findByText('1')).toBeInTheDocument();
});
});
@@ -14,31 +14,36 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import { Entity } from '@backstage/catalog-model';
import { IconComponent } from '@backstage/core';
import {
Card,
List,
ListItemIcon,
ListItemSecondaryAction,
ListItemText,
MenuItem,
Typography,
Theme,
makeStyles,
MenuItem,
Theme,
Typography,
} from '@material-ui/core';
import type { IconComponent } from '@backstage/core';
import { EntityGroup } from '../../data/filters';
import { EntitiesByFilter } from '../../hooks/useEntities';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { FilterGroup, useEntityFilterGroup } from '../../filter';
export type CatalogFilterItem = {
id: EntityGroup;
label: string;
icon?: IconComponent;
count?: number | FC;
};
export type CatalogFilterGroup = {
export type ButtonGroup = {
name: string;
items: CatalogFilterItem[];
items: {
id: string;
label: string;
icon?: IconComponent;
filterFn: (entity: Entity) => boolean;
}[];
};
const useStyles = makeStyles<Theme>(theme => ({
@@ -67,21 +72,56 @@ const useStyles = makeStyles<Theme>(theme => ({
},
}));
export const CatalogFilter: FC<{
selectedFilter: EntityGroup;
onFilterChange: (type: EntityGroup) => void;
entitiesByFilter: EntitiesByFilter;
groups: CatalogFilterGroup[];
}> = ({
selectedFilter: selectedId,
onFilterChange: setSelectedFilter,
entitiesByFilter,
groups,
}) => {
type OnChangeCallback = (item: { id: string; label: string }) => void;
type Props = {
buttonGroups: ButtonGroup[];
initiallySelected: string;
onChange?: OnChangeCallback;
};
/**
* The main filter group in the sidebar, toggling owned/starred/all.
*/
export const CatalogFilter = ({
buttonGroups,
onChange,
initiallySelected,
}: Props) => {
const classes = useStyles();
const { currentFilter, setCurrentFilter, getFilterCount } = useFilter(
buttonGroups,
initiallySelected,
);
const onChangeRef = useRef<OnChangeCallback>();
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
const setCurrent = useCallback(
(item: { id: string; label: string }) => {
setCurrentFilter(item.id);
onChangeRef.current?.({ id: item.id, label: item.label });
},
[setCurrentFilter],
);
// Make one initial onChange to inform the surroundings about the selected
// item
useEffect(() => {
const items = buttonGroups.flatMap(g => g.items);
const item = items.find(i => i.id === initiallySelected) || items[0];
if (item) {
onChangeRef.current?.({ id: item.id, label: item.label });
}
// intentionally only happens on startup
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Card className={classes.root}>
{groups.map(group => (
{buttonGroups.map(group => (
<React.Fragment key={group.name}>
<Typography variant="subtitle2" className={classes.title}>
{group.name}
@@ -93,10 +133,8 @@ export const CatalogFilter: FC<{
key={item.id}
button
divider
onClick={() => {
setSelectedFilter(item.id);
}}
selected={item.id === selectedId}
onClick={() => setCurrent(item)}
selected={item.id === currentFilter}
className={classes.menuItem}
>
{item.icon && (
@@ -109,7 +147,9 @@ export const CatalogFilter: FC<{
{item.label}
</Typography>
</ListItemText>
{entitiesByFilter[item.id]?.length ?? '-'}
<ListItemSecondaryAction>
{getFilterCount(item.id) ?? '-'}
</ListItemSecondaryAction>
</MenuItem>
))}
</List>
@@ -119,3 +159,53 @@ export const CatalogFilter: FC<{
</Card>
);
};
function useFilter(
buttonGroups: ButtonGroup[],
initiallySelected: string,
): {
currentFilter: string;
setCurrentFilter: (filterId: string) => void;
getFilterCount: (filterId: string) => number | undefined;
} {
const [currentFilter, setCurrentFilter] = useState(initiallySelected);
const filterGroup = useMemo<FilterGroup>(
() => ({
filters: Object.fromEntries(
buttonGroups.flatMap(g => g.items).map(i => [i.id, i.filterFn]),
),
}),
[buttonGroups],
);
const { setSelectedFilters, state } = useEntityFilterGroup(
'primary-sidebar',
filterGroup,
[initiallySelected],
);
const setCurrent = useCallback(
(filterId: string) => {
setCurrentFilter(filterId);
setSelectedFilters([filterId]);
},
[setCurrentFilter, setSelectedFilters],
);
const getFilterCount = useCallback(
(filterId: string) => {
if (state.type !== 'ready') {
return undefined;
}
return state.state.filters[filterId].matchCount;
},
[state],
);
return {
currentFilter,
setCurrentFilter: setCurrent,
getFilterCount,
};
}
@@ -14,26 +14,29 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import {
Header,
HomepageTimer,
identityApiRef,
Page,
pageTheme,
identityApiRef,
useApi,
} from '@backstage/core';
import React from 'react';
import { getTimeBasedGreeting } from './utils/timeUtil';
const CatalogLayout: FC<{}> = props => {
const { children } = props;
type Props = {
children?: React.ReactNode;
};
const CatalogLayout = ({ children }: Props) => {
const greeting = getTimeBasedGreeting();
const identityApi = useApi(identityApiRef);
const userId = useApi(identityApiRef).getUserId();
return (
<Page theme={pageTheme.home}>
<Header
title={`${greeting.greeting}, ${identityApi.getUserId()}!`}
title={`${greeting.greeting}, ${userId}!`}
subtitle="Backstage Service Catalog"
tooltip={greeting.language}
pageTitleOverride="Home"
@@ -14,45 +14,43 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import {
ApiProvider,
ApiRegistry,
errorApiRef,
storageApiRef,
WebStorage,
IdentityApi,
identityApiRef,
storageApiRef,
} from '@backstage/core';
import { MockErrorApi, wrapInTestApp } from '@backstage/test-utils';
import { render, fireEvent } from '@testing-library/react';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { fireEvent, render } from '@testing-library/react';
import React from 'react';
import { catalogApiRef } from '../..';
import { CatalogApi } from '../../api/types';
import { EntityFilterGroupsProvider } from '../../filter';
import { CatalogPage } from './CatalogPage';
import { Entity } from '@backstage/catalog-model';
describe('CatalogPage', () => {
const mockErrorApi = new MockErrorApi();
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
},
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
spec: {
owner: 'not-tools@example.com',
type: 'service',
@@ -62,49 +60,32 @@ describe('CatalogPage', () => {
getLocationByEntity: () =>
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
};
const mockIndentityApi: Partial<IdentityApi> = {
const indentityApi: Partial<IdentityApi> = {
getUserId: () => 'tools@example.com',
};
const renderWrapped = (children: React.ReactNode) =>
render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[identityApiRef, indentityApi],
[storageApiRef, MockStorageApi.create()],
])}
>
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>,
</ApiProvider>,
),
);
// this test right now causes some red lines in the log output when running tests
// related to some theme issues in mui-table
// https://github.com/mbrn/material-table/issues/1293
it('should render', async () => {
const { findByText } = render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[errorApiRef, mockErrorApi],
[catalogApiRef, catalogApi],
[storageApiRef, new WebStorage('@mock', mockErrorApi)],
[identityApiRef, mockIndentityApi],
])}
>
<CatalogPage />
</ApiProvider>,
),
);
const items = await findByText(/All Services \(2\)/);
expect(items).toBeInTheDocument();
});
it('should filter by owner', async () => {
const { findByText, getByText } = render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[errorApiRef, mockErrorApi],
[catalogApiRef, catalogApi],
[storageApiRef, new WebStorage('@mock', mockErrorApi)],
[identityApiRef, mockIndentityApi],
])}
>
<CatalogPage />
</ApiProvider>,
),
);
fireEvent.click(getByText(/Owned/));
const items = await findByText(/Owned \(1\)/);
expect(items).toBeInTheDocument();
const { findByText, getByText } = renderWrapped(<CatalogPage />);
expect(await findByText(/Owned \(1\)/)).toBeInTheDocument();
fireEvent.click(getByText(/All/));
expect(await findByText(/All \(2\)/)).toBeInTheDocument();
});
});
@@ -14,39 +14,26 @@
* limitations under the License.
*/
import { Entity, LocationSpec } from '@backstage/catalog-model';
import {
Content,
ContentHeader,
DismissableBanner,
HeaderTabs,
identityApiRef,
SupportButton,
useApi,
} from '@backstage/core';
import CatalogLayout from './CatalogLayout';
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
import {
Button,
Link,
makeStyles,
Typography,
withStyles,
} 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 React, { FC } from 'react';
import { Button, makeStyles } from '@material-ui/core';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
import React, { useMemo, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { CatalogFilter } from '../CatalogFilter/CatalogFilter';
import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import { CatalogFilter, ButtonGroup } from '../CatalogFilter/CatalogFilter';
import { CatalogTable } from '../CatalogTable/CatalogTable';
import { useEntities } from '../../hooks/useEntities';
import { findLocationForEntityMeta } from '../../data/utils';
import {
getCatalogFilterItemByType,
EntityGroup,
filterGroups,
labeledEntityTypes,
} from '../../data/filters';
import CatalogLayout from './CatalogLayout';
import { CatalogTabs, LabeledComponentType } from './CatalogTabs';
import { WelcomeBanner } from './WelcomeBanner';
const useStyles = makeStyles(theme => ({
contentWrapper: {
@@ -55,138 +42,116 @@ const useStyles = makeStyles(theme => ({
gridTemplateColumns: '250px 1fr',
gridColumnGap: theme.spacing(2),
},
emoji: {
fontSize: '125%',
marginRight: theme.spacing(2),
},
}));
export const CatalogPage: FC<{}> = () => {
const {
entitiesByFilter,
error,
loading,
selectedFilter,
setSelectedFilter,
toggleStarredEntity,
isStarredEntity,
selectTypeFilter,
} = useEntities();
const filteredEntities = entitiesByFilter[selectedFilter ?? EntityGroup.ALL];
const CatalogPageContents = () => {
const styles = useStyles();
const { loading, error, matchingEntities } = useFilteredEntities();
const { isStarredEntity } = useStarredEntities();
const userId = useApi(identityApiRef).getUserId();
const [selectedTab, setSelectedTab] = useState<string>();
const [selectedSidebarItem, setSelectedSidebarItem] = useState<string>();
const YellowStar = withStyles({
root: {
color: '#f3ba37',
},
})(Star);
const tabs = useMemo<LabeledComponentType[]>(
() => [
{
id: 'service',
label: 'Services',
},
{
id: 'website',
label: 'Websites',
},
{
id: 'library',
label: 'Libraries',
},
{
id: 'documentation',
label: 'Documentation',
},
{
id: 'other',
label: 'Other',
},
],
[],
);
const actions = [
(rowData: Entity) => {
const location = findLocationForEntityMeta(rowData.metadata);
return {
icon: GitHub,
tooltip: 'View on GitHub',
onClick: () => {
if (!location) return;
window.open(location.target, '_blank');
},
hidden: location?.type !== 'github',
};
},
(rowData: Entity) => {
const createEditLink = (location: LocationSpec): string => {
switch (location.type) {
case 'github':
return location.target.replace('/blob/', '/edit/');
default:
return location.target;
}
};
const location = findLocationForEntityMeta(rowData.metadata);
return {
icon: Edit,
tooltip: 'Edit',
iconProps: { size: 'small' },
onClick: () => {
if (!location) return;
window.open(createEditLink(location), '_blank');
},
hidden: location?.type !== 'github',
};
},
(rowData: Entity) => {
const isStarred = isStarredEntity(rowData);
return {
icon: isStarred ? YellowStar : StarOutline,
tooltip: isStarred ? 'Remove from favorites' : 'Add to favorites',
onClick: () => toggleStarredEntity(rowData),
};
},
];
const filterGroups = useMemo<ButtonGroup[]>(
() => [
{
name: 'Personal',
items: [
{
id: 'owned',
label: 'Owned',
icon: SettingsIcon,
filterFn: entity => entity.spec?.owner === userId,
},
{
id: 'starred',
label: 'Starred',
icon: StarIcon,
filterFn: isStarredEntity,
},
],
},
{
name: 'Company', // TODO: Replace with Company name, read from app config.
items: [
{
id: 'all',
label: 'All',
filterFn: () => true,
},
],
},
],
[isStarredEntity, userId],
);
return (
<CatalogLayout>
<HeaderTabs
tabs={labeledEntityTypes}
onChange={(index: Number) => {
selectTypeFilter(labeledEntityTypes[index as number].id);
}}
<CatalogTabs
tabs={tabs}
onChange={({ label }) => setSelectedTab(label)}
/>
<Content>
<DismissableBanner
variant="info"
message={
<Typography>
<span role="img" aria-label="wave" className={styles.emoji}>
👋🏼
</span>
Welcome to Backstage, we are happy to have you. Start by checking
out our{' '}
<Link href="/welcome" color="textSecondary">
getting started
</Link>{' '}
page.
</Typography>
}
id="catalog_page_welcome_banner"
/>
<ContentHeader title="Services">
<WelcomeBanner />
<ContentHeader title={selectedTab ?? ''}>
<Button
component={RouterLink}
variant="contained"
color="primary"
to={scaffolderRootRoute.path}
>
Create Service
Create Component
</Button>
<SupportButton>All your software catalog entities</SupportButton>
</ContentHeader>
<div className={styles.contentWrapper}>
<div>
<CatalogFilter
groups={filterGroups}
selectedFilter={selectedFilter ?? EntityGroup.ALL}
onFilterChange={setSelectedFilter}
entitiesByFilter={entitiesByFilter}
buttonGroups={filterGroups}
onChange={({ label }) => setSelectedSidebarItem(label)}
initiallySelected="owned"
/>
</div>
<CatalogTable
titlePreamble={
getCatalogFilterItemByType(selectedFilter ?? EntityGroup.ALL)
?.label ?? ''
}
entities={filteredEntities || []}
loading={loading && !error}
titlePreamble={selectedSidebarItem ?? ''}
entities={matchingEntities}
loading={loading}
error={error}
actions={actions}
/>
</div>
</Content>
</CatalogLayout>
);
};
export const CatalogPage = () => (
<EntityFilterGroupsProvider>
<CatalogPageContents />
</EntityFilterGroupsProvider>
);
@@ -0,0 +1,85 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { HeaderTabs } from '@backstage/core';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { FilterGroup, useEntityFilterGroup } from '../../filter';
/**
* A component type, and a human readable label for it.
*/
export type LabeledComponentType = {
id: string;
label: string;
};
/**
* Called on mount, and when the selected tab changes.
*/
export type OnChangeCallback = (tab: LabeledComponentType) => void;
type Props = {
tabs: LabeledComponentType[];
onChange?: OnChangeCallback;
};
/**
* The tabs at the top of the catalog list page, for component type filtering.
*/
export const CatalogTabs = ({ tabs, onChange }: Props) => {
const filterGroup = useMemo<FilterGroup>(() => {
return {
filters: Object.fromEntries(
tabs.map(t => [t.id, (entity: Entity) => entity.spec?.type === t.id]),
),
};
}, [tabs]);
const { setSelectedFilters } = useEntityFilterGroup('type', filterGroup, [
tabs[0].id,
]);
const [currentTabIndex, setCurrentTabIndex] = useState<number>(0);
// Hold a reference to the callback
const onChangeRef = useRef<OnChangeCallback>();
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
onChangeRef.current?.(tabs[currentTabIndex]);
}, [tabs, currentTabIndex]);
const switchTab = useCallback(
(index: number) => {
const tab = tabs[index];
setSelectedFilters([tab.id]);
setCurrentTabIndex(index);
onChangeRef.current?.(tab);
},
[tabs, setSelectedFilters],
);
return <HeaderTabs tabs={tabs} onChange={switchTab} />;
};
@@ -0,0 +1,55 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DismissableBanner } from '@backstage/core';
import { Link, makeStyles, Typography } from '@material-ui/core';
import React from 'react';
const useStyles = makeStyles(theme => ({
contentWrapper: {
display: 'grid',
gridTemplateAreas: "'filters' 'table'",
gridTemplateColumns: '250px 1fr',
gridColumnGap: theme.spacing(2),
},
emoji: {
fontSize: '125%',
marginRight: theme.spacing(2),
},
}));
export const WelcomeBanner = () => {
const classes = useStyles();
return (
<DismissableBanner
variant="info"
message={
<Typography>
<span role="img" aria-label="wave" className={classes.emoji}>
👋🏼
</span>
Welcome to Backstage, we are happy to have you. Start by checking out
our{' '}
<Link href="/welcome" color="textSecondary">
getting started
</Link>{' '}
page.
</Typography>
}
id="catalog_page_welcome_banner"
/>
);
};
@@ -66,11 +66,9 @@ describe('CatalogTable component', () => {
/>,
),
);
expect(
await rendered.findByText(`Owned (${entites.length})`),
).toBeInTheDocument();
expect(await rendered.findByText('component1')).toBeInTheDocument();
expect(await rendered.findByText('component2')).toBeInTheDocument();
expect(await rendered.findByText('component3')).toBeInTheDocument();
expect(rendered.getByText(/Owned \(3\)/)).toBeInTheDocument();
expect(rendered.getByText(/component1/)).toBeInTheDocument();
expect(rendered.getByText(/component2/)).toBeInTheDocument();
expect(rendered.getByText(/component3/)).toBeInTheDocument();
});
});
@@ -13,16 +13,21 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { Table, TableColumn } from '@backstage/core';
import { Entity, LocationSpec } from '@backstage/catalog-model';
import { Table, TableColumn, TableProps } from '@backstage/core';
import { Link } from '@material-ui/core';
import Edit from '@material-ui/icons/Edit';
import GitHub from '@material-ui/icons/GitHub';
import Star from '@material-ui/icons/Star';
import StarOutline from '@material-ui/icons/StarBorder';
import { Alert } from '@material-ui/lab';
import React, { FC } from 'react';
import React from 'react';
import { generatePath, Link as RouterLink } from 'react-router-dom';
import { findLocationForEntityMeta } from '../../data/utils';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import { entityRoute } from '../../routes';
const columns: TableColumn[] = [
const columns: TableColumn<Entity>[] = [
{
title: 'Name',
field: 'metadata.name',
@@ -63,16 +68,16 @@ type CatalogTableProps = {
titlePreamble: string;
loading: boolean;
error?: any;
actions?: any;
};
export const CatalogTable: FC<CatalogTableProps> = ({
export const CatalogTable = ({
entities,
loading,
error,
titlePreamble,
actions,
}) => {
}: CatalogTableProps) => {
const { isStarredEntity, toggleStarredEntity } = useStarredEntities();
if (error) {
return (
<div>
@@ -83,8 +88,57 @@ export const CatalogTable: FC<CatalogTableProps> = ({
);
}
const actions: TableProps<Entity>['actions'] = [
(rowData: Entity) => {
const location = findLocationForEntityMeta(rowData.metadata);
return {
icon: () => <GitHub fontSize="small" />,
tooltip: 'View on GitHub',
onClick: () => {
if (!location) return;
window.open(location.target, '_blank');
},
hidden: location?.type !== 'github',
};
},
(rowData: Entity) => {
const createEditLink = (location: LocationSpec): string => {
switch (location.type) {
case 'github':
return location.target.replace('/blob/', '/edit/');
default:
return location.target;
}
};
const location = findLocationForEntityMeta(rowData.metadata);
return {
icon: () => <Edit fontSize="small" />,
tooltip: 'Edit',
onClick: () => {
if (!location) return;
window.open(createEditLink(location), '_blank');
},
hidden: location?.type !== 'github',
};
},
(rowData: Entity) => {
const isStarred = isStarredEntity(rowData);
return {
cellStyle: { paddingLeft: '1em' },
icon: () =>
isStarred ? (
<Star htmlColor="#f3ba37" fontSize="small" />
) : (
<StarOutline fontSize="small" />
),
tooltip: isStarred ? 'Remove from favorites' : 'Add to favorites',
onClick: () => toggleStarredEntity(rowData),
};
},
];
return (
<Table
<Table<Entity>
isLoading={loading}
columns={columns}
options={{
@@ -27,7 +27,7 @@ jest.mock('react-router-dom', () => {
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { wrapInTestApp } from '@backstage/test-utils';
import { render, wait } from '@testing-library/react';
import { render, waitFor } from '@testing-library/react';
import * as React from 'react';
import { CatalogApi, catalogApiRef } from '../../api/types';
import { EntityPage, getPageTheme } from './EntityPage';
@@ -66,7 +66,7 @@ describe('EntityPage', () => {
),
);
await wait(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog'));
await waitFor(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog'));
});
});
-123
View File
@@ -1,123 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
import {
CatalogFilterGroup,
CatalogFilterItem,
} from '../components/CatalogFilter/CatalogFilter';
export enum EntityGroup {
ALL = 'ALL',
STARRED = 'STARRED',
OWNED = 'OWNED',
}
export const filterGroups: CatalogFilterGroup[] = [
{
name: 'Personal',
items: [
{
id: EntityGroup.OWNED,
label: 'Owned',
icon: SettingsIcon,
},
{
id: EntityGroup.STARRED,
label: 'Starred',
icon: StarIcon,
},
],
},
{
// TODO: Replace with Company name, read from app config.
name: 'Company',
items: [
{
id: EntityGroup.ALL,
label: 'All Services',
},
],
},
];
export const getCatalogFilterItemByType = (filterType: EntityGroup) => {
for (const group of filterGroups) {
for (const filter of group.items) {
if (filter.id === filterType) {
return filter;
}
}
}
return null;
};
type EntityFilter = (entity: Entity, options: EntityFilterOptions) => boolean;
type EntityFilterOptions = Partial<{
isStarred: boolean;
userId: string;
}>;
type Owned = {
owner: string;
};
export const entityFilters: Record<string, EntityFilter> = {
[EntityGroup.OWNED]: (e, { userId }) => {
const owner = (e.spec! as Owned).owner;
return owner === userId;
},
[EntityGroup.ALL]: () => true,
[EntityGroup.STARRED]: (_, { isStarred }) => !!isStarred,
};
export const entityTypeFilter = (e: Entity, type: string) =>
(e.spec as any)?.type === type;
type EntityType = 'service' | 'website' | 'library' | 'documentation' | 'other';
type LabeledEntityType = {
id: EntityType;
label: string;
};
export const labeledEntityTypes: LabeledEntityType[] = [
{
id: 'service',
label: 'Services',
},
{
id: 'website',
label: 'Websites',
},
{
id: 'library',
label: 'Libraries',
},
{
id: 'documentation',
label: 'Documentation',
},
{
id: 'other',
label: 'Other',
},
];
export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0];
@@ -0,0 +1,207 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { useApi } from '@backstage/core';
import React, { useCallback, useRef, useState } from 'react';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../api/types';
import { filterGroupsContext, FilterGroupsContext } from './context';
import {
EntityFilterFn,
FilterGroup,
FilterGroupState,
FilterGroupStates,
} from './types';
/**
* Implementation of the shared filter groups state.
*/
export const EntityFilterGroupsProvider = ({
children,
}: {
children?: React.ReactNode;
}) => {
const state = useProvideEntityFilters();
return (
<filterGroupsContext.Provider value={state}>
{children}
</filterGroupsContext.Provider>
);
};
// The hook that implements the actual context building
function useProvideEntityFilters(): FilterGroupsContext {
const catalogApi = useApi(catalogApiRef);
const { value: entities, error } = useAsync(() => catalogApi.getEntities());
const filterGroups = useRef<{
[filterGroupId: string]: FilterGroup;
}>({});
const selectedFilterKeys = useRef<{
[filterGroupId: string]: Set<string>;
}>({});
const [filterGroupStates, setFilterGroupStates] = useState<{
[filterGroupId: string]: FilterGroupStates;
}>({});
const [matchingEntities, setMatchingEntities] = useState<Entity[]>([]);
const rebuild = useCallback(() => {
setFilterGroupStates(
buildStates(
filterGroups.current,
selectedFilterKeys.current,
entities,
error,
),
);
setMatchingEntities(
buildMatchingEntities(
filterGroups.current,
selectedFilterKeys.current,
entities,
),
);
}, [entities, error]);
const register = useCallback(
(
filterGroupId: string,
filterGroup: FilterGroup,
initialSelectedFilterIds?: string[],
) => {
filterGroups.current[filterGroupId] = filterGroup;
selectedFilterKeys.current[filterGroupId] = new Set(
initialSelectedFilterIds ?? [],
);
rebuild();
},
[rebuild],
);
const unregister = useCallback(
(filterGroupId: string) => {
delete filterGroups.current[filterGroupId];
delete selectedFilterKeys.current[filterGroupId];
rebuild();
},
[rebuild],
);
const setGroupSelectedFilters = useCallback(
(filterGroupId: string, filters: string[]) => {
selectedFilterKeys.current[filterGroupId] = new Set(filters);
rebuild();
},
[rebuild],
);
return {
register,
unregister,
setGroupSelectedFilters,
loading: !error && !entities,
error,
filterGroupStates,
matchingEntities,
};
}
// Given all filter groups and what filters are actually selected, along with
// the loading state for entities, generate the state of each individual filter
function buildStates(
filterGroups: { [filterGroupId: string]: FilterGroup },
selectedFilterKeys: { [filterGroupId: string]: Set<string> },
entities?: Entity[],
error?: Error,
): { [filterGroupId: string]: FilterGroupStates } {
// On error - all entries are an error state
if (error) {
return Object.fromEntries(
Object.keys(filterGroups).map(filterGroupId => [
filterGroupId,
{ type: 'error', error },
]),
);
}
// On startup - all entries are a loading state
if (!entities) {
return Object.fromEntries(
Object.keys(filterGroups).map(filterGroupId => [
filterGroupId,
{ type: 'loading' },
]),
);
}
const result: { [filterGroupId: string]: FilterGroupStates } = {};
for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) {
const otherMatchingEntities = buildMatchingEntities(
filterGroups,
selectedFilterKeys,
entities,
filterGroupId,
);
const groupState: FilterGroupState = { filters: {} };
for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) {
const isSelected = !!selectedFilterKeys[filterGroupId]?.has(filterId);
const matchCount = otherMatchingEntities.filter(entity =>
filterFn(entity),
).length;
groupState.filters[filterId] = { isSelected, matchCount };
}
result[filterGroupId] = { type: 'ready', state: groupState };
}
return result;
}
// Given all filter groups and what filters are actually selected, extract all
// entities that match all those filter groups.
function buildMatchingEntities(
filterGroups: { [filterGroupId: string]: FilterGroup },
selectedFilterKeys: { [filterGroupId: string]: Set<string> },
entities?: Entity[],
excludeFilterGroupId?: string,
): Entity[] {
// Build one filter fn per filter group
const allFilters: EntityFilterFn[] = [];
for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) {
if (excludeFilterGroupId === filterGroupId) {
continue;
}
// Pick out all of the filter functions in the group that are actually
// selected
const groupFilters: EntityFilterFn[] = [];
for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) {
if (!!selectedFilterKeys[filterGroupId]?.has(filterId)) {
groupFilters.push(filterFn);
}
}
// Need to match any of the selected filters in the group - if there is
// any at all
if (groupFilters.length) {
allFilters.push(entity => groupFilters.some(fn => fn(entity)));
}
}
// All filter groups that had any checked filters need to match. Note that
// every() always returns true for an empty array.
return entities?.filter(entity => allFilters.every(fn => fn(entity))) ?? [];
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { createContext } from 'react';
import { FilterGroup, FilterGroupStates } from './types';
export type FilterGroupsContext = {
register: (
filterGroupId: string,
filterGroup: FilterGroup,
initialSelectedFilterIds?: string[],
) => void;
unregister: (filterGroupId: string) => void;
setGroupSelectedFilters: (filterGroupId: string, filterIds: string[]) => void;
loading: boolean;
error?: Error;
filterGroupStates: { [filterGroupId: string]: FilterGroupStates };
matchingEntities: Entity[];
};
/**
* The context that maintains shared state for all visible filter groups.
*/
export const filterGroupsContext = createContext<
FilterGroupsContext | undefined
>(undefined);
+28
View File
@@ -0,0 +1,28 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider';
export type {
EntityFilterFn,
FilterGroup,
FilterGroupState,
FilterGroupStates,
FilterGroupStatesError,
FilterGroupStatesLoading,
FilterGroupStatesReady,
} from './types';
export { useEntityFilterGroup } from './useEntityFilterGroup';
export { useFilteredEntities } from './useFilteredEntities';
+53
View File
@@ -0,0 +1,53 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
export type EntityFilterFn = (entity: Entity) => boolean;
export type FilterGroup = {
filters: {
[filterId: string]: EntityFilterFn;
};
};
export type FilterGroupState = {
filters: {
[filterId: string]: {
isSelected: boolean;
matchCount: number;
};
};
};
export type FilterGroupStatesReady = {
type: 'ready';
state: FilterGroupState;
};
export type FilterGroupStatesError = {
type: 'error';
error: Error;
};
export type FilterGroupStatesLoading = {
type: 'loading';
};
export type FilterGroupStates =
| FilterGroupStatesReady
| FilterGroupStatesError
| FilterGroupStatesLoading;
@@ -0,0 +1,118 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core';
import { act, renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { catalogApiRef } from '../api/types';
import { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider';
import { FilterGroupStatesReady, FilterGroup } from './types';
import { useEntityFilterGroup } from './useEntityFilterGroup';
import { MockStorageApi } from '@backstage/test-utils';
describe('useEntityFilterGroup', () => {
let catalogApi: jest.Mocked<typeof catalogApiRef.T>;
let wrapper: ({ children }: { children?: React.ReactNode }) => JSX.Element;
beforeEach(() => {
catalogApi = {
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
addLocation: jest.fn((_a, _b) => new Promise(() => {})),
getEntities: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
};
const apis = ApiRegistry.with(catalogApiRef, catalogApi).with(
storageApiRef,
MockStorageApi.create(),
);
wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={apis}>
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>
</ApiProvider>
);
});
it('works for an empty set of filters', async () => {
catalogApi.getEntities.mockResolvedValue([]);
const group: FilterGroup = { filters: {} };
const { result, wait } = renderHook(
() => useEntityFilterGroup('g1', group),
{ wrapper },
);
await wait(() => expect(result.current.state.type).toBe('ready'));
});
it('works for a single group', async () => {
catalogApi.getEntities.mockResolvedValue([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'n' },
},
]);
const group: FilterGroup = {
filters: {
f1: e => e.metadata.name === 'n',
f2: e => e.metadata.name !== 'n',
},
};
const { result, wait } = renderHook(
() => useEntityFilterGroup('g1', group),
{ wrapper },
);
await wait(() => expect(result.current.state.type).toEqual('ready'));
let state = result.current.state as FilterGroupStatesReady;
expect(state.state.filters.f1).toEqual({
isSelected: false,
matchCount: 1,
});
expect(state.state.filters.f2).toEqual({
isSelected: false,
matchCount: 0,
});
act(() => result.current.setSelectedFilters(['f1']));
await wait(() => expect(result.current.state.type).toEqual('ready'));
state = result.current.state as FilterGroupStatesReady;
expect(state.state.filters.f1).toEqual({
isSelected: true,
matchCount: 1,
});
expect(state.state.filters.f2).toEqual({
isSelected: false,
matchCount: 0,
});
act(() => result.current.setSelectedFilters(['f2']));
await wait(() => expect(result.current.state.type).toEqual('ready'));
state = result.current.state as FilterGroupStatesReady;
expect(state.state.filters.f1).toEqual({
isSelected: false,
matchCount: 1,
});
expect(state.state.filters.f2).toEqual({
isSelected: true,
matchCount: 0,
});
});
});
@@ -0,0 +1,69 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useCallback, useContext, useEffect, useMemo } from 'react';
import { filterGroupsContext } from './context';
import { FilterGroup, FilterGroupStates } from './types';
export type EntityFilterGroupOutput = {
state: FilterGroupStates;
setSelectedFilters: (filterIds: string[]) => void;
};
/**
* Hook that exposes the relevant data and operations for a single filter
* group.
*/
export const useEntityFilterGroup = (
filterGroupId: string,
filterGroup: FilterGroup,
initialSelectedFilters?: string[],
): EntityFilterGroupOutput => {
const context = useContext(filterGroupsContext);
if (!context) {
throw new Error(`Must be used inside an EntityFilterGroupsProvider`);
}
const {
register,
unregister,
setGroupSelectedFilters,
filterGroupStates,
} = context;
// Intentionally consider initial set only at mount time
// eslint-disable-next-line react-hooks/exhaustive-deps
const initialMemo = useMemo(() => initialSelectedFilters?.slice(), []);
// Register the group on mount, and unregister on unmount
useEffect(() => {
register(filterGroupId, filterGroup, initialMemo);
return () => unregister(filterGroupId);
}, [register, unregister, filterGroupId, filterGroup, initialMemo]);
const setSelectedFilters = useCallback(
(filters: string[]) => {
setGroupSelectedFilters(filterGroupId, filters);
},
[setGroupSelectedFilters, filterGroupId],
);
let state = filterGroupStates[filterGroupId];
if (!state) {
state = { type: 'loading' };
}
return { state, setSelectedFilters };
};
@@ -0,0 +1,34 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useContext } from 'react';
import { filterGroupsContext } from './context';
/**
* Hook that exposes the result of applying a set of filter groups.
*/
export function useFilteredEntities() {
const context = useContext(filterGroupsContext);
if (!context) {
throw new Error(`Must be used inside an EntityFilterGroupsProvider`);
}
return {
loading: context.loading,
error: context.error,
matchingEntities: context.matchingEntities,
};
}
-103
View File
@@ -1,103 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useState, useMemo } from 'react';
import {
EntityGroup,
entityFilters,
entityTypeFilter,
labeledEntityTypes,
} from '../data/filters';
import { useApi, identityApiRef } from '@backstage/core';
import { catalogApiRef } from '..';
import { useStarredEntities } from './useStarredEntites';
import { Entity } from '@backstage/catalog-model';
import useStaleWhileRevalidate from 'swr';
export type EntitiesByFilter = Record<EntityGroup, Entity[] | undefined>;
type UseEntities = {
selectedFilter: EntityGroup | undefined;
setSelectedFilter: (f: EntityGroup) => void;
error: Error | null;
toggleStarredEntity: any;
isStarredEntity: (e: Entity) => boolean;
entitiesByFilter: EntitiesByFilter;
loading: boolean;
selectedTypeFilter: string;
selectTypeFilter: (id: string) => void;
};
export const useEntities = (): UseEntities => {
const [selectedFilter, setSelectedFilter] = useState<
EntityGroup | undefined
>();
const catalogApi = useApi(catalogApiRef);
const { toggleStarredEntity, isStarredEntity } = useStarredEntities();
const { data: entities, error } = useStaleWhileRevalidate(
['catalog/all', entityFilters[selectedFilter ?? EntityGroup.ALL]],
async () => catalogApi.getEntities(),
);
const indentityApi = useApi(identityApiRef);
const userId = indentityApi.getUserId();
const [selectedTypeFilter, selectTypeFilter] = useState<string>(
labeledEntityTypes[0].id,
);
const entitiesByFilter = useMemo(() => {
const filterEntities = (
ents: Entity[] | undefined,
filterId: EntityGroup,
isStarred: (e: Entity) => boolean,
user: string,
) => {
return ents
?.filter((e: Entity) =>
entityFilters[filterId](e, {
isStarred: isStarred(e),
userId: user,
}),
)
.filter(e => entityTypeFilter(e, selectedTypeFilter));
};
const data = Object.keys(EntityGroup).reduce(
(res, key) => ({
...res,
[key]: filterEntities(
entities,
key as EntityGroup,
isStarredEntity,
userId,
),
}),
{} as EntitiesByFilter,
);
return data;
}, [entities, isStarredEntity, userId, selectedTypeFilter]);
return {
selectedFilter,
setSelectedFilter,
error,
toggleStarredEntity,
isStarredEntity,
entitiesByFilter,
loading: entities === undefined,
selectedTypeFilter,
selectTypeFilter,
};
};
+1 -1
View File
@@ -28,7 +28,7 @@
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0"
},
"devDependencies": {
+22
View File
@@ -79,6 +79,14 @@ export interface ListClusterRequest {
gitHubToken: string;
}
export interface GithubUserInfoRequest {
accessToken: string;
}
export interface GithubUserInfoResponse {
login: string;
}
export class FetchError extends Error {
get name(): string {
return this.constructor.name;
@@ -100,6 +108,7 @@ export type GitOpsApi = {
cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise<any>;
applyProfiles(req: ApplyProfileRequest): Promise<any>;
listClusters(req: ListClusterRequest): Promise<ListClusterStatusesResponse>;
fetchUserInfo(req: GithubUserInfoRequest): Promise<GithubUserInfoResponse>;
};
export const gitOpsApiRef = createApiRef<GitOpsApi>({
@@ -116,6 +125,19 @@ export class GitOpsRestApi implements GitOpsApi {
return await resp.json();
}
async fetchUserInfo(
req: GithubUserInfoRequest,
): Promise<GithubUserInfoResponse> {
const resp = await fetch(`https://api.github.com/user`, {
method: 'get',
headers: new Headers({
Authorization: `token ${req.accessToken}`,
}),
});
if (!resp.ok) throw await FetchError.forResponse(resp);
return await resp.json();
}
async fetchLog(req: PollLogRequest): Promise<StatusResponse> {
return await this.fetch<StatusResponse>(`/api/cluster/run-status`, {
method: 'post',
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import React, { FC, useState } from 'react';
import {
Content,
ContentHeader,
@@ -25,32 +25,30 @@ import {
Progress,
HeaderLabel,
useApi,
githubAuthApiRef,
} from '@backstage/core';
import ClusterTable from '../ClusterTable/ClusterTable';
import { Button } from '@material-ui/core';
import { useAsync, useLocalStorage } from 'react-use';
import { useAsync } from 'react-use';
import { gitOpsApiRef, ListClusterStatusesResponse } from '../../api';
import { Alert } from '@material-ui/lab';
const ClusterList: FC<{}> = () => {
const [loginInfo] = useLocalStorage<{
token: string;
username: string;
name: string;
}>('githubLoginDetails', {
token: '',
username: '',
name: 'Guest',
});
const api = useApi(gitOpsApiRef);
const githubAuth = useApi(githubAuthApiRef);
const [githubUsername, setGithubUsername] = useState(String);
const { loading, error, value } = useAsync<ListClusterStatusesResponse>(
() => {
async () => {
const accessToken = await githubAuth.getAccessToken(['repo', 'user']);
if (!githubUsername) {
const userInfo = await api.fetchUserInfo({ accessToken });
setGithubUsername(userInfo.login);
}
return api.listClusters({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
gitHubToken: accessToken,
gitHubUser: githubUsername,
});
},
);
@@ -73,9 +71,6 @@ const ClusterList: FC<{}> = () => {
Please make sure that you start GitOps-API backend on localhost port
3008 before using this plugin.
</Alert>
<Alert severity="info">
If you're Guest, please login via GitHub first.
</Alert>
</div>
</Content>
);
@@ -100,7 +95,7 @@ const ClusterList: FC<{}> = () => {
return (
<Page theme={pageTheme.home}>
<Header title="GitOps-managed Clusters">
<HeaderLabel label="Welcome" value={loginInfo.name} />
<HeaderLabel label="Welcome" value={githubUsername} />
</Header>
{content}
</Page>
@@ -24,21 +24,16 @@ import {
Progress,
HeaderLabel,
useApi,
githubAuthApiRef,
} from '@backstage/core';
import { Link } from '@material-ui/core';
import { useParams } from 'react-router-dom';
import { useLocalStorage } from 'react-use';
import { gitOpsApiRef, Status } from '../../api';
import { transformRunStatus } from '../ProfileCatalog';
const ClusterPage: FC<{}> = () => {
const params = useParams() as { owner: string; repo: string };
const [loginInfo] = useLocalStorage<{
token: string;
username: string;
name: string;
}>('githubLoginDetails');
const [pollingLog, setPollingLog] = useState(true);
const [runStatus, setRunStatus] = useState<Status[]>([]);
@@ -46,6 +41,9 @@ const ClusterPage: FC<{}> = () => {
const [showProgress, setShowProgress] = useState(true);
const api = useApi(gitOpsApiRef);
const githubAuth = useApi(githubAuthApiRef);
const [githubAccessToken, setGithubAccessToken] = useState(String);
const [githubUsername, setGithubUsername] = useState(String);
const columns = [
{ field: 'status', title: 'Status' },
@@ -53,31 +51,43 @@ const ClusterPage: FC<{}> = () => {
];
useEffect(() => {
if (pollingLog) {
const interval = setInterval(async () => {
const resp = await api.fetchLog({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
targetOrg: params.owner,
targetRepo: params.repo,
});
const fetchGithubUserInfo = async () => {
const accessToken = await githubAuth.getAccessToken(['repo', 'user']);
const userInfo = await api.fetchUserInfo({ accessToken });
setGithubAccessToken(accessToken);
setGithubUsername(userInfo.login);
};
setRunStatus(resp.result);
setRunLink(resp.link);
if (resp.status === 'completed') {
setPollingLog(false);
setShowProgress(false);
}
}, 10000);
return () => clearInterval(interval);
if (!githubAccessToken || !githubUsername) {
fetchGithubUserInfo();
} else {
if (pollingLog) {
const interval = setInterval(async () => {
const resp = await api.fetchLog({
gitHubToken: githubAccessToken,
gitHubUser: githubUsername,
targetOrg: params.owner,
targetRepo: params.repo,
});
setRunStatus(resp.result);
setRunLink(resp.link);
if (resp.status === 'completed') {
setPollingLog(false);
setShowProgress(false);
}
}, 10000);
return () => clearInterval(interval);
}
}
return () => {};
}, [pollingLog, api, loginInfo, params]);
}, [pollingLog, api, params, githubAuth, githubAccessToken, githubUsername]);
return (
<Page theme={pageTheme.home}>
<Header title={`Cluster ${params.owner}/${params.repo}`}>
<HeaderLabel label="Welcome" value={loginInfo.name} />
<HeaderLabel label="Welcome" value={githubUsername} />
</Header>
<Content>
<Progress hidden={!showProgress} />
@@ -20,13 +20,28 @@ import mockFetch from 'jest-fetch-mock';
import ProfileCatalog from './ProfileCatalog';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import {
ApiProvider,
ApiRegistry,
githubAuthApiRef,
GithubAuth,
OAuthRequestManager,
} from '@backstage/core';
import { gitOpsApiRef, GitOpsRestApi } from '../../api';
describe('ProfileCatalog', () => {
it('should render', () => {
const oauthRequestApi = new OAuthRequestManager();
const apis = ApiRegistry.from([
[gitOpsApiRef, new GitOpsRestApi('http://localhost:3008')],
[
githubAuthApiRef,
GithubAuth.create({
apiOrigin: 'http://localhost:7000',
basePath: '/auth/',
oauthRequestApi,
}),
],
]);
mockFetch.mockResponse(() => new Promise(() => {}));
const rendered = render(
@@ -35,6 +35,7 @@ import {
StatusPending,
StatusAborted,
useApi,
githubAuthApiRef,
} from '@backstage/core';
import { TextField, List, ListItem, Link } from '@material-ui/core';
@@ -111,17 +112,12 @@ const ProfileCatalog: FC<{}> = () => {
},
]);
const [loginInfo] = useLocalStorage('githubLoginDetails', {
name: 'Guest',
username: '',
token: '',
});
const [templateRepo] = useLocalStorage<string>('gitops-template-repo');
const [gitopsProfiles] = useLocalStorage<string[]>('gitops-profiles');
const [showProgress, setShowProgress] = useState(false);
const [pollingLog, setPollingLog] = useState(false);
const [gitHubOrg, setGitHubOrg] = useState(loginInfo.username);
const [gitHubOrg, setGitHubOrg] = useState(String);
const [gitHubRepo, setGitHubRepo] = useState('new-cluster');
const [awsAccessKeyId, setAwsAccessKeyId] = useState(String);
const [awsSecretAccessKey, setAwsSecretAccessKey] = useState(String);
@@ -129,28 +125,52 @@ const ProfileCatalog: FC<{}> = () => {
const [runLink, setRunLink] = useState<string>('');
const api = useApi(gitOpsApiRef);
const githubAuth = useApi(githubAuthApiRef);
const [githubAccessToken, setGithubAccessToken] = useState(String);
const [githubUsername, setGithubUsername] = useState(String);
useEffect(() => {
if (pollingLog) {
const interval = setInterval(async () => {
const resp = await api.fetchLog({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
});
const fetchGithubUserInfo = async () => {
const accessToken = await githubAuth.getAccessToken(['repo', 'user']);
const userInfo = await api.fetchUserInfo({ accessToken });
setGithubAccessToken(accessToken);
setGithubUsername(userInfo.login);
setGitHubOrg(userInfo.login);
};
setRunStatus(resp.result);
setRunLink(resp.link);
if (resp.status === 'completed') {
setPollingLog(false);
setShowProgress(false);
}
}, 10000);
return () => clearInterval(interval);
if (!githubAccessToken || !githubUsername) {
fetchGithubUserInfo();
} else {
if (pollingLog) {
const interval = setInterval(async () => {
const resp = await api.fetchLog({
gitHubToken: githubAccessToken,
gitHubUser: githubUsername,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
});
setRunStatus(resp.result);
setRunLink(resp.link);
if (resp.status === 'completed') {
setPollingLog(false);
setShowProgress(false);
}
}, 10000);
return () => clearInterval(interval);
}
}
return () => {};
}, [pollingLog, api, gitHubOrg, gitHubRepo, loginInfo]);
}, [
pollingLog,
api,
gitHubOrg,
gitHubRepo,
githubAuth,
githubAccessToken,
githubUsername,
]);
const showFailureMessage = (msg: string) => {
setRunStatus(
@@ -182,8 +202,8 @@ const ProfileCatalog: FC<{}> = () => {
const cloneResponse = await api.cloneClusterFromTemplate({
templateRepository: templateRepo,
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
gitHubToken: githubAccessToken,
gitHubUser: githubUsername,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
secrets: {
@@ -200,8 +220,8 @@ const ProfileCatalog: FC<{}> = () => {
}
const applyProfileResp = await api.applyProfiles({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
gitHubToken: githubAccessToken,
gitHubUser: githubUsername,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
profiles: gitopsProfiles,
@@ -215,8 +235,8 @@ const ProfileCatalog: FC<{}> = () => {
}
const clusterStateResp = await api.changeClusterState({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
gitHubToken: githubAccessToken,
gitHubUser: githubUsername,
targetOrg: gitHubOrg,
targetRepo: gitHubRepo,
clusterState: 'present',
@@ -244,7 +264,7 @@ const ProfileCatalog: FC<{}> = () => {
title="Create GitOps-managed Cluster"
subtitle="Kubernetes cluster with ready-to-use profiles"
>
<HeaderLabel label="Welcome" value={loginInfo.name} />
<HeaderLabel label="Welcome" value={githubUsername} />
</Header>
<Content>
<ContentHeader title="Create Cluster">
+8 -3
View File
@@ -18,12 +18,17 @@ import { createPlugin } from '@backstage/core';
import ProfileCatalog from './components/ProfileCatalog';
import ClusterPage from './components/ClusterPage';
import ClusterList from './components/ClusterList';
import {
gitOpsClusterListRoute,
gitOpsClusterDetailsRoute,
gitOpsClusterCreateRoute,
} from './routes';
export const plugin = createPlugin({
id: 'gitops-profiles',
register({ router }) {
router.registerRoute('/gitops-clusters', ClusterList);
router.registerRoute('/gitops-cluster/:owner/:repo', ClusterPage);
router.registerRoute('/gitops-cluster-create', ProfileCatalog);
router.addRoute(gitOpsClusterListRoute, ClusterList);
router.addRoute(gitOpsClusterDetailsRoute, ClusterPage);
router.addRoute(gitOpsClusterCreateRoute, ProfileCatalog);
},
});
+37
View File
@@ -0,0 +1,37 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createRouteRef } from '@backstage/core';
const NoIcon = () => null;
export const gitOpsClusterListRoute = createRouteRef({
icon: NoIcon,
path: '/gitops-clusters',
title: 'GitOps Clusters',
});
export const gitOpsClusterDetailsRoute = createRouteRef({
icon: NoIcon,
path: '/gitops-cluster/:owner/:repo',
title: 'GitOps Cluster details',
});
export const gitOpsClusterCreateRoute = createRouteRef({
icon: NoIcon,
path: '/gitops-cluster-create',
title: 'GitOps Cluster create',
});
@@ -4,6 +4,10 @@ metadata:
name: react-ssr-template
title: React SSR Template
description: Next.js application skeleton for creating isomorphic web applications.
tags:
- Recommended
- React
spec:
type: cookiecutter
processor: cookiecutter
type: website
path: '.'
@@ -0,0 +1,13 @@
apiVersion: backstage.io/v1alpha1
kind: Template
metadata:
name: springboot-template
title: Spring Boot Service
description: Standard Spring Boot (Java) microservice with recommended configuration.
tags:
- Recommended
- Java
spec:
processor: cookiecutter
type: service
path: '.'
+11 -6
View File
@@ -1,8 +1,13 @@
#!/usr/bin/env bash
curl \
--location \
--request POST 'localhost:7000/catalog/locations' \
--header 'Content-Type: application/json' \
--data-raw "{\"type\": \"file\", \"target\": \"$(pwd)/sample-templates/react-ssr-template/template.yaml\"}"
for URL in \
'react-ssr-template' \
'springboot-template' \
; do \
curl \
--location \
--request POST 'localhost:7000/catalog/locations' \
--header 'Content-Type: application/json' \
--data-raw "{\"type\": \"file\", \"target\": \"$(pwd)/sample-templates/${URL}/template.yaml\"}"
echo
done
+4 -1
View File
@@ -21,6 +21,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.12",
"@backstage/plugin-catalog": "^0.1.1-alpha.12",
"@backstage/core": "^0.1.1-alpha.12",
"@backstage/theme": "^0.1.1-alpha.12",
"@material-ui/core": "^4.9.1",
@@ -29,7 +31,8 @@
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0"
"react-use": "^14.2.0",
"swr": "^0.2.2"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.12",
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import React, { useEffect } from 'react';
import {
Lifecycle,
Content,
@@ -23,33 +23,39 @@ import {
SupportButton,
Page,
pageTheme,
useApi,
errorApiRef,
} from '@backstage/core';
import { Button, Grid, Link, Typography } from '@material-ui/core';
import { catalogApiRef } from '@backstage/plugin-catalog';
import {
Typography,
Link,
Button,
Grid,
LinearProgress,
} from '@material-ui/core';
import { Link as RouterLink } from 'react-router-dom';
import TemplateCard from '../TemplateCard';
import useStaleWhileRevalidate from 'swr';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
// TODO(blam): Connect to backend
const STATIC_DATA = [
{
id: 'springboot-template',
type: 'service',
name: 'Spring Boot Service',
tags: ['Recommended', 'Java'],
description:
'Standard Spring Boot (Java) microservice with recommended configuration.',
ownerId: 'spotify',
},
{
id: 'react-ssr-template',
type: 'website',
name: 'SSR React Website',
tags: ['Recommended', 'React'],
description:
'Next.js application skeleton for creating isomorphic web applications.',
ownerId: 'spotify',
},
];
const ScaffolderPage: React.FC<{}> = () => {
const catalogApi = useApi(catalogApiRef);
const errorApi = useApi(errorApiRef);
const { data: templates, isValidating, error } = useStaleWhileRevalidate(
'templates/all',
async () =>
catalogApi.getEntities({ kind: 'Template' }) as Promise<
TemplateEntityV1alpha1[]
>,
);
useEffect(() => {
if (!error) return;
errorApi.post(error);
}, [error, errorApi]);
return (
<Page theme={pageTheme.home}>
<Header
@@ -84,18 +90,24 @@ const ScaffolderPage: React.FC<{}> = () => {
</Link>
.
</Typography>
{!templates && isValidating && <LinearProgress />}
<Grid container>
{STATIC_DATA.map(item => {
return (
<TemplateCard
key={item.id}
title={item.name}
type={item.type}
description={item.description}
tags={item.tags}
/>
);
})}
{templates &&
templates.map(template => {
return (
<Grid item xs={12} sm={6} md={3}>
<TemplateCard
key={template.metadata.uid}
title={`${
(template.metadata.title || template.metadata.name) ?? ''
}`}
type={template.spec.type ?? ''}
description={template.metadata.description ?? '-'}
tags={(template.metadata?.tags as string[]) ?? []}
/>
</Grid>
);
})}
</Grid>
</Content>
</Page>
@@ -14,14 +14,7 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import {
Button,
Card,
Chip,
Grid,
Typography,
makeStyles,
} from '@material-ui/core';
import { Button, Card, Chip, Typography, makeStyles } from '@material-ui/core';
const useStyles = makeStyles(theme => ({
header: {
@@ -59,25 +52,23 @@ const TemplateCard: FC<TemplateCardProps> = ({
const classes = useStyles();
return (
<Grid item xs={12} sm={6} md={3}>
<Card>
<div className={classes.header}>
<Typography variant="subtitle2">{type}</Typography>
<Typography variant="h6">{title}</Typography>
<Card>
<div className={classes.header}>
<Typography variant="subtitle2">{type}</Typography>
<Typography variant="h6">{title}</Typography>
</div>
<div className={classes.content}>
{tags?.map(tag => (
<Chip label={tag} key={tag} />
))}
<Typography variant="body2" paragraph className={classes.description}>
{description}
</Typography>
<div className={classes.footer}>
<Button color="primary">Choose</Button>
</div>
<div className={classes.content}>
{tags?.map(tag => (
<Chip label={tag} />
))}
<Typography variant="body2" paragraph className={classes.description}>
{description}
</Typography>
<div className={classes.footer}>
<Button color="primary">Choose</Button>
</div>
</div>
</Card>
</Grid>
</div>
</Card>
);
};
@@ -64,7 +64,7 @@ const SentryIssuesTable: FC<SentryIssuesTableProps> = ({ sentryIssues }) => {
return (
<Table
columns={columns}
options={{ paging: true, search: false, pageSize: 5 }}
options={{ padding: 'dense', paging: true, search: false, pageSize: 5 }}
title="Sentry issues"
data={sentryIssues}
/>
+1 -1
View File
@@ -27,7 +27,6 @@
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"color": "^3.1.2",
"d3-force": "^2.0.1",
"prop-types": "^15.7.2",
@@ -43,6 +42,7 @@
"@testing-library/user-event": "^12.0.7",
"@types/color": "^3.0.1",
"@types/d3-force": "^1.2.1",
"@types/react": "^16.9",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"jest-fetch-mock": "^3.0.3"
+2 -1
View File
@@ -15,6 +15,7 @@
*/
import { createApiRef } from '@backstage/core';
import { MovedState } from './utils/types';
/**
* Types related to the Radar's visualization.
@@ -34,7 +35,7 @@ export interface RadarQuadrant {
export interface RadarEntry {
key: string; // react key
id: string;
moved: number;
moved: MovedState;
quadrant: RadarQuadrant;
ring: RadarRing;
title: string;
@@ -20,9 +20,9 @@ import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import Radar from './Radar';
import Radar, { Props } from './Radar';
const minProps = {
const minProps: Props = {
width: 500,
height: 200,
quadrants: [{ id: 'languages', name: 'Languages' }],
@@ -14,12 +14,12 @@
* limitations under the License.
*/
import React, { FC, useState, useRef } from 'react';
import React, { useState, useRef } from 'react';
import RadarPlot from '../RadarPlot';
import { Ring, Quadrant, Entry } from '../../utils/types';
import type { Ring, Quadrant, Entry } from '../../utils/types';
import { adjustQuadrants, adjustRings, adjustEntries } from './utils';
type Props = {
export type Props = {
width: number;
height: number;
quadrants: Quadrant[];
@@ -28,17 +28,17 @@ type Props = {
svgProps?: object;
};
const Radar: FC<Props> = props => {
const Radar = (props: Props): JSX.Element => {
const { width, height, quadrants, rings, entries } = props;
const radius = Math.min(width, height) / 2;
const [activeEntry, setActiveEntry] = useState<Entry | null>();
const [activeEntry, setActiveEntry] = useState<Entry>();
const node = useRef<SVGSVGElement>(null);
// TODO(dflemstr): most of this can be heavily memoized if performance becomes a problem
adjustQuadrants(quadrants, radius, width, height);
adjustRings(rings, radius);
adjustEntries(entries, activeEntry, quadrants, rings, radius);
adjustEntries(entries, quadrants, rings, radius, activeEntry);
return (
<svg ref={node} width={width} height={height} {...props.svgProps}>
@@ -49,9 +49,9 @@ const Radar: FC<Props> = props => {
entries={entries}
quadrants={quadrants}
rings={rings}
activeEntry={activeEntry || undefined}
activeEntry={activeEntry}
onEntryMouseEnter={entry => setActiveEntry(entry)}
onEntryMouseLeave={() => setActiveEntry(null)}
onEntryMouseLeave={() => setActiveEntry(undefined)}
/>
</svg>
);
@@ -17,7 +17,7 @@
import color from 'color';
import { forceCollide, forceSimulation } from 'd3-force';
import Segment from '../../utils/segment';
import { Ring, Quadrant, Entry } from '../../utils/types';
import type { Ring, Quadrant, Entry } from '../../utils/types';
export const adjustQuadrants = (
quadrants: Quadrant[],
@@ -81,14 +81,14 @@ export const adjustQuadrants = (
},
];
quadrants.forEach((quadrant, idx) => {
const legendParam = legendParams[idx % 4];
quadrants.forEach((quadrant, index) => {
const legendParam = legendParams[index % 4];
quadrant.idx = idx;
quadrant.radialMin = (idx * Math.PI) / 2;
quadrant.radialMax = ((idx + 1) * Math.PI) / 2;
quadrant.offsetX = idx % 4 === 0 || idx % 4 === 3 ? 1 : -1;
quadrant.offsetY = idx % 4 === 0 || idx % 4 === 1 ? 1 : -1;
quadrant.index = index;
quadrant.radialMin = (index * Math.PI) / 2;
quadrant.radialMax = ((index + 1) * Math.PI) / 2;
quadrant.offsetX = index % 4 === 0 || index % 4 === 3 ? 1 : -1;
quadrant.offsetY = index % 4 === 0 || index % 4 === 1 ? 1 : -1;
quadrant.legendX = legendParam.x;
quadrant.legendY = legendParam.y;
quadrant.legendWidth = legendParam.width;
@@ -98,13 +98,13 @@ export const adjustQuadrants = (
export const adjustEntries = (
entries: Entry[],
activeEntry: Entry | null | undefined,
quadrants: Quadrant[],
rings: Ring[],
radius: number,
activeEntry?: Entry,
) => {
let seed = 42;
entries.forEach((entry, idx) => {
entries.forEach((entry, index) => {
const quadrant = quadrants.find(q => {
const match =
typeof entry.quadrant === 'object' ? entry.quadrant.id : entry.quadrant;
@@ -124,7 +124,7 @@ export const adjustEntries = (
throw new Error(`Unknown ring ${entry.ring} for entry ${entry.id}!`);
}
entry.idx = idx;
entry.index = index;
entry.quadrant = quadrant;
entry.ring = ring;
entry.segment = new Segment(quadrant, ring, radius, () => seed++);
@@ -163,10 +163,10 @@ export const adjustEntries = (
};
export const adjustRings = (rings: Ring[], radius: number) => {
rings.forEach((ring, idx) => {
ring.idx = idx;
ring.outerRadius = ((idx + 2) / (rings.length + 1)) * radius;
rings.forEach((ring, index) => {
ring.index = index;
ring.outerRadius = ((index + 2) / (rings.length + 1)) * radius;
ring.innerRadius =
((idx === 0 ? 0 : idx + 1) / (rings.length + 1)) * radius;
((index === 0 ? 0 : index + 1) / (rings.length + 1)) * radius;
});
};
@@ -0,0 +1,52 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import RadarBubble, { Props } from './RadarBubble';
const minProps: Props = {
visible: true,
text: 'RadarBubble',
x: 2,
y: 2,
};
describe('RadarBubble', () => {
beforeAll(() => {
GetBBoxPolyfill.create(0, 0, 1000, 500);
});
afterAll(() => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<svg>
<RadarBubble {...minProps} />
</svg>
</ThemeProvider>,
);
expect(rendered.getByText(minProps.text)).toBeInTheDocument();
});
});
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import React, { FC, useRef, useLayoutEffect } from 'react';
import React, { useRef, useLayoutEffect } from 'react';
import { makeStyles, Theme } from '@material-ui/core';
type Props = {
export type Props = {
visible: boolean;
text: string;
x: number;
@@ -46,7 +46,7 @@ const useStyles = makeStyles<Theme>(() => ({
},
}));
const RadarBubble: FC<Props> = props => {
const RadarBubble = (props: Props): JSX.Element => {
const classes = useStyles(props);
const { visible, text } = props;
@@ -98,6 +98,7 @@ const RadarBubble: FC<Props> = props => {
x={0}
y={0}
className={visible ? classes.visibleBubble : classes.bubble}
data-testid="radar-bubble"
>
<rect ref={rectElem} rx={4} ry={4} className={classes.background} />
<text ref={textElem} className={classes.text}>
@@ -14,50 +14,38 @@
* limitations under the License.
*/
import React, { useEffect, useState, FC } from 'react';
import React, { useEffect } from 'react';
import { Progress, useApi, errorApiRef, ErrorApi } from '@backstage/core';
import { useAsync } from 'react-use';
import Radar from '../components/Radar';
import { TechRadarComponentProps, TechRadarLoaderResponse } from '../api';
import getSampleData from '../sampleData';
const useTechRadarLoader = (props: TechRadarComponentProps) => {
const errorApi = useApi<ErrorApi>(errorApiRef);
const [state, setState] = useState<{
loading: boolean;
error?: Error;
data?: TechRadarLoaderResponse;
}>({
loading: true,
error: undefined,
data: undefined,
});
const { getData } = props;
useEffect(() => {
if (!getData) {
return;
const state = useAsync(async () => {
if (getData) {
const response: TechRadarLoaderResponse = await getData();
return response;
}
getData()
.then((payload: TechRadarLoaderResponse) => {
setState({ loading: false, error: undefined, data: payload });
})
.catch((err: Error) => {
errorApi.post(err);
setState({
loading: false,
error: err,
data: undefined,
});
});
return undefined;
}, [getData, errorApi]);
useEffect(() => {
const { error } = state;
if (error) {
errorApi.post(error);
}
}, [errorApi, state]);
return state;
};
const RadarComponent: FC<TechRadarComponentProps> = props => {
const { loading, error, data } = useTechRadarLoader(props);
const RadarComponent = (props: TechRadarComponentProps): JSX.Element => {
const { loading, error, value: data } = useTechRadarLoader(props);
return (
<>
@@ -0,0 +1,56 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import RadarEntry, { Props } from './RadarEntry';
const minProps: Props = {
x: 2,
y: 2,
value: 2,
color: 'red',
};
describe('RadarEntry', () => {
beforeAll(() => {
GetBBoxPolyfill.create(0, 0, 1000, 500);
});
afterAll(() => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<svg>
<RadarEntry {...minProps} />
</svg>
</ThemeProvider>,
);
const radarEntry = rendered.getByTestId('radar-entry');
const { x, y } = minProps;
expect(radarEntry).toBeInTheDocument();
expect(radarEntry.getAttribute('transform')).toBe(`translate(${x}, ${y})`);
expect(rendered.getByText(String(minProps.value))).toBeInTheDocument();
});
});
@@ -14,13 +14,14 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import React from 'react';
import { makeStyles, Theme } from '@material-ui/core';
import { WithLink } from '../../utils/components';
type Props = {
export type Props = {
x: number;
y: number;
number: number;
value: number;
color: string;
url?: string;
moved?: number;
@@ -43,14 +44,27 @@ const useStyles = makeStyles<Theme>(() => ({
},
}));
const RadarEntry: FC<Props> = props => {
const makeBlip = (color: string, moved?: number) => {
const style = { fill: color };
let blip = <circle r={9} style={style} />;
if (moved && moved > 0) {
blip = <path d="M -11,5 11,5 0,-13 z" style={style} />; // triangle pointing up
} else if (moved && moved < 0) {
blip = <path d="M -11,-5 11,-5 0,13 z" style={style} />; // triangle pointing down
}
return blip;
};
const RadarEntry = (props: Props): JSX.Element => {
const classes = useStyles(props);
const {
moved,
color,
url,
number,
value,
x,
y,
onMouseEnter,
@@ -58,24 +72,7 @@ const RadarEntry: FC<Props> = props => {
onClick,
} = props;
const style = { fill: color };
let blip;
if (moved && moved > 0) {
blip = <path d="M -11,5 11,5 0,-13 z" style={style} />; // triangle pointing up
} else if (moved && moved < 0) {
blip = <path d="M -11,-5 11,-5 0,13 z" style={style} />; // triangle pointing down
} else {
blip = <circle r={9} style={style} />;
}
if (url) {
blip = (
<a href={url} className={classes.link}>
{blip}
</a>
);
}
const blip = makeBlip(color, moved);
return (
<g
@@ -83,10 +80,13 @@ const RadarEntry: FC<Props> = props => {
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={onClick}
data-testid="radar-entry"
>
{blip}
<WithLink url={url} className={classes.link}>
{blip}
</WithLink>
<text y={3} className={classes.text}>
{number}
{value}
</text>
</g>
);
@@ -0,0 +1,52 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import RadarFooter, { Props } from './RadarFooter';
const minProps: Props = {
x: 2,
y: 2,
};
describe('RadarFooter', () => {
beforeAll(() => {
GetBBoxPolyfill.create(0, 0, 1000, 500);
});
afterAll(() => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<svg>
<RadarFooter {...minProps} />
</svg>
</ThemeProvider>,
);
const radarFooter = rendered.getByTestId('radar-footer');
const { x, y } = minProps;
expect(radarFooter).toBeInTheDocument();
expect(radarFooter.getAttribute('transform')).toBe(`translate(${x}, ${y})`);
});
});
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import React from 'react';
import { makeStyles, Theme } from '@material-ui/core';
type Props = {
export type Props = {
x: number;
y: number;
};
@@ -31,12 +31,16 @@ const useStyles = makeStyles<Theme>(() => ({
},
}));
const RadarFooter: FC<Props> = props => {
const RadarFooter = (props: Props): JSX.Element => {
const { x, y } = props;
const classes = useStyles(props);
return (
<text transform={`translate(${x}, ${y})`} className={classes.text}>
<text
data-testid="radar-footer"
transform={`translate(${x}, ${y})`}
className={classes.text}
>
{'▲ moved up\u00a0\u00a0\u00a0\u00a0\u00a0▼ moved down'}
</text>
);
@@ -0,0 +1,51 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import RadarGrid, { Props } from './RadarGrid';
const minProps: Props = {
radius: 5,
rings: [{ id: 'use', name: 'USE', color: '#93c47d' }],
};
describe('RadarGrid', () => {
beforeAll(() => {
GetBBoxPolyfill.create(0, 0, 1000, 500);
});
afterAll(() => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<svg>
<RadarGrid {...minProps} />
</svg>
</ThemeProvider>,
);
expect(rendered.getByTestId('radar-grid-x-line')).toBeInTheDocument();
expect(rendered.getByTestId('radar-grid-y-line')).toBeInTheDocument();
});
});
@@ -16,9 +16,9 @@
import React from 'react';
import { makeStyles, Theme } from '@material-ui/core';
import { Ring } from '../../utils/types';
import type { Ring } from '../../utils/types';
type Props = {
export type Props = {
radius: number;
rings: Ring[];
};
@@ -49,7 +49,7 @@ const RadarGrid = (props: Props) => {
const { radius, rings } = props;
const classes = useStyles(props);
const makeRingNode = (ringRadius: number | undefined, ringIndex: number) => [
const makeRingNode = (ringIndex: number, ringRadius?: number) => [
<circle
key={`c${ringIndex}`}
cx={0}
@@ -76,6 +76,7 @@ const RadarGrid = (props: Props) => {
x2={0}
y2={radius}
className={classes.axis}
data-testid="radar-grid-x-line"
/>,
// Y axis
<line
@@ -85,10 +86,13 @@ const RadarGrid = (props: Props) => {
x2={radius}
y2={0}
className={classes.axis}
data-testid="radar-grid-y-line"
/>,
];
const ringNodes = rings.map(r => r.outerRadius).map(makeRingNode);
const ringNodes = rings
.map(r => r.outerRadius)
.map((ringRadius, ringIndex) => makeRingNode(ringIndex, ringRadius));
return <>{axisNodes.concat(...ringNodes)}</>;
};
@@ -0,0 +1,62 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import RadarLegend, { Props } from './RadarLegend';
const minProps: Props = {
quadrants: [{ id: 'languages', name: 'Languages' }],
rings: [{ id: 'use', name: 'USE', color: '#93c47d' }],
entries: [
{
id: 'typescript',
title: 'TypeScript',
quadrant: { id: 'languages', name: 'Languages' },
moved: 0,
ring: { id: 'use', name: 'USE', color: '#93c47d' },
url: '#',
},
],
};
describe('RadarLegend', () => {
beforeAll(() => {
GetBBoxPolyfill.create(0, 0, 1000, 500);
});
afterAll(() => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<svg>
<RadarLegend {...minProps} />
</svg>
</ThemeProvider>,
);
expect(rendered.getByTestId('radar-legend')).toBeInTheDocument();
expect(rendered.getAllByTestId('radar-quadrant')).toHaveLength(1);
expect(rendered.getAllByTestId('radar-ring')).toHaveLength(1);
});
});
@@ -13,15 +13,16 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
import React from 'react';
import { makeStyles, Theme } from '@material-ui/core';
import { Quadrant, Ring, Entry } from '../../utils/types';
import type { Quadrant, Ring, Entry } from '../../utils/types';
import { WithLink } from '../../utils/components';
type Segments = {
[k: number]: { [k: number]: Entry[] };
};
type Props = {
export type Props = {
quadrants: Quadrant[];
rings: Ring[];
entries: Entry[];
@@ -29,7 +30,7 @@ type Props = {
onEntryMouseLeave?: (entry: Entry) => void;
};
const useStyles = makeStyles<Theme>(() => ({
const useStyles = makeStyles<Theme>(theme => ({
quadrant: {
height: '100%',
width: '100%',
@@ -40,7 +41,7 @@ const useStyles = makeStyles<Theme>(() => ({
pointerEvents: 'none',
userSelect: 'none',
marginTop: 0,
marginBottom: 'calc(18px * 0.375)',
marginBottom: theme.spacing(8 / (18 * 0.375)),
fontSize: '18px',
},
rings: {
@@ -56,7 +57,7 @@ const useStyles = makeStyles<Theme>(() => ({
pointerEvents: 'none',
userSelect: 'none',
marginTop: 0,
marginBottom: 'calc(12px * 0.375)',
marginBottom: theme.spacing(8 / (12 * 0.375)),
fontSize: '12px',
fontWeight: 800,
},
@@ -79,73 +80,81 @@ const useStyles = makeStyles<Theme>(() => ({
},
}));
const RadarLegend: FC<Props> = props => {
const RadarLegend = (props: Props): JSX.Element => {
const classes = useStyles(props);
const _getSegment = (
const getSegment = (
segmented: Segments,
quadrant: Quadrant,
ring: Ring,
ringOffset = 0,
) => {
const qidx = quadrant.idx;
const ridx = ring.idx;
const segmentedData = qidx === undefined ? {} : segmented[qidx] || {};
return ridx === undefined ? [] : segmentedData[ridx + ringOffset] || [];
const quadrantIndex = quadrant.index;
const ringIndex = ring.index;
const segmentedData =
quadrantIndex === undefined ? {} : segmented[quadrantIndex] || {};
return ringIndex === undefined
? []
: segmentedData[ringIndex + ringOffset] || [];
};
const _renderRing = (
ring: Ring,
entries: Entry[],
onEntryMouseEnter?: Props['onEntryMouseEnter'],
onEntryMouseLeave?: Props['onEntryMouseEnter'],
) => {
type RadarLegendRingProps = {
ring: Ring;
entries: Entry[];
onEntryMouseEnter?: Props['onEntryMouseEnter'];
onEntryMouseLeave?: Props['onEntryMouseEnter'];
};
const RadarLegendRing = ({
ring,
entries,
onEntryMouseEnter,
onEntryMouseLeave,
}: RadarLegendRingProps) => {
return (
<div key={ring.id} className={classes.ring}>
<div data-testid="radar-ring" key={ring.id} className={classes.ring}>
<h3 className={classes.ringHeading}>{ring.name}</h3>
{entries.length === 0 ? (
<p>(empty)</p>
) : (
<ol className={classes.ringList}>
{entries.map(entry => {
let node = <span className={classes.entry}>{entry.title}</span>;
if (entry.url) {
node = (
<a className={classes.entryLink} href={entry.url}>
{node}
</a>
);
}
return (
<li
key={entry.id}
value={(entry.idx || 0) + 1}
onMouseEnter={
onEntryMouseEnter && (() => onEntryMouseEnter(entry))
}
onMouseLeave={
onEntryMouseLeave && (() => onEntryMouseLeave(entry))
}
>
{node}
</li>
);
})}
{entries.map(entry => (
<li
key={entry.id}
value={(entry.index || 0) + 1}
onMouseEnter={
onEntryMouseEnter && (() => onEntryMouseEnter(entry))
}
onMouseLeave={
onEntryMouseLeave && (() => onEntryMouseLeave(entry))
}
>
<WithLink url={entry.url} className={classes.entryLink}>
<span className={classes.entry}>{entry.title}</span>
</WithLink>
</li>
))}
</ol>
)}
</div>
);
};
const _renderQuadrant = (
segments: Segments,
quadrant: Quadrant,
rings: Ring[],
onEntryMouseEnter: Props['onEntryMouseEnter'],
onEntryMouseLeave: Props['onEntryMouseLeave'],
) => {
type RadarLegendQuadrantProps = {
segments: Segments;
quadrant: Quadrant;
rings: Ring[];
onEntryMouseEnter: Props['onEntryMouseEnter'];
onEntryMouseLeave: Props['onEntryMouseLeave'];
};
const RadarLegendQuadrant = ({
segments,
quadrant,
rings,
onEntryMouseEnter,
onEntryMouseLeave,
}: RadarLegendQuadrantProps) => {
return (
<foreignObject
key={quadrant.id}
@@ -153,46 +162,48 @@ const RadarLegend: FC<Props> = props => {
y={quadrant.legendY}
width={quadrant.legendWidth}
height={quadrant.legendHeight}
data-testid="radar-quadrant"
>
<div className={classes.quadrant}>
<h2 className={classes.quadrantHeading}>{quadrant.name}</h2>
<div className={classes.rings}>
{rings.map(ring =>
_renderRing(
ring,
_getSegment(segments, quadrant, ring),
onEntryMouseEnter,
onEntryMouseLeave,
),
)}
{rings.map(ring => (
<RadarLegendRing
key={ring.id}
ring={ring}
entries={getSegment(segments, quadrant, ring)}
onEntryMouseEnter={onEntryMouseEnter}
onEntryMouseLeave={onEntryMouseLeave}
/>
))}
</div>
</div>
</foreignObject>
);
};
const _setupSegments = (entries: Entry[]) => {
const setupSegments = (entries: Entry[]) => {
const segments: Segments = {};
for (const entry of entries) {
const qidx = entry.quadrant.idx;
const ridx = entry.ring.idx;
const quadrantIndex = entry.quadrant.index;
const ringIndex = entry.ring.index;
let quadrantData: { [k: number]: Entry[] } = {};
if (qidx !== undefined) {
if (segments[qidx] === undefined) {
segments[qidx] = {};
if (quadrantIndex !== undefined) {
if (segments[quadrantIndex] === undefined) {
segments[quadrantIndex] = {};
}
quadrantData = segments[qidx];
quadrantData = segments[quadrantIndex];
}
let ringData = [];
if (ridx !== undefined) {
if (quadrantData[ridx] === undefined) {
quadrantData[ridx] = [];
if (ringIndex !== undefined) {
if (quadrantData[ringIndex] === undefined) {
quadrantData[ringIndex] = [];
}
ringData = quadrantData[ridx];
ringData = quadrantData[ringIndex];
}
ringData.push(entry);
@@ -209,19 +220,20 @@ const RadarLegend: FC<Props> = props => {
onEntryMouseLeave,
} = props;
const segments: Segments = _setupSegments(entries);
const segments: Segments = setupSegments(entries);
return (
<g>
{quadrants.map(quadrant =>
_renderQuadrant(
segments,
quadrant,
rings,
onEntryMouseEnter,
onEntryMouseLeave,
),
)}
<g data-testid="radar-legend">
{quadrants.map(quadrant => (
<RadarLegendQuadrant
key={quadrant.id}
segments={segments}
quadrant={quadrant}
rings={rings}
onEntryMouseEnter={onEntryMouseEnter}
onEntryMouseLeave={onEntryMouseLeave}
/>
))}
</g>
);
};
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import React from 'react';
import { Grid } from '@material-ui/core';
import {
Content,
@@ -29,7 +29,7 @@ import {
import RadarComponent from '../components/RadarComponent';
import { techRadarApiRef, TechRadarApi } from '../api';
const RadarPage: FC<{}> = () => {
const RadarPage = (): JSX.Element => {
const techRadarApi = useApi<TechRadarApi>(techRadarApiRef);
return (
@@ -0,0 +1,67 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
import RadarPlot, { Props } from './RadarPlot';
const minProps: Props = {
width: 500,
height: 200,
radius: 50,
quadrants: [{ id: 'languages', name: 'Languages' }],
rings: [{ id: 'use', name: 'USE', color: '#93c47d' }],
entries: [
{
id: 'typescript',
title: 'TypeScript',
quadrant: { id: 'languages', name: 'Languages' },
moved: 0,
ring: { id: 'use', name: 'USE', color: '#93c47d' },
url: '#',
},
],
};
describe('RadarPlot', () => {
beforeAll(() => {
GetBBoxPolyfill.create(0, 0, 1000, 500);
});
afterAll(() => {
GetBBoxPolyfill.remove();
});
it('should render', () => {
const rendered = render(
<ThemeProvider theme={lightTheme}>
<svg>
<RadarPlot {...minProps} />
</svg>
</ThemeProvider>,
);
expect(rendered.getByTestId('radar-plot')).toBeInTheDocument();
expect(rendered.getByTestId('radar-legend')).toBeInTheDocument();
expect(rendered.getByTestId('radar-footer')).toBeInTheDocument();
expect(rendered.getByTestId('radar-bubble')).toBeInTheDocument();
expect(rendered.getAllByTestId('radar-entry')).toHaveLength(1);
});
});
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import { Quadrant, Ring, Entry } from '../../utils/types';
import React from 'react';
import type { Quadrant, Ring, Entry } from '../../utils/types';
import RadarGrid from '../RadarGrid';
import RadarEntry from '../RadarEntry';
@@ -23,7 +23,7 @@ import RadarBubble from '../RadarBubble';
import RadarFooter from '../RadarFooter';
import RadarLegend from '../RadarLegend';
type Props = {
export type Props = {
width: number;
height: number;
radius: number;
@@ -36,7 +36,7 @@ type Props = {
};
// A component that draws the radar circle.
const RadarPlot: FC<Props> = props => {
const RadarPlot = (props: Props): JSX.Element => {
const {
width,
height,
@@ -50,7 +50,7 @@ const RadarPlot: FC<Props> = props => {
} = props;
return (
<g>
<g data-testid="radar-plot">
<RadarLegend
quadrants={quadrants}
rings={rings}
@@ -71,7 +71,7 @@ const RadarPlot: FC<Props> = props => {
x={entry.x || 0}
y={entry.y || 0}
color={entry.color || ''}
number={((entry && entry.idx) || 0) + 1}
value={(entry?.index || 0) + 1}
url={entry.url}
moved={entry.moved}
onMouseEnter={onEntryMouseEnter && (() => onEntryMouseEnter(entry))}
@@ -80,9 +80,9 @@ const RadarPlot: FC<Props> = props => {
))}
<RadarBubble
visible={!!activeEntry}
text={activeEntry ? activeEntry.title : ''}
x={activeEntry ? activeEntry.x || 0 : 0}
y={activeEntry ? activeEntry.y || 0 : 0}
text={activeEntry?.title || ''}
x={activeEntry?.x || 0}
y={activeEntry?.y || 0}
/>
</g>
</g>
@@ -0,0 +1,35 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
type WithLinkProps = {
url?: string;
className: string;
children: React.ReactNode;
};
export const WithLink = ({
url,
className,
children,
}: WithLinkProps): JSX.Element =>
url ? (
<a href={url} className={className}>
{children}
</a>
) : (
<>{children}</>
);
+10 -4
View File
@@ -17,7 +17,7 @@
// Parameters for a ring; its index in an array determines how close to the center this ring is.
export type Ring = {
id: string;
idx?: number;
index?: number;
name: string;
color: string;
outerRadius?: number;
@@ -27,7 +27,7 @@ export type Ring = {
// Parameters for a quadrant (there should be exactly 4 of course)
export type Quadrant = {
id: string;
idx?: number;
index?: number;
name: string;
legendX?: number;
legendY?: number;
@@ -45,9 +45,15 @@ export type Segment = {
random: Function;
};
export enum MovedState {
Down = -1,
NoChange = 0,
Up = 1,
}
export type Entry = {
id: string;
idx?: number;
index?: number;
x?: number;
y?: number;
color?: string;
@@ -61,7 +67,7 @@ export type Entry = {
// An URL to a longer description as to why this entry is where it is
url?: string;
// How this entry has recently moved; -1 for "down", +1 for "up", 0 for not moved
moved?: number;
moved?: MovedState;
active?: boolean;
};
+1 -1
View File
@@ -10,7 +10,7 @@ Welcome to the TechDocs plugin - Spotify's docs-like-code approach built directl
## Getting started
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/techdocs](http://localhost:3000/techdocs).
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/docs](http://localhost:3000/docs).
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
+2
View File
@@ -29,6 +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-use": "^14.2.0"
},
"devDependencies": {
+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 const docStorageURL =
'https://techdocs-mock-sites.storage.googleapis.com';
+6
View File
@@ -34,6 +34,11 @@ import { Reader } from './reader/components/Reader';
export const rootRouteRef = createRouteRef({
path: '/docs',
title: 'TechDocs Landing Page',
});
export const rootDocsRouteRef = createRouteRef({
path: '/docs/:componentId/*',
title: 'Docs',
});
@@ -41,5 +46,6 @@ export const plugin = createPlugin({
id: 'techdocs',
register({ router }) {
router.addRoute(rootRouteRef, Reader);
router.addRoute(rootDocsRouteRef, Reader);
},
});
+107 -785
View File
@@ -16,801 +16,123 @@
import React from 'react';
import { useShadowDom } from '..';
const mockHtml: string = `
<!doctype html>
<html lang="en" class="no-js">
<head>
<base href="https://mkdocs.peaceiris.com/getting-started/download-boilerplate/" />
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="MkDocs Material Boilerplate (Starter Kit) - Deploy documentation to hosting platforms (Netlify, GitHub Pages, GitLab Pages, and AWS Amplify Console) with GitHub Actions, CircleCI, Docker, pipenv, and CI/CD">
<link rel="canonical" href="https://mkdocs.peaceiris.com/getting-started/download-boilerplate/">
<meta name="author" content="peaceiris">
<link rel="shortcut icon" href="../../assets/images/favicon.png">
<meta name="generator" content="mkdocs-1.1.2, mkdocs-material-5.3.2">
<title>Download boilerplate - MkDocs Material Boilerplate</title>
<link rel="stylesheet" href="https://mkdocs.peaceiris.com/assets/stylesheets/main.fe0cca5b.min.css">
<link rel="stylesheet" href="https://mkdocs.peaceiris.com/assets/stylesheets/palette.a46bcfb3.min.css">
<meta name="theme-color" content="#2196f3">
<link href="https://fonts.gstatic.com" rel="preconnect" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,400i,700%7CRoboto+Mono&display=fallback">
<style>body,input{font-family:"Roboto",-apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif}code,kbd,pre{font-family:"Roboto Mono",SFMono-Regular,Consolas,Menlo,monospace}</style>
<link rel="manifest" href="../../manifest.json" crossorigin="use-credentials">
<link rel="stylesheet" href="../../assets/css/custom.css">
</head>
<body dir="ltr" data-md-color-scheme="" data-md-color-primary="blue" data-md-color-accent="blue">
<input class="md-toggle" data-md-toggle="drawer" type="checkbox" id="__drawer" autocomplete="off">
<input class="md-toggle" data-md-toggle="search" type="checkbox" id="__search" autocomplete="off">
<label class="md-overlay" for="__drawer"></label>
<div data-md-component="skip">
<a href="#download_boilerplate" class="md-skip">
Skip to content
</a>
</div>
<div data-md-component="announce">
</div>
<header class="md-header" data-md-component="header">
<nav class="md-header-nav md-grid" aria-label="Header">
<a href="https://mkdocs.peaceiris.com/" title="MkDocs Material Boilerplate" class="md-header-nav__button md-logo" aria-label="MkDocs Material Boilerplate">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 8a3 3 0 003-3 3 3 0 00-3-3 3 3 0 00-3 3 3 3 0 003 3m0 3.54C9.64 9.35 6.5 8 3 8v11c3.5 0 6.64 1.35 9 3.54 2.36-2.19 5.5-3.54 9-3.54V8c-3.5 0-6.64 1.35-9 3.54z"/></svg>
</a>
<label class="md-header-nav__button md-icon" for="__drawer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 6h18v2H3V6m0 5h18v2H3v-2m0 5h18v2H3v-2z"/></svg>
</label>
<div class="md-header-nav__title" data-md-component="header-title">
<div class="md-header-nav__ellipsis">
<span class="md-header-nav__topic md-ellipsis">
MkDocs Material Boilerplate
</span>
<span class="md-header-nav__topic md-ellipsis">
Download boilerplate
</span>
</div>
</div>
<label class="md-header-nav__button md-icon" for="__search">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0116 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.516 6.516 0 019.5 16 6.5 6.5 0 013 9.5 6.5 6.5 0 019.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5z"/></svg>
</label>
<div class="md-search" data-md-component="search" role="dialog">
<label class="md-search__overlay" for="__search"></label>
<div class="md-search__inner" role="search">
<form class="md-search__form" name="search">
<input type="text" class="md-search__input" name="query" aria-label="Search" placeholder="Search" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" data-md-component="search-query" data-md-state="active">
<label class="md-search__icon md-icon" for="__search">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0116 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.516 6.516 0 019.5 16 6.5 6.5 0 013 9.5 6.5 6.5 0 019.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11h12z"/></svg>
</label>
<button type="reset" class="md-search__icon md-icon" aria-label="Clear" data-md-component="search-reset" tabindex="-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>
</button>
</form>
<div class="md-search__output">
<div class="md-search__scrollwrap" data-md-scrollfix>
<div class="md-search-result" data-md-component="search-result">
<div class="md-search-result__meta">
Initializing search
</div>
<ol class="md-search-result__list"></ol>
</div>
</div>
</div>
</div>
</div>
<div class="md-header-nav__source">
<a href="https://github.com/peaceiris/mkdocs-material-boilerplate/" title="Go to repository" class="md-source">
<div class="md-source__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M439.55 236.05L244 40.45a28.87 28.87 0 00-40.81 0l-40.66 40.63 51.52 51.52c27.06-9.14 52.68 16.77 43.39 43.68l49.66 49.66c34.23-11.8 61.18 31 35.47 56.69-26.49 26.49-70.21-2.87-56-37.34L240.22 199v121.85c25.3 12.54 22.26 41.85 9.08 55a34.34 34.34 0 01-48.55 0c-17.57-17.6-11.07-46.91 11.25-56v-123c-20.8-8.51-24.6-30.74-18.64-45L142.57 101 8.45 235.14a28.86 28.86 0 000 40.81l195.61 195.6a28.86 28.86 0 0040.8 0l194.69-194.69a28.86 28.86 0 000-40.81z"/></svg>
</div>
<div class="md-source__repository">
GitHub
</div>
</a>
</div>
</nav>
</header>
<div class="md-container" data-md-component="container">
<nav class="md-tabs md-tabs--active" aria-label="Tabs" data-md-component="tabs">
<div class="md-tabs__inner md-grid">
<ul class="md-tabs__list">
<li class="md-tabs__item">
<a href="../.." class="md-tabs__link">
Home
</a>
</li>
<li class="md-tabs__item">
<a href="./" class="md-tabs__link md-tabs__link--active">
Getting started
</a>
</li>
<li class="md-tabs__item">
<a href="../../hosting-and-deployment/combinations/" class="md-tabs__link">
Hosting and Deployment
</a>
</li>
<li class="md-tabs__item">
<a href="../../extensions/mathjax/" class="md-tabs__link">
Extensions
</a>
</li>
</ul>
</div>
</nav>
<main class="md-main" data-md-component="main">
<div class="md-main__inner md-grid">
<div class="md-sidebar md-sidebar--primary" data-md-component="navigation">
<div class="md-sidebar__scrollwrap">
<div class="md-sidebar__inner">
<nav class="md-nav md-nav--primary" aria-label="Navigation" data-md-level="0">
<label class="md-nav__title" for="__drawer">
<a href="https://mkdocs.peaceiris.com/" title="MkDocs Material Boilerplate" class="md-nav__button md-logo" aria-label="MkDocs Material Boilerplate">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 8a3 3 0 003-3 3 3 0 00-3-3 3 3 0 00-3 3 3 3 0 003 3m0 3.54C9.64 9.35 6.5 8 3 8v11c3.5 0 6.64 1.35 9 3.54 2.36-2.19 5.5-3.54 9-3.54V8c-3.5 0-6.64 1.35-9 3.54z"/></svg>
</a>
MkDocs Material Boilerplate
</label>
<div class="md-nav__source">
<a href="https://github.com/peaceiris/mkdocs-material-boilerplate/" title="Go to repository" class="md-source">
<div class="md-source__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M439.55 236.05L244 40.45a28.87 28.87 0 00-40.81 0l-40.66 40.63 51.52 51.52c27.06-9.14 52.68 16.77 43.39 43.68l49.66 49.66c34.23-11.8 61.18 31 35.47 56.69-26.49 26.49-70.21-2.87-56-37.34L240.22 199v121.85c25.3 12.54 22.26 41.85 9.08 55a34.34 34.34 0 01-48.55 0c-17.57-17.6-11.07-46.91 11.25-56v-123c-20.8-8.51-24.6-30.74-18.64-45L142.57 101 8.45 235.14a28.86 28.86 0 000 40.81l195.61 195.6a28.86 28.86 0 0040.8 0l194.69-194.69a28.86 28.86 0 000-40.81z"/></svg>
</div>
<div class="md-source__repository">
GitHub
</div>
</a>
</div>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="../.." title="Home" class="md-nav__link">
Home
</a>
</li>
<li class="md-nav__item">
<a href="../../material-for-mkdocs/" title="Material for MkDocs" class="md-nav__link">
Material for MkDocs
</a>
</li>
<li class="md-nav__item md-nav__item--active md-nav__item--nested">
<input class="md-nav__toggle md-toggle" data-md-toggle="nav-3" type="checkbox" id="nav-3" checked>
<label class="md-nav__link" for="nav-3">
Getting started
<span class="md-nav__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M8.59 16.58L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.42z"/></svg>
</span>
</label>
<nav class="md-nav" aria-label="Getting started" data-md-level="1">
<label class="md-nav__title" for="nav-3">
<span class="md-nav__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11h12z"/></svg>
</span>
Getting started
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item md-nav__item--active">
<input class="md-nav__toggle md-toggle" data-md-toggle="toc" type="checkbox" id="__toc">
<label class="md-nav__link md-nav__link--active" for="__toc">
Download boilerplate
<span class="md-nav__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 9h14V7H3v2m0 4h14v-2H3v2m0 4h14v-2H3v2m16 0h2v-2h-2v2m0-10v2h2V7h-2m0 6h2v-2h-2v2z"/></svg>
</span>
</label>
<a href="./" title="Download boilerplate" class="md-nav__link md-nav__link--active">
Download boilerplate
</a>
<nav class="md-nav md-nav--secondary" aria-label="Table of contents">
<label class="md-nav__title" for="__toc">
<span class="md-nav__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11h12z"/></svg>
</span>
Table of contents
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="#git_clone" class="md-nav__link">
Git clone
</a>
</li>
<li class="md-nav__item">
<a href="#download_zip" class="md-nav__link">
Download zip
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="../pipenv/" title="pipenv" class="md-nav__link">
pipenv
</a>
</li>
<li class="md-nav__item">
<a href="../invoke/" title="invoke" class="md-nav__link">
invoke
</a>
</li>
<li class="md-nav__item">
<a href="../docker/" title="Docker" class="md-nav__link">
Docker
</a>
</li>
<li class="md-nav__item">
<a href="../pip/" title="pip" class="md-nav__link">
pip
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item md-nav__item--nested">
<input class="md-nav__toggle md-toggle" data-md-toggle="nav-4" type="checkbox" id="nav-4">
<label class="md-nav__link" for="nav-4">
Hosting and Deployment
<span class="md-nav__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M8.59 16.58L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.42z"/></svg>
</span>
</label>
<nav class="md-nav" aria-label="Hosting and Deployment" data-md-level="1">
<label class="md-nav__title" for="nav-4">
<span class="md-nav__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11h12z"/></svg>
</span>
Hosting and Deployment
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="../../hosting-and-deployment/combinations/" title="Combinations" class="md-nav__link">
Combinations
</a>
</li>
<li class="md-nav__item">
<a href="../../hosting-and-deployment/netlify/" title="Netlify" class="md-nav__link">
Netlify
</a>
</li>
<li class="md-nav__item">
<a href="../../hosting-and-deployment/github-pages/" title="GitHub Pages" class="md-nav__link">
GitHub Pages
</a>
</li>
<li class="md-nav__item">
<a href="../../hosting-and-deployment/gitlab-pages/" title="GitLab Pages" class="md-nav__link">
GitLab Pages
</a>
</li>
<li class="md-nav__item">
<a href="../../hosting-and-deployment/aws-amplify-console/" title="AWS Amplify Console" class="md-nav__link">
AWS Amplify Console
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item md-nav__item--nested">
<input class="md-nav__toggle md-toggle" data-md-toggle="nav-5" type="checkbox" id="nav-5">
<label class="md-nav__link" for="nav-5">
Extensions
<span class="md-nav__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M8.59 16.58L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.42z"/></svg>
</span>
</label>
<nav class="md-nav" aria-label="Extensions" data-md-level="1">
<label class="md-nav__title" for="nav-5">
<span class="md-nav__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11h12z"/></svg>
</span>
Extensions
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="../../extensions/mathjax/" title="MathJax" class="md-nav__link">
MathJax
</a>
</li>
<li class="md-nav__item">
<a href="../../extensions/code-hilite/" title="Code Hilite" class="md-nav__link">
Code Hilite
</a>
</li>
<li class="md-nav__item">
<a href="../../extensions/footnote/" title="Footnote" class="md-nav__link">
Footnote
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="../../license/" title="License" class="md-nav__link">
License
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
<div class="md-sidebar md-sidebar--secondary" data-md-component="toc">
<div class="md-sidebar__scrollwrap">
<div class="md-sidebar__inner">
<nav class="md-nav md-nav--secondary" aria-label="Table of contents">
<label class="md-nav__title" for="__toc">
<span class="md-nav__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11h12z"/></svg>
</span>
Table of contents
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="#git_clone" class="md-nav__link">
Git clone
</a>
</li>
<li class="md-nav__item">
<a href="#download_zip" class="md-nav__link">
Download zip
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
<div class="md-content">
<article class="md-content__inner md-typeset">
<a href="https://github.com/peaceiris/mkdocs-material-boilerplate/edit/master/docs_sample/getting-started/download-boilerplate.md" title="Edit this page" class="md-content__button md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20.71 7.04c.39-.39.39-1.04 0-1.41l-2.34-2.34c-.37-.39-1.02-.39-1.41 0l-1.84 1.83 3.75 3.75M3 17.25V21h3.75L17.81 9.93l-3.75-3.75L3 17.25z"/></svg>
</a>
<h1 id="download_boilerplate">Download boilerplate<a class="headerlink" href="#download_boilerplate" title="Permanent link">&para;</a></h1>
<h2 id="git_clone">Git clone<a class="headerlink" href="#git_clone" title="Permanent link">&para;</a></h2>
<div class="codehilite"><pre><span></span><code>git clone https://github.com/peaceiris/mkdocs-material-boilerplate.git
<span class="nb">cd</span> mkdocs-material-boilerplate
</code></pre></div>
<h2 id="download_zip">Download zip<a class="headerlink" href="#download_zip" title="Permanent link">&para;</a></h2>
<div class="codehilite"><pre><span></span><code>wget <span class="s1">&#39;https://github.com/peaceiris/mkdocs-material-boilerplate/archive/master.zip&#39;</span>
unzip master.zip
<span class="nb">cd</span> mkdocs-material-boilerplate-master
</code></pre></div>
<p>👉 <a href="https://github.com/peaceiris/mkdocs-material-boilerplate/archive/master.zip">Click me to download zip</a></p>
</article>
</div>
</div>
</main>
<footer class="md-footer">
<div class="md-footer-nav">
<nav class="md-footer-nav__inner md-grid" aria-label="Footer">
<a href="../../material-for-mkdocs/" title="Material for MkDocs" class="md-footer-nav__link md-footer-nav__link--prev" rel="prev">
<div class="md-footer-nav__button md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11h12z"/></svg>
</div>
<div class="md-footer-nav__title">
<div class="md-ellipsis">
<span class="md-footer-nav__direction">
Previous
</span>
Material for MkDocs
</div>
</div>
</a>
<a href="../pipenv/" title="pipenv" class="md-footer-nav__link md-footer-nav__link--next" rel="next">
<div class="md-footer-nav__title">
<div class="md-ellipsis">
<span class="md-footer-nav__direction">
Next
</span>
pipenv
</div>
</div>
<div class="md-footer-nav__button md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4 11v2h12l-5.5 5.5 1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5 16 11H4z"/></svg>
</div>
</a>
</nav>
</div>
<div class="md-footer-meta md-typeset">
<div class="md-footer-meta__inner md-grid">
<div class="md-footer-copyright">
<div class="md-footer-copyright__highlight">
&copy; 2019 peaceiris
</div>
Made with
<a href="https://squidfunk.github.io/mkdocs-material/" target="_blank" rel="noopener">
Material for MkDocs
</a>
</div>
<div class="md-footer-social">
<a href="https://github.com/peaceiris" target="_blank" rel="noopener" title="github.com" class="md-footer-social__link">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 512"><path d="M186.1 328.7c0 20.9-10.9 55.1-36.7 55.1s-36.7-34.2-36.7-55.1 10.9-55.1 36.7-55.1 36.7 34.2 36.7 55.1zM480 278.2c0 31.9-3.2 65.7-17.5 95-37.9 76.6-142.1 74.8-216.7 74.8-75.8 0-186.2 2.7-225.6-74.8-14.6-29-20.2-63.1-20.2-95 0-41.9 13.9-81.5 41.5-113.6-5.2-15.8-7.7-32.4-7.7-48.8 0-21.5 4.9-32.3 14.6-51.8 45.3 0 74.3 9 108.8 36 29-6.9 58.8-10 88.7-10 27 0 54.2 2.9 80.4 9.2 34-26.7 63-35.2 107.8-35.2 9.8 19.5 14.6 30.3 14.6 51.8 0 16.4-2.6 32.7-7.7 48.2 27.5 32.4 39 72.3 39 114.2zm-64.3 50.5c0-43.9-26.7-82.6-73.5-82.6-18.9 0-37 3.4-56 6-14.9 2.3-29.8 3.2-45.1 3.2-15.2 0-30.1-.9-45.1-3.2-18.7-2.6-37-6-56-6-46.8 0-73.5 38.7-73.5 82.6 0 87.8 80.4 101.3 150.4 101.3h48.2c70.3 0 150.6-13.4 150.6-101.3zm-82.6-55.1c-25.8 0-36.7 34.2-36.7 55.1s10.9 55.1 36.7 55.1 36.7-34.2 36.7-55.1-10.9-55.1-36.7-55.1z"/></svg>
</a>
<a href="https://twitter.com/piris314en" target="_blank" rel="noopener" title="twitter.com" class="md-footer-social__link">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"/></svg>
</a>
</div>
</div>
</div>
</footer>
</div>
<script src="../../assets/javascripts/vendor.d710d30a.min.js"></script>
<script src="../../assets/javascripts/bundle.7f4f3c92.min.js"></script><script id="__lang" type="application/json">{"clipboard.copy": "Copy to clipboard", "clipboard.copied": "Copied to clipboard", "search.config.lang": "en", "search.config.pipeline": "trimmer, stopWordFilter", "search.config.separator": "[\\s\\-]+", "search.result.placeholder": "Type to start searching", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents"}</script>
<script>
app = initialize({
base: "../..",
features: ["tabs"],
search: Object.assign({
worker: "../../assets/javascripts/worker/search.9b3611bd.min.js"
}, typeof search !== "undefined" && search)
})
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.6/MathJax.js?config=TeX-MML-AM_CHTML"></script>
</body>
</html>
`;
import { useAsync } from 'react-use';
import { useLocation, useParams, useNavigate } from 'react-router-dom';
import { Grid } from '@material-ui/core';
import { Header, Content, ItemCard } from '@backstage/core';
import transformer, {
addBaseUrl,
rewriteDocLinks,
addEventListener,
removeMkdocsHeader,
modifyCss,
} from '../transformers';
import { docStorageURL } from '../../config';
import URLParser from '../urlParser';
const useFetch = (url: string) => {
const state = useAsync(async () => {
const response = await fetch(url);
const raw = await response.text();
return raw;
}, [url]);
return state;
};
const useEnforcedTrailingSlash = (): void => {
React.useEffect(() => {
const actualUrl = window.location.href;
const expectedUrl = new URLParser(window.location.href, '.').parse();
if (actualUrl !== expectedUrl) {
window.history.replaceState({}, document.title, expectedUrl);
}
}, []);
};
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) {
return;
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(),
]);
divElement.shadowRoot.innerHTML = '';
if (transformedElement) {
divElement.shadowRoot.appendChild(transformedElement);
transformer(divElement.shadowRoot.children[0], [
addEventListener({
onClick: navigate,
}),
]);
}
}
divElement.shadowRoot.innerHTML = mockHtml;
}, [shadowDomRef]);
}, [shadowDomRef, state, componentId, path, navigate]);
return (
<>
<h3>Shadow DOM should be underneath</h3>
<div ref={shadowDomRef} />
<Header
title={componentId ?? 'Documentation'}
subtitle={componentId ?? 'Documentation available in Backstage'}
/>
<Content>
{componentId ? (
<div ref={shadowDomRef} />
) : (
<Grid container>
<Grid item xs={12} sm={6} md={3}>
<ItemCard
onClick={() => navigate('/docs/mkdocs')}
tags={['Developer Tool']}
title="MkDocs"
label="Read Docs"
description="MkDocs is a fast, simple and downright gorgeous static site generator that's geared towards building project documentation. "
/>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<ItemCard
onClick={() => navigate('/docs/backstage-microsite')}
tags={['Service']}
title="Backstage"
label="Read Docs"
description="Getting started guides, API Overview, documentation around how to Create a Plugin and more. "
/>
</Grid>
</Grid>
)}
</Content>
</>
);
};
@@ -0,0 +1,89 @@
/*
* 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 { createTestShadowDom, FIXTURES, getSample } from '../../test-utils';
import { addBaseUrl } from '../transformers';
const DOC_STORAGE_URL = 'https://example-host.storage.googleapis.com';
describe('addBaseUrl', () => {
it('contains relative paths', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE);
expect(getSample(shadowDom, 'img', 'src')).toEqual([
'img/win-py-install.png',
'img/initial-layout.png',
]);
expect(getSample(shadowDom, 'link', 'href')).toEqual([
'https://www.mkdocs.org/',
'assets/images/favicon.png',
]);
expect(getSample(shadowDom, 'script', 'src')).toEqual([
'https://www.google-analytics.com/analytics.js',
'assets/javascripts/vendor.d710d30a.min.js',
]);
});
it('contains transformed absolute paths', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
transformers: [
addBaseUrl({
docStorageURL: DOC_STORAGE_URL,
componentId: 'example-docs',
path: '',
}),
],
});
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',
]);
});
it('includes path option', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
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',
]);
expect(getSample(shadowDom, 'link', 'href')).toEqual([
'https://www.mkdocs.org/',
'https://example-host.storage.googleapis.com/example-docs/examplepath/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',
]);
});
});
@@ -0,0 +1,53 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import URLParser from '../urlParser';
import type { Transformer } from './index';
type AddBaseUrlOptions = {
docStorageURL: string;
componentId: string;
path: string;
};
export const addBaseUrl = ({
docStorageURL,
componentId,
path,
}: AddBaseUrlOptions): Transformer => {
return dom => {
const updateDom = <T extends Element>(
list: HTMLCollectionOf<T> | NodeListOf<T>,
attributeName: string,
): void => {
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);
});
};
updateDom<HTMLImageElement>(dom.querySelectorAll('img'), 'src');
updateDom<HTMLScriptElement>(dom.querySelectorAll('script'), 'src');
updateDom<HTMLLinkElement>(dom.querySelectorAll('link'), 'href');
return dom;
};
};
@@ -0,0 +1,35 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createTestShadowDom, FIXTURES } from '../../test-utils';
import { addEventListener } from '../transformers';
describe('addEventListener', () => {
it('calls onClick when a link has been clicked', () => {
const fn = jest.fn();
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
transformers: [
addEventListener({
onClick: fn,
}),
],
});
shadowDom.querySelector('a')?.click();
expect(fn).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,41 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Transformer } from './index';
type AddEventListenerOptions = {
onClick: (newUrl: string) => void;
};
export const addEventListener = ({
onClick,
}: AddEventListenerOptions): 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, ''),
);
}
});
});
return dom;
};
};
@@ -0,0 +1,32 @@
/*
* 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 transform, { Transformer } from '.';
describe('transform', () => {
it('calls the transformers', () => {
const fn = jest.fn();
const mockTransformer = (): Transformer => (dom: Element) => {
fn(dom);
return dom;
};
transform('<html></html>', [mockTransformer()]);
expect(fn).toHaveBeenCalledTimes(1);
expect(fn).toHaveBeenCalledWith(expect.any(Element));
});
});
@@ -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.
*/
export * from './addBaseUrl';
export * from './rewriteDocLinks';
export * from './addEventListener';
export * from './removeMkdocsHeader';
export * from './modifyCss';
export type Transformer = (dom: Element) => Element;
function transform(
html: string | Element,
transformers: Transformer[],
): Element {
let dom: Element;
if (typeof html === 'string') {
dom = new DOMParser().parseFromString(html, 'text/html').documentElement;
} else if (html instanceof Element) {
dom = html;
} else {
throw new Error('dom is not a recognized type');
}
transformers.forEach(transformer => transformer(dom));
return dom;
}
export default transform;
@@ -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 { createTestShadowDom } from '../../test-utils';
import { modifyCss } from '../transformers';
describe('modifyCss', () => {
it('does not modify css', () => {
const shadowDom = createTestShadowDom(
`<div class="md-typeset" style="font-size: 0.8em"></div>`,
{
transformers: [],
},
);
const { fontSize } = getComputedStyle(
shadowDom.querySelector<HTMLElement>('.md-typeset')!,
);
expect(fontSize).toBe('0.8em');
});
it('does modify css', () => {
const shadowDom = createTestShadowDom(
`<div class="md-typeset" style="font-size: 1px"></div>`,
{
transformers: [
modifyCss({
cssTransforms: {
'.md-typeset': [{ 'font-size': '1em' }],
},
}),
],
},
);
const { fontSize } = getComputedStyle(
shadowDom.querySelector<HTMLElement>('.md-typeset')!,
);
expect(fontSize).toBe('1em');
});
});
@@ -0,0 +1,43 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Transformer } from './index';
type ModifyCssOptions = {
// Example: { '.md-container': { 'marginTop': '10px' }}
cssTransforms: { [key: string]: { [key: string]: string }[] };
};
export const modifyCss = ({ cssTransforms }: ModifyCssOptions): Transformer => {
return dom => {
Object.entries(cssTransforms).forEach(([cssSelector, cssChanges]) => {
const elementsToChange = Array.from(
dom.querySelectorAll<HTMLElement>(cssSelector),
);
if (elementsToChange.length < 1) return;
cssChanges.forEach(changes => {
elementsToChange.forEach((element: HTMLElement) => {
Object.entries(changes).forEach(([cssProperty, cssValue]) => {
element.style.setProperty(cssProperty, cssValue);
});
});
});
});
return dom;
};
};
@@ -0,0 +1,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 { createTestShadowDom, FIXTURES } from '../../test-utils';
import { removeMkdocsHeader } from '../transformers';
describe('removeMkdocsHeader', () => {
it('does not remove mkdocs header', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
transformers: [],
});
expect(shadowDom.querySelector('.md-header')).toBeTruthy();
});
it('does remove mkdocs header', () => {
const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, {
transformers: [removeMkdocsHeader()],
});
expect(shadowDom.querySelector('.md-header')).toBeFalsy();
});
});
@@ -0,0 +1,26 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Transformer } from './index';
export const removeMkdocsHeader = (): Transformer => {
return dom => {
// Remove the header
dom.querySelector('.md-header')?.remove();
return dom;
};
};
@@ -0,0 +1,57 @@
/*
* 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 { createTestShadowDom, getSample } from '../../test-utils';
import { rewriteDocLinks } from '../transformers';
describe('rewriteDocLinks', () => {
it('should not do anything', () => {
const shadowDom = createTestShadowDom(`
<a href="http://example.org/">Test</a>
<a href="../example">Test</a>
<a href="example-docs">Test</a>
<a href="example-docs/example-page">Test Sub Page</a>
`);
expect(getSample(shadowDom, 'a', 'href', 6)).toEqual([
'http://example.org/',
'../example',
'example-docs',
'example-docs/example-page',
]);
});
it('should transform a href with licalhost as baseUrl', () => {
const shadowDom = createTestShadowDom(
`
<a href="http://example.org/">Test</a>
<a href="../example">Test</a>
<a href="example-docs">Test</a>
<a href="example-docs/example-page">Test Sub Page</a>
`,
{
transformers: [rewriteDocLinks()],
},
);
expect(getSample(shadowDom, 'a', 'href', 6)).toEqual([
'http://example.org/',
'http://localhost/example',
'http://localhost/example-docs',
'http://localhost/example-docs/example-page',
]);
});
});
@@ -0,0 +1,43 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import URLParser from '../urlParser';
import type { Transformer } from './index';
export const rewriteDocLinks = (): Transformer => {
return dom => {
const updateDom = <T extends Element>(
list: Array<T>,
attributeName: string,
): void => {
Array.from(list)
.filter(elem => elem.hasAttribute(attributeName))
.forEach((elem: T) => {
elem.setAttribute(
attributeName,
new URLParser(
window.location.href,
elem.getAttribute(attributeName)!,
).parse(),
);
});
};
updateDom(Array.from(dom.getElementsByTagName('a')), 'href');
return dom;
};
};
@@ -0,0 +1,61 @@
/*
* 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/',
);
});
});
+31
View File
@@ -0,0 +1,31 @@
/*
* 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.
*/
const normalizeBaseURL = (baseURL: string): string => {
const url = new URL(baseURL);
url.pathname = url.pathname.replace(/([^/])$/, '$1/');
return url.toString();
};
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();
}
}
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
/*
* 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 FIXTURE_STANDARD_PAGE from './fixtures/mkdocs-index';
import transformer from '../reader/transformers';
import type { Transformer } from '../reader/transformers';
export const FIXTURES = {
FIXTURE_STANDARD_PAGE,
};
export type CreateTestShadowDomOptions = {
transformers: Transformer[];
};
export const createTestShadowDom = (
fixture: string,
opts: CreateTestShadowDomOptions = { transformers: [] },
): ShadowRoot => {
const divElement = document.createElement('div');
divElement.attachShadow({ mode: 'open' });
document.body.appendChild(divElement);
const domParser = new DOMParser().parseFromString(fixture, 'text/html');
divElement.shadowRoot?.appendChild(domParser.documentElement);
if (opts.transformers) {
transformer(divElement.shadowRoot!.children[0], opts.transformers);
}
return divElement.shadowRoot!;
};
export const getSample = (
shadowDom: ShadowRoot,
elementName: string,
elementAttribute: string,
sampleSize = 2,
) => {
const rootElement = shadowDom.children[0];
return Array.from(rootElement.getElementsByTagName(elementName))
.filter(elem => {
return elem.hasAttribute(elementAttribute);
})
.slice(0, sampleSize)
.map(elem => {
return elem.getAttribute(elementAttribute);
});
};