Merge remote-tracking branch 'origin/master' into orkohunter/use-config-for-backend-base-url
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Vendored
+52
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user