Merge remote-tracking branch 'origin/master' into orkohunter/use-config-for-backend-base-url

This commit is contained in:
Himanshu Mishra
2020-11-18 20:44:13 +01:00
104 changed files with 3241 additions and 479 deletions
@@ -26,24 +26,26 @@ import { ApiExplorerPage } from './ApiExplorerPage';
describe('ApiCatalogPage', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'Entity1',
Promise.resolve({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'Entity1',
},
spec: { type: 'openapi' },
},
spec: { type: 'openapi' },
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'Entity2',
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'Entity2',
},
spec: { type: 'openapi' },
},
spec: { type: 'openapi' },
},
] as Entity[]),
] as Entity[],
}),
getLocationByEntity: () =>
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
};
@@ -25,8 +25,8 @@ import { ApiExplorerLayout } from './ApiExplorerLayout';
export const ApiExplorerPage = () => {
const catalogApi = useApi(catalogApiRef);
const { loading, error, value: matchingEntities } = useAsync(() => {
return catalogApi.getEntities({ kind: 'API' });
const { loading, error, value: catalogResponse } = useAsync(() => {
return catalogApi.getEntities({ filter: { kind: 'API' } });
}, [catalogApi]);
return (
@@ -44,7 +44,7 @@ export const ApiExplorerPage = () => {
<SupportButton>All your APIs</SupportButton>
</ContentHeader>
<ApiExplorerTable
entities={matchingEntities!}
entities={catalogResponse?.items ?? []}
loading={loading}
error={error}
/>
+1
View File
@@ -22,6 +22,7 @@
"dependencies": {
"@backstage/backend-common": "^0.2.0",
"@backstage/config-loader": "^0.2.0",
"@backstage/config": "^0.1.1",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
+45 -26
View File
@@ -17,7 +17,7 @@
import fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { getVoidLogger } from '@backstage/backend-common';
import { injectEnvConfig } from './config';
import { injectConfig } from './config';
jest.mock('fs-extra');
@@ -29,12 +29,12 @@ const readFileMock = (fsMock.readFile as unknown) as jest.MockedFunction<
const MOCK_DIR = 'mock-dir';
const baseOptions = {
env: {},
appConfigs: [],
staticDir: MOCK_DIR,
logger: getVoidLogger(),
};
describe('injectEnvConfig', () => {
describe('injectConfig', () => {
beforeEach(() => {
fsMock.readdir.mockResolvedValue(['main.js']);
});
@@ -43,11 +43,23 @@ describe('injectEnvConfig', () => {
jest.resetAllMocks();
});
it('should not inject without config', async () => {
await injectEnvConfig(baseOptions);
expect(fsMock.readdir).toHaveBeenCalledTimes(0);
expect(fsMock.readFile).toHaveBeenCalledTimes(0);
expect(fsMock.writeFile).toHaveBeenCalledTimes(0);
it('should inject without config', async () => {
fsMock.readdir.mockResolvedValue(['main.js']);
readFileMock.mockImplementation(
async () => '"__APP_INJECTED_RUNTIME_CONFIG__"',
);
await injectConfig(baseOptions);
expect(fsMock.readdir).toHaveBeenCalledTimes(1);
expect(fsMock.readFile).toHaveBeenCalledTimes(1);
expect(fsMock.writeFile).toHaveBeenCalledTimes(1);
expect(fsMock.writeFile).toHaveBeenCalledWith(
resolvePath(MOCK_DIR, 'main.js'),
'/*__APP_INJECTED_CONFIG_MARKER__*/"[]"/*__INJECTED_END__*/',
'utf8',
);
// eslint-disable-next-line no-eval
expect(JSON.parse(eval(fsMock.writeFile.mock.calls[0][1]))).toEqual([]);
});
it('should find the correct file to inject', async () => {
@@ -64,7 +76,10 @@ describe('injectEnvConfig', () => {
return 'NO_PLACEHOLDER_HERE';
});
await injectEnvConfig({ ...baseOptions, env: { APP_CONFIG_x: '0' } });
await injectConfig({
...baseOptions,
appConfigs: [{ data: { x: 0 }, context: 'test' }],
});
expect(fsMock.readFile).toHaveBeenCalledTimes(2);
expect(fsMock.readFile).toHaveBeenNthCalledWith(
1,
@@ -80,14 +95,19 @@ describe('injectEnvConfig', () => {
expect(fsMock.writeFile).toHaveBeenCalledTimes(1);
expect(fsMock.writeFile).toHaveBeenCalledWith(
resolvePath(MOCK_DIR, 'main.js'),
'/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":0}"/*__INJECTED_END__*/',
'/*__APP_INJECTED_CONFIG_MARKER__*/"[{\\"data\\":{\\"x\\":0},\\"context\\":\\"test\\"}]"/*__INJECTED_END__*/',
'utf8',
);
// eslint-disable-next-line no-eval
expect(JSON.parse(eval(fsMock.writeFile.mock.calls[0][1]))).toEqual({
x: 0,
});
expect(JSON.parse(eval(fsMock.writeFile.mock.calls[0][1]))).toEqual([
{
data: {
x: 0,
},
context: 'test',
},
]);
});
it('should re-inject config', async () => {
@@ -96,41 +116,40 @@ describe('injectEnvConfig', () => {
'JSON.parse("__APP_INJECTED_RUNTIME_CONFIG__")',
);
await injectEnvConfig({
await injectConfig({
...baseOptions,
env: {
APP_CONFIG_x: '0',
},
appConfigs: [{ data: { x: 0 }, context: 'test' }],
});
expect(fsMock.writeFile).toHaveBeenCalledTimes(1);
expect(fsMock.writeFile).toHaveBeenCalledWith(
resolvePath(MOCK_DIR, 'main.js'),
'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":0}"/*__INJECTED_END__*/)',
'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"[{\\"data\\":{\\"x\\":0},\\"context\\":\\"test\\"}]"/*__INJECTED_END__*/)',
'utf8',
);
// eslint-disable-next-line no-eval
expect(eval(fsMock.writeFile.mock.calls[0][1])).toEqual({ x: 0 });
expect(eval(fsMock.writeFile.mock.calls[0][1])).toEqual([
{ data: { x: 0 }, context: 'test' },
]);
readFileMock.mockResolvedValue(fsMock.writeFile.mock.calls[0][1]);
await injectEnvConfig({
await injectConfig({
...baseOptions,
env: {
APP_CONFIG_x: '1',
APP_CONFIG_y: '2',
},
appConfigs: [{ data: { x: 1, y: 2 }, context: 'test' }],
});
expect(fsMock.writeFile).toHaveBeenCalledTimes(2);
expect(fsMock.writeFile).toHaveBeenLastCalledWith(
resolvePath(MOCK_DIR, 'main.js'),
'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":1,\\"y\\":2}"/*__INJECTED_END__*/)',
'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"[{\\"data\\":{\\"x\\":1,\\"y\\":2},\\"context\\":\\"test\\"}]"/*__INJECTED_END__*/)',
'utf8',
);
// eslint-disable-next-line no-eval
expect(eval(fsMock.writeFile.mock.calls[1][1])).toEqual({ x: 1, y: 2 });
expect(eval(fsMock.writeFile.mock.calls[1][1])).toEqual([
{ data: { x: 1, y: 2 }, context: 'test' },
]);
});
});
+38 -15
View File
@@ -16,34 +16,27 @@
import fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { readEnvConfig } from '@backstage/config-loader';
import { Logger } from 'winston';
import { AppConfig, Config, JsonObject } from '@backstage/config';
import { loadConfigSchema, readEnvConfig } from '@backstage/config-loader';
type Options = {
// Environment to read config from
env: { [name: string]: string | undefined };
type InjectOptions = {
appConfigs: AppConfig[];
// Directory of the static JS files to search for file to inject
staticDir: string;
logger: Logger;
};
/**
* Injects config from APP_CONFIG_ env vars, replacing existing
* injected config if it has already been injected.
* Injects configs into the app bundle, replacing any existing injected config.
*/
export async function injectEnvConfig(options: Options) {
const { env, staticDir, logger } = options;
const envConfig = readEnvConfig(env);
if (envConfig.length === 0) {
return;
}
export async function injectConfig(options: InjectOptions) {
const { staticDir, logger, appConfigs } = options;
const files = await fs.readdir(staticDir);
const jsFiles = files.filter(file => file.endsWith('.js'));
const [{ data }] = envConfig;
const escapedData = JSON.stringify(data).replace(/("|'|\\)/g, '\\$1');
const escapedData = JSON.stringify(appConfigs).replace(/("|'|\\)/g, '\\$1');
const injected = `/*__APP_INJECTED_CONFIG_MARKER__*/"${escapedData}"/*__INJECTED_END__*/`;
for (const jsFile of jsFiles) {
@@ -72,3 +65,33 @@ export async function injectEnvConfig(options: Options) {
}
logger.info('Env config not injected');
}
type ReadOptions = {
env: { [name: string]: string | undefined };
appDistDir: string;
config: Config;
};
/**
* Read config from environment and process the backend config using the
* schema that is embedded in the frontend build.
*/
export async function readConfigs(options: ReadOptions): Promise<AppConfig[]> {
const { env, appDistDir, config } = options;
const appConfigs = readEnvConfig(env);
const schemaPath = resolvePath(appDistDir, '.config-schema.json');
if (await fs.pathExists(schemaPath)) {
const serializedSchema = await fs.readJson(schemaPath);
const schema = await loadConfigSchema({ serialized: serializedSchema });
const frontendConfigs = await schema.process(
[{ data: config.get() as JsonObject, context: 'app' }],
{ visiblity: ['frontend'] },
);
appConfigs.push(...frontendConfigs);
}
return appConfigs;
}
@@ -16,13 +16,16 @@
import { resolve as resolvePath } from 'path';
import { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import express from 'express';
import Router from 'express-promise-router';
import request from 'supertest';
import { createRouter } from './router';
jest.mock('../lib/config', () => ({ injectEnvConfig: jest.fn() }));
jest.mock('../lib/config', () => ({
injectConfig: jest.fn(),
readConfigs: jest.fn(),
}));
global.__non_webpack_require__ = {
/* eslint-disable-next-line no-restricted-syntax */
@@ -35,6 +38,7 @@ describe('createRouter', () => {
beforeAll(async () => {
const router = await createRouter({
logger: getVoidLogger(),
config: new ConfigReader({}),
appPackageName: 'example-app',
});
app = express().use(router);
@@ -76,6 +80,7 @@ describe('createRouter with static fallback handler', () => {
const router = await createRouter({
logger: getVoidLogger(),
config: new ConfigReader({}),
appPackageName: 'example-app',
staticFallbackHandler,
});
+16 -9
View File
@@ -15,13 +15,15 @@
*/
import { resolve as resolvePath } from 'path';
import { notFoundHandler, resolvePackagePath } from '@backstage/backend-common';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { injectEnvConfig } from '../lib/config';
import { notFoundHandler, resolvePackagePath } from '@backstage/backend-common';
import { Config } from '@backstage/config';
import { injectConfig, readConfigs } from '../lib/config';
export interface RouterOptions {
config: Config;
logger: Logger;
appPackageName: string;
staticFallbackHandler?: express.Handler;
@@ -30,22 +32,27 @@ export interface RouterOptions {
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const appDistDir = resolvePackagePath(options.appPackageName, 'dist');
options.logger.info(`Serving static app content from ${appDistDir}`);
const { config, logger, appPackageName, staticFallbackHandler } = options;
await injectEnvConfig({
const appDistDir = resolvePackagePath(appPackageName, 'dist');
logger.info(`Serving static app content from ${appDistDir}`);
const staticDir = resolvePath(appDistDir, 'static');
const appConfigs = await readConfigs({
config,
appDistDir,
env: process.env,
logger: options.logger,
staticDir: resolvePath(appDistDir, 'static'),
});
await injectConfig({ appConfigs, logger, staticDir });
const router = Router();
// Use a separate router for static content so that a fallback can be provided by backend
const staticRouter = Router();
staticRouter.use(express.static(resolvePath(appDistDir, 'static')));
if (options.staticFallbackHandler) {
staticRouter.use(options.staticFallbackHandler);
if (staticFallbackHandler) {
staticRouter.use(staticFallbackHandler);
}
staticRouter.use(notFoundHandler());
@@ -14,14 +14,16 @@
* limitations under the License.
*/
import { createServiceBuilder } from '@backstage/backend-common';
import { Server } from 'http';
import { Logger } from 'winston';
import { createServiceBuilder } from '@backstage/backend-common';
import { Config } from '@backstage/config';
import { createRouter } from './router';
export interface ServerOptions {
port: number;
enableCors: boolean;
config: Config;
logger: Logger;
}
@@ -32,6 +34,7 @@ export async function startStandaloneServer(
logger.debug('Starting application server...');
const router = await createRouter({
logger,
config: options.config,
appPackageName: 'example-app',
});
@@ -33,30 +33,32 @@ import { ButtonGroup, CatalogFilter } from './CatalogFilter';
describe('Catalog Filter', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
Promise.resolve({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
spec: {
owner: 'tools@example.com',
type: 'service',
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
] as Entity[]),
] as Entity[],
}),
};
const identityApi: Partial<IdentityApi> = {
@@ -34,37 +34,39 @@ import { CatalogPage } from './CatalogPage';
describe('CatalogPage', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
Promise.resolve({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
spec: {
owner: 'tools@example.com',
type: 'service',
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
] as Entity[]),
] as Entity[],
}),
getLocationByEntity: () =>
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
};
const testProfile: Partial<ProfileInfo> = {
displayName: 'Display Name',
};
const indentityApi: Partial<IdentityApi> = {
const identityApi: Partial<IdentityApi> = {
getUserId: () => 'tools@example.com',
getProfile: () => testProfile,
};
@@ -75,7 +77,7 @@ describe('CatalogPage', () => {
<ApiProvider
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[identityApiRef, indentityApi],
[identityApiRef, identityApi],
[storageApiRef, MockStorageApi.create()],
])}
>
@@ -33,46 +33,48 @@ import { ResultsFilter } from './ResultsFilter';
describe('Results Filter', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
tags: ['java'],
Promise.resolve({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
tags: ['java'],
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
spec: {
owner: 'tools@example.com',
type: 'service',
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity3',
tags: ['java', 'test'],
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity3',
tags: ['java', 'test'],
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
] as Entity[]),
] as Entity[],
}),
};
const indentityApi: Partial<IdentityApi> = {
const identityApi: Partial<IdentityApi> = {
getUserId: () => 'tools@example.com',
};
@@ -82,7 +84,7 @@ describe('Results Filter', () => {
<ApiProvider
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[identityApiRef, indentityApi],
[identityApiRef, identityApi],
[storageApiRef, MockStorageApi.create()],
])}
>
@@ -44,9 +44,13 @@ function useColocatedEntities(entity: Entity): AsyncState<Entity[]> {
const catalogApi = useApi(catalogApiRef);
return useAsync(async () => {
const myLocation = entity.metadata.annotations?.[LOCATION_ANNOTATION];
return myLocation
? await catalogApi.getEntities({ [LOCATION_ANNOTATION]: myLocation })
: [];
if (!myLocation) {
return [];
}
const response = await catalogApi.getEntities({
filter: { [LOCATION_ANNOTATION]: myLocation },
});
return response.items;
}, [catalogApi, entity]);
}
@@ -46,9 +46,12 @@ export const EntityFilterGroupsProvider = ({
// The hook that implements the actual context building
function useProvideEntityFilters(): FilterGroupsContext {
const catalogApi = useApi(catalogApiRef);
const [{ value: entities, error }, doReload] = useAsyncFn(() =>
catalogApi.getEntities({ kind: 'Component' }),
);
const [{ value: entities, error }, doReload] = useAsyncFn(async () => {
const response = await catalogApi.getEntities({
filter: { kind: 'Component' },
});
return response.items;
});
const filterGroups = useRef<{
[filterGroupId: string]: FilterGroup;
@@ -49,7 +49,7 @@ describe('useEntityFilterGroup', () => {
});
it('works for an empty set of filters', async () => {
catalogApi.getEntities.mockResolvedValue([]);
catalogApi.getEntities.mockResolvedValue({ items: [] });
const group: FilterGroup = { filters: {} };
const { result, waitFor } = renderHook(
() => useEntityFilterGroup('g1', group),
@@ -60,13 +60,15 @@ describe('useEntityFilterGroup', () => {
});
it('works for a single group', async () => {
catalogApi.getEntities.mockResolvedValue([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'n' },
},
]);
catalogApi.getEntities.mockResolvedValue({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'n' },
},
],
});
const group: FilterGroup = {
filters: {
f1: e => e.metadata.name === 'n',
+52
View File
@@ -0,0 +1,52 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
interface Config {
costInsights: {
/**
* @visibility frontend
*/
engineerCost: number;
products: {
[kind: string]: {
/**
* @visibility frontend
*/
name: string;
/**
* @visibility frontend
*/
icon: 'compute' | 'data' | 'database' | 'storage' | 'search' | 'ml';
};
};
metrics?: {
[kind: string]: {
/**
* @visibility frontend
*/
name: string;
/**
* @visibility frontend
*/
default?: boolean;
};
};
};
}
+4 -2
View File
@@ -61,6 +61,8 @@
"msw": "^0.21.2"
},
"files": [
"dist"
]
"dist",
"config.d.ts"
],
"configSchema": "config.d.ts"
}
@@ -35,7 +35,6 @@ export const BarChartTooltip = ({
children,
}: PropsWithChildren<BarChartTooltipProps>) => {
const classes = useStyles();
return (
<Box className={classes.tooltip} display="flex" flexDirection="column">
<Box
@@ -47,14 +46,16 @@ export const BarChartTooltip = ({
pt={2}
>
<Box display="flex" flexDirection="column">
<Typography variant="h6">{title}</Typography>
<Typography className={classes.truncate} variant="h6">
{title}
</Typography>
{subtitle && (
<Typography className={classes.subtitle} variant="subtitle1">
{subtitle}
</Typography>
)}
</Box>
{topRight}
{topRight && <Box ml={1}>{topRight}</Box>}
</Box>
{content && (
<Box px={2} pt={2}>
+7 -1
View File
@@ -386,7 +386,7 @@ export const useTooltipStyles = makeStyles<CostInsightsTheme>(
boxShadow: theme.shadows[1],
color: theme.palette.tooltip.color,
fontSize: theme.typography.fontSize,
width: 250,
maxWidth: 300,
},
actions: {
padding: theme.spacing(2),
@@ -403,6 +403,12 @@ export const useTooltipStyles = makeStyles<CostInsightsTheme>(
divider: {
backgroundColor: emphasize(theme.palette.divider, 1),
},
truncate: {
maxWidth: 200,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
subtitle: {
fontStyle: 'italic',
},
+17 -1
View File
@@ -52,5 +52,21 @@
},
"files": [
"dist"
]
],
"configSchema": {
"$schema": "https://backstage.io/schema/config-v1",
"title": "@backstage/lighthouse",
"type": "object",
"properties": {
"lighthouse": {
"type": "object",
"properties": {
"baseUrl": {
"type": "string",
"visibility": "frontend"
}
}
}
}
}
}
+17 -1
View File
@@ -52,5 +52,21 @@
},
"files": [
"dist"
]
],
"configSchema": {
"$schema": "https://backstage.io/schema/config-v1",
"title": "@backstage/rollbar",
"type": "object",
"properties": {
"rollbar": {
"type": "object",
"properties": {
"organization": {
"type": "string",
"visibility": "frontend"
}
}
}
}
}
}
@@ -52,7 +52,7 @@ describe('RollbarHome component', () => {
catalogApiRef,
({
async getEntities() {
return [];
return { items: [] };
},
} as Partial<CatalogApi>) as CatalogApi,
],
@@ -28,8 +28,8 @@ export function useRollbarEntities() {
configApi.getString('organization.name');
const { value, loading, error } = useAsync(async () => {
const entities = await catalogApi.getEntities();
return entities.filter(entity => {
const response = await catalogApi.getEntities();
return response.items.filter(entity => {
return !!entity.metadata.annotations?.[ROLLBAR_ANNOTATION];
});
}, [catalogApi]);
@@ -53,10 +53,12 @@ export const ScaffolderPage = () => {
const { data: templates, isValidating, error } = useStaleWhileRevalidate(
'templates/all',
async () =>
catalogApi.getEntities({ kind: 'Template' }) as Promise<
TemplateEntityV1alpha1[]
>,
async () => {
const response = await catalogApi.getEntities({
filter: { kind: 'Template' },
});
return response.items as TemplateEntityV1alpha1[];
},
);
useEffect(() => {
@@ -13,18 +13,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { TemplatePage } from './TemplatePage';
import { wrapInTestApp, renderWithEffects } from '@backstage/test-utils';
import { ApiRegistry, errorApiRef, ApiProvider } from '@backstage/core';
import { scaffolderApiRef, ScaffolderApi } from '../../api';
import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog';
import { mutate } from 'swr';
import { act } from 'react-dom/test-utils';
import { Route, MemoryRouter } from 'react-router';
import { rootRoute } from '../../routes';
import { ThemeProvider } from '@material-ui/core';
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { renderInTestApp, renderWithEffects } from '@backstage/test-utils';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { MemoryRouter, Route } from 'react-router';
import { ScaffolderApi, scaffolderApiRef } from '../../api';
import { rootRoute } from '../../routes';
import { TemplatePage } from './TemplatePage';
const templateMock = {
apiVersion: 'backstage.io/v1alpha1',
@@ -90,48 +89,43 @@ const apis = ApiRegistry.from([
]);
describe('TemplatePage', () => {
afterEach(async () => {
// Cleaning up swr's cache
await act(async () => {
await mutate('templates/test');
});
});
beforeEach(() => jest.resetAllMocks());
it('renders correctly', async () => {
catalogApiMock.getEntities.mockResolvedValueOnce([templateMock]);
const rendered = await renderWithEffects(
wrapInTestApp(
<ApiProvider apis={apis}>
<TemplatePage />
</ApiProvider>,
),
catalogApiMock.getEntities.mockResolvedValueOnce({ items: [templateMock] });
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<TemplatePage />
</ApiProvider>,
);
expect(rendered.queryByText('Create a new component')).toBeInTheDocument();
expect(rendered.queryByText('React SSR Template')).toBeInTheDocument();
// await act(async () => await mutate('templates/test'));
});
it('renders spinner while loading', async () => {
let resolve: Function;
const promise = new Promise<any>(res => {
resolve = res;
});
catalogApiMock.getEntities.mockResolvedValueOnce(promise);
const rendered = await renderWithEffects(
wrapInTestApp(
<ApiProvider apis={apis}>
<TemplatePage />
</ApiProvider>,
),
catalogApiMock.getEntities.mockReturnValueOnce(promise);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<TemplatePage />
</ApiProvider>,
);
expect(rendered.queryByText('Create a new component')).toBeInTheDocument();
expect(rendered.queryByTestId('loading-progress')).toBeInTheDocument();
// Need to cleanup the promise or will timeout
resolve!();
act(() => {
resolve!({ items: [] });
});
});
it('navigates away if no template was loaded', async () => {
catalogApiMock.getEntities.mockResolvedValueOnce([]);
catalogApiMock.getEntities.mockResolvedValueOnce({ items: [] });
const rendered = await renderWithEffects(
<ApiProvider apis={apis}>
@@ -27,28 +27,26 @@ import { catalogApiRef } from '@backstage/plugin-catalog';
import { LinearProgress } from '@material-ui/core';
import { IChangeEvent } from '@rjsf/core';
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import useStaleWhileRevalidate from 'swr';
import { scaffolderApiRef } from '../../api';
import { JobStatusModal } from '../JobStatusModal';
import { Job } from '../../types';
import { MultistepJsonForm } from '../MultistepJsonForm';
import { Navigate } from 'react-router';
import { useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { scaffolderApiRef } from '../../api';
import { rootRoute } from '../../routes';
import { Job } from '../../types';
import { JobStatusModal } from '../JobStatusModal';
import { MultistepJsonForm } from '../MultistepJsonForm';
const useTemplate = (
templateName: string,
catalogApi: typeof catalogApiRef.T,
) => {
const { data, error } = useStaleWhileRevalidate(
`templates/${templateName}`,
async () =>
catalogApi.getEntities({
kind: 'Template',
'metadata.name': templateName,
}) as Promise<TemplateEntityV1alpha1[]>,
);
return { template: data?.[0], loading: !error && !data, error };
const { value, loading, error } = useAsync(async () => {
const response = await catalogApi.getEntities({
filter: { kind: 'Template', 'metadata.name': templateName },
});
return response.items as TemplateEntityV1alpha1[];
});
return { template: value?.[0], loading, error };
};
const OWNER_REPO_SCHEMA = {
@@ -110,7 +108,7 @@ export const TemplatePage = () => {
const handleCreateComplete = async (job: Job) => {
const target = job.metadata.remoteUrl?.replace(
/\.git$/,
// TODO(Rugvip): This is not the location we want. As part of scaffodler v2 we
// TODO(Rugvip): This is not the location we want. As part of scaffolder v2 we
// want this to be more flexible, but before that we might want
// to update all templates to use catalog-info.yaml instead.
'/blob/master/component-info.yaml',
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+9
View File
@@ -0,0 +1,9 @@
# Backstage Search
**This plugin is still under development.**
You can follow the progress under the Global search in Backstage [milestone](https://github.com/backstage/backstage/milestone/21) or reach out to us in the #search Discord channel.
## Getting started
Run `yarn start` in the root directory, and then navigate to [/search](http://localhost:3000/search)to check out the plugin.
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
createDevApp().registerPlugin(plugin).render();
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@backstage/plugin-search",
"version": "0.2.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.3.0",
"@backstage/theme": "^0.2.1",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@backstage/plugin-catalog": "^0.2.0",
"react-router-dom": "6.0.0-beta.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.2.0",
"@backstage/dev-utils": "^0.1.3",
"@backstage/test-utils": "^0.1.2",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"msw": "^0.21.2",
"cross-fetch": "^3.0.6"
},
"files": [
"dist"
]
}
+52
View File
@@ -0,0 +1,52 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CatalogApi } from '@backstage/plugin-catalog';
export type Result = {
name: string;
description: string;
owner: string;
kind: string;
lifecycle: string;
};
export type SearchResults = Array<Result>;
class SearchApi {
private catalogApi: CatalogApi;
constructor(catalogApi: CatalogApi) {
this.catalogApi = catalogApi;
}
private async entities() {
const entities = await this.catalogApi.getEntities();
return entities.items.map((result: any) => ({
name: result.metadata.name,
description: result.metadata.description,
owner: result.spec.owner,
kind: result.kind,
lifecycle: result.spec.lifecycle,
}));
}
public getSearchResult(): Promise<SearchResults> {
return this.entities();
}
}
export default SearchApi;
@@ -0,0 +1,132 @@
/*
* 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 {
makeStyles,
Typography,
Divider,
Card,
CardHeader,
Button,
CardContent,
Select,
Checkbox,
List,
ListItem,
ListItemText,
MenuItem,
} from '@material-ui/core';
const useStyles = makeStyles(theme => ({
filters: {
background: 'transparent',
boxShadow: '0px 0px 0px 0px',
},
checkbox: {
padding: theme.spacing(0, 1, 0, 1),
},
dropdown: {
width: '100%',
},
}));
export type FiltersState = {
selected: string;
checked: Array<string>;
};
type FiltersProps = {
filters: FiltersState;
resetFilters: () => void;
updateSelected: (filter: string) => void;
updateChecked: (filter: string) => void;
};
export const Filters = ({
filters,
resetFilters,
updateSelected,
updateChecked,
}: FiltersProps) => {
const classes = useStyles();
// TODO: move mocked filters out of filters component to make it more generic
const filter1 = ['All', 'API', 'Component', 'Location', 'Template'];
const filter2 = ['deprecated', 'recommended', 'experimental', 'production'];
return (
<Card className={classes.filters}>
<CardHeader
title={<Typography variant="h6">Filters</Typography>}
action={
<Button color="primary" onClick={() => resetFilters()}>
CLEAR ALL
</Button>
}
/>
<Divider />
<CardContent>
<Typography variant="subtitle2">Kind</Typography>
<Select
id="outlined-select"
onChange={(e: React.ChangeEvent<any>) =>
updateSelected(e?.target?.value)
}
variant="outlined"
className={classes.dropdown}
value={filters.selected}
>
{filter1.map(filter => (
<MenuItem
selected={filter === 'All'}
dense
key={filter}
value={filter}
>
{filter}
</MenuItem>
))}
</Select>
</CardContent>
<CardContent>
<Typography variant="subtitle2">Lifecycle</Typography>
<List disablePadding dense>
{filter2.map(filter => (
<ListItem
key={filter}
dense
button
onClick={() => updateChecked(filter)}
>
<Checkbox
edge="start"
disableRipple
className={classes.checkbox}
color="primary"
checked={filters.checked.includes(filter)}
tabIndex={-1}
value={filter}
name={filter}
/>
<ListItemText id={filter} primary={filter} />
</ListItem>
))}
</List>
</CardContent>
</Card>
);
};
@@ -0,0 +1,56 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import FilterListIcon from '@material-ui/icons/FilterList';
import { makeStyles, IconButton, Typography } from '@material-ui/core';
const useStyles = makeStyles(theme => ({
filters: {
width: '250px',
display: 'flex',
},
icon: {
margin: theme.spacing(-1, 0, 0, 0),
},
}));
type FiltersButtonProps = {
numberOfSelectedFilters: number;
handleToggleFilters: () => void;
};
export const FiltersButton = ({
numberOfSelectedFilters,
handleToggleFilters,
}: FiltersButtonProps) => {
const classes = useStyles();
return (
<div className={classes.filters}>
<IconButton
className={classes.icon}
aria-label="settings"
onClick={handleToggleFilters}
>
<FilterListIcon />
</IconButton>
<Typography variant="h6">
Filters ({numberOfSelectedFilters ? numberOfSelectedFilters : 0})
</Typography>
</div>
);
};
@@ -0,0 +1,19 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { FiltersButton } from './FiltersButton';
export { Filters } from './Filters';
export type { FiltersState } from './Filters';
@@ -0,0 +1,69 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { makeStyles } from '@material-ui/core/styles';
import { Paper } from '@material-ui/core';
import InputBase from '@material-ui/core/InputBase';
import IconButton from '@material-ui/core/IconButton';
import SearchIcon from '@material-ui/icons/Search';
import ClearButton from '@material-ui/icons/Clear';
const useStyles = makeStyles(() => ({
root: {
display: 'flex',
alignItems: 'center',
},
input: {
flex: 1,
},
}));
type SearchBarProps = {
searchQuery: string;
handleSearch: any;
handleClearSearchBar: any;
};
export const SearchBar = ({
searchQuery,
handleSearch,
handleClearSearchBar,
}: SearchBarProps) => {
const classes = useStyles();
return (
<Paper
component="form"
onSubmit={e => handleSearch(e)}
className={classes.root}
>
<IconButton disabled type="submit" aria-label="search">
<SearchIcon />
</IconButton>
<InputBase
className={classes.input}
placeholder="Search in Backstage"
value={searchQuery}
onChange={e => handleSearch(e)}
inputProps={{ 'aria-label': 'search backstage' }}
/>
<IconButton aria-label="search" onClick={() => handleClearSearchBar()}>
<ClearButton />
</IconButton>
</Paper>
);
};
@@ -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 { SearchBar } from './SearchBar';
@@ -0,0 +1,55 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState } from 'react';
import { Header, Content, Page } from '@backstage/core';
import { Grid } from '@material-ui/core';
import { SearchBar } from '../SearchBar';
import { SearchResult } from '../SearchResult';
export const SearchPage = () => {
const [searchQuery, setSearchQuery] = useState('');
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
event.preventDefault();
setSearchQuery(event.target.value);
};
const handleClearSearchBar = () => {
setSearchQuery('');
};
return (
<Page themeId="home">
<Header title="Search" />
<Content>
<Grid container direction="row">
<Grid item xs={12}>
<SearchBar
handleSearch={handleSearch}
handleClearSearchBar={handleClearSearchBar}
searchQuery={searchQuery}
/>
</Grid>
<Grid item xs={12}>
<SearchResult searchQuery={searchQuery.toLowerCase()} />
</Grid>
</Grid>
</Content>
</Page>
);
};
@@ -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 { SearchPage } from './SearchPage';
@@ -0,0 +1,227 @@
/*
* 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, { useState, useEffect } from 'react';
import { useAsync } from 'react-use';
import { makeStyles, Typography, Grid, Divider } from '@material-ui/core';
import { Table, TableColumn, useApi } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog';
import { FiltersButton, Filters, FiltersState } from '../Filters';
import SearchApi, { Result, SearchResults } from '../../apis';
const useStyles = makeStyles(theme => ({
searchTerm: {
background: '#eee',
borderRadius: '10%',
},
tableHeader: {
margin: theme.spacing(1, 0, 0, 0),
display: 'flex',
},
divider: {
width: '1px',
margin: theme.spacing(0, 2),
padding: theme.spacing(2, 0),
},
}));
type SearchResultProps = {
searchQuery?: string;
};
type TableHeaderProps = {
searchQuery?: string;
numberOfSelectedFilters: number;
numberOfResults: number;
handleToggleFilters: () => void;
};
type Filters = {
selected: string;
checked: Array<string | null>;
};
// TODO: move out column to make the search result component more generic
const columns: TableColumn[] = [
{
title: 'Component Id',
field: 'name',
highlight: true,
},
{
title: 'Description',
field: 'description',
},
{
title: 'Owner',
field: 'owner',
},
{
title: 'Kind',
field: 'kind',
},
{
title: 'LifeCycle',
field: 'lifecycle',
},
];
const TableHeader = ({
searchQuery,
numberOfSelectedFilters,
numberOfResults,
handleToggleFilters,
}: TableHeaderProps) => {
const classes = useStyles();
return (
<div className={classes.tableHeader}>
<FiltersButton
numberOfSelectedFilters={numberOfSelectedFilters}
handleToggleFilters={handleToggleFilters}
/>
<Divider className={classes.divider} orientation="vertical" />
<Grid item xs={12}>
{searchQuery ? (
<Typography variant="h6">
{`${numberOfResults} `}
{numberOfResults > 1 ? `results for ` : `result for `}
<span className={classes.searchTerm}>"{searchQuery}"</span>{' '}
</Typography>
) : (
<Typography variant="h6">{`${numberOfResults} results`}</Typography>
)}
</Grid>
</div>
);
};
export const SearchResult = ({ searchQuery }: SearchResultProps) => {
const catalogApi = useApi(catalogApiRef);
const [showFilters, toggleFilters] = useState(false);
const [filters, setFilters] = useState<FiltersState>({
selected: 'All',
checked: [],
});
const [filteredResults, setFilteredResults] = useState<SearchResults>([]);
const searchApi = new SearchApi(catalogApi);
const { loading, error, value: results } = useAsync(() => {
return searchApi.getSearchResult();
}, []);
useEffect(() => {
if (results) {
let withFilters = results;
// apply filters
// filter on selected
if (filters.selected !== 'All') {
withFilters = results.filter((result: Result) =>
filters.selected.includes(result.kind),
);
}
// filter on checked
if (filters.checked.length > 0) {
withFilters = withFilters.filter((result: Result) =>
filters.checked.includes(result.lifecycle),
);
}
// filter on searchQuery
if (searchQuery) {
withFilters = withFilters.filter(
(result: Result) =>
result.name?.toLowerCase().includes(searchQuery) ||
result.description?.toLowerCase().includes(searchQuery),
);
}
setFilteredResults(withFilters);
}
}, [filters, searchQuery, results]);
if (loading || error || !results) return null;
const resetFilters = () => {
setFilters({
selected: 'All',
checked: [],
});
};
const updateSelected = (filter: string) => {
setFilters(prevState => ({
...prevState,
selected: filter,
}));
};
const updateChecked = (filter: string) => {
if (filters.checked.includes(filter)) {
setFilters(prevState => ({
...prevState,
checked: prevState.checked.filter(item => item !== filter),
}));
return;
}
setFilters(prevState => ({
...prevState,
checked: [...prevState.checked, filter],
}));
};
return (
<>
<Grid container>
{showFilters && (
<Grid item xs={3}>
<Filters
filters={filters}
resetFilters={resetFilters}
updateSelected={updateSelected}
updateChecked={updateChecked}
/>
</Grid>
)}
<Grid item xs={showFilters ? 9 : 12}>
<Table
options={{ paging: true, search: false }}
data={filteredResults}
columns={columns}
title={
<TableHeader
searchQuery={searchQuery}
numberOfResults={filteredResults.length}
numberOfSelectedFilters={
(filters.selected !== 'All' ? 1 : 0) + filters.checked.length
}
handleToggleFilters={() => toggleFilters(!showFilters)}
/>
}
/>
</Grid>
</Grid>
</>
);
};
@@ -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 { SearchResult } from './SearchResult';
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './Filters';
export * from './SearchBar';
export * from './SearchPage';
export * from './SearchResult';
+16
View File
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { plugin } from './plugin';
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { plugin } from './plugin';
describe('search', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
});
});
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createPlugin, createRouteRef } from '@backstage/core';
import { SearchPage } from './components/SearchPage';
export const rootRouteRef = createRouteRef({
path: '/search',
title: 'search',
});
export const plugin = createPlugin({
id: 'search',
register({ router }) {
router.addRoute(rootRouteRef, SearchPage);
},
});
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@testing-library/jest-dom';
import 'cross-fetch/polyfill';
+17 -1
View File
@@ -49,5 +49,21 @@
},
"files": [
"dist"
]
],
"configSchema": {
"$schema": "https://backstage.io/schema/config-v1",
"title": "@backstage/sentry",
"type": "object",
"properties": {
"sentry": {
"type": "object",
"properties": {
"organization": {
"type": "string",
"visibility": "frontend"
}
}
}
}
}
}
+23 -1
View File
@@ -53,5 +53,27 @@
},
"files": [
"dist"
]
],
"configSchema": {
"$schema": "https://backstage.io/schema/config-v1",
"title": "@backstage/techdocs",
"type": "object",
"properties": {
"techdocs": {
"type": "object",
"properties": {
"requestUrl": {
"type": "string",
"visibility": "frontend"
},
"storageUrl": {
"type": "string"
}
},
"required": [
"requestUrl"
]
}
}
}
}
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core-api';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { wrapInTestApp } from '@backstage/test-utils';
@@ -24,7 +23,7 @@ import { TechDocsHome } from './TechDocsHome';
describe('TechDocs Home', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () => Promise.resolve([] as Entity[]),
getEntities: () => Promise.resolve({ items: [] }),
};
const apiRegistry = ApiRegistry.from([[catalogApiRef, catalogApi]]);
@@ -14,19 +14,19 @@
* limitations under the License.
*/
import React from 'react';
import { useAsync } from 'react-use';
import { useNavigate, generatePath } from 'react-router-dom';
import { Grid } from '@material-ui/core';
import {
Content,
Header,
ItemCard,
Page,
Progress,
useApi,
Content,
Page,
Header,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog';
import { Grid } from '@material-ui/core';
import React from 'react';
import { generatePath, useNavigate } from 'react-router-dom';
import { useAsync } from 'react-use';
import { rootDocsRouteRef } from '../../plugin';
export const TechDocsHome = () => {
@@ -34,8 +34,8 @@ export const TechDocsHome = () => {
const navigate = useNavigate();
const { value, loading, error } = useAsync(async () => {
const entities = await catalogApi.getEntities();
return entities.filter(entity => {
const response = await catalogApi.getEntities();
return response.items.filter(entity => {
return !!entity.metadata.annotations?.['backstage.io/techdocs-ref'];
});
});
+20 -1
View File
@@ -45,5 +45,24 @@
},
"files": [
"dist"
]
],
"configSchema": {
"$schema": "https://backstage.io/schema/config-v1",
"title": "@backstage/user-settings",
"type": "object",
"properties": {
"auth": {
"type": "object",
"properties": {
"providers": {
"type": "object",
"additionalProperties": {
"type": "object",
"visibility": "frontend"
}
}
}
}
}
}
}