Merge branch 'master' of github.com:spotify/backstage into ndudnik/use-catalog-backend
# Conflicts: # plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx # plugins/catalog/src/components/CatalogPage/CatalogPage.tsx # plugins/catalog/src/components/ComponentPage/ComponentPage.tsx # plugins/catalog/src/data/mock-factory.ts # plugins/catalog/src/plugin.ts
This commit is contained in:
@@ -25,10 +25,16 @@
|
||||
"morgan": "^1.10.0",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0",
|
||||
"passport": "0.4.1",
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"@types/passport": "1.0.3",
|
||||
"@types/passport-google-oauth20": "2.0.3"
|
||||
"passport": "^0.4.1",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-oauth2-refresh": "^2.0.0",
|
||||
"passport-oauth2": "^1.5.0",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"@types/passport-oauth2-refresh": "^1.1.1",
|
||||
"@types/passport": "^1.0.3",
|
||||
"@types/passport-google-oauth20": "^2.0.3",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/passport-oauth2": "^1.4.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.6",
|
||||
|
||||
@@ -14,10 +14,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { GoogleAuthProvider } from './provider';
|
||||
import {
|
||||
GoogleAuthProvider,
|
||||
THOUSAND_DAYS_MS,
|
||||
TEN_MINUTES_MS,
|
||||
} from './provider';
|
||||
import passport from 'passport';
|
||||
import express from 'express';
|
||||
import * as utils from './../utils';
|
||||
import refresh from 'passport-oauth2-refresh';
|
||||
|
||||
const googleAuthProviderConfig = {
|
||||
provider: 'google',
|
||||
@@ -51,11 +56,16 @@ describe('GoogleAuthProvider', () => {
|
||||
});
|
||||
|
||||
describe('start authentication handler', () => {
|
||||
const mockResponse: any = ({} as unknown) as express.Response;
|
||||
const mockResponse = ({
|
||||
send: jest.fn().mockReturnThis(),
|
||||
status: jest.fn().mockReturnThis(),
|
||||
cookie: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
const mockNext: express.NextFunction = jest.fn();
|
||||
|
||||
it('should initiate authenticate request with provided scopes', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
query: {
|
||||
scope: 'a,b',
|
||||
},
|
||||
@@ -74,11 +84,36 @@ describe('GoogleAuthProvider', () => {
|
||||
scope: 'a,b',
|
||||
accessType: 'offline',
|
||||
prompt: 'consent',
|
||||
state: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('should set a nonce cookie', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
query: {
|
||||
scope: 'a,b',
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
googleAuthProvider.start(mockRequest, mockResponse, mockNext);
|
||||
expect(mockResponse.cookie).toBeCalledTimes(1);
|
||||
expect(mockResponse.cookie).toBeCalledWith(
|
||||
'google-nonce',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
maxAge: TEN_MINUTES_MS,
|
||||
path: `/auth/${googleAuthProviderConfig.provider}/handler`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if no scopes provided', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
query: {},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
@@ -92,11 +127,14 @@ describe('GoogleAuthProvider', () => {
|
||||
});
|
||||
|
||||
describe('logout handler', () => {
|
||||
const mockRequest = ({} as unknown) as express.Request;
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
} as unknown) as express.Request;
|
||||
|
||||
it('should perform logout and respond with 200', () => {
|
||||
const mockResponse: any = ({
|
||||
send: jest.fn(),
|
||||
cookie: jest.fn(),
|
||||
} as unknown) as express.Response;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
@@ -110,23 +148,83 @@ describe('GoogleAuthProvider', () => {
|
||||
googleAuthProvider.logout(mockRequest, mockResponse);
|
||||
expect(spyResponse).toBeCalledTimes(1);
|
||||
expect(spyResponse).toBeCalledWith('logout!');
|
||||
expect(mockResponse.cookie).toBeCalledTimes(1);
|
||||
expect(mockResponse.cookie).toBeCalledWith(
|
||||
'google-refresh-token',
|
||||
'',
|
||||
expect.objectContaining({ maxAge: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redirect frame handler', () => {
|
||||
const mockRequest = ({} as unknown) as express.Request;
|
||||
const mockResponse: any = ({
|
||||
send: jest.fn(),
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
cookie: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
const mockNext: express.NextFunction = jest.fn();
|
||||
|
||||
it('should call authenticate and post a response ', () => {
|
||||
it('should call authenticate and post a response', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
cookies: { 'google-nonce': 'NONCE' },
|
||||
query: {
|
||||
state: 'NONCE',
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const spyPostMessage = jest
|
||||
.spyOn(utils, 'postMessageResponse')
|
||||
.mockImplementation(() => jest.fn());
|
||||
|
||||
const spyPassport = jest
|
||||
.spyOn(passport, 'authenticate')
|
||||
.mockImplementation((_x, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb(null, { refreshToken: 'REFRESH_TOKEN' });
|
||||
return jest.fn();
|
||||
});
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
|
||||
expect(spyPassport).toBeCalledTimes(1);
|
||||
expect(spyPostMessage).toBeCalledTimes(1);
|
||||
expect(mockResponse.cookie).toBeCalledTimes(1);
|
||||
expect(mockResponse.cookie).toBeCalledWith(
|
||||
'google-refresh-token',
|
||||
'REFRESH_TOKEN',
|
||||
expect.objectContaining({
|
||||
path: '/auth/google',
|
||||
sameSite: 'none',
|
||||
httpOnly: true,
|
||||
maxAge: THOUSAND_DAYS_MS,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should respond with a error message if no refresh token returned', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
cookies: { 'google-nonce': 'NONCE' },
|
||||
query: {
|
||||
state: 'NONCE',
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const spyPassport = jest
|
||||
.spyOn(passport, 'authenticate')
|
||||
.mockImplementation((_x, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb(null, {});
|
||||
return jest.fn();
|
||||
});
|
||||
|
||||
const spyPostMessage = jest
|
||||
.spyOn(utils, 'postMessageResponse')
|
||||
.mockImplementation(() => jest.fn());
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
@@ -134,10 +232,100 @@ describe('GoogleAuthProvider', () => {
|
||||
);
|
||||
|
||||
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
|
||||
const callbackFunc = spyPassport.mock.calls[0][1] as Function;
|
||||
callbackFunc();
|
||||
expect(spyPassport).toBeCalledTimes(1);
|
||||
expect(spyPostMessage).toBeCalledTimes(1);
|
||||
expect(spyPostMessage).toBeCalledWith(mockResponse, {
|
||||
type: 'auth-result',
|
||||
error: new Error('Missing refresh token'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should respond with a error message if auth failed', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
cookies: { 'google-nonce': 'NONCE' },
|
||||
query: {
|
||||
state: 'NONCE',
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const spyPassport = jest
|
||||
.spyOn(passport, 'authenticate')
|
||||
.mockImplementation((_x, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb(new Error('TokenError'), null);
|
||||
return jest.fn();
|
||||
});
|
||||
|
||||
const spyPostMessage = jest
|
||||
.spyOn(utils, 'postMessageResponse')
|
||||
.mockImplementation(() => jest.fn());
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
|
||||
expect(spyPassport).toBeCalledTimes(1);
|
||||
expect(spyPostMessage).toBeCalledTimes(1);
|
||||
expect(spyPostMessage).toBeCalledWith(mockResponse, {
|
||||
type: 'auth-result',
|
||||
error: new Error('Google auth failed, Error: TokenError'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should respond with a error message if cookie nonce is missing', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
cookies: {},
|
||||
query: { state: 'NONCE' },
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith('Missing nonce');
|
||||
expect(mockResponse.status).toBeCalledTimes(1);
|
||||
expect(mockResponse.status).toBeCalledWith(401);
|
||||
});
|
||||
|
||||
it('should respond with a error message if state nonce is missing', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
cookies: { 'google-nonce': 'NONCE' },
|
||||
query: {},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith('Missing nonce');
|
||||
expect(mockResponse.status).toBeCalledTimes(1);
|
||||
expect(mockResponse.status).toBeCalledWith(401);
|
||||
});
|
||||
|
||||
it('should respond with a error message if nonce mismatch', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
cookies: { 'google-nonce': 'NONCA' },
|
||||
query: { state: 'NONCEB' },
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith('Invalid nonce');
|
||||
expect(mockResponse.status).toBeCalledTimes(1);
|
||||
expect(mockResponse.status).toBeCalledWith(401);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,4 +347,176 @@ describe('GoogleAuthProvider', () => {
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh token handler', () => {
|
||||
const mockResponse = ({
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
|
||||
describe('no refresh token cookie', () => {
|
||||
it('should respond with a 401', () => {
|
||||
const mockRequest = ({
|
||||
cookies: jest.fn(),
|
||||
header: () => 'XMLHttpRequest',
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
googleAuthProvider.refresh(mockRequest, mockResponse);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith('Missing session cookie');
|
||||
|
||||
expect(mockResponse.status).toBeCalledTimes(1);
|
||||
expect(mockResponse.status).toBeCalledWith(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh token cookie, no scope', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
cookies: { 'google-refresh-token': 'REFRESH_TOKEN' },
|
||||
query: {},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
it('should request for a new access token and fail if no access token returned', () => {
|
||||
const spyRefresh = jest
|
||||
.spyOn(refresh, 'requestNewAccessToken')
|
||||
.mockImplementation((_x, _y, _z, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb(undefined, undefined, undefined, {});
|
||||
});
|
||||
|
||||
googleAuthProvider.refresh(mockRequest, mockResponse);
|
||||
expect(spyRefresh).toBeCalledTimes(1);
|
||||
expect(spyRefresh).toBeCalledWith(
|
||||
'google',
|
||||
'REFRESH_TOKEN',
|
||||
{},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockResponse.status).toBeCalledTimes(1);
|
||||
expect(mockResponse.status).toBeCalledWith(401);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith(
|
||||
'Failed to refresh access token',
|
||||
);
|
||||
});
|
||||
|
||||
it('should request for a new access token and return 401 if any error', () => {
|
||||
const spyRefresh = jest
|
||||
.spyOn(refresh, 'requestNewAccessToken')
|
||||
.mockImplementation((_x, _y, _z, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb({ error: 'ERROR' }, undefined, undefined, {});
|
||||
});
|
||||
|
||||
googleAuthProvider.refresh(mockRequest, mockResponse);
|
||||
expect(spyRefresh).toBeCalledTimes(1);
|
||||
expect(spyRefresh).toBeCalledWith(
|
||||
'google',
|
||||
'REFRESH_TOKEN',
|
||||
{},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockResponse.status).toBeCalledTimes(1);
|
||||
expect(mockResponse.status).toBeCalledWith(401);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith(
|
||||
'Failed to refresh access token',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch and return a new access token', () => {
|
||||
const spyRefresh = jest
|
||||
.spyOn(refresh, 'requestNewAccessToken')
|
||||
.mockImplementation((_x, _y, _z, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb(undefined, 'ACCESS_TOKEN', undefined, {
|
||||
expires_in: 'EXPIRES_IN',
|
||||
id_token: 'ID_TOKEN',
|
||||
});
|
||||
});
|
||||
|
||||
googleAuthProvider.refresh(mockRequest, mockResponse);
|
||||
expect(spyRefresh).toBeCalledTimes(1);
|
||||
expect(spyRefresh).toBeCalledWith(
|
||||
'google',
|
||||
'REFRESH_TOKEN',
|
||||
{},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith({
|
||||
accessToken: 'ACCESS_TOKEN',
|
||||
idToken: 'ID_TOKEN',
|
||||
expiresInSeconds: 'EXPIRES_IN',
|
||||
scope: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh token cookie and scope', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
cookies: { 'google-refresh-token': 'REFRESH_TOKEN' },
|
||||
query: {
|
||||
scope: 'a,b',
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
it('should fetch and return a new access token with scopes', () => {
|
||||
const spyRefresh = jest
|
||||
.spyOn(refresh, 'requestNewAccessToken')
|
||||
.mockImplementation((_x, _y, _z, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb(undefined, 'ACCESS_TOKEN', undefined, {
|
||||
expires_in: 'EXPIRES_IN',
|
||||
id_token: 'ID_TOKEN',
|
||||
scope: 'a,b',
|
||||
});
|
||||
});
|
||||
|
||||
googleAuthProvider.refresh(mockRequest, mockResponse);
|
||||
expect(spyRefresh).toBeCalledTimes(1);
|
||||
expect(spyRefresh).toBeCalledWith(
|
||||
'google',
|
||||
'REFRESH_TOKEN',
|
||||
{ scope: 'a,b' },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith({
|
||||
accessToken: 'ACCESS_TOKEN',
|
||||
idToken: 'ID_TOKEN',
|
||||
expiresInSeconds: 'EXPIRES_IN',
|
||||
scope: 'a,b',
|
||||
});
|
||||
});
|
||||
|
||||
it('ensures x-requested-with header', () => {
|
||||
const mockHeaderRequest = ({
|
||||
header: () => 'TEST',
|
||||
} as unknown) as express.Request;
|
||||
|
||||
googleAuthProvider.refresh(mockHeaderRequest, mockResponse);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith(
|
||||
'Invalid X-Requested-With header',
|
||||
);
|
||||
expect(mockResponse.status).toBeCalledTimes(1);
|
||||
expect(mockResponse.status).toBeCalledWith(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,16 +15,20 @@
|
||||
*/
|
||||
|
||||
import passport from 'passport';
|
||||
import express from 'express';
|
||||
import express, { CookieOptions } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
||||
import refresh from 'passport-oauth2-refresh';
|
||||
import {
|
||||
AuthProvider,
|
||||
AuthProviderRouteHandlers,
|
||||
AuthProviderConfig,
|
||||
} from './../types';
|
||||
import { postMessageResponse } from './../utils';
|
||||
import { postMessageResponse, ensuresXRequestedWith } from './../utils';
|
||||
import { InputError } from '@backstage/backend-common';
|
||||
|
||||
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
|
||||
export const TEN_MINUTES_MS = 600 * 1000;
|
||||
export class GoogleAuthProvider
|
||||
implements AuthProvider, AuthProviderRouteHandlers {
|
||||
private readonly providerConfig: AuthProviderConfig;
|
||||
@@ -37,6 +41,19 @@ export class GoogleAuthProvider
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) {
|
||||
const nonce = crypto.randomBytes(16).toString('base64');
|
||||
|
||||
const options: CookieOptions = {
|
||||
maxAge: TEN_MINUTES_MS,
|
||||
secure: false,
|
||||
sameSite: 'none',
|
||||
domain: 'localhost',
|
||||
path: `/auth/${this.providerConfig.provider}/handler`,
|
||||
httpOnly: true,
|
||||
};
|
||||
|
||||
res.cookie(`${this.providerConfig.provider}-nonce`, nonce, options);
|
||||
|
||||
const scope = req.query.scope?.toString() ?? '';
|
||||
if (!scope) {
|
||||
throw new InputError('missing scope parameter');
|
||||
@@ -45,6 +62,7 @@ export class GoogleAuthProvider
|
||||
scope,
|
||||
accessType: 'offline',
|
||||
prompt: 'consent',
|
||||
state: nonce,
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
@@ -53,16 +71,106 @@ export class GoogleAuthProvider
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) {
|
||||
return passport.authenticate('google', (_, user) => {
|
||||
postMessageResponse(res, {
|
||||
const cookieNonce = req.cookies[`${this.providerConfig.provider}-nonce`];
|
||||
const stateNonce = req.query.state;
|
||||
|
||||
if (!cookieNonce || !stateNonce) {
|
||||
return res.status(401).send('Missing nonce');
|
||||
}
|
||||
|
||||
if (cookieNonce !== stateNonce) {
|
||||
return res.status(401).send('Invalid nonce');
|
||||
}
|
||||
|
||||
return passport.authenticate('google', (err, user) => {
|
||||
if (err) {
|
||||
return postMessageResponse(res, {
|
||||
type: 'auth-result',
|
||||
error: new Error(`Google auth failed, ${err}`),
|
||||
});
|
||||
}
|
||||
|
||||
const { refreshToken } = user;
|
||||
|
||||
if (!refreshToken) {
|
||||
return postMessageResponse(res, {
|
||||
type: 'auth-result',
|
||||
error: new Error('Missing refresh token'),
|
||||
});
|
||||
}
|
||||
|
||||
delete user.refreshToken;
|
||||
|
||||
const options: CookieOptions = {
|
||||
maxAge: THOUSAND_DAYS_MS,
|
||||
secure: false,
|
||||
sameSite: 'none',
|
||||
domain: 'localhost',
|
||||
path: `/auth/${this.providerConfig.provider}`,
|
||||
httpOnly: true,
|
||||
};
|
||||
|
||||
res.cookie(
|
||||
`${this.providerConfig.provider}-refresh-token`,
|
||||
refreshToken,
|
||||
options,
|
||||
);
|
||||
return postMessageResponse(res, {
|
||||
type: 'auth-result',
|
||||
payload: user,
|
||||
});
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
async logout(_req: express.Request, res: express.Response) {
|
||||
res.send('logout!');
|
||||
async logout(req: express.Request, res: express.Response) {
|
||||
if (!ensuresXRequestedWith(req)) {
|
||||
return res.status(401).send('Invalid X-Requested-With header');
|
||||
}
|
||||
|
||||
const options: CookieOptions = {
|
||||
maxAge: 0,
|
||||
secure: false,
|
||||
sameSite: 'none',
|
||||
domain: 'localhost',
|
||||
path: `/auth/${this.providerConfig.provider}`,
|
||||
httpOnly: true,
|
||||
};
|
||||
|
||||
res.cookie(`${this.providerConfig.provider}-refresh-token`, '', options);
|
||||
return res.send('logout!');
|
||||
}
|
||||
|
||||
async refresh(req: express.Request, res: express.Response) {
|
||||
if (!ensuresXRequestedWith(req)) {
|
||||
return res.status(401).send('Invalid X-Requested-With header');
|
||||
}
|
||||
|
||||
const refreshToken =
|
||||
req.cookies[`${this.providerConfig.provider}-refresh-token`];
|
||||
|
||||
if (!refreshToken) {
|
||||
return res.status(401).send('Missing session cookie');
|
||||
}
|
||||
|
||||
const scope = req.query.scope?.toString() ?? '';
|
||||
const refreshTokenRequestParams = scope ? { scope } : {};
|
||||
|
||||
return refresh.requestNewAccessToken(
|
||||
this.providerConfig.provider,
|
||||
refreshToken,
|
||||
refreshTokenRequestParams,
|
||||
(err, accessToken, _refreshToken, params) => {
|
||||
if (err || !accessToken) {
|
||||
return res.status(401).send('Failed to refresh access token');
|
||||
}
|
||||
return res.send({
|
||||
accessToken,
|
||||
idToken: params.id_token,
|
||||
expiresInSeconds: params.expires_in,
|
||||
scope: params.scope,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
strategy(): passport.Strategy {
|
||||
@@ -81,6 +189,8 @@ export class GoogleAuthProvider
|
||||
idToken: params.id_token,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
scope: params.scope,
|
||||
expiresInSeconds: params.expires_in,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -20,11 +20,11 @@ import { ProviderFactories } from './factories';
|
||||
|
||||
export const defaultRouter = (provider: AuthProviderRouteHandlers) => {
|
||||
const router = Router();
|
||||
router.get('/start', provider.start);
|
||||
router.get('/handler/frame', provider.frameHandler);
|
||||
router.get('/logout', provider.logout);
|
||||
router.get('/start', provider.start.bind(provider));
|
||||
router.get('/handler/frame', provider.frameHandler.bind(provider));
|
||||
router.get('/logout', provider.logout.bind(provider));
|
||||
if (provider.refresh) {
|
||||
router.get('/refreshToken', provider.refresh);
|
||||
router.get('/refresh', provider.refresh.bind(provider));
|
||||
}
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -58,16 +58,25 @@ export type AuthProviderFactory = {
|
||||
new (providerConfig: any): AuthProvider & AuthProviderRouteHandlers;
|
||||
};
|
||||
|
||||
export type AuthInfo = {
|
||||
profile: passport.Profile;
|
||||
export type AuthInfoBase = {
|
||||
accessToken: string;
|
||||
idToken?: string;
|
||||
expiresInSeconds?: number;
|
||||
scope: string;
|
||||
};
|
||||
|
||||
export type AuthInfoWithProfile = AuthInfoBase & {
|
||||
profile: passport.Profile;
|
||||
};
|
||||
|
||||
export type AuthInfoPrivate = AuthInfoWithProfile & {
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
export type AuthResponse =
|
||||
| {
|
||||
type: 'auth-result';
|
||||
payload: AuthInfo;
|
||||
payload: AuthInfoBase | AuthInfoWithProfile;
|
||||
}
|
||||
| {
|
||||
type: 'auth-result';
|
||||
|
||||
@@ -39,3 +39,12 @@ export const postMessageResponse = (
|
||||
</html>
|
||||
`);
|
||||
};
|
||||
|
||||
export const ensuresXRequestedWith = (req: express.Request) => {
|
||||
const requiredHeader = req.header('X-Requested-With');
|
||||
|
||||
if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003;
|
||||
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
|
||||
const logger = getRootLogger();
|
||||
|
||||
startStandaloneServer({ port, enableCors, logger }).catch((err) => {
|
||||
startStandaloneServer({ port, enableCors, logger }).catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import passport from 'passport';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import refresh from 'passport-oauth2-refresh';
|
||||
import OAuth2Strategy from 'passport-oauth2';
|
||||
import { Logger } from 'winston';
|
||||
import { providers } from './../providers/config';
|
||||
import { makeProvider } from '../providers';
|
||||
@@ -39,6 +42,9 @@ export async function createRouter(
|
||||
);
|
||||
logger.info(`Configuring provider: ${providerId}`);
|
||||
passport.use(strategy);
|
||||
if (strategy instanceof OAuth2Strategy) {
|
||||
refresh.use(strategy);
|
||||
}
|
||||
providerRouters[providerId] = providerRouter;
|
||||
}
|
||||
|
||||
@@ -52,6 +58,7 @@ export async function createRouter(
|
||||
|
||||
router.use(passport.initialize());
|
||||
router.use(passport.session());
|
||||
router.use(cookieParser());
|
||||
|
||||
for (const providerId in providerRouters) {
|
||||
if (providerRouters.hasOwnProperty(providerId)) {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.1.1-alpha.6",
|
||||
"@types/node-fetch": "^2.5.7",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
@@ -37,9 +38,10 @@
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.6",
|
||||
"@types/lodash": "^4.14.151",
|
||||
"@types/uuid": "^7.0.3",
|
||||
"@types/uuid": "^8.0.0",
|
||||
"@types/yup": "^0.28.2",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"supertest": "^4.0.2",
|
||||
"tsc-watch": "^4.2.3"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -14,32 +14,47 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { NotFoundError } from '@backstage/backend-common';
|
||||
import { Database } from '../database';
|
||||
import { DescriptorEnvelope } from '../ingestion/types';
|
||||
import { EntitiesCatalog } from './types';
|
||||
import { EntitiesCatalog, EntityFilters } from './types';
|
||||
|
||||
export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
constructor(private readonly database: Database) {}
|
||||
|
||||
async entities(): Promise<DescriptorEnvelope[]> {
|
||||
async entities(filters?: EntityFilters): Promise<DescriptorEnvelope[]> {
|
||||
const items = await this.database.transaction(tx =>
|
||||
this.database.entities(tx),
|
||||
this.database.entities(tx, filters),
|
||||
);
|
||||
return items.map(i => i.entity);
|
||||
}
|
||||
|
||||
async entity(
|
||||
async entityByUid(uid: string): Promise<DescriptorEnvelope | undefined> {
|
||||
const matches = await this.database.transaction(tx =>
|
||||
this.database.entities(tx, [{ key: 'uid', values: [uid] }]),
|
||||
);
|
||||
|
||||
return matches.length ? matches[0].entity : undefined;
|
||||
}
|
||||
|
||||
async entityByName(
|
||||
kind: string,
|
||||
name: string,
|
||||
namespace: string | undefined,
|
||||
): Promise<DescriptorEnvelope | undefined> {
|
||||
const item = await this.database.transaction(tx =>
|
||||
this.database.entity(tx, kind, name, namespace),
|
||||
const matches = await this.database.transaction(tx =>
|
||||
this.database.entities(tx, [
|
||||
{ key: 'kind', values: [kind] },
|
||||
{ key: 'name', values: [name] },
|
||||
{
|
||||
key: 'namespace',
|
||||
values:
|
||||
!namespace || namespace === 'default'
|
||||
? [null, 'default']
|
||||
: [namespace],
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (!item) {
|
||||
throw new NotFoundError('Entity cannot be found');
|
||||
}
|
||||
return item.entity;
|
||||
|
||||
return matches.length ? matches[0].entity : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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 { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog';
|
||||
import knex from 'knex';
|
||||
import path from 'path';
|
||||
|
||||
import { Database } from '../database';
|
||||
import { ReaderOutput } from '../ingestion/types';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
|
||||
describe('DatabaseLocationsCatalog', () => {
|
||||
const database = knex({
|
||||
client: 'sqlite3',
|
||||
connection: ':memory:',
|
||||
useNullAsDefault: true,
|
||||
});
|
||||
database.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
|
||||
resource.run('PRAGMA foreign_keys = ON', () => {});
|
||||
});
|
||||
let db: Database;
|
||||
let catalog: DatabaseLocationsCatalog;
|
||||
|
||||
const mockLocationReader = {
|
||||
read: async (type: string, target: string): Promise<ReaderOutput[]> => {
|
||||
if (type !== 'valid_type') {
|
||||
throw new Error(`Unknown location type ${type}`);
|
||||
}
|
||||
if (target === 'valid_target') {
|
||||
return Promise.resolve([{ type: 'data', data: {} }]);
|
||||
}
|
||||
throw new Error(
|
||||
`Can't read location at ${target} with error: Something is broken`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await database.migrate.latest({
|
||||
directory: path.resolve(__dirname, '../database/migrations'),
|
||||
loadExtensions: ['.ts'],
|
||||
});
|
||||
db = new Database(database, getVoidLogger());
|
||||
catalog = new DatabaseLocationsCatalog(db, mockLocationReader);
|
||||
});
|
||||
|
||||
it('resolves to location with id', async () => {
|
||||
return expect(
|
||||
catalog.addLocation({ type: 'valid_type', target: 'valid_target' }),
|
||||
).resolves.toEqual({
|
||||
id: expect.anything(),
|
||||
type: 'valid_type',
|
||||
target: 'valid_target',
|
||||
});
|
||||
});
|
||||
it('rejects for invalid type', async () => {
|
||||
const type = 'invalid_type';
|
||||
return expect(
|
||||
catalog.addLocation({ type, target: 'valid_target' }),
|
||||
).rejects.toThrow(/Unknown location type/);
|
||||
});
|
||||
it('rejects for unreadable target ', async () => {
|
||||
const target = 'invalid_target';
|
||||
return expect(
|
||||
catalog.addLocation({ type: 'valid_type', target }),
|
||||
).rejects.toThrow(
|
||||
`Can't read location at ${target} with error: Something is broken`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -16,11 +16,24 @@
|
||||
|
||||
import { Database } from '../database';
|
||||
import { AddLocation, Location, LocationsCatalog } from './types';
|
||||
import { LocationReader } from '../ingestion';
|
||||
|
||||
export class DatabaseLocationsCatalog implements LocationsCatalog {
|
||||
constructor(private readonly database: Database) {}
|
||||
constructor(
|
||||
private readonly database: Database,
|
||||
private readonly reader: LocationReader,
|
||||
) {}
|
||||
|
||||
async addLocation(location: AddLocation): Promise<Location> {
|
||||
const outputs = await this.reader.read(location.type, location.target);
|
||||
outputs.forEach(output => {
|
||||
if (output.type === 'error') {
|
||||
throw new Error(
|
||||
`Can't read location at ${location.target}, ${output.error}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const added = await this.database.addLocation(location);
|
||||
return added;
|
||||
}
|
||||
@@ -35,7 +48,7 @@ export class DatabaseLocationsCatalog implements LocationsCatalog {
|
||||
}
|
||||
|
||||
async location(id: string): Promise<Location> {
|
||||
const item = await this.location(id);
|
||||
const item = await this.database.location(id);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,15 @@ export class StaticEntitiesCatalog implements EntitiesCatalog {
|
||||
return lodash.cloneDeep(this._entities);
|
||||
}
|
||||
|
||||
async entity(
|
||||
async entityByUid(uid: string): Promise<DescriptorEnvelope | undefined> {
|
||||
const item = this._entities.find(e => uid === e.metadata?.uid);
|
||||
if (!item) {
|
||||
throw new NotFoundError('Entity cannot be found');
|
||||
}
|
||||
return lodash.cloneDeep(item);
|
||||
}
|
||||
|
||||
async entityByName(
|
||||
kind: string,
|
||||
name: string,
|
||||
namespace: string | undefined,
|
||||
|
||||
@@ -18,15 +18,22 @@ import * as yup from 'yup';
|
||||
import { DescriptorEnvelope } from '../ingestion';
|
||||
|
||||
//
|
||||
// Items
|
||||
// Entities
|
||||
//
|
||||
|
||||
export type EntityFilter = {
|
||||
key: string;
|
||||
values: (string | null)[];
|
||||
};
|
||||
export type EntityFilters = EntityFilter[];
|
||||
|
||||
export type EntitiesCatalog = {
|
||||
entities(): Promise<DescriptorEnvelope[]>;
|
||||
entity(
|
||||
entities(filters?: EntityFilters): Promise<DescriptorEnvelope[]>;
|
||||
entityByUid(uid: string): Promise<DescriptorEnvelope | undefined>;
|
||||
entityByName(
|
||||
kind: string,
|
||||
name: string,
|
||||
namespace: string | undefined,
|
||||
name: string,
|
||||
): Promise<DescriptorEnvelope | undefined>;
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@backstage/backend-common';
|
||||
import Knex from 'knex';
|
||||
import path from 'path';
|
||||
import { DescriptorEnvelope } from '../ingestion';
|
||||
import { Database } from './Database';
|
||||
import {
|
||||
AddDatabaseLocation,
|
||||
@@ -242,4 +243,112 @@ describe('Database', () => {
|
||||
).rejects.toThrow(ConflictError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entities', () => {
|
||||
it('can get all entities with empty filters list', async () => {
|
||||
const catalog = new Database(database, getVoidLogger());
|
||||
const e1: DescriptorEnvelope = { apiVersion: 'a', kind: 'b' };
|
||||
const e2: DescriptorEnvelope = {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
spec: { c: null },
|
||||
};
|
||||
await catalog.transaction(async tx => {
|
||||
await catalog.addEntity(tx, { entity: e1 });
|
||||
await catalog.addEntity(tx, { entity: e2 });
|
||||
});
|
||||
const result = await catalog.transaction(async tx =>
|
||||
catalog.entities(tx, []),
|
||||
);
|
||||
expect(result.length).toEqual(2);
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ locationId: undefined, entity: expect.objectContaining(e1) },
|
||||
{ locationId: undefined, entity: expect.objectContaining(e2) },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('can get all specific entities for matching filters (naive case)', async () => {
|
||||
const catalog = new Database(database, getVoidLogger());
|
||||
const entities: DescriptorEnvelope[] = [
|
||||
{ apiVersion: 'a', kind: 'b' },
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
spec: { c: 'some' },
|
||||
},
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
spec: { c: null },
|
||||
},
|
||||
];
|
||||
|
||||
await catalog.transaction(async tx => {
|
||||
for (const entity of entities) {
|
||||
await catalog.addEntity(tx, { entity });
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
catalog.transaction(async tx =>
|
||||
catalog.entities(tx, [
|
||||
{ key: 'kind', values: ['b'] },
|
||||
{ key: 'spec.c', values: ['some'] },
|
||||
]),
|
||||
),
|
||||
).resolves.toEqual([
|
||||
{ locationId: undefined, entity: expect.objectContaining(entities[1]) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('can get all specific entities for matching filters with nulls (both missing and literal null value)', async () => {
|
||||
const catalog = new Database(database, getVoidLogger());
|
||||
const entities: DescriptorEnvelope[] = [
|
||||
{ apiVersion: 'a', kind: 'b' },
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
spec: { c: 'some' },
|
||||
},
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
spec: { c: null },
|
||||
},
|
||||
];
|
||||
|
||||
await catalog.transaction(async tx => {
|
||||
for (const entity of entities) {
|
||||
await catalog.addEntity(tx, { entity });
|
||||
}
|
||||
});
|
||||
|
||||
const rows = await catalog.transaction(async tx =>
|
||||
catalog.entities(tx, [
|
||||
{ key: 'kind', values: ['b'] },
|
||||
{ key: 'spec.c', values: [null, 'some'] },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rows.length).toEqual(3);
|
||||
expect(rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining(entities[0]),
|
||||
},
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining(entities[1]),
|
||||
},
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining(entities[2]),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import Knex from 'knex';
|
||||
import lodash from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Logger } from 'winston';
|
||||
import { EntityFilters } from '../catalog';
|
||||
import { DescriptorEnvelope, EntityMeta } from '../ingestion';
|
||||
import { buildEntitySearch } from './search';
|
||||
import {
|
||||
@@ -186,11 +187,12 @@ export class Database {
|
||||
}
|
||||
|
||||
const newEntity = lodash.cloneDeep(request.entity);
|
||||
newEntity.metadata = Object.assign({}, newEntity.metadata, {
|
||||
newEntity.metadata = {
|
||||
...newEntity.metadata,
|
||||
uid: generateUid(),
|
||||
etag: generateEtag(),
|
||||
generation: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const newRow = toEntityRow(request.locationId, newEntity);
|
||||
await tx<DbEntitiesRow>('entities').insert(newRow);
|
||||
@@ -275,11 +277,12 @@ export class Database {
|
||||
? oldRow.generation
|
||||
: oldRow.generation + 1;
|
||||
const newEntity = lodash.cloneDeep(request.entity);
|
||||
newEntity.metadata = Object.assign({}, request.entity.metadata, {
|
||||
newEntity.metadata = {
|
||||
...newEntity.metadata,
|
||||
uid: oldRow.id,
|
||||
etag: newEtag,
|
||||
generation: newGeneration,
|
||||
});
|
||||
};
|
||||
|
||||
// Preserve annotations that were set on the old version of the entity,
|
||||
// unless the new version overwrites them
|
||||
@@ -309,10 +312,30 @@ export class Database {
|
||||
return { locationId: request.locationId, entity: newEntity };
|
||||
}
|
||||
|
||||
async entities(tx: Knex.Transaction<any, any>): Promise<DbEntityResponse[]> {
|
||||
const rows = await tx<DbEntitiesRow>('entities')
|
||||
async entities(
|
||||
tx: Knex.Transaction<any, any>,
|
||||
filters?: EntityFilters,
|
||||
): Promise<DbEntityResponse[]> {
|
||||
let builder = tx<DbEntitiesRow>('entities');
|
||||
for (const [index, filter] of (filters ?? []).entries()) {
|
||||
builder = builder
|
||||
.leftOuterJoin(`entities_search as t${index}`, function join() {
|
||||
this.on('entities.id', '=', `t${index}.entity_id`).onIn(
|
||||
`t${index}.value`,
|
||||
filter.values.filter(x => x),
|
||||
);
|
||||
if (filter.values.some(x => !x)) {
|
||||
this.orOnNull(`t${index}.value`);
|
||||
}
|
||||
})
|
||||
.where(`t${index}.key`, '=', filter.key);
|
||||
}
|
||||
|
||||
const rows = await builder
|
||||
.orderBy('namespace', 'name')
|
||||
.select();
|
||||
.select('entities.*')
|
||||
.groupBy('id');
|
||||
|
||||
return rows.map(row => toEntityResponse(row));
|
||||
}
|
||||
|
||||
@@ -336,9 +359,7 @@ export class Database {
|
||||
async addLocation(location: AddDatabaseLocation): Promise<DbLocationsRow> {
|
||||
return await this.database.transaction<DbLocationsRow>(async tx => {
|
||||
const existingLocation = await tx<DbLocationsRow>('locations')
|
||||
.where({
|
||||
target: location.target,
|
||||
})
|
||||
.where({ target: location.target })
|
||||
.select();
|
||||
|
||||
if (existingLocation?.[0]) {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import Knex from 'knex';
|
||||
import {
|
||||
ComponentDescriptor,
|
||||
DescriptorParser,
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
import { Database } from './Database';
|
||||
import { DatabaseManager } from './DatabaseManager';
|
||||
import { DatabaseLocationUpdateLogStatus, DbLocationsRow } from './types';
|
||||
import Knex from 'knex';
|
||||
|
||||
describe('DatabaseManager', () => {
|
||||
describe('refreshLocations', () => {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { EntitiesCatalog, Location, LocationsCatalog } from '../catalog';
|
||||
import { DescriptorEnvelope } from '../ingestion';
|
||||
import { createRouter } from './router';
|
||||
|
||||
class MockEntitiesCatalog implements EntitiesCatalog {
|
||||
entities = jest.fn();
|
||||
entityByUid = jest.fn();
|
||||
entityByName = jest.fn();
|
||||
}
|
||||
|
||||
class MockLocationsCatalog implements LocationsCatalog {
|
||||
addLocation = jest.fn();
|
||||
removeLocation = jest.fn();
|
||||
locations = jest.fn();
|
||||
location = jest.fn();
|
||||
}
|
||||
|
||||
describe('createRouter', () => {
|
||||
describe('entities', () => {
|
||||
it('happy path: lists entities', async () => {
|
||||
const entities: DescriptorEnvelope[] = [{ apiVersion: 'a', kind: 'b' }];
|
||||
|
||||
const catalog = new MockEntitiesCatalog();
|
||||
catalog.entities.mockResolvedValueOnce(entities);
|
||||
|
||||
const router = await createRouter({
|
||||
entitiesCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).get('/entities');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(entities);
|
||||
});
|
||||
|
||||
it('parses single and multiple request parameters and passes them down', async () => {
|
||||
const catalog = new MockEntitiesCatalog();
|
||||
|
||||
const router = await createRouter({
|
||||
entitiesCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).get('/entities?a=1&a=&a=3&b=4&c=');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(catalog.entities).toHaveBeenCalledWith([
|
||||
{ key: 'a', values: ['1', null, '3'] },
|
||||
{ key: 'b', values: ['4'] },
|
||||
{ key: 'c', values: [null] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entityByUid', () => {
|
||||
it('can fetch entity by uid', async () => {
|
||||
const entity: DescriptorEnvelope = {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
metadata: {
|
||||
name: 'c',
|
||||
},
|
||||
};
|
||||
const catalog = new MockEntitiesCatalog();
|
||||
catalog.entityByUid.mockResolvedValue(entity);
|
||||
|
||||
const router = await createRouter({
|
||||
entitiesCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).get('/entities/by-uid/zzz');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(expect.objectContaining(entity));
|
||||
});
|
||||
|
||||
it('responds with a 404 for missing entities', async () => {
|
||||
const catalog = new MockEntitiesCatalog();
|
||||
catalog.entityByUid.mockResolvedValue(undefined);
|
||||
|
||||
const router = await createRouter({
|
||||
entitiesCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).get('/entities/by-uid/zzz');
|
||||
|
||||
expect(response.status).toEqual(404);
|
||||
expect(response.text).toMatch(/uid/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entityByName', () => {
|
||||
it('can fetch entity by name', async () => {
|
||||
const entity: DescriptorEnvelope = {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
metadata: {
|
||||
name: 'c',
|
||||
namespace: 'd',
|
||||
},
|
||||
};
|
||||
const catalog = new MockEntitiesCatalog();
|
||||
catalog.entityByName.mockResolvedValue(entity);
|
||||
|
||||
const router = await createRouter({
|
||||
entitiesCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).get('/entities/by-name/b/d/c');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(expect.objectContaining(entity));
|
||||
});
|
||||
|
||||
it('responds with a 404 for missing entities', async () => {
|
||||
const catalog = new MockEntitiesCatalog();
|
||||
catalog.entityByName.mockResolvedValue(undefined);
|
||||
|
||||
const router = await createRouter({
|
||||
entitiesCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).get('/entities/by-name//b/d/c');
|
||||
|
||||
expect(response.status).toEqual(404);
|
||||
expect(response.text).toMatch(/name/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('locations', () => {
|
||||
it('happy path: lists locations', async () => {
|
||||
const locations: Location[] = [{ id: 'a', type: 'b', target: 'c' }];
|
||||
|
||||
const catalog = new MockLocationsCatalog();
|
||||
catalog.locations.mockResolvedValueOnce(locations);
|
||||
|
||||
const router = await createRouter({
|
||||
locationsCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).get('/locations');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(locations);
|
||||
});
|
||||
|
||||
it('rejects malformed locations', async () => {
|
||||
const location = ({
|
||||
id: 'a',
|
||||
typez: 'b',
|
||||
target: 'c',
|
||||
} as unknown) as Location;
|
||||
|
||||
const catalog = new MockLocationsCatalog();
|
||||
const router = await createRouter({
|
||||
locationsCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).post('/locations').send(location);
|
||||
|
||||
expect(response.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,12 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { errorHandler, InputError } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import {
|
||||
addLocationSchema,
|
||||
EntitiesCatalog,
|
||||
EntityFilters,
|
||||
LocationsCatalog,
|
||||
} from '../catalog';
|
||||
import { validateRequestBody } from './util';
|
||||
@@ -34,17 +36,43 @@ export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const { entitiesCatalog, locationsCatalog } = options;
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
if (entitiesCatalog) {
|
||||
// Entities
|
||||
router.get('/entities', async (_req, res) => {
|
||||
const entities = await entitiesCatalog.entities();
|
||||
res.status(200).send(entities);
|
||||
});
|
||||
router
|
||||
.get('/entities', async (req, res) => {
|
||||
const filters = translateQueryToEntityFilters(req);
|
||||
const entities = await entitiesCatalog.entities(filters);
|
||||
res.status(200).send(entities);
|
||||
})
|
||||
.get('/entities/by-uid/:uid', async (req, res) => {
|
||||
const { uid } = req.params;
|
||||
const entity = await entitiesCatalog.entityByUid(uid);
|
||||
if (!entity) {
|
||||
res.status(404).send(`No entity with uid ${uid}`);
|
||||
}
|
||||
res.status(200).send(entity);
|
||||
})
|
||||
.get('/entities/by-name/:kind/:namespace/:name', async (req, res) => {
|
||||
const { kind, namespace, name } = req.params;
|
||||
const entity = await entitiesCatalog.entityByName(
|
||||
kind,
|
||||
name,
|
||||
namespace,
|
||||
);
|
||||
if (!entity) {
|
||||
res
|
||||
.status(404)
|
||||
.send(
|
||||
`No entity with kind ${kind} namespace ${namespace} name ${name}`,
|
||||
);
|
||||
}
|
||||
res.status(200).send(entity);
|
||||
});
|
||||
}
|
||||
|
||||
// Locations
|
||||
if (locationsCatalog) {
|
||||
router
|
||||
.post('/locations', async (req, res) => {
|
||||
@@ -68,5 +96,29 @@ export async function createRouter(
|
||||
});
|
||||
}
|
||||
|
||||
router.use(errorHandler());
|
||||
return router;
|
||||
}
|
||||
|
||||
function translateQueryToEntityFilters(
|
||||
request: express.Request,
|
||||
): EntityFilters {
|
||||
const filters: EntityFilters = [];
|
||||
|
||||
for (const [key, valueOrValues] of Object.entries(request.query)) {
|
||||
const values = Array.isArray(valueOrValues)
|
||||
? valueOrValues
|
||||
: [valueOrValues];
|
||||
|
||||
if (values.some(v => typeof v !== 'string')) {
|
||||
throw new InputError('Complex query parameters are not supported');
|
||||
}
|
||||
|
||||
filters.push({
|
||||
key,
|
||||
values: values.map(v => v || null) as string[],
|
||||
});
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
@@ -157,40 +157,3 @@ export type KindParser = {
|
||||
envelope: DescriptorEnvelope,
|
||||
): Promise<DescriptorEnvelope | undefined>;
|
||||
};
|
||||
|
||||
export class ParserError extends Error {
|
||||
constructor(message?: string, private _entityName?: string | undefined) {
|
||||
super(message);
|
||||
}
|
||||
get entityName() {
|
||||
return this._entityName;
|
||||
}
|
||||
}
|
||||
|
||||
export type ReaderOutput =
|
||||
| { type: 'error'; error: Error }
|
||||
| { type: 'data'; data: object };
|
||||
|
||||
export type LocationReader = {
|
||||
/**
|
||||
* Reads the contents of a single location.
|
||||
*
|
||||
* @param type The type of location to read
|
||||
* @param target The location target (type-specific)
|
||||
* @returns The parsed contents, as an array of unverified descriptors or
|
||||
* errors where the individual documents could not be parsed.
|
||||
* @throws An error if the location as a whole could not be read
|
||||
*/
|
||||
read(type: string, target: string): Promise<ReaderOutput[]>;
|
||||
};
|
||||
|
||||
export type LocationSource = {
|
||||
/**
|
||||
* Reads the contents of a single location.
|
||||
*
|
||||
* @param target The location target to read
|
||||
* @returns The parsed contents, as an array of unverified descriptors
|
||||
* @throws An error if the location target could not be read
|
||||
*/
|
||||
read(target: string): Promise<ReaderOutput[]>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* 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, fireEvent } from '@testing-library/react';
|
||||
import { wrapInThemedTestApp } from '@backstage/test-utils';
|
||||
import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter';
|
||||
|
||||
describe('Catalog Filter', () => {
|
||||
it('should render the different groups', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
{ name: 'Test Group 1', items: [] },
|
||||
{ name: 'Test Group 2', items: [] },
|
||||
];
|
||||
const { findByText } = render(
|
||||
wrapInThemedTestApp(<CatalogFilter groups={mockGroups} />),
|
||||
);
|
||||
|
||||
for (const group of mockGroups) {
|
||||
expect(await findByText(group.name)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('should render the different items and their names', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
{
|
||||
name: 'Test Group 1',
|
||||
items: [
|
||||
{
|
||||
id: 'first',
|
||||
label: 'First Label',
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
label: 'Second Label',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { findByText } = render(
|
||||
wrapInThemedTestApp(<CatalogFilter groups={mockGroups} />),
|
||||
);
|
||||
|
||||
const [group] = mockGroups;
|
||||
for (const item of group.items) {
|
||||
expect(await findByText(item.label)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('should render the count in each item', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
{
|
||||
name: 'Test Group 1',
|
||||
items: [
|
||||
{
|
||||
id: 'first',
|
||||
label: 'First Label',
|
||||
count: 100,
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
label: 'Second Label',
|
||||
count: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { findByText } = render(
|
||||
wrapInThemedTestApp(<CatalogFilter groups={mockGroups} />),
|
||||
);
|
||||
|
||||
const [group] = mockGroups;
|
||||
for (const item of group.items) {
|
||||
expect(await findByText(item.count!.toString())).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('should fire the callback when an item is clicked', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
{
|
||||
name: 'Test Group 1',
|
||||
items: [
|
||||
{
|
||||
id: 'first',
|
||||
label: 'First Label',
|
||||
count: 100,
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
label: 'Second Label',
|
||||
count: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const onSelectedChangeHandler = jest.fn();
|
||||
|
||||
const { findByText } = render(
|
||||
wrapInThemedTestApp(
|
||||
<CatalogFilter
|
||||
groups={mockGroups}
|
||||
onSelectedChange={onSelectedChangeHandler}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const item = mockGroups[0].items[0];
|
||||
|
||||
const element = await findByText(item.label);
|
||||
|
||||
fireEvent.click(element);
|
||||
|
||||
expect(onSelectedChangeHandler).toHaveBeenCalledWith(item);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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 {
|
||||
Card,
|
||||
List,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
Typography,
|
||||
Theme,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import type { IconComponent } from '@backstage/core';
|
||||
|
||||
export type CatalogFilterItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: IconComponent;
|
||||
count?: number;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export type CatalogFilterGroup = {
|
||||
name: string;
|
||||
items: CatalogFilterItem[];
|
||||
};
|
||||
|
||||
export type CatalogFilterProps = {
|
||||
groups: CatalogFilterGroup[];
|
||||
selectedId?: string;
|
||||
onSelectedChange?: (item: CatalogFilterItem) => void;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles<Theme>(theme => ({
|
||||
root: {
|
||||
backgroundColor: 'rgba(0, 0, 0, .11)',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
title: {
|
||||
margin: theme.spacing(1, 0, 0, 1),
|
||||
textTransform: 'uppercase',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
listIcon: {
|
||||
minWidth: 30,
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
menuItem: {
|
||||
minHeight: theme.spacing(6),
|
||||
},
|
||||
groupWrapper: {
|
||||
margin: theme.spacing(1, 1, 2, 1),
|
||||
},
|
||||
menuTitle: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
}));
|
||||
|
||||
export const CatalogFilter: React.FC<CatalogFilterProps> = ({
|
||||
groups,
|
||||
selectedId,
|
||||
onSelectedChange,
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Card className={classes.root}>
|
||||
{groups.map(group => (
|
||||
<React.Fragment key={group.name}>
|
||||
<Typography variant="subtitle2" className={classes.title}>
|
||||
{group.name}
|
||||
</Typography>
|
||||
<Card className={classes.groupWrapper}>
|
||||
<List disablePadding dense>
|
||||
{group.items.map(item => (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
button
|
||||
divider
|
||||
onClick={() => onSelectedChange?.(item)}
|
||||
selected={item.id === selectedId}
|
||||
className={classes.menuItem}
|
||||
>
|
||||
{item.icon && (
|
||||
<ListItemIcon className={classes.listIcon}>
|
||||
<item.icon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
<ListItemText>
|
||||
<Typography variant="body1" className={classes.menuTitle}>
|
||||
{item.label}
|
||||
</Typography>
|
||||
</ListItemText>
|
||||
{item.count}
|
||||
</MenuItem>
|
||||
))}
|
||||
</List>
|
||||
</Card>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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 { CatalogFilter } from './CatalogFilter';
|
||||
@@ -25,6 +25,9 @@ const errorApi = { post: () => {} };
|
||||
const catalogApi = { getEntities: () => Promise.resolve([{ kind: '' }]) };
|
||||
|
||||
describe('CatalogPage', () => {
|
||||
// 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 rendered = render(
|
||||
wrapInTheme(
|
||||
|
||||
@@ -18,6 +18,7 @@ import React, { FC } from 'react';
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
DismissableBanner,
|
||||
Header,
|
||||
HomepageTimer,
|
||||
SupportButton,
|
||||
@@ -27,7 +28,21 @@ import {
|
||||
} from '@backstage/core';
|
||||
import { useAsync } from 'react-use';
|
||||
import CatalogTable from '../CatalogTable/CatalogTable';
|
||||
import { Button } from '@material-ui/core';
|
||||
import {
|
||||
CatalogFilter,
|
||||
CatalogFilterItem,
|
||||
} from '../CatalogFilter/CatalogFilter';
|
||||
import { Button, makeStyles, Typography, Link } from '@material-ui/core';
|
||||
import { filterGroups, defaultFilter } from '../../data/filters';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contentWrapper: {
|
||||
display: 'grid',
|
||||
gridTemplateAreas: "'filters' 'table'",
|
||||
gridTemplateColumns: '250px 1fr',
|
||||
gridColumnGap: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
import { catalogApiRef } from '../..';
|
||||
import { envelopeToComponent } from '../../data/utils';
|
||||
@@ -35,6 +50,15 @@ import { envelopeToComponent } from '../../data/utils';
|
||||
const CatalogPage: FC<{}> = () => {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const { value, error, loading } = useAsync(() => catalogApi.getEntities());
|
||||
const [selectedFilter, setSelectedFilter] = React.useState<CatalogFilterItem>(
|
||||
defaultFilter,
|
||||
);
|
||||
|
||||
const onFilterSelected = React.useCallback(
|
||||
selected => setSelectedFilter(selected),
|
||||
[],
|
||||
);
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
@@ -42,17 +66,43 @@ const CatalogPage: FC<{}> = () => {
|
||||
<HomepageTimer />
|
||||
</Header>
|
||||
<Content>
|
||||
<DismissableBanner
|
||||
variant="info"
|
||||
message={
|
||||
<Typography>
|
||||
<span role="img" aria-label="wave" style={{ fontSize: '125%' }}>
|
||||
👋🏼
|
||||
</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>
|
||||
}
|
||||
/>
|
||||
<ContentHeader title="Services">
|
||||
<Button variant="contained" color="primary" href="/create">
|
||||
Create Service
|
||||
</Button>
|
||||
<SupportButton>All your components</SupportButton>
|
||||
</ContentHeader>
|
||||
<CatalogTable
|
||||
components={(value && value.map(envelopeToComponent)) || []}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
<div className={styles.contentWrapper}>
|
||||
<div>
|
||||
<CatalogFilter
|
||||
groups={filterGroups}
|
||||
selectedId={selectedFilter.id}
|
||||
onSelectedChange={onFilterSelected}
|
||||
/>
|
||||
</div>
|
||||
<CatalogTable
|
||||
titlePreamble={selectedFilter.label}
|
||||
components={(value && value.map(envelopeToComponent)) || []}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
</div>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -20,15 +20,17 @@ import CatalogTable from './CatalogTable';
|
||||
import { Component } from '../../data/component';
|
||||
|
||||
const components: Component[] = [
|
||||
{ name: 'component1' },
|
||||
{ name: 'component2' },
|
||||
{ name: 'component3' },
|
||||
{ name: 'component1', kind: 'Component', description: 'Placeholder' },
|
||||
{ name: 'component2', kind: 'Component', description: 'Placeholder' },
|
||||
{ name: 'component3', kind: 'Component', description: 'Placeholder' },
|
||||
];
|
||||
|
||||
describe('CatalogTable component', () => {
|
||||
it('should render loading when loading prop it set to true', async () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(<CatalogTable components={[]} loading />),
|
||||
wrapInThemedTestApp(
|
||||
<CatalogTable titlePreamble="Owned" components={[]} loading />,
|
||||
),
|
||||
);
|
||||
const progress = await rendered.findByTestId('progress');
|
||||
expect(progress).toBeInTheDocument();
|
||||
@@ -38,6 +40,7 @@ describe('CatalogTable component', () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(
|
||||
<CatalogTable
|
||||
titlePreamble="Owned"
|
||||
components={[]}
|
||||
loading={false}
|
||||
error={{ code: 'error' }}
|
||||
@@ -53,9 +56,16 @@ describe('CatalogTable component', () => {
|
||||
it('should display component names when loading has finished and no error occurred', async () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(
|
||||
<CatalogTable components={components} loading={false} />,
|
||||
<CatalogTable
|
||||
titlePreamble="Owned"
|
||||
components={components}
|
||||
loading={false}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await rendered.findByText(`Owned (${components.length})`),
|
||||
).toBeInTheDocument();
|
||||
expect(await rendered.findByText('component1')).toBeInTheDocument();
|
||||
expect(await rendered.findByText('component2')).toBeInTheDocument();
|
||||
expect(await rendered.findByText('component3')).toBeInTheDocument();
|
||||
|
||||
@@ -39,6 +39,7 @@ const columns: TableColumn[] = [
|
||||
|
||||
type CatalogTableProps = {
|
||||
components: Component[];
|
||||
titlePreamble: string;
|
||||
loading: boolean;
|
||||
error?: any;
|
||||
};
|
||||
@@ -46,6 +47,7 @@ const CatalogTable: FC<CatalogTableProps> = ({
|
||||
components,
|
||||
loading,
|
||||
error,
|
||||
titlePreamble,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
@@ -63,7 +65,7 @@ const CatalogTable: FC<CatalogTableProps> = ({
|
||||
<Table
|
||||
columns={columns}
|
||||
options={{ paging: false }}
|
||||
title={`Owned (${(components && components.length) || 0})`}
|
||||
title={`${titlePreamble} (${(components && components.length) || 0})`}
|
||||
data={components}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
CatalogFilterGroup,
|
||||
CatalogFilterItem,
|
||||
} from '../components/CatalogFilter/CatalogFilter';
|
||||
import SettingsIcon from '@material-ui/icons/Settings';
|
||||
import StarIcon from '@material-ui/icons/Star';
|
||||
|
||||
export const filterGroups: CatalogFilterGroup[] = [
|
||||
{
|
||||
name: 'Personal',
|
||||
items: [
|
||||
{
|
||||
id: 'owned',
|
||||
label: 'Owned',
|
||||
count: 123,
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
{
|
||||
id: 'starred',
|
||||
label: 'Starred',
|
||||
count: 10,
|
||||
icon: StarIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Spotify',
|
||||
items: [
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All Services',
|
||||
count: 123,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0];
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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 { Component, ComponentFactory } from './component';
|
||||
import mock from './mock-factory-data.json';
|
||||
|
||||
const ARTIFICIAL_TIMEOUT = 800;
|
||||
let inMemoryStore = [...mock];
|
||||
export const MockComponentFactory: ComponentFactory = {
|
||||
getAllComponents(): Promise<Component[]> {
|
||||
return new Promise(resolve =>
|
||||
setTimeout(() => resolve(inMemoryStore), ARTIFICIAL_TIMEOUT),
|
||||
);
|
||||
},
|
||||
getComponentByName(name: string): Promise<Component | undefined> {
|
||||
return new Promise((resolve, reject) =>
|
||||
setTimeout(() => {
|
||||
const mockComponent = inMemoryStore.find(
|
||||
component => component.name === name,
|
||||
);
|
||||
if (mockComponent) return resolve(mockComponent);
|
||||
return reject({ code: 'Component not found!' });
|
||||
}, ARTIFICIAL_TIMEOUT),
|
||||
);
|
||||
},
|
||||
removeComponentByName(name: string): Promise<boolean> {
|
||||
return new Promise(resolve =>
|
||||
setTimeout(() => {
|
||||
inMemoryStore = inMemoryStore.filter(
|
||||
component => component.name !== name,
|
||||
);
|
||||
resolve(true);
|
||||
}, ARTIFICIAL_TIMEOUT),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -21,7 +21,7 @@ import ComponentPage from './components/ComponentPage/ComponentPage';
|
||||
export const plugin = createPlugin({
|
||||
id: 'catalog',
|
||||
register({ router }) {
|
||||
router.registerRoute('/catalog', CatalogPage);
|
||||
router.registerRoute('/', CatalogPage);
|
||||
router.registerRoute('/catalog/:name/', ComponentPage);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ import SettingsIcon from '@material-ui/icons/Settings';
|
||||
import { useSettings } from '../../state';
|
||||
|
||||
export type Props = { title?: string };
|
||||
export const PluginHeader: FC<Props> = ({ title = 'Circle CI' }) => {
|
||||
export const PluginHeader: FC<Props> = ({ title = 'CircleCI' }) => {
|
||||
const [, { showSettings }] = useSettings();
|
||||
const location = useLocation();
|
||||
const notRoot = !location.pathname.match(/\/circleci\/?$/);
|
||||
|
||||
@@ -80,7 +80,7 @@ const Settings = () => {
|
||||
value={token}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
onChange={e => setToken(e.target.value)}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
@@ -90,7 +90,7 @@ const Settings = () => {
|
||||
label="Owner"
|
||||
variant="outlined"
|
||||
value={owner}
|
||||
onChange={(e) => setOwner(e.target.value)}
|
||||
onChange={e => setOwner(e.target.value)}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
@@ -100,7 +100,7 @@ const Settings = () => {
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={repo}
|
||||
onChange={(e) => setRepo(e.target.value)}
|
||||
onChange={e => setRepo(e.target.value)}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
|
||||
@@ -35,7 +35,7 @@ const BuildName: FC<{ build?: BuildWithSteps }> = ({ build }) => (
|
||||
</IconLink>
|
||||
</Box>
|
||||
);
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
const useStyles = makeStyles(theme => ({
|
||||
neutral: {},
|
||||
failed: {
|
||||
position: 'relative',
|
||||
|
||||
@@ -50,8 +50,8 @@ export const ActionOutput: FC<{
|
||||
const [messages, setMessages] = useState([]);
|
||||
useEffect(() => {
|
||||
fetch(url)
|
||||
.then((res) => res.json())
|
||||
.then((actionOutput) => {
|
||||
.then(res => res.json())
|
||||
.then(actionOutput => {
|
||||
if (typeof actionOutput !== 'undefined') {
|
||||
setMessages(
|
||||
actionOutput.map(({ message }: { message: string }) => message),
|
||||
|
||||
@@ -26,7 +26,7 @@ export const useAsyncPolling = (
|
||||
|
||||
while (isPolling.current === true) {
|
||||
await pollingFn();
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export function useSettings() {
|
||||
if (
|
||||
stateFromStorage &&
|
||||
Object.keys(stateFromStorage).some(
|
||||
(k) => (settings as any)[k] !== stateFromStorage[k],
|
||||
k => (settings as any)[k] !== stateFromStorage[k],
|
||||
)
|
||||
)
|
||||
dispatch({
|
||||
|
||||
@@ -22,7 +22,7 @@ const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003;
|
||||
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
|
||||
const logger = getRootLogger();
|
||||
|
||||
startStandaloneServer({ port, enableCors, logger }).catch((err) => {
|
||||
startStandaloneServer({ port, enableCors, logger }).catch(err => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('AuditView', () => {
|
||||
apis = ApiRegistry.from([
|
||||
[lighthouseApiRef, new LighthouseRestApi('https://lighthouse')],
|
||||
]);
|
||||
id = websiteResponse.audits.find((a) => a.status === 'COMPLETED')
|
||||
id = websiteResponse.audits.find(a => a.status === 'COMPLETED')
|
||||
?.id as string;
|
||||
useParams.mockReturnValue({ id });
|
||||
});
|
||||
@@ -101,7 +101,7 @@ describe('AuditView', () => {
|
||||
|
||||
await rendered.findByTestId('audit-sidebar');
|
||||
|
||||
websiteResponse.audits.forEach((a) => {
|
||||
websiteResponse.audits.forEach(a => {
|
||||
expect(
|
||||
rendered.queryByText(formatTime(a.timeCreated)),
|
||||
).toBeInTheDocument();
|
||||
@@ -119,14 +119,14 @@ describe('AuditView', () => {
|
||||
|
||||
await rendered.findByTestId('audit-sidebar');
|
||||
|
||||
const audit = websiteResponse.audits.find((a) => a.id === id) as Audit;
|
||||
const audit = websiteResponse.audits.find(a => a.id === id) as Audit;
|
||||
const auditElement = rendered.getByText(formatTime(audit.timeCreated));
|
||||
expect(auditElement.parentElement?.parentElement?.className).toContain(
|
||||
'selected',
|
||||
);
|
||||
|
||||
const notSelectedAudit = websiteResponse.audits.find(
|
||||
(a) => a.id !== id,
|
||||
a => a.id !== id,
|
||||
) as Audit;
|
||||
const notSelectedAuditElement = rendered.getByText(
|
||||
formatTime(notSelectedAudit.timeCreated),
|
||||
@@ -147,7 +147,7 @@ describe('AuditView', () => {
|
||||
|
||||
await rendered.findByTestId('audit-sidebar');
|
||||
|
||||
websiteResponse.audits.forEach((a) => {
|
||||
websiteResponse.audits.forEach(a => {
|
||||
expect(
|
||||
rendered.getByText(formatTime(a.timeCreated)).parentElement
|
||||
?.parentElement,
|
||||
@@ -186,7 +186,7 @@ describe('AuditView', () => {
|
||||
|
||||
describe.skip('when a loading audit is accessed', () => {
|
||||
it('shows a loading view', async () => {
|
||||
id = websiteResponse.audits.find((a) => a.status === 'RUNNING')
|
||||
id = websiteResponse.audits.find(a => a.status === 'RUNNING')
|
||||
?.id as string;
|
||||
useParams.mockReturnValueOnce({ id });
|
||||
|
||||
@@ -206,7 +206,7 @@ describe('AuditView', () => {
|
||||
|
||||
describe.skip('when a failed audit is accessed', () => {
|
||||
it('shows an error message', async () => {
|
||||
id = websiteResponse.audits.find((a) => a.status === 'FAILED')
|
||||
id = websiteResponse.audits.find(a => a.status === 'FAILED')
|
||||
?.id as string;
|
||||
useParams.mockReturnValueOnce({ id });
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('CreateAudit', () => {
|
||||
let errorApi: ErrorApi;
|
||||
|
||||
beforeEach(() => {
|
||||
errorApi = { post: jest.fn() };
|
||||
errorApi = { post: jest.fn(), error$: jest.fn() };
|
||||
apis = ApiRegistry.from([
|
||||
[lighthouseApiRef, new LighthouseRestApi('http://lighthouse')],
|
||||
[errorApiRef, errorApi],
|
||||
@@ -165,7 +165,7 @@ describe('CreateAudit', () => {
|
||||
fireEvent.click(rendered.getByText(/Create Audit/));
|
||||
|
||||
await wait(() => expect(rendered.getByLabelText(/URL/)).toBeEnabled());
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
|
||||
expect(errorApi.post).toHaveBeenCalledWith(expect.any(Error));
|
||||
});
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ import { BackstageTheme } from '@backstage/theme';
|
||||
import { Progress } from '@backstage/core';
|
||||
import { ComponentIdValidators } from '../../util/validate';
|
||||
|
||||
const useStyles = makeStyles<BackstageTheme>((theme) => ({
|
||||
const useStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
form: {
|
||||
alignItems: 'flex-start',
|
||||
display: 'flex',
|
||||
|
||||
@@ -40,6 +40,7 @@ const ScaffolderPage: React.FC<{}> = () => {
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Header
|
||||
pageTitleOverride="Create a new component"
|
||||
title={
|
||||
<>
|
||||
Create a new component <Lifecycle alpha shorthand />{' '}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# sentry-backend
|
||||
|
||||
Simple plugin forwarding requests to [Sentry](https://sentry.io) API.
|
||||
Simple plugin forwarding requests to [Sentry](https://sentry.io) API.
|
||||
|
||||
@@ -22,7 +22,7 @@ import { BackstageTheme } from '@backstage/theme';
|
||||
function stripText(text: string, maxLength: number) {
|
||||
return text.length > maxLength ? `${text.substr(0, maxLength)}...` : text;
|
||||
}
|
||||
const useStyles = makeStyles<BackstageTheme>((theme) => ({
|
||||
const useStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
root: {
|
||||
minWidth: 260,
|
||||
position: 'relative',
|
||||
|
||||
@@ -24,16 +24,16 @@ import { ErrorGraph } from '../ErrorGraph/ErrorGraph';
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Error',
|
||||
render: (data) => <ErrorCell sentryIssue={data as SentryIssue} />,
|
||||
render: data => <ErrorCell sentryIssue={data as SentryIssue} />,
|
||||
},
|
||||
{
|
||||
title: 'Graph',
|
||||
render: (data) => <ErrorGraph sentryIssue={data as SentryIssue} />,
|
||||
render: data => <ErrorGraph sentryIssue={data as SentryIssue} />,
|
||||
},
|
||||
{
|
||||
title: 'First seen',
|
||||
field: 'firstSeen',
|
||||
render: (data) => {
|
||||
render: data => {
|
||||
const { firstSeen } = data as SentryIssue;
|
||||
return format(firstSeen);
|
||||
},
|
||||
@@ -41,7 +41,7 @@ const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Last seen',
|
||||
field: 'lastSeen',
|
||||
render: (data) => {
|
||||
render: data => {
|
||||
const { lastSeen } = data as SentryIssue;
|
||||
return format(lastSeen);
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@ function getMockIssues(number: number): SentryIssue[] {
|
||||
}
|
||||
export class MockSentryApi implements SentryApi {
|
||||
fetchIssues(): Promise<SentryIssue[]> {
|
||||
return new Promise((resolve) => {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(() => resolve(getMockIssues(14)), 800);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
"main:src": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "backstage-cli plugin:build",
|
||||
"lint": "backstage-cli lint",
|
||||
|
||||
@@ -105,14 +105,14 @@ export default class Radar extends React.Component {
|
||||
static adjustEntries(entries, activeEntry, quadrants, rings, radius) {
|
||||
let seed = 42;
|
||||
entries.forEach((entry, idx) => {
|
||||
const quadrant = quadrants.find((q) => {
|
||||
const quadrant = quadrants.find(q => {
|
||||
const match =
|
||||
typeof entry.quadrant === 'object'
|
||||
? entry.quadrant.id
|
||||
: entry.quadrant;
|
||||
return q.id === match;
|
||||
});
|
||||
const ring = rings.find((r) => {
|
||||
const ring = rings.find(r => {
|
||||
const match =
|
||||
typeof entry.ring === 'object' ? entry.ring.id : entry.ring;
|
||||
return r.id === match;
|
||||
@@ -190,7 +190,7 @@ export default class Radar extends React.Component {
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={(node) => {
|
||||
ref={node => {
|
||||
this.node = node;
|
||||
}}
|
||||
width={width}
|
||||
@@ -205,7 +205,7 @@ export default class Radar extends React.Component {
|
||||
quadrants={quadrants}
|
||||
rings={rings}
|
||||
activeEntry={activeEntry}
|
||||
onEntryMouseEnter={(entry) => this._setActiveEntry(entry)}
|
||||
onEntryMouseEnter={entry => this._setActiveEntry(entry)}
|
||||
onEntryMouseLeave={() => this._clearActiveEntry()}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
@@ -49,16 +49,16 @@ class RadarBubble extends React.PureComponent {
|
||||
this._updatePosition();
|
||||
}
|
||||
|
||||
_setRect = (rect) => {
|
||||
_setRect = rect => {
|
||||
this.rect = rect;
|
||||
};
|
||||
_setNode = (node) => {
|
||||
_setNode = node => {
|
||||
this.node = node;
|
||||
};
|
||||
_setText = (text) => {
|
||||
_setText = text => {
|
||||
this.text = text;
|
||||
};
|
||||
_setPath = (path) => {
|
||||
_setPath = path => {
|
||||
this.path = path;
|
||||
};
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ class RadarGrid extends React.PureComponent {
|
||||
/>,
|
||||
];
|
||||
|
||||
const ringNodes = rings.map((r) => r.outerRadius).map(makeRingNode);
|
||||
const ringNodes = rings.map(r => r.outerRadius).map(makeRingNode);
|
||||
|
||||
return axisNodes.concat(ringNodes);
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ class RadarLegend extends React.PureComponent {
|
||||
<div className={classes.quadrant}>
|
||||
<h2 className={classes.quadrantHeading}>{quadrant.name}</h2>
|
||||
<div className={classes.rings}>
|
||||
{rings.map((ring) =>
|
||||
{rings.map(ring =>
|
||||
RadarLegend._renderRing(
|
||||
ring,
|
||||
RadarLegend._getSegment(segments, quadrant, ring),
|
||||
@@ -117,7 +117,7 @@ class RadarLegend extends React.PureComponent {
|
||||
<p>(empty)</p>
|
||||
) : (
|
||||
<ol className={classes.ringList}>
|
||||
{entries.map((entry) => {
|
||||
{entries.map(entry => {
|
||||
let node = <span className={classes.entry}>{entry.title}</span>;
|
||||
|
||||
if (entry.url) {
|
||||
@@ -175,7 +175,7 @@ class RadarLegend extends React.PureComponent {
|
||||
|
||||
return (
|
||||
<g>
|
||||
{quadrants.map((quadrant) =>
|
||||
{quadrants.map(quadrant =>
|
||||
RadarLegend._renderQuadrant(
|
||||
segments,
|
||||
quadrant,
|
||||
|
||||
@@ -46,16 +46,16 @@ export default class RadarPlot extends React.PureComponent {
|
||||
rings={rings}
|
||||
entries={entries}
|
||||
onEntryMouseEnter={
|
||||
onEntryMouseEnter && ((entry) => onEntryMouseEnter(entry))
|
||||
onEntryMouseEnter && (entry => onEntryMouseEnter(entry))
|
||||
}
|
||||
onEntryMouseLeave={
|
||||
onEntryMouseLeave && ((entry) => onEntryMouseLeave(entry))
|
||||
onEntryMouseLeave && (entry => onEntryMouseLeave(entry))
|
||||
}
|
||||
/>
|
||||
<g transform={`translate(${width / 2}, ${height / 2})`}>
|
||||
<RadarGrid radius={radius} rings={rings} />
|
||||
<RadarFooter x={-0.5 * width} y={0.5 * height} />
|
||||
{entries.map((entry) => (
|
||||
{entries.map(entry => (
|
||||
<RadarEntry
|
||||
key={entry.id}
|
||||
x={entry.x}
|
||||
|
||||
@@ -20,7 +20,7 @@ import WelcomePage from './components/WelcomePage';
|
||||
export const plugin = createPlugin({
|
||||
id: 'welcome',
|
||||
register({ router, featureFlags }) {
|
||||
router.registerRoute('/', WelcomePage);
|
||||
router.registerRoute('/welcome', WelcomePage);
|
||||
|
||||
featureFlags.register('enable-welcome-box');
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user