Merge remote-tracking branch 'origin' into ndudnik/unregister-component
This commit is contained in:
@@ -34,7 +34,9 @@
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-saml": "^1.3.3",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
"yn": "^4.0.0",
|
||||
"jwt-decode": "2.2.0",
|
||||
"@types/jwt-decode": "2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.6",
|
||||
|
||||
@@ -143,6 +143,14 @@ describe('PassportStrategyHelper', () => {
|
||||
class MyCustomRefreshTokenSuccess extends passport.Strategy {
|
||||
// @ts-ignore
|
||||
private _oauth2 = new MyCustomOAuth2Success();
|
||||
userProfile(_accessToken: string, callback: Function) {
|
||||
callback(null, {
|
||||
provider: 'a',
|
||||
email: 'b',
|
||||
name: 'c',
|
||||
picture: 'd',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const mockStrategy = new MyCustomRefreshTokenSuccess();
|
||||
|
||||
@@ -16,7 +16,43 @@
|
||||
|
||||
import express from 'express';
|
||||
import passport from 'passport';
|
||||
import { RedirectInfo, RefreshTokenResponse } from './types';
|
||||
import jwtDecoder from 'jwt-decode';
|
||||
import { RedirectInfo, RefreshTokenResponse, ProfileInfo } from './types';
|
||||
|
||||
export const makeProfileInfo = (
|
||||
profile: passport.Profile,
|
||||
params: any,
|
||||
): ProfileInfo => {
|
||||
const { provider, displayName: name } = profile;
|
||||
|
||||
let email = '';
|
||||
if (profile.emails) {
|
||||
const [firstEmail] = profile.emails;
|
||||
email = firstEmail.value;
|
||||
}
|
||||
|
||||
if (!email && params.id_token) {
|
||||
try {
|
||||
const decoded: { email: string } = jwtDecoder(params.id_token);
|
||||
email = decoded.email;
|
||||
} catch (e) {
|
||||
console.error('Failed to parse id token and get profile info');
|
||||
}
|
||||
}
|
||||
|
||||
let picture = '';
|
||||
if (profile.photos) {
|
||||
const [firstPhoto] = profile.photos;
|
||||
picture = firstPhoto.value;
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
name,
|
||||
email,
|
||||
picture,
|
||||
};
|
||||
};
|
||||
|
||||
export const executeRedirectStrategy = async (
|
||||
req: express.Request,
|
||||
@@ -98,6 +134,7 @@ export const executeRefreshTokenStrategy = async (
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
resolve({
|
||||
accessToken,
|
||||
params,
|
||||
@@ -106,3 +143,24 @@ export const executeRefreshTokenStrategy = async (
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const executeFetchUserProfileStrategy = async (
|
||||
providerstrategy: passport.Strategy,
|
||||
accessToken: string,
|
||||
params: any,
|
||||
): Promise<ProfileInfo> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const anyStrategy = providerstrategy as any;
|
||||
anyStrategy.userProfile(
|
||||
accessToken,
|
||||
(error: Error, passportProfile: passport.Profile) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
}
|
||||
|
||||
const profile = makeProfileInfo(passportProfile, params);
|
||||
resolve(profile);
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
executeFrameHandlerStrategy,
|
||||
executeRedirectStrategy,
|
||||
executeRefreshTokenStrategy,
|
||||
makeProfileInfo,
|
||||
executeFetchUserProfileStrategy,
|
||||
} from '../PassportStrategyHelper';
|
||||
import {
|
||||
OAuthProviderHandlers,
|
||||
@@ -27,8 +29,10 @@ import {
|
||||
AuthInfoPrivate,
|
||||
RedirectInfo,
|
||||
AuthProviderConfig,
|
||||
AuthInfoWithProfile,
|
||||
} from '../types';
|
||||
import { OAuthProvider } from '../OAuthProvider';
|
||||
import passport from 'passport';
|
||||
|
||||
export class GoogleAuthProvider implements OAuthProviderHandlers {
|
||||
private readonly providerConfig: AuthProviderConfig;
|
||||
@@ -43,13 +47,14 @@ export class GoogleAuthProvider implements OAuthProviderHandlers {
|
||||
accessToken: any,
|
||||
refreshToken: any,
|
||||
params: any,
|
||||
profile: any,
|
||||
profile: passport.Profile,
|
||||
done: any,
|
||||
) => {
|
||||
const profileInfo = makeProfileInfo(profile, params);
|
||||
done(
|
||||
undefined,
|
||||
{
|
||||
profile,
|
||||
profile: profileInfo,
|
||||
idToken: params.id_token,
|
||||
accessToken,
|
||||
scope: params.scope,
|
||||
@@ -73,18 +78,28 @@ export class GoogleAuthProvider implements OAuthProviderHandlers {
|
||||
return await executeFrameHandlerStrategy(req, this._strategy);
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string, scope: string): Promise<AuthInfoBase> {
|
||||
async refresh(
|
||||
refreshToken: string,
|
||||
scope: string,
|
||||
): Promise<AuthInfoWithProfile> {
|
||||
const { accessToken, params } = await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
refreshToken,
|
||||
scope,
|
||||
);
|
||||
|
||||
const profile = await executeFetchUserProfileStrategy(
|
||||
this._strategy,
|
||||
accessToken,
|
||||
params,
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
idToken: params.id_token,
|
||||
expiresInSeconds: params.expires_in,
|
||||
scope: params.scope,
|
||||
profile,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import passport from 'passport';
|
||||
|
||||
export type AuthProviderConfig = {
|
||||
provider: string;
|
||||
@@ -49,7 +48,14 @@ export type AuthInfoBase = {
|
||||
};
|
||||
|
||||
export type AuthInfoWithProfile = AuthInfoBase & {
|
||||
profile: passport.Profile;
|
||||
profile:
|
||||
| {
|
||||
provider: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export type AuthInfoPrivate = {
|
||||
@@ -71,6 +77,13 @@ export type RedirectInfo = {
|
||||
status?: number;
|
||||
};
|
||||
|
||||
export type ProfileInfo = {
|
||||
provider: string;
|
||||
email: string;
|
||||
name: string;
|
||||
picture: string;
|
||||
};
|
||||
|
||||
export type RefreshTokenResponse = {
|
||||
accessToken: string;
|
||||
params: any;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
curl \
|
||||
--location \
|
||||
--request POST 'localhost:3003/locations' \
|
||||
--request POST 'localhost:7000/catalog/locations' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw '{
|
||||
"type": "github",
|
||||
|
||||
@@ -18,7 +18,7 @@ import { getRootLogger } from '@backstage/backend-common';
|
||||
import yn from 'yn';
|
||||
import { startStandaloneServer } from './service/standaloneServer';
|
||||
|
||||
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003;
|
||||
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000;
|
||||
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
|
||||
const logger = getRootLogger();
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function createStandaloneApplication(
|
||||
app.use(express.json());
|
||||
app.use(requestLoggingHandler());
|
||||
app.use(
|
||||
'/',
|
||||
'/catalog',
|
||||
await createRouter({
|
||||
entitiesCatalog,
|
||||
locationsCatalog,
|
||||
|
||||
@@ -17,15 +17,18 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/core": "^0.1.1-alpha.6",
|
||||
"@backstage/theme": "^0.1.1-alpha.6",
|
||||
"@backstage/catalog-model": "^0.1.1-alpha.6",
|
||||
"@backstage/core": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-scaffolder": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-sentry": "^0.1.1-alpha.6",
|
||||
"@backstage/theme": "^0.1.1-alpha.6",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"@backstage/plugin-sentry": "^0.1.1-alpha.6",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router": "^5.2.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -63,6 +63,34 @@ export class CatalogClient implements CatalogApi {
|
||||
if (entity) return entity;
|
||||
throw new Error(`'Entity not found: ${name}`);
|
||||
}
|
||||
|
||||
async addLocation(type: string, target: string) {
|
||||
const response = await fetch(
|
||||
`${this.apiOrigin}${this.basePath}/locations`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type, target }),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status !== 201) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
const { location, entities } = await response.json();
|
||||
|
||||
if (!location || entities.length === 0)
|
||||
throw new Error(`Location wasn't added: ${target}`);
|
||||
|
||||
return {
|
||||
location,
|
||||
entities,
|
||||
};
|
||||
}
|
||||
|
||||
async getLocationByEntity(entity: Entity): Promise<Location | undefined> {
|
||||
const locationId = entity.metadata.annotations?.[LOCATION_ANNOTATION];
|
||||
if (!locationId) return undefined;
|
||||
|
||||
@@ -27,5 +27,8 @@ export interface CatalogApi {
|
||||
getEntities(): Promise<Entity[]>;
|
||||
getEntityByName(name: string): Promise<Entity>;
|
||||
getEntitiesByLocationId(id: string): Promise<Entity[]>;
|
||||
addLocation(type: string, target: string): Promise<AddLocationResponse>;
|
||||
getLocationByEntity(entity: Entity): Promise<Location | undefined>;
|
||||
}
|
||||
|
||||
export type AddLocationResponse = { location: Location; entities: Entity[] };
|
||||
|
||||
@@ -20,11 +20,23 @@ import CatalogPage from './CatalogPage';
|
||||
import { ApiRegistry, ApiProvider, errorApiRef } from '@backstage/core';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { catalogApiRef } from '../..';
|
||||
import { CatalogApi } from '../../api/types';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
const errorApi = { post: () => {} };
|
||||
const catalogApi = {
|
||||
getEntities: () => Promise.resolve([{ kind: '', metadata: {} }]),
|
||||
getLocationByEntity: () => Promise.resolve({ data: {} }),
|
||||
const catalogApi: Partial<CatalogApi> = {
|
||||
getEntities: () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
metadata: {
|
||||
name: 'Entity1',
|
||||
},
|
||||
apiVersion: 'backstage.io/v1beta1',
|
||||
kind: 'Component',
|
||||
},
|
||||
] as Entity[]),
|
||||
getLocationByEntity: () =>
|
||||
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
|
||||
};
|
||||
|
||||
describe('CatalogPage', () => {
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
} from '../CatalogFilter/CatalogFilter';
|
||||
import { Button, makeStyles, Typography, Link } from '@material-ui/core';
|
||||
import { filterGroups, defaultFilter } from '../../data/filters';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
|
||||
import GitHub from '@material-ui/icons/GitHub';
|
||||
import {
|
||||
Entity,
|
||||
@@ -51,7 +53,7 @@ const useStyles = makeStyles(theme => ({
|
||||
}));
|
||||
|
||||
import { catalogApiRef } from '../..';
|
||||
import { envelopeToComponent, findLocationForEntity } from '../../data/utils';
|
||||
import { entityToComponent, findLocationForEntity } from '../../data/utils';
|
||||
import { Component } from '../../data/component';
|
||||
|
||||
const CatalogPage: FC<{}> = () => {
|
||||
@@ -111,18 +113,23 @@ const CatalogPage: FC<{}> = () => {
|
||||
<Typography>
|
||||
<span role="img" aria-label="wave" style={{ fontSize: '125%' }}>
|
||||
👋🏼
|
||||
</span>{' '}
|
||||
</span>
|
||||
Welcome to Backstage, we are happy to have you. Start by checking
|
||||
out our{' '}
|
||||
out our
|
||||
<Link href="/welcome" color="textSecondary">
|
||||
getting started
|
||||
</Link>{' '}
|
||||
</Link>
|
||||
page.
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<ContentHeader title="Services">
|
||||
<Button variant="contained" color="primary" href="/create">
|
||||
<Button
|
||||
component={RouterLink}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
to={scaffolderRootRoute.path}
|
||||
>
|
||||
Create Service
|
||||
</Button>
|
||||
<SupportButton>All your components</SupportButton>
|
||||
@@ -142,7 +149,7 @@ const CatalogPage: FC<{}> = () => {
|
||||
(value &&
|
||||
value.map(val => {
|
||||
return {
|
||||
...envelopeToComponent(val),
|
||||
...entityToComponent(val),
|
||||
location: findLocationForEntity(val, locations),
|
||||
};
|
||||
})) ||
|
||||
|
||||
@@ -17,6 +17,8 @@ import React, { FC } from 'react';
|
||||
import { Component } from '../../data/component';
|
||||
import { InfoCard, Progress, Table, TableColumn } from '@backstage/core';
|
||||
import { Typography, Link } from '@material-ui/core';
|
||||
import { Link as RouterLink, generatePath } from 'react-router-dom';
|
||||
import { entityRoute } from '../../routes';
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
@@ -24,7 +26,12 @@ const columns: TableColumn[] = [
|
||||
field: 'name',
|
||||
highlight: true,
|
||||
render: (componentData: any) => (
|
||||
<Link href={`/catalog/${componentData.name}`}>{componentData.name}</Link>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={generatePath(entityRoute.path, { name: componentData.name })}
|
||||
>
|
||||
{componentData.name}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -30,7 +30,7 @@ import ComponentRemovalDialog from '../ComponentRemovalDialog/ComponentRemovalDi
|
||||
import { SentryIssuesWidget } from '@backstage/plugin-sentry';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import { catalogApiRef } from '../..';
|
||||
import { envelopeToComponent as entityToComponent } from '../../data/utils';
|
||||
import { entityToComponent } from '../../data/utils';
|
||||
import { Component } from '../../data/component';
|
||||
|
||||
const REDIRECT_DELAY = 1000;
|
||||
|
||||
@@ -14,11 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Location } from '@backstage/catalog-model';
|
||||
|
||||
export type Component = {
|
||||
name: string;
|
||||
kind: string;
|
||||
description: string;
|
||||
description: React.ReactNode;
|
||||
location?: Location;
|
||||
};
|
||||
|
||||
@@ -1,36 +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 { Component } from './component';
|
||||
import {
|
||||
Entity,
|
||||
Location,
|
||||
LOCATION_ANNOTATION,
|
||||
} from '@backstage/catalog-model';
|
||||
|
||||
export const envelopeToComponent = (envelope: Entity): Component => {
|
||||
return {
|
||||
name: envelope.metadata?.name ?? '',
|
||||
kind: envelope.kind ?? 'unknown',
|
||||
description: envelope.metadata?.annotations?.description ?? 'placeholder',
|
||||
};
|
||||
};
|
||||
export const findLocationForEntity = (
|
||||
entity: Entity,
|
||||
locations: Location[],
|
||||
): Location | undefined => {
|
||||
const entityLocationId = entity.metadata.annotations?.[LOCATION_ANNOTATION];
|
||||
return locations.find(location => location.id === entityLocationId);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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 { Component } from './component';
|
||||
import { Entity, Location } from '@backstage/catalog-model';
|
||||
import Edit from '@material-ui/icons/Edit';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import { styled } from '@material-ui/core/styles';
|
||||
import { LOCATION_ANNOTATION } from '../../../../packages/catalog-model/src/location/annotation';
|
||||
|
||||
const DescriptionWrapper = styled('span')({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const createEditLink = (url: string): string => url.replace('blob', 'edit');
|
||||
|
||||
export function entityToComponent(
|
||||
envelope: Entity,
|
||||
location?: Location,
|
||||
): Component {
|
||||
return {
|
||||
name: envelope.metadata?.name ?? '',
|
||||
kind: envelope.kind ?? 'unknown',
|
||||
description: (
|
||||
<DescriptionWrapper>
|
||||
{envelope.metadata?.annotations?.description ?? 'placeholder'}
|
||||
{location?.target ? (
|
||||
<a href={createEditLink(location?.target)}>
|
||||
<IconButton size="small">
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
</a>
|
||||
) : null}
|
||||
</DescriptionWrapper>
|
||||
),
|
||||
location,
|
||||
};
|
||||
}
|
||||
|
||||
export function findLocationForEntity(
|
||||
entity: Entity,
|
||||
locations: Location[],
|
||||
): Location | undefined {
|
||||
for (const loc of locations) {
|
||||
if (loc.id === entity.metadata.annotations?.[LOCATION_ANNOTATION]) {
|
||||
return loc;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -18,3 +18,4 @@ export { plugin } from './plugin';
|
||||
export * from './api/CatalogClient';
|
||||
export * from './api/types';
|
||||
export * from './types';
|
||||
export * from './routes';
|
||||
|
||||
@@ -17,11 +17,12 @@
|
||||
import { createPlugin } from '@backstage/core';
|
||||
import CatalogPage from './components/CatalogPage';
|
||||
import ComponentPage from './components/ComponentPage/ComponentPage';
|
||||
import { rootRoute, entityRoute } from './routes';
|
||||
|
||||
export const plugin = createPlugin({
|
||||
id: 'catalog',
|
||||
register({ router }) {
|
||||
router.registerRoute('/', CatalogPage);
|
||||
router.registerRoute('/catalog/:name/', ComponentPage);
|
||||
router.addRoute(rootRoute, CatalogPage);
|
||||
router.addRoute(entityRoute, ComponentPage);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 rootRoute = createRouteRef({
|
||||
icon: NoIcon,
|
||||
path: '/',
|
||||
title: 'Catalog',
|
||||
});
|
||||
export const entityRoute = createRouteRef({
|
||||
icon: NoIcon,
|
||||
path: '/catalog/:name/',
|
||||
title: 'Entity',
|
||||
});
|
||||
@@ -18,6 +18,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/core": "^0.1.1-alpha.6",
|
||||
"@backstage/catalog-model": "^0.1.1-alpha.6",
|
||||
"@backstage/plugin-catalog": "^0.1.1-alpha.6",
|
||||
"@backstage/theme": "^0.1.1-alpha.6",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
@@ -25,6 +27,8 @@
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-hook-form": "^5.7.2",
|
||||
"react-router": "^5.2.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+30
-24
@@ -15,17 +15,29 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import RegisterComponentForm from './RegisterComponentForm';
|
||||
import { render, fireEvent, cleanup } from '@testing-library/react';
|
||||
import RegisterComponentForm, { Props } from './RegisterComponentForm';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
const setup = (props?: Partial<Props>) => {
|
||||
return {
|
||||
rendered: render(
|
||||
<RegisterComponentForm
|
||||
onSubmit={jest.fn()}
|
||||
submitting={false}
|
||||
{...props}
|
||||
/>,
|
||||
),
|
||||
};
|
||||
};
|
||||
describe('RegisterComponentForm', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('should initially render a disabled button', async () => {
|
||||
const rendered = render(
|
||||
<RegisterComponentForm onSubmit={jest.fn()} submitting={false} />,
|
||||
);
|
||||
const { rendered } = setup();
|
||||
expect(
|
||||
await rendered.findByText(
|
||||
'Enter the full path to the service-info.yaml file in GHE to start tracking your component. It must be in a public repo, on the master branch.',
|
||||
'Enter the full path to the service-info.yaml file in GitHub to start tracking your component. It must be in a public repo.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
@@ -34,27 +46,21 @@ describe('RegisterComponentForm', () => {
|
||||
});
|
||||
|
||||
it('should enable a submit form when data when component url is set ', async () => {
|
||||
const rendered = render(
|
||||
<RegisterComponentForm onSubmit={jest.fn()} submitting={false} />,
|
||||
);
|
||||
const { rendered } = setup();
|
||||
const input = (await rendered.getByRole('textbox')) as HTMLInputElement;
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'https://example.com/blob/master/service.yaml' },
|
||||
await act(async () => {
|
||||
// react-hook-form uses `input` event for changes
|
||||
fireEvent.input(input, {
|
||||
target: { value: 'https://example.com/blob/master/service.yaml' },
|
||||
});
|
||||
});
|
||||
const submit = (await rendered.findByText('Submit')) as HTMLButtonElement;
|
||||
const submit = (await rendered.getByRole('button')) as HTMLButtonElement;
|
||||
|
||||
expect(submit.disabled).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should hide input on submission ', async () => {
|
||||
const rendered = render(
|
||||
<RegisterComponentForm onSubmit={jest.fn()} submitting />,
|
||||
);
|
||||
|
||||
expect(
|
||||
await rendered.findByText(
|
||||
'Your component is being registered. Please wait.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show spinner while submitting', async () => {
|
||||
const { rendered } = setup({ submitting: true });
|
||||
expect(rendered.getByTestId('loading-progress')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
+15
-24
@@ -20,12 +20,11 @@ import {
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
TextField,
|
||||
Typography,
|
||||
LinearProgress,
|
||||
} from '@material-ui/core';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { Progress } from '@backstage/core';
|
||||
import { ComponentIdValidators } from '../../util/validate';
|
||||
|
||||
const useStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
@@ -39,57 +38,49 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
type RegisterComponentProps = {
|
||||
onSubmit: () => any;
|
||||
export type Props = {
|
||||
onSubmit: (formData: Record<string, string>) => Promise<void>;
|
||||
submitting: boolean;
|
||||
};
|
||||
|
||||
const RegisterComponentForm: FC<RegisterComponentProps> = ({
|
||||
onSubmit,
|
||||
submitting,
|
||||
}) => {
|
||||
const RegisterComponentForm: FC<Props> = ({ onSubmit, submitting }) => {
|
||||
const { register, handleSubmit, errors, formState } = useForm({
|
||||
mode: 'onChange',
|
||||
});
|
||||
const classes = useStyles();
|
||||
const hasErrors = !!errors.componentIdInput;
|
||||
const hasErrors = !!errors.componentLocation;
|
||||
const dirty = formState?.dirty;
|
||||
if (submitting) {
|
||||
return (
|
||||
<>
|
||||
<Typography variant="subtitle1" paragraph>
|
||||
Your component is being registered. Please wait.
|
||||
</Typography>
|
||||
<Progress />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
|
||||
return submitting ? (
|
||||
<LinearProgress data-testid="loading-progress" />
|
||||
) : (
|
||||
<form
|
||||
autoComplete="off"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className={classes.form}
|
||||
data-testid="register-form"
|
||||
>
|
||||
<FormControl>
|
||||
<TextField
|
||||
id="registerComponentInput"
|
||||
variant="outlined"
|
||||
label="Component service file URL"
|
||||
data-testid="componentLocationInput"
|
||||
error={hasErrors}
|
||||
placeholder="https://example.com/user/some-service/blob/master/service-info.yaml"
|
||||
name="componentIdInput"
|
||||
name="componentLocation"
|
||||
required
|
||||
margin="normal"
|
||||
helperText="Enter the full path to the service-info.yaml file in GHE to start tracking your component. It must be in a public repo, on the master branch."
|
||||
helperText="Enter the full path to the service-info.yaml file in GitHub to start tracking your component. It must be in a public repo."
|
||||
inputRef={register({
|
||||
required: true,
|
||||
validate: ComponentIdValidators,
|
||||
})}
|
||||
/>
|
||||
|
||||
{errors.componentIdInput && (
|
||||
{errors.componentLocation && (
|
||||
<FormHelperText error={hasErrors} id="register-component-helper-text">
|
||||
{errors.componentIdInput.message}
|
||||
{errors.componentLocation.message}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
+35
-9
@@ -15,20 +15,46 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import mockFetch from 'jest-fetch-mock';
|
||||
import { render, cleanup } from '@testing-library/react';
|
||||
import RegisterComponentPage from './RegisterComponentPage';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { errorApiRef, ApiProvider, ApiRegistry } from '@backstage/core';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
const errorApi = { post: () => {} };
|
||||
const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
||||
addLocation: jest.fn((_a, _b) => new Promise(() => {})),
|
||||
getEntities: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
getLocationByEntity: jest.fn(),
|
||||
};
|
||||
|
||||
const setup = () => ({
|
||||
rendered: render(
|
||||
<MemoryRouter>
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([
|
||||
[errorApiRef, errorApi],
|
||||
[catalogApiRef, catalogApi],
|
||||
])}
|
||||
>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<RegisterComponentPage />
|
||||
</ThemeProvider>
|
||||
</ApiProvider>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
});
|
||||
describe('RegisterComponentPage', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('should render', () => {
|
||||
mockFetch.mockResponse(() => new Promise(() => {}));
|
||||
const rendered = render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<RegisterComponentPage />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(rendered.getByText('Register Component')).toBeInTheDocument();
|
||||
const { rendered } = setup();
|
||||
expect(
|
||||
rendered.getByText('Register existing component'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+76
-19
@@ -14,50 +14,107 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, useEffect, useState } from 'react';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import React, { FC, useState } from 'react';
|
||||
import { Grid, makeStyles } from '@material-ui/core';
|
||||
import {
|
||||
InfoCard,
|
||||
Page,
|
||||
pageTheme,
|
||||
Content,
|
||||
ContentHeader,
|
||||
SupportButton,
|
||||
useApi,
|
||||
errorApiRef,
|
||||
Header,
|
||||
} from '@backstage/core';
|
||||
import RegisterComponentForm from '../RegisterComponentForm';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog';
|
||||
import { useMountedState } from 'react-use';
|
||||
import { Entity, Location } from '@backstage/catalog-model';
|
||||
import { RegisterComponentResultDialog } from '../RegisterComponentResultDialog';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
dialogPaper: {
|
||||
minHeight: 250,
|
||||
minWidth: 600,
|
||||
},
|
||||
icon: {
|
||||
width: 20,
|
||||
marginRight: theme.spacing(1),
|
||||
},
|
||||
contentText: {
|
||||
paddingBottom: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
const FormStates = {
|
||||
Idle: 'idle',
|
||||
Success: 'success',
|
||||
Submitting: 'submitting',
|
||||
} as const;
|
||||
|
||||
type ValuesOf<T> = T extends Record<any, infer V> ? V : never;
|
||||
const RegisterComponentPage: FC<{}> = () => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const classes = useStyles();
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const [formState, setFormState] = useState<ValuesOf<typeof FormStates>>(
|
||||
FormStates.Idle,
|
||||
);
|
||||
const isMounted = useMountedState();
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting) {
|
||||
setTimeout(() => {
|
||||
setIsSubmitting(false);
|
||||
}, 4000);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
|
||||
const [result, setResult] = useState<{
|
||||
data: {
|
||||
entities: Entity[];
|
||||
location: Location;
|
||||
} | null;
|
||||
error: null | Error;
|
||||
}>({
|
||||
data: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const handleSubmit = async (formData: Record<string, string>) => {
|
||||
setFormState(FormStates.Submitting);
|
||||
const { componentLocation: target } = formData;
|
||||
try {
|
||||
const data = await catalogApi.addLocation('github', target);
|
||||
|
||||
if (!isMounted()) return;
|
||||
|
||||
setResult({ error: null, data });
|
||||
setFormState(FormStates.Success);
|
||||
} catch (e) {
|
||||
errorApi.post(e);
|
||||
|
||||
if (!isMounted()) return;
|
||||
|
||||
setResult({ error: e, data: null });
|
||||
setFormState(FormStates.Idle);
|
||||
}
|
||||
}, [isSubmitting]);
|
||||
|
||||
const onSubmit = () => {
|
||||
setIsSubmitting(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Header title="Register existing component" />
|
||||
<Content>
|
||||
<ContentHeader title="Register Component">
|
||||
<SupportButton>Documentation</SupportButton>
|
||||
</ContentHeader>
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<InfoCard title="Start tracking your component in Backstage">
|
||||
<RegisterComponentForm
|
||||
onSubmit={onSubmit}
|
||||
submitting={isSubmitting}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={formState === FormStates.Submitting}
|
||||
/>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
{formState === FormStates.Success && (
|
||||
<RegisterComponentResultDialog
|
||||
entities={result.data!.entities}
|
||||
onClose={() => setFormState(FormStates.Idle)}
|
||||
classes={{ paper: classes.dialogPaper }}
|
||||
/>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
+82
@@ -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 React, { ComponentProps } from 'react';
|
||||
import { render, cleanup } from '@testing-library/react';
|
||||
import { RegisterComponentResultDialog } from './RegisterComponentResultDialog';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
const setup = (
|
||||
props?: Partial<ComponentProps<typeof RegisterComponentResultDialog>>,
|
||||
) => ({
|
||||
rendered: render(
|
||||
<MemoryRouter>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<RegisterComponentResultDialog
|
||||
onClose={() => {}}
|
||||
entities={[]}
|
||||
{...props}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
});
|
||||
describe('RegisterComponentResultDialog', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('should render', () => {
|
||||
const { rendered } = setup();
|
||||
expect(
|
||||
rendered.getByText('Component Registration Result'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show a list of components if success', async () => {
|
||||
const { rendered } = setup({
|
||||
entities: [
|
||||
{
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'Component1',
|
||||
},
|
||||
spec: {
|
||||
type: 'website',
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'Component2',
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
},
|
||||
},
|
||||
] as Entity[],
|
||||
});
|
||||
|
||||
expect(
|
||||
rendered.getByText(
|
||||
'The following components have been succefully created:',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(rendered.getByText('Component1')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Component2')).toBeInTheDocument();
|
||||
});
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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, { FC } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
List,
|
||||
ListItem,
|
||||
Link,
|
||||
Divider,
|
||||
DialogActions,
|
||||
Button,
|
||||
} from '@material-ui/core';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { StructuredMetadataTable } from '@backstage/core';
|
||||
import { generatePath } from 'react-router';
|
||||
import {
|
||||
entityRoute,
|
||||
rootRoute as catalogRootRoute,
|
||||
} from '@backstage/plugin-catalog';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
classes?: Record<string, string>;
|
||||
entities: Entity[];
|
||||
};
|
||||
|
||||
export const RegisterComponentResultDialog: FC<Props> = ({
|
||||
onClose,
|
||||
classes,
|
||||
entities,
|
||||
}) => (
|
||||
<Dialog open onClose={onClose} classes={classes}>
|
||||
<DialogTitle>Component Registration Result</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
The following components have been succefully created:
|
||||
</DialogContentText>
|
||||
<List>
|
||||
{entities.map((entity: any, index: number) => (
|
||||
<React.Fragment
|
||||
key={`${entity.metadata.namespace}-${entity.metadata.name}`}
|
||||
>
|
||||
<ListItem>
|
||||
<StructuredMetadataTable
|
||||
dense
|
||||
metadata={{
|
||||
name: entity.metadata.name,
|
||||
type: entity.spec.type,
|
||||
link: (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={generatePath(entityRoute.path, {
|
||||
name: entity.metadata.name,
|
||||
})}
|
||||
>
|
||||
{generatePath(entityRoute.path, {
|
||||
name: entity.metadata.name,
|
||||
})}
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
{index < entities.length - 1 && <Divider component="li" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button component={RouterLink} to={catalogRootRoute.path} color="default">
|
||||
To Catalog
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -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 { RegisterComponentResultDialog } from './RegisterComponentResultDialog';
|
||||
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { plugin } from './plugin';
|
||||
export { plugin, rootRoute } from './plugin';
|
||||
|
||||
@@ -14,12 +14,18 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createPlugin } from '@backstage/core';
|
||||
import { createPlugin, createRouteRef } from '@backstage/core';
|
||||
import RegisterComponentPage from './components/RegisterComponentPage';
|
||||
|
||||
export const rootRoute = createRouteRef({
|
||||
icon: () => null,
|
||||
path: '/register-component',
|
||||
title: 'Register component',
|
||||
});
|
||||
|
||||
export const plugin = createPlugin({
|
||||
id: 'register-component',
|
||||
register({ router }) {
|
||||
router.registerRoute('/register-component', RegisterComponentPage);
|
||||
router.addRoute(rootRoute, RegisterComponentPage);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,6 +24,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-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
Page,
|
||||
pageTheme,
|
||||
} from '@backstage/core';
|
||||
import { Typography, Link } from '@material-ui/core';
|
||||
import { Typography, Link, Button } from '@material-ui/core';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
// TODO(blam): Connect to backend
|
||||
const STATIC_DATA = [
|
||||
@@ -49,7 +50,16 @@ const ScaffolderPage: React.FC<{}> = () => {
|
||||
subtitle="Create new software components using standard templates"
|
||||
/>
|
||||
<Content>
|
||||
<ContentHeader title="Available templates" />
|
||||
<ContentHeader title="Available templates">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
component={RouterLink}
|
||||
to="/register-component"
|
||||
>
|
||||
Register existing component
|
||||
</Button>
|
||||
</ContentHeader>
|
||||
<Typography variant="body2" paragraph style={{ fontStyle: 'italic' }}>
|
||||
<strong>NOTE!</strong> This feature is WIP. You can follow progress{' '}
|
||||
<Link href="https://github.com/spotify/backstage/milestone/11">
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { plugin } from './plugin';
|
||||
export { plugin, rootRoute } from './plugin';
|
||||
|
||||
@@ -14,12 +14,18 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createPlugin } from '@backstage/core';
|
||||
import { createPlugin, createRouteRef } from '@backstage/core';
|
||||
import ScaffolderPage from './components/ScaffolderPage';
|
||||
|
||||
export const rootRoute = createRouteRef({
|
||||
icon: () => null,
|
||||
path: '/create',
|
||||
title: 'Create entity',
|
||||
});
|
||||
|
||||
export const plugin = createPlugin({
|
||||
id: 'scaffolder',
|
||||
register({ router }) {
|
||||
router.registerRoute('/create', ScaffolderPage);
|
||||
router.addRoute(rootRoute, ScaffolderPage);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user