Merge branch 'master' of github.com:spotify/backstage into mob/techdocs-landing-page
This commit is contained in:
@@ -15,4 +15,5 @@ for URL in \
|
||||
--request POST 'localhost:7000/catalog/locations' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw "{\"type\": \"github\", \"target\": \"https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/${URL}\"}"
|
||||
echo
|
||||
done
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"helmet": "^3.22.0",
|
||||
"morgan": "^1.10.0",
|
||||
"nodegit": "0.26.5",
|
||||
"uuid": "^8.2.0",
|
||||
"winston": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -9,4 +9,5 @@ for URL in \
|
||||
--request POST 'localhost:7000/catalog/locations' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw "{\"type\": \"file\", \"target\": \"$(pwd)/sample-templates/${URL}/template.yaml\"}"
|
||||
echo
|
||||
done
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './templater';
|
||||
export * from './prepare';
|
||||
export * from './templater/cookiecutter';
|
||||
export * from './stages/templater';
|
||||
export * from './stages/templater/cookiecutter';
|
||||
export * from './stages/prepare';
|
||||
export * from './jobs';
|
||||
|
||||
@@ -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 * from './processor';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 { PassThrough } from 'stream';
|
||||
import winston from 'winston';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
|
||||
export const useLogStream = (meta: Record<string, JsonValue>) => {
|
||||
const log: string[] = [];
|
||||
|
||||
// Create an empty stream to collect all the log lines into
|
||||
// one variable for the API.
|
||||
const stream = new PassThrough();
|
||||
stream.on('data', chunk => log.push(chunk.toString()));
|
||||
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.timestamp(),
|
||||
winston.format.simple(),
|
||||
),
|
||||
defaultMeta: meta,
|
||||
});
|
||||
|
||||
logger.add(new winston.transports.Stream({ stream }));
|
||||
|
||||
return {
|
||||
log,
|
||||
stream,
|
||||
logger,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* 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 { JobProcessor } from './processor';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { StageInput } from './types';
|
||||
|
||||
describe('JobProcessor', () => {
|
||||
const mockEntity: TemplateEntityV1alpha1 = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
annotations: {
|
||||
'backstage.io/managed-by-location':
|
||||
'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml',
|
||||
},
|
||||
name: 'graphql-starter',
|
||||
title: 'GraphQL Service',
|
||||
description:
|
||||
'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n',
|
||||
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
|
||||
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
|
||||
|
||||
generation: 1,
|
||||
},
|
||||
spec: {
|
||||
type: 'cookiecutter',
|
||||
path: './template',
|
||||
},
|
||||
};
|
||||
|
||||
const mockValues = { component_id: 'bob' };
|
||||
|
||||
describe('create', () => {
|
||||
it('creates should create a new job with a unique id', async () => {
|
||||
const processor = new JobProcessor();
|
||||
|
||||
const job = processor.create({
|
||||
entity: mockEntity,
|
||||
values: mockValues,
|
||||
stages: [],
|
||||
});
|
||||
|
||||
expect(job.id).toMatch(
|
||||
/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should setup the correct context for the job', async () => {
|
||||
const processor = new JobProcessor();
|
||||
|
||||
const job = processor.create({
|
||||
entity: mockEntity,
|
||||
values: mockValues,
|
||||
stages: [],
|
||||
});
|
||||
|
||||
expect(job.context.entity).toBe(mockEntity);
|
||||
expect(job.context.values).toBe(mockValues);
|
||||
});
|
||||
|
||||
it('should set the status as pending', async () => {
|
||||
const processor = new JobProcessor();
|
||||
|
||||
const job = processor.create({
|
||||
entity: mockEntity,
|
||||
values: mockValues,
|
||||
stages: [],
|
||||
});
|
||||
|
||||
expect(job.status).toBe('PENDING');
|
||||
});
|
||||
|
||||
it('should create the correct stages', async () => {
|
||||
const stages: StageInput[] = [
|
||||
{
|
||||
name: 'Do something cool step 1',
|
||||
handler: jest.fn(),
|
||||
},
|
||||
{
|
||||
name: 'Do something cool step 2',
|
||||
handler: jest.fn(),
|
||||
},
|
||||
];
|
||||
|
||||
const processor = new JobProcessor();
|
||||
|
||||
const job = processor.create({
|
||||
entity: mockEntity,
|
||||
values: mockValues,
|
||||
stages,
|
||||
});
|
||||
|
||||
expect(job.stages).toHaveLength(stages.length);
|
||||
|
||||
for (let i = 0; i < job.stages.length; i++) {
|
||||
expect(job.stages[i].name).toBe(stages[i].name);
|
||||
expect(job.stages[i].status).toBe('PENDING');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('return undefined for when the job does not exist', () => {
|
||||
const processor = new JobProcessor();
|
||||
expect(processor.get('123')).not.toBeDefined();
|
||||
});
|
||||
|
||||
it('should return the exact same instance of the job when one is created', async () => {
|
||||
const processor = new JobProcessor();
|
||||
const job = processor.create({
|
||||
entity: mockEntity,
|
||||
values: mockValues,
|
||||
stages: [],
|
||||
});
|
||||
|
||||
expect(processor.get(job.id)).toBe(job);
|
||||
});
|
||||
});
|
||||
describe('process', () => {
|
||||
it('throws an error when the status of the job is not in pending state', async () => {
|
||||
const processor = new JobProcessor();
|
||||
const job = processor.create({
|
||||
entity: mockEntity,
|
||||
values: mockValues,
|
||||
stages: [],
|
||||
});
|
||||
|
||||
job.status = 'STARTED';
|
||||
|
||||
await expect(processor.run(job)).rejects.toThrow(
|
||||
/Job is not in a 'PENDING' state/,
|
||||
);
|
||||
});
|
||||
|
||||
it('will call each of the handlers in the stages', async () => {
|
||||
const stages: StageInput[] = [
|
||||
{
|
||||
name: 'c/o',
|
||||
handler: jest.fn(),
|
||||
},
|
||||
{
|
||||
name: 'g/p',
|
||||
handler: jest.fn(),
|
||||
},
|
||||
];
|
||||
|
||||
const processor = new JobProcessor();
|
||||
const job = processor.create({
|
||||
entity: mockEntity,
|
||||
values: mockValues,
|
||||
stages,
|
||||
});
|
||||
|
||||
await processor.run(job);
|
||||
|
||||
for (const stage of stages) {
|
||||
expect(stage.handler).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should set all stages to complete and the job to complete when finishes without errors', async () => {
|
||||
const stages: StageInput[] = [
|
||||
{
|
||||
name: 'c/o',
|
||||
handler: jest.fn(),
|
||||
},
|
||||
{
|
||||
name: 'g/p',
|
||||
handler: jest.fn(),
|
||||
},
|
||||
];
|
||||
|
||||
const processor = new JobProcessor();
|
||||
const job = processor.create({
|
||||
entity: mockEntity,
|
||||
values: mockValues,
|
||||
stages,
|
||||
});
|
||||
|
||||
await processor.run(job);
|
||||
|
||||
for (const stage of job.stages) {
|
||||
expect(stage.status).toBe('COMPLETED');
|
||||
}
|
||||
|
||||
expect(job.status).toBe('COMPLETED');
|
||||
});
|
||||
|
||||
it('should merge the return value from previous steps into the context of the next step', async () => {
|
||||
const stages: StageInput[] = [
|
||||
{
|
||||
name: 'c/o',
|
||||
handler: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ first: 'ben', second: 'lambert' }),
|
||||
},
|
||||
{
|
||||
name: 'g/p',
|
||||
handler: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ second: 'linus', third: 'lambert' }),
|
||||
},
|
||||
{
|
||||
name: 'go',
|
||||
handler: jest.fn(),
|
||||
},
|
||||
];
|
||||
|
||||
const processor = new JobProcessor();
|
||||
const job = processor.create({
|
||||
entity: mockEntity,
|
||||
values: mockValues,
|
||||
stages,
|
||||
});
|
||||
|
||||
await processor.run(job);
|
||||
|
||||
expect(stages[1].handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ first: 'ben', second: 'lambert' }),
|
||||
);
|
||||
|
||||
expect(stages[2].handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
first: 'ben',
|
||||
second: 'linus',
|
||||
third: 'lambert',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail the job and the step if one of them fails', async () => {
|
||||
const fail = new Error('something went wrong here');
|
||||
const stages: StageInput[] = [
|
||||
{
|
||||
name: 'c/o',
|
||||
handler: jest.fn(),
|
||||
},
|
||||
{
|
||||
name: 'g/p',
|
||||
handler: jest.fn().mockRejectedValue(fail),
|
||||
},
|
||||
{
|
||||
name: 'go',
|
||||
handler: jest.fn(),
|
||||
},
|
||||
];
|
||||
|
||||
const processor = new JobProcessor();
|
||||
const job = processor.create({
|
||||
entity: mockEntity,
|
||||
values: mockValues,
|
||||
stages,
|
||||
});
|
||||
|
||||
await processor.run(job);
|
||||
|
||||
expect(job.status).toBe('FAILED');
|
||||
expect(job.stages[0].status).toBe('COMPLETED');
|
||||
expect(job.stages[1].status).toBe('FAILED');
|
||||
expect(job.stages[2].status).toBe('PENDING');
|
||||
expect(job.error?.message).toBe('something went wrong here');
|
||||
expect(job.stages[1].log.join()).toContain('something went wrong here');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* 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 { Processor, Job, StageContext, StageInput } from './types';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import * as uuid from 'uuid';
|
||||
import Docker from 'dockerode';
|
||||
import { RequiredTemplateValues, TemplaterBase } from '../stages/templater';
|
||||
import { PreparerBuilder } from '../stages/prepare';
|
||||
import { useLogStream } from './logger';
|
||||
|
||||
export type JobProcessorArguments = {
|
||||
preparers: PreparerBuilder;
|
||||
templater: TemplaterBase;
|
||||
dockerClient: Docker;
|
||||
};
|
||||
|
||||
export type JobAndDirectoryTuple = {
|
||||
job: Job;
|
||||
directory: string;
|
||||
};
|
||||
|
||||
export class JobProcessor implements Processor {
|
||||
private jobs = new Map<string, Job>();
|
||||
|
||||
create({
|
||||
entity,
|
||||
values,
|
||||
stages,
|
||||
}: {
|
||||
entity: TemplateEntityV1alpha1;
|
||||
values: RequiredTemplateValues & Record<string, JsonValue>;
|
||||
stages: StageInput[];
|
||||
}): Job {
|
||||
const id = uuid.v4();
|
||||
const { logger, stream } = useLogStream({ id });
|
||||
|
||||
const context: StageContext = {
|
||||
entity,
|
||||
values,
|
||||
logger,
|
||||
logStream: stream,
|
||||
};
|
||||
|
||||
const job: Job = {
|
||||
id,
|
||||
context,
|
||||
stages: stages.map(stage => ({
|
||||
handler: stage.handler,
|
||||
log: [],
|
||||
name: stage.name,
|
||||
status: 'PENDING',
|
||||
})),
|
||||
status: 'PENDING',
|
||||
};
|
||||
|
||||
this.jobs.set(job.id, job);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
get(id: string): Job | undefined {
|
||||
return this.jobs.get(id);
|
||||
}
|
||||
|
||||
async run(job: Job): Promise<void> {
|
||||
if (job.status !== 'PENDING') {
|
||||
throw new Error("Job is not in a 'PENDING' state");
|
||||
}
|
||||
|
||||
job.status = 'STARTED';
|
||||
|
||||
try {
|
||||
for (const stage of job.stages) {
|
||||
// Create a logger for each stage so we can create seperate
|
||||
// Streams for each step.
|
||||
const { logger, log, stream } = useLogStream({
|
||||
id: job.id,
|
||||
stage: stage.name,
|
||||
});
|
||||
// Attach the logger to the stage, and setup some timestamps.
|
||||
stage.log = log;
|
||||
stage.startedAt = Date.now();
|
||||
|
||||
try {
|
||||
// Run the handler with the context created for the Job and some
|
||||
// Additional logging helpers.
|
||||
const handlerResponse = await stage.handler({
|
||||
...job.context,
|
||||
logger,
|
||||
logStream: stream,
|
||||
});
|
||||
|
||||
// If the handler returns something, then let's merge this onto the ontext
|
||||
// For the next stage to use as it might be relevant.
|
||||
if (handlerResponse) {
|
||||
job.context = {
|
||||
...job.context,
|
||||
...handlerResponse,
|
||||
};
|
||||
}
|
||||
|
||||
// Complete the current stage
|
||||
stage.status = 'COMPLETED';
|
||||
} catch (error) {
|
||||
// Log to the current stage the error that occured and fail the stage.
|
||||
logger.error(`Stage failed with error: ${error.message}`);
|
||||
stage.status = 'FAILED';
|
||||
|
||||
// Throw the error so the job can be failed too.
|
||||
throw error;
|
||||
} finally {
|
||||
// Always set the stage end timestamp.
|
||||
stage.endedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
// If all went to plan, complete the job.
|
||||
job.status = 'COMPLETED';
|
||||
} catch (error) {
|
||||
// If something went wrong, fail the job, and set the error property on the job.
|
||||
job.error = { name: error.name, message: error.message };
|
||||
job.status = 'FAILED';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import type { Writable } from 'stream';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { RequiredTemplateValues } from '../stages/templater';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
// Context will be a mutable object which is passed between stages
|
||||
// To share data, but also thinking that we can pass in functions here too
|
||||
// To maybe create sub steps or fail the entire thing, or skip stages down the line.
|
||||
export type StageContext<T = {}> = {
|
||||
values: RequiredTemplateValues & Record<string, JsonValue>;
|
||||
entity: TemplateEntityV1alpha1;
|
||||
logger: Logger;
|
||||
logStream: Writable;
|
||||
} & T;
|
||||
|
||||
export type ProcessorStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED';
|
||||
|
||||
export interface Stage extends StageInput {
|
||||
log: string[];
|
||||
status: ProcessorStatus;
|
||||
startedAt?: number;
|
||||
endedAt?: number;
|
||||
}
|
||||
|
||||
export interface StageInput<T = {}> {
|
||||
name: string;
|
||||
handler(ctx: StageContext<T>): Promise<void | object>;
|
||||
}
|
||||
|
||||
export type Job = {
|
||||
id: string;
|
||||
context: StageContext;
|
||||
status: ProcessorStatus;
|
||||
stages: Stage[];
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
export type Processor = {
|
||||
create({
|
||||
entity,
|
||||
values,
|
||||
stages,
|
||||
}: {
|
||||
entity: TemplateEntityV1alpha1;
|
||||
values: RequiredTemplateValues & Record<string, JsonValue>;
|
||||
stages: StageInput[];
|
||||
}): Job;
|
||||
|
||||
get(id: string): Job | undefined;
|
||||
|
||||
run(job: Job): Promise<void>;
|
||||
};
|
||||
+2
-2
@@ -60,7 +60,7 @@ describe('GitHubPreparer', () => {
|
||||
1,
|
||||
'https://github.com/benjdlambert/backstage-graphql-template',
|
||||
expect.any(String),
|
||||
{ checkoutOpts: { paths: ['template'] } },
|
||||
{},
|
||||
);
|
||||
});
|
||||
it('calls the clone command with the correct arguments for a repository when no path is provided', async () => {
|
||||
@@ -71,7 +71,7 @@ describe('GitHubPreparer', () => {
|
||||
1,
|
||||
'https://github.com/benjdlambert/backstage-graphql-template',
|
||||
expect.any(String),
|
||||
{ checkoutOpts: {} },
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
+1
-7
@@ -21,7 +21,7 @@ import { parseLocationAnnotation } from './helpers';
|
||||
import { InputError } from '@backstage/backend-common';
|
||||
import { PreparerBase } from './types';
|
||||
import GitUriParser from 'git-url-parse';
|
||||
import { Clone, CheckoutOptions } from 'nodegit';
|
||||
import { Clone } from 'nodegit';
|
||||
|
||||
export class GithubPreparer implements PreparerBase {
|
||||
async prepare(template: TemplateEntityV1alpha1): Promise<string> {
|
||||
@@ -45,13 +45,7 @@ export class GithubPreparer implements PreparerBase {
|
||||
template.spec.path ?? '.',
|
||||
);
|
||||
|
||||
const checkoutOptions = new CheckoutOptions();
|
||||
if (template.spec.path) {
|
||||
checkoutOptions.paths = [templateDirectory];
|
||||
}
|
||||
|
||||
await Clone.clone(repositoryCheckoutUrl, tempDir, {
|
||||
checkoutOpts: checkoutOptions,
|
||||
// TODO(blam): Maybe need some auth here?
|
||||
});
|
||||
|
||||
+5
-1
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import type { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
export type PreparerBase = {
|
||||
/**
|
||||
@@ -21,7 +22,10 @@ export type PreparerBase = {
|
||||
* with contents from the remote location in temporary storage and return the path
|
||||
* @param template The template entity from the Service Catalog
|
||||
*/
|
||||
prepare(template: TemplateEntityV1alpha1): Promise<string>;
|
||||
prepare(
|
||||
template: TemplateEntityV1alpha1,
|
||||
opts: { logger: Logger },
|
||||
): Promise<string>;
|
||||
};
|
||||
|
||||
export type PreparerBuilder = {
|
||||
+36
-16
@@ -18,6 +18,7 @@ jest.mock('./helpers', () => ({ runDockerContainer: jest.fn() }));
|
||||
import { CookieCutter } from './cookiecutter';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { RunDockerContainerOptions } from './helpers';
|
||||
import { PassThrough } from 'stream';
|
||||
import Docker from 'dockerode';
|
||||
@@ -33,12 +34,15 @@ describe('CookieCutter Templater', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
await fs.remove(`${os.tmpdir()}/cookiecutter.json`);
|
||||
});
|
||||
|
||||
const mkTemp = async () => {
|
||||
const tempDir = os.tmpdir();
|
||||
return await fs.promises.mkdtemp(path.join(tempDir, 'temp'));
|
||||
};
|
||||
|
||||
it('should write a cookiecutter.json file with the values from the entitiy', async () => {
|
||||
const tempdir = os.tmpdir();
|
||||
const tempdir = await mkTemp();
|
||||
|
||||
const values = {
|
||||
component_id: 'test',
|
||||
@@ -53,10 +57,11 @@ describe('CookieCutter Templater', () => {
|
||||
});
|
||||
|
||||
it('should merge any value that is in the cookiecutter.json path already', async () => {
|
||||
const tempdir = os.tmpdir();
|
||||
const tempdir = await mkTemp();
|
||||
const existingJson = {
|
||||
_copy_without_render: ['./github/workflows/*'],
|
||||
};
|
||||
|
||||
await fs.writeJSON(`${tempdir}/cookiecutter.json`, existingJson);
|
||||
|
||||
const values = {
|
||||
@@ -72,7 +77,7 @@ describe('CookieCutter Templater', () => {
|
||||
});
|
||||
|
||||
it('should throw an error if the cookiecutter json is malformed and not missing', async () => {
|
||||
const tempdir = os.tmpdir();
|
||||
const tempdir = await mkTemp();
|
||||
|
||||
await fs.writeFile(`${tempdir}/cookiecutter.json`, "{'");
|
||||
|
||||
@@ -87,7 +92,7 @@ describe('CookieCutter Templater', () => {
|
||||
});
|
||||
|
||||
it('should run the correct docker container with the correct bindings for the volumes', async () => {
|
||||
const tempdir = os.tmpdir();
|
||||
const tempdir = await mkTemp();
|
||||
|
||||
const values = {
|
||||
component_id: 'test',
|
||||
@@ -97,35 +102,43 @@ describe('CookieCutter Templater', () => {
|
||||
await cookie.run({ directory: tempdir, values, dockerClient: mockDocker });
|
||||
|
||||
expect(runDockerContainer).toHaveBeenCalledWith({
|
||||
imageName: 'backstage/cookiecutter',
|
||||
args: ['cookiecutter', '--no-input', '-o', '/result', '/template'],
|
||||
imageName: 'spotify/backstage-cookiecutter',
|
||||
args: [
|
||||
'cookiecutter',
|
||||
'--no-input',
|
||||
'-o',
|
||||
'/result',
|
||||
'/template',
|
||||
'--verbose',
|
||||
],
|
||||
templateDir: tempdir,
|
||||
resultDir: `${tempdir}/result`,
|
||||
resultDir: expect.stringContaining(`${tempdir}-result`),
|
||||
logStream: undefined,
|
||||
dockerClient: mockDocker,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the result path to the end templated folder', async () => {
|
||||
const tempdir = os.tmpdir();
|
||||
const tempdir = await mkTemp();
|
||||
|
||||
const values = {
|
||||
component_id: 'test',
|
||||
description: 'description',
|
||||
};
|
||||
|
||||
const path = await cookie.run({
|
||||
const returnPath = await cookie.run({
|
||||
directory: tempdir,
|
||||
values,
|
||||
dockerClient: mockDocker,
|
||||
});
|
||||
|
||||
expect(path).toBe(`${tempdir}/result`);
|
||||
expect(returnPath.startsWith(`${tempdir}-result`)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should pass through the streamer to the run docker helper', async () => {
|
||||
const stream = new PassThrough();
|
||||
|
||||
const tempdir = os.tmpdir();
|
||||
const tempdir = await mkTemp();
|
||||
|
||||
const values = {
|
||||
component_id: 'test',
|
||||
@@ -140,10 +153,17 @@ describe('CookieCutter Templater', () => {
|
||||
});
|
||||
|
||||
expect(runDockerContainer).toHaveBeenCalledWith({
|
||||
imageName: 'backstage/cookiecutter',
|
||||
args: ['cookiecutter', '--no-input', '-o', '/result', '/template'],
|
||||
imageName: 'spotify/backstage-cookiecutter',
|
||||
args: [
|
||||
'cookiecutter',
|
||||
'--no-input',
|
||||
'-o',
|
||||
'/result',
|
||||
'/template',
|
||||
'--verbose',
|
||||
],
|
||||
templateDir: tempdir,
|
||||
resultDir: `${tempdir}/result`,
|
||||
resultDir: expect.stringContaining(`${tempdir}-result`),
|
||||
logStream: stream,
|
||||
dockerClient: mockDocker,
|
||||
});
|
||||
+10
-6
@@ -48,14 +48,18 @@ export class CookieCutter implements TemplaterBase {
|
||||
await fs.writeJSON(`${options.directory}/cookiecutter.json`, cookieInfo);
|
||||
|
||||
const templateDir = options.directory;
|
||||
|
||||
// TODO(blam): This should be an entirely different directory on the host machine
|
||||
// not in the template directory
|
||||
const resultDir = `${templateDir}/result`;
|
||||
const resultDir = await fs.promises.mkdtemp(`${options.directory}-result`);
|
||||
|
||||
await runDockerContainer({
|
||||
imageName: 'backstage/cookiecutter',
|
||||
args: ['cookiecutter', '--no-input', '-o', '/result', '/template'],
|
||||
imageName: 'spotify/backstage-cookiecutter',
|
||||
args: [
|
||||
'cookiecutter',
|
||||
'--no-input',
|
||||
'-o',
|
||||
'/result',
|
||||
'/template',
|
||||
'--verbose',
|
||||
],
|
||||
templateDir,
|
||||
resultDir,
|
||||
logStream: options.logStream,
|
||||
+2
-1
@@ -16,6 +16,7 @@
|
||||
|
||||
import type { Writable } from 'stream';
|
||||
import Docker from 'dockerode';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
|
||||
export interface RequiredTemplateValues {
|
||||
component_id: string;
|
||||
@@ -23,7 +24,7 @@ export interface RequiredTemplateValues {
|
||||
|
||||
export interface TemplaterRunOptions {
|
||||
directory: string;
|
||||
values: RequiredTemplateValues & object;
|
||||
values: RequiredTemplateValues & Record<string, JsonValue>;
|
||||
logStream?: Writable;
|
||||
dockerClient: Docker;
|
||||
}
|
||||
@@ -17,10 +17,11 @@
|
||||
import { Logger } from 'winston';
|
||||
import Router from 'express-promise-router';
|
||||
import express from 'express';
|
||||
import { PreparerBuilder, TemplaterBase } from '../scaffolder';
|
||||
import { PreparerBuilder, TemplaterBase, JobProcessor } from '../scaffolder';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import Docker from 'dockerode';
|
||||
|
||||
import {} from '@backstage/backend-common';
|
||||
import { StageContext } from '../scaffolder/jobs/types';
|
||||
export interface RouterOptions {
|
||||
preparers: PreparerBuilder;
|
||||
templater: TemplaterBase;
|
||||
@@ -35,51 +36,117 @@ export async function createRouter(
|
||||
const { preparers, templater, logger: parentLogger, dockerClient } = options;
|
||||
const logger = parentLogger.child({ plugin: 'scaffolder' });
|
||||
|
||||
router.post('/v1/jobs', async (_, res) => {
|
||||
// TODO(blam): Create a unique job here and return the ID so that
|
||||
// The end user can poll for updates on the current job
|
||||
res.status(201).json({ accepted: true });
|
||||
const jobProcessor = new JobProcessor();
|
||||
|
||||
// TODO(blam): Take this entity from the post body sent from the frontend
|
||||
const mockEntity: TemplateEntityV1alpha1 = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
annotations: {
|
||||
'backstage.io/managed-by-location':
|
||||
'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml',
|
||||
router
|
||||
.get('/v1/job/:jobId/stage/:index/log', ({ params }, res) => {
|
||||
const job = jobProcessor.get(params.jobId);
|
||||
|
||||
if (!job) {
|
||||
res.status(404).send({ error: 'job not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.send(job.stages[Number(params.index)].log.join(''));
|
||||
})
|
||||
.get('/v1/job/:jobId', ({ params }, res) => {
|
||||
const job = jobProcessor.get(params.jobId);
|
||||
|
||||
if (!job) {
|
||||
res.status(404).send({ error: 'job not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.send({
|
||||
id: job.id,
|
||||
metadata: {
|
||||
...job.context,
|
||||
logger: undefined,
|
||||
logStream: undefined,
|
||||
},
|
||||
name: 'graphql-starter',
|
||||
title: 'GraphQL Service',
|
||||
description:
|
||||
'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n',
|
||||
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
|
||||
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
|
||||
status: job.status,
|
||||
stages: job.stages.map(stage => ({
|
||||
...stage,
|
||||
handler: undefined,
|
||||
})),
|
||||
error: job.error,
|
||||
});
|
||||
})
|
||||
.post('/v1/jobs', async (_, res) => {
|
||||
// TODO(blam): Create a unique job here and return the ID so that
|
||||
// The end user can poll for updates on the current job
|
||||
|
||||
generation: 1,
|
||||
},
|
||||
spec: {
|
||||
type: 'cookiecutter',
|
||||
path: './template',
|
||||
},
|
||||
};
|
||||
// TODO(blam): Take this entity from the post body sent from the frontend
|
||||
const mockEntity: TemplateEntityV1alpha1 = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
annotations: {
|
||||
'backstage.io/managed-by-location':
|
||||
'github:https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml',
|
||||
},
|
||||
name: 'graphql-starter',
|
||||
title: 'GraphQL Service',
|
||||
description:
|
||||
'A GraphQL starter template for backstage to get you up and running\nthe best pracices with GraphQL\n',
|
||||
uid: '9cf16bad-16e0-4213-b314-c4eec773c50b',
|
||||
etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2',
|
||||
|
||||
// Get the preparer for the mock entity
|
||||
const preparer = preparers.get(mockEntity);
|
||||
generation: 1,
|
||||
},
|
||||
spec: {
|
||||
type: 'cookiecutter',
|
||||
path: './template',
|
||||
},
|
||||
};
|
||||
|
||||
// Run the preparer for the mock entity to produce a temporary directory with template in
|
||||
const skeletonPath = await preparer.prepare(mockEntity);
|
||||
const job = jobProcessor.create({
|
||||
entity: mockEntity,
|
||||
values: { component_id: 'blob' },
|
||||
stages: [
|
||||
{
|
||||
name: 'Prepare the skeleton',
|
||||
handler: async ctx => {
|
||||
const preparer = preparers.get(ctx.entity);
|
||||
const skeletonDir = await preparer.prepare(ctx.entity, {
|
||||
logger: ctx.logger,
|
||||
});
|
||||
return { skeletonDir };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Run the templater',
|
||||
handler: async (ctx: StageContext<{ skeletonDir: string }>) => {
|
||||
const resultDir = await templater.run({
|
||||
directory: ctx.skeletonDir,
|
||||
dockerClient,
|
||||
logStream: ctx.logStream,
|
||||
values: ctx.values,
|
||||
});
|
||||
|
||||
// Run the templater on the mock directory with values from the post body
|
||||
const templatedPath = await templater.run({
|
||||
directory: skeletonPath,
|
||||
values: { component_id: 'test' },
|
||||
dockerClient,
|
||||
return { resultDir };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Create VCS Repo',
|
||||
handler: async (ctx: StageContext<{ resultDir: string }>) => {
|
||||
ctx.logger.info('Should now create the VCS repo');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Push to remote',
|
||||
handler: async ctx => {
|
||||
ctx.logger.info('Should now push to the remote');
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
res.status(201).json({ id: job.id });
|
||||
|
||||
jobProcessor.run(job);
|
||||
});
|
||||
|
||||
console.warn(templatedPath);
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.set('logger', logger);
|
||||
app.use('/', router);
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"@types/react": "^16.9",
|
||||
"color": "^3.1.2",
|
||||
"d3-force": "^2.0.1",
|
||||
"prop-types": "^15.7.2",
|
||||
@@ -43,6 +42,7 @@
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/color": "^3.0.1",
|
||||
"@types/d3-force": "^1.2.1",
|
||||
"@types/react": "^16.9",
|
||||
"@types/jest": "^25.2.2",
|
||||
"@types/node": "^12.0.0",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { createApiRef } from '@backstage/core';
|
||||
import { MovedState } from './utils/types';
|
||||
|
||||
/**
|
||||
* Types related to the Radar's visualization.
|
||||
@@ -34,7 +35,7 @@ export interface RadarQuadrant {
|
||||
export interface RadarEntry {
|
||||
key: string; // react key
|
||||
id: string;
|
||||
moved: number;
|
||||
moved: MovedState;
|
||||
quadrant: RadarQuadrant;
|
||||
ring: RadarRing;
|
||||
title: string;
|
||||
|
||||
@@ -20,9 +20,9 @@ import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
|
||||
|
||||
import Radar from './Radar';
|
||||
import Radar, { Props } from './Radar';
|
||||
|
||||
const minProps = {
|
||||
const minProps: Props = {
|
||||
width: 500,
|
||||
height: 200,
|
||||
quadrants: [{ id: 'languages', name: 'Languages' }],
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, useState, useRef } from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import RadarPlot from '../RadarPlot';
|
||||
import { Ring, Quadrant, Entry } from '../../utils/types';
|
||||
import type { Ring, Quadrant, Entry } from '../../utils/types';
|
||||
import { adjustQuadrants, adjustRings, adjustEntries } from './utils';
|
||||
|
||||
type Props = {
|
||||
export type Props = {
|
||||
width: number;
|
||||
height: number;
|
||||
quadrants: Quadrant[];
|
||||
@@ -28,17 +28,17 @@ type Props = {
|
||||
svgProps?: object;
|
||||
};
|
||||
|
||||
const Radar: FC<Props> = props => {
|
||||
const Radar = (props: Props): JSX.Element => {
|
||||
const { width, height, quadrants, rings, entries } = props;
|
||||
const radius = Math.min(width, height) / 2;
|
||||
|
||||
const [activeEntry, setActiveEntry] = useState<Entry | null>();
|
||||
const [activeEntry, setActiveEntry] = useState<Entry>();
|
||||
const node = useRef<SVGSVGElement>(null);
|
||||
|
||||
// TODO(dflemstr): most of this can be heavily memoized if performance becomes a problem
|
||||
adjustQuadrants(quadrants, radius, width, height);
|
||||
adjustRings(rings, radius);
|
||||
adjustEntries(entries, activeEntry, quadrants, rings, radius);
|
||||
adjustEntries(entries, quadrants, rings, radius, activeEntry);
|
||||
|
||||
return (
|
||||
<svg ref={node} width={width} height={height} {...props.svgProps}>
|
||||
@@ -49,9 +49,9 @@ const Radar: FC<Props> = props => {
|
||||
entries={entries}
|
||||
quadrants={quadrants}
|
||||
rings={rings}
|
||||
activeEntry={activeEntry || undefined}
|
||||
activeEntry={activeEntry}
|
||||
onEntryMouseEnter={entry => setActiveEntry(entry)}
|
||||
onEntryMouseLeave={() => setActiveEntry(null)}
|
||||
onEntryMouseLeave={() => setActiveEntry(undefined)}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import color from 'color';
|
||||
import { forceCollide, forceSimulation } from 'd3-force';
|
||||
import Segment from '../../utils/segment';
|
||||
import { Ring, Quadrant, Entry } from '../../utils/types';
|
||||
import type { Ring, Quadrant, Entry } from '../../utils/types';
|
||||
|
||||
export const adjustQuadrants = (
|
||||
quadrants: Quadrant[],
|
||||
@@ -81,14 +81,14 @@ export const adjustQuadrants = (
|
||||
},
|
||||
];
|
||||
|
||||
quadrants.forEach((quadrant, idx) => {
|
||||
const legendParam = legendParams[idx % 4];
|
||||
quadrants.forEach((quadrant, index) => {
|
||||
const legendParam = legendParams[index % 4];
|
||||
|
||||
quadrant.idx = idx;
|
||||
quadrant.radialMin = (idx * Math.PI) / 2;
|
||||
quadrant.radialMax = ((idx + 1) * Math.PI) / 2;
|
||||
quadrant.offsetX = idx % 4 === 0 || idx % 4 === 3 ? 1 : -1;
|
||||
quadrant.offsetY = idx % 4 === 0 || idx % 4 === 1 ? 1 : -1;
|
||||
quadrant.index = index;
|
||||
quadrant.radialMin = (index * Math.PI) / 2;
|
||||
quadrant.radialMax = ((index + 1) * Math.PI) / 2;
|
||||
quadrant.offsetX = index % 4 === 0 || index % 4 === 3 ? 1 : -1;
|
||||
quadrant.offsetY = index % 4 === 0 || index % 4 === 1 ? 1 : -1;
|
||||
quadrant.legendX = legendParam.x;
|
||||
quadrant.legendY = legendParam.y;
|
||||
quadrant.legendWidth = legendParam.width;
|
||||
@@ -98,13 +98,13 @@ export const adjustQuadrants = (
|
||||
|
||||
export const adjustEntries = (
|
||||
entries: Entry[],
|
||||
activeEntry: Entry | null | undefined,
|
||||
quadrants: Quadrant[],
|
||||
rings: Ring[],
|
||||
radius: number,
|
||||
activeEntry?: Entry,
|
||||
) => {
|
||||
let seed = 42;
|
||||
entries.forEach((entry, idx) => {
|
||||
entries.forEach((entry, index) => {
|
||||
const quadrant = quadrants.find(q => {
|
||||
const match =
|
||||
typeof entry.quadrant === 'object' ? entry.quadrant.id : entry.quadrant;
|
||||
@@ -124,7 +124,7 @@ export const adjustEntries = (
|
||||
throw new Error(`Unknown ring ${entry.ring} for entry ${entry.id}!`);
|
||||
}
|
||||
|
||||
entry.idx = idx;
|
||||
entry.index = index;
|
||||
entry.quadrant = quadrant;
|
||||
entry.ring = ring;
|
||||
entry.segment = new Segment(quadrant, ring, radius, () => seed++);
|
||||
@@ -163,10 +163,10 @@ export const adjustEntries = (
|
||||
};
|
||||
|
||||
export const adjustRings = (rings: Ring[], radius: number) => {
|
||||
rings.forEach((ring, idx) => {
|
||||
ring.idx = idx;
|
||||
ring.outerRadius = ((idx + 2) / (rings.length + 1)) * radius;
|
||||
rings.forEach((ring, index) => {
|
||||
ring.index = index;
|
||||
ring.outerRadius = ((index + 2) / (rings.length + 1)) * radius;
|
||||
ring.innerRadius =
|
||||
((idx === 0 ? 0 : idx + 1) / (rings.length + 1)) * radius;
|
||||
((index === 0 ? 0 : index + 1) / (rings.length + 1)) * radius;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
|
||||
|
||||
import RadarBubble, { Props } from './RadarBubble';
|
||||
|
||||
const minProps: Props = {
|
||||
visible: true,
|
||||
text: 'RadarBubble',
|
||||
x: 2,
|
||||
y: 2,
|
||||
};
|
||||
|
||||
describe('RadarBubble', () => {
|
||||
beforeAll(() => {
|
||||
GetBBoxPolyfill.create(0, 0, 1000, 500);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
GetBBoxPolyfill.remove();
|
||||
});
|
||||
|
||||
it('should render', () => {
|
||||
const rendered = render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<svg>
|
||||
<RadarBubble {...minProps} />
|
||||
</svg>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(rendered.getByText(minProps.text)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -14,10 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, useRef, useLayoutEffect } from 'react';
|
||||
import React, { useRef, useLayoutEffect } from 'react';
|
||||
import { makeStyles, Theme } from '@material-ui/core';
|
||||
|
||||
type Props = {
|
||||
export type Props = {
|
||||
visible: boolean;
|
||||
text: string;
|
||||
x: number;
|
||||
@@ -46,7 +46,7 @@ const useStyles = makeStyles<Theme>(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const RadarBubble: FC<Props> = props => {
|
||||
const RadarBubble = (props: Props): JSX.Element => {
|
||||
const classes = useStyles(props);
|
||||
const { visible, text } = props;
|
||||
|
||||
@@ -98,6 +98,7 @@ const RadarBubble: FC<Props> = props => {
|
||||
x={0}
|
||||
y={0}
|
||||
className={visible ? classes.visibleBubble : classes.bubble}
|
||||
data-testid="radar-bubble"
|
||||
>
|
||||
<rect ref={rectElem} rx={4} ry={4} className={classes.background} />
|
||||
<text ref={textElem} className={classes.text}>
|
||||
|
||||
@@ -14,50 +14,38 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, FC } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Progress, useApi, errorApiRef, ErrorApi } from '@backstage/core';
|
||||
import { useAsync } from 'react-use';
|
||||
import Radar from '../components/Radar';
|
||||
import { TechRadarComponentProps, TechRadarLoaderResponse } from '../api';
|
||||
import getSampleData from '../sampleData';
|
||||
|
||||
const useTechRadarLoader = (props: TechRadarComponentProps) => {
|
||||
const errorApi = useApi<ErrorApi>(errorApiRef);
|
||||
const [state, setState] = useState<{
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
data?: TechRadarLoaderResponse;
|
||||
}>({
|
||||
loading: true,
|
||||
error: undefined,
|
||||
data: undefined,
|
||||
});
|
||||
|
||||
const { getData } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (!getData) {
|
||||
return;
|
||||
const state = useAsync(async () => {
|
||||
if (getData) {
|
||||
const response: TechRadarLoaderResponse = await getData();
|
||||
return response;
|
||||
}
|
||||
|
||||
getData()
|
||||
.then((payload: TechRadarLoaderResponse) => {
|
||||
setState({ loading: false, error: undefined, data: payload });
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
errorApi.post(err);
|
||||
setState({
|
||||
loading: false,
|
||||
error: err,
|
||||
data: undefined,
|
||||
});
|
||||
});
|
||||
return undefined;
|
||||
}, [getData, errorApi]);
|
||||
|
||||
useEffect(() => {
|
||||
const { error } = state;
|
||||
if (error) {
|
||||
errorApi.post(error);
|
||||
}
|
||||
}, [errorApi, state]);
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
const RadarComponent: FC<TechRadarComponentProps> = props => {
|
||||
const { loading, error, data } = useTechRadarLoader(props);
|
||||
const RadarComponent = (props: TechRadarComponentProps): JSX.Element => {
|
||||
const { loading, error, value: data } = useTechRadarLoader(props);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
|
||||
|
||||
import RadarEntry, { Props } from './RadarEntry';
|
||||
|
||||
const minProps: Props = {
|
||||
x: 2,
|
||||
y: 2,
|
||||
value: 2,
|
||||
color: 'red',
|
||||
};
|
||||
|
||||
describe('RadarEntry', () => {
|
||||
beforeAll(() => {
|
||||
GetBBoxPolyfill.create(0, 0, 1000, 500);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
GetBBoxPolyfill.remove();
|
||||
});
|
||||
|
||||
it('should render', () => {
|
||||
const rendered = render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<svg>
|
||||
<RadarEntry {...minProps} />
|
||||
</svg>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
const radarEntry = rendered.getByTestId('radar-entry');
|
||||
const { x, y } = minProps;
|
||||
expect(radarEntry).toBeInTheDocument();
|
||||
expect(radarEntry.getAttribute('transform')).toBe(`translate(${x}, ${y})`);
|
||||
expect(rendered.getByText(String(minProps.value))).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -14,13 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import React from 'react';
|
||||
import { makeStyles, Theme } from '@material-ui/core';
|
||||
import { WithLink } from '../../utils/components';
|
||||
|
||||
type Props = {
|
||||
export type Props = {
|
||||
x: number;
|
||||
y: number;
|
||||
number: number;
|
||||
value: number;
|
||||
color: string;
|
||||
url?: string;
|
||||
moved?: number;
|
||||
@@ -43,14 +44,27 @@ const useStyles = makeStyles<Theme>(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const RadarEntry: FC<Props> = props => {
|
||||
const makeBlip = (color: string, moved?: number) => {
|
||||
const style = { fill: color };
|
||||
|
||||
let blip = <circle r={9} style={style} />;
|
||||
if (moved && moved > 0) {
|
||||
blip = <path d="M -11,5 11,5 0,-13 z" style={style} />; // triangle pointing up
|
||||
} else if (moved && moved < 0) {
|
||||
blip = <path d="M -11,-5 11,-5 0,13 z" style={style} />; // triangle pointing down
|
||||
}
|
||||
|
||||
return blip;
|
||||
};
|
||||
|
||||
const RadarEntry = (props: Props): JSX.Element => {
|
||||
const classes = useStyles(props);
|
||||
|
||||
const {
|
||||
moved,
|
||||
color,
|
||||
url,
|
||||
number,
|
||||
value,
|
||||
x,
|
||||
y,
|
||||
onMouseEnter,
|
||||
@@ -58,24 +72,7 @@ const RadarEntry: FC<Props> = props => {
|
||||
onClick,
|
||||
} = props;
|
||||
|
||||
const style = { fill: color };
|
||||
|
||||
let blip;
|
||||
if (moved && moved > 0) {
|
||||
blip = <path d="M -11,5 11,5 0,-13 z" style={style} />; // triangle pointing up
|
||||
} else if (moved && moved < 0) {
|
||||
blip = <path d="M -11,-5 11,-5 0,13 z" style={style} />; // triangle pointing down
|
||||
} else {
|
||||
blip = <circle r={9} style={style} />;
|
||||
}
|
||||
|
||||
if (url) {
|
||||
blip = (
|
||||
<a href={url} className={classes.link}>
|
||||
{blip}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
const blip = makeBlip(color, moved);
|
||||
|
||||
return (
|
||||
<g
|
||||
@@ -83,10 +80,13 @@ const RadarEntry: FC<Props> = props => {
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onClick={onClick}
|
||||
data-testid="radar-entry"
|
||||
>
|
||||
{blip}
|
||||
<WithLink url={url} className={classes.link}>
|
||||
{blip}
|
||||
</WithLink>
|
||||
<text y={3} className={classes.text}>
|
||||
{number}
|
||||
{value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
|
||||
|
||||
import RadarFooter, { Props } from './RadarFooter';
|
||||
|
||||
const minProps: Props = {
|
||||
x: 2,
|
||||
y: 2,
|
||||
};
|
||||
|
||||
describe('RadarFooter', () => {
|
||||
beforeAll(() => {
|
||||
GetBBoxPolyfill.create(0, 0, 1000, 500);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
GetBBoxPolyfill.remove();
|
||||
});
|
||||
|
||||
it('should render', () => {
|
||||
const rendered = render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<svg>
|
||||
<RadarFooter {...minProps} />
|
||||
</svg>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
const radarFooter = rendered.getByTestId('radar-footer');
|
||||
const { x, y } = minProps;
|
||||
expect(radarFooter).toBeInTheDocument();
|
||||
expect(radarFooter.getAttribute('transform')).toBe(`translate(${x}, ${y})`);
|
||||
});
|
||||
});
|
||||
@@ -14,10 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import React from 'react';
|
||||
import { makeStyles, Theme } from '@material-ui/core';
|
||||
|
||||
type Props = {
|
||||
export type Props = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
@@ -31,12 +31,16 @@ const useStyles = makeStyles<Theme>(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const RadarFooter: FC<Props> = props => {
|
||||
const RadarFooter = (props: Props): JSX.Element => {
|
||||
const { x, y } = props;
|
||||
const classes = useStyles(props);
|
||||
|
||||
return (
|
||||
<text transform={`translate(${x}, ${y})`} className={classes.text}>
|
||||
<text
|
||||
data-testid="radar-footer"
|
||||
transform={`translate(${x}, ${y})`}
|
||||
className={classes.text}
|
||||
>
|
||||
{'▲ moved up\u00a0\u00a0\u00a0\u00a0\u00a0▼ moved down'}
|
||||
</text>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
|
||||
|
||||
import RadarGrid, { Props } from './RadarGrid';
|
||||
|
||||
const minProps: Props = {
|
||||
radius: 5,
|
||||
rings: [{ id: 'use', name: 'USE', color: '#93c47d' }],
|
||||
};
|
||||
|
||||
describe('RadarGrid', () => {
|
||||
beforeAll(() => {
|
||||
GetBBoxPolyfill.create(0, 0, 1000, 500);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
GetBBoxPolyfill.remove();
|
||||
});
|
||||
|
||||
it('should render', () => {
|
||||
const rendered = render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<svg>
|
||||
<RadarGrid {...minProps} />
|
||||
</svg>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(rendered.getByTestId('radar-grid-x-line')).toBeInTheDocument();
|
||||
expect(rendered.getByTestId('radar-grid-y-line')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -16,9 +16,9 @@
|
||||
|
||||
import React from 'react';
|
||||
import { makeStyles, Theme } from '@material-ui/core';
|
||||
import { Ring } from '../../utils/types';
|
||||
import type { Ring } from '../../utils/types';
|
||||
|
||||
type Props = {
|
||||
export type Props = {
|
||||
radius: number;
|
||||
rings: Ring[];
|
||||
};
|
||||
@@ -49,7 +49,7 @@ const RadarGrid = (props: Props) => {
|
||||
const { radius, rings } = props;
|
||||
const classes = useStyles(props);
|
||||
|
||||
const makeRingNode = (ringRadius: number | undefined, ringIndex: number) => [
|
||||
const makeRingNode = (ringIndex: number, ringRadius?: number) => [
|
||||
<circle
|
||||
key={`c${ringIndex}`}
|
||||
cx={0}
|
||||
@@ -76,6 +76,7 @@ const RadarGrid = (props: Props) => {
|
||||
x2={0}
|
||||
y2={radius}
|
||||
className={classes.axis}
|
||||
data-testid="radar-grid-x-line"
|
||||
/>,
|
||||
// Y axis
|
||||
<line
|
||||
@@ -85,10 +86,13 @@ const RadarGrid = (props: Props) => {
|
||||
x2={radius}
|
||||
y2={0}
|
||||
className={classes.axis}
|
||||
data-testid="radar-grid-y-line"
|
||||
/>,
|
||||
];
|
||||
|
||||
const ringNodes = rings.map(r => r.outerRadius).map(makeRingNode);
|
||||
const ringNodes = rings
|
||||
.map(r => r.outerRadius)
|
||||
.map((ringRadius, ringIndex) => makeRingNode(ringIndex, ringRadius));
|
||||
|
||||
return <>{axisNodes.concat(...ringNodes)}</>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
|
||||
|
||||
import RadarLegend, { Props } from './RadarLegend';
|
||||
|
||||
const minProps: Props = {
|
||||
quadrants: [{ id: 'languages', name: 'Languages' }],
|
||||
rings: [{ id: 'use', name: 'USE', color: '#93c47d' }],
|
||||
entries: [
|
||||
{
|
||||
id: 'typescript',
|
||||
title: 'TypeScript',
|
||||
quadrant: { id: 'languages', name: 'Languages' },
|
||||
moved: 0,
|
||||
ring: { id: 'use', name: 'USE', color: '#93c47d' },
|
||||
url: '#',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('RadarLegend', () => {
|
||||
beforeAll(() => {
|
||||
GetBBoxPolyfill.create(0, 0, 1000, 500);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
GetBBoxPolyfill.remove();
|
||||
});
|
||||
|
||||
it('should render', () => {
|
||||
const rendered = render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<svg>
|
||||
<RadarLegend {...minProps} />
|
||||
</svg>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(rendered.getByTestId('radar-legend')).toBeInTheDocument();
|
||||
expect(rendered.getAllByTestId('radar-quadrant')).toHaveLength(1);
|
||||
expect(rendered.getAllByTestId('radar-ring')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -13,15 +13,16 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React, { FC } from 'react';
|
||||
import React from 'react';
|
||||
import { makeStyles, Theme } from '@material-ui/core';
|
||||
import { Quadrant, Ring, Entry } from '../../utils/types';
|
||||
import type { Quadrant, Ring, Entry } from '../../utils/types';
|
||||
import { WithLink } from '../../utils/components';
|
||||
|
||||
type Segments = {
|
||||
[k: number]: { [k: number]: Entry[] };
|
||||
};
|
||||
|
||||
type Props = {
|
||||
export type Props = {
|
||||
quadrants: Quadrant[];
|
||||
rings: Ring[];
|
||||
entries: Entry[];
|
||||
@@ -29,7 +30,7 @@ type Props = {
|
||||
onEntryMouseLeave?: (entry: Entry) => void;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles<Theme>(() => ({
|
||||
const useStyles = makeStyles<Theme>(theme => ({
|
||||
quadrant: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
@@ -40,7 +41,7 @@ const useStyles = makeStyles<Theme>(() => ({
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
marginTop: 0,
|
||||
marginBottom: 'calc(18px * 0.375)',
|
||||
marginBottom: theme.spacing(8 / (18 * 0.375)),
|
||||
fontSize: '18px',
|
||||
},
|
||||
rings: {
|
||||
@@ -56,7 +57,7 @@ const useStyles = makeStyles<Theme>(() => ({
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
marginTop: 0,
|
||||
marginBottom: 'calc(12px * 0.375)',
|
||||
marginBottom: theme.spacing(8 / (12 * 0.375)),
|
||||
fontSize: '12px',
|
||||
fontWeight: 800,
|
||||
},
|
||||
@@ -79,73 +80,81 @@ const useStyles = makeStyles<Theme>(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const RadarLegend: FC<Props> = props => {
|
||||
const RadarLegend = (props: Props): JSX.Element => {
|
||||
const classes = useStyles(props);
|
||||
|
||||
const _getSegment = (
|
||||
const getSegment = (
|
||||
segmented: Segments,
|
||||
quadrant: Quadrant,
|
||||
ring: Ring,
|
||||
ringOffset = 0,
|
||||
) => {
|
||||
const qidx = quadrant.idx;
|
||||
const ridx = ring.idx;
|
||||
const segmentedData = qidx === undefined ? {} : segmented[qidx] || {};
|
||||
return ridx === undefined ? [] : segmentedData[ridx + ringOffset] || [];
|
||||
const quadrantIndex = quadrant.index;
|
||||
const ringIndex = ring.index;
|
||||
const segmentedData =
|
||||
quadrantIndex === undefined ? {} : segmented[quadrantIndex] || {};
|
||||
return ringIndex === undefined
|
||||
? []
|
||||
: segmentedData[ringIndex + ringOffset] || [];
|
||||
};
|
||||
|
||||
const _renderRing = (
|
||||
ring: Ring,
|
||||
entries: Entry[],
|
||||
onEntryMouseEnter?: Props['onEntryMouseEnter'],
|
||||
onEntryMouseLeave?: Props['onEntryMouseEnter'],
|
||||
) => {
|
||||
type RadarLegendRingProps = {
|
||||
ring: Ring;
|
||||
entries: Entry[];
|
||||
onEntryMouseEnter?: Props['onEntryMouseEnter'];
|
||||
onEntryMouseLeave?: Props['onEntryMouseEnter'];
|
||||
};
|
||||
|
||||
const RadarLegendRing = ({
|
||||
ring,
|
||||
entries,
|
||||
onEntryMouseEnter,
|
||||
onEntryMouseLeave,
|
||||
}: RadarLegendRingProps) => {
|
||||
return (
|
||||
<div key={ring.id} className={classes.ring}>
|
||||
<div data-testid="radar-ring" key={ring.id} className={classes.ring}>
|
||||
<h3 className={classes.ringHeading}>{ring.name}</h3>
|
||||
{entries.length === 0 ? (
|
||||
<p>(empty)</p>
|
||||
) : (
|
||||
<ol className={classes.ringList}>
|
||||
{entries.map(entry => {
|
||||
let node = <span className={classes.entry}>{entry.title}</span>;
|
||||
|
||||
if (entry.url) {
|
||||
node = (
|
||||
<a className={classes.entryLink} href={entry.url}>
|
||||
{node}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
key={entry.id}
|
||||
value={(entry.idx || 0) + 1}
|
||||
onMouseEnter={
|
||||
onEntryMouseEnter && (() => onEntryMouseEnter(entry))
|
||||
}
|
||||
onMouseLeave={
|
||||
onEntryMouseLeave && (() => onEntryMouseLeave(entry))
|
||||
}
|
||||
>
|
||||
{node}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{entries.map(entry => (
|
||||
<li
|
||||
key={entry.id}
|
||||
value={(entry.index || 0) + 1}
|
||||
onMouseEnter={
|
||||
onEntryMouseEnter && (() => onEntryMouseEnter(entry))
|
||||
}
|
||||
onMouseLeave={
|
||||
onEntryMouseLeave && (() => onEntryMouseLeave(entry))
|
||||
}
|
||||
>
|
||||
<WithLink url={entry.url} className={classes.entryLink}>
|
||||
<span className={classes.entry}>{entry.title}</span>
|
||||
</WithLink>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const _renderQuadrant = (
|
||||
segments: Segments,
|
||||
quadrant: Quadrant,
|
||||
rings: Ring[],
|
||||
onEntryMouseEnter: Props['onEntryMouseEnter'],
|
||||
onEntryMouseLeave: Props['onEntryMouseLeave'],
|
||||
) => {
|
||||
type RadarLegendQuadrantProps = {
|
||||
segments: Segments;
|
||||
quadrant: Quadrant;
|
||||
rings: Ring[];
|
||||
onEntryMouseEnter: Props['onEntryMouseEnter'];
|
||||
onEntryMouseLeave: Props['onEntryMouseLeave'];
|
||||
};
|
||||
|
||||
const RadarLegendQuadrant = ({
|
||||
segments,
|
||||
quadrant,
|
||||
rings,
|
||||
onEntryMouseEnter,
|
||||
onEntryMouseLeave,
|
||||
}: RadarLegendQuadrantProps) => {
|
||||
return (
|
||||
<foreignObject
|
||||
key={quadrant.id}
|
||||
@@ -153,46 +162,48 @@ const RadarLegend: FC<Props> = props => {
|
||||
y={quadrant.legendY}
|
||||
width={quadrant.legendWidth}
|
||||
height={quadrant.legendHeight}
|
||||
data-testid="radar-quadrant"
|
||||
>
|
||||
<div className={classes.quadrant}>
|
||||
<h2 className={classes.quadrantHeading}>{quadrant.name}</h2>
|
||||
<div className={classes.rings}>
|
||||
{rings.map(ring =>
|
||||
_renderRing(
|
||||
ring,
|
||||
_getSegment(segments, quadrant, ring),
|
||||
onEntryMouseEnter,
|
||||
onEntryMouseLeave,
|
||||
),
|
||||
)}
|
||||
{rings.map(ring => (
|
||||
<RadarLegendRing
|
||||
key={ring.id}
|
||||
ring={ring}
|
||||
entries={getSegment(segments, quadrant, ring)}
|
||||
onEntryMouseEnter={onEntryMouseEnter}
|
||||
onEntryMouseLeave={onEntryMouseLeave}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
);
|
||||
};
|
||||
|
||||
const _setupSegments = (entries: Entry[]) => {
|
||||
const setupSegments = (entries: Entry[]) => {
|
||||
const segments: Segments = {};
|
||||
|
||||
for (const entry of entries) {
|
||||
const qidx = entry.quadrant.idx;
|
||||
const ridx = entry.ring.idx;
|
||||
const quadrantIndex = entry.quadrant.index;
|
||||
const ringIndex = entry.ring.index;
|
||||
let quadrantData: { [k: number]: Entry[] } = {};
|
||||
if (qidx !== undefined) {
|
||||
if (segments[qidx] === undefined) {
|
||||
segments[qidx] = {};
|
||||
if (quadrantIndex !== undefined) {
|
||||
if (segments[quadrantIndex] === undefined) {
|
||||
segments[quadrantIndex] = {};
|
||||
}
|
||||
|
||||
quadrantData = segments[qidx];
|
||||
quadrantData = segments[quadrantIndex];
|
||||
}
|
||||
|
||||
let ringData = [];
|
||||
if (ridx !== undefined) {
|
||||
if (quadrantData[ridx] === undefined) {
|
||||
quadrantData[ridx] = [];
|
||||
if (ringIndex !== undefined) {
|
||||
if (quadrantData[ringIndex] === undefined) {
|
||||
quadrantData[ringIndex] = [];
|
||||
}
|
||||
|
||||
ringData = quadrantData[ridx];
|
||||
ringData = quadrantData[ringIndex];
|
||||
}
|
||||
|
||||
ringData.push(entry);
|
||||
@@ -209,19 +220,20 @@ const RadarLegend: FC<Props> = props => {
|
||||
onEntryMouseLeave,
|
||||
} = props;
|
||||
|
||||
const segments: Segments = _setupSegments(entries);
|
||||
const segments: Segments = setupSegments(entries);
|
||||
|
||||
return (
|
||||
<g>
|
||||
{quadrants.map(quadrant =>
|
||||
_renderQuadrant(
|
||||
segments,
|
||||
quadrant,
|
||||
rings,
|
||||
onEntryMouseEnter,
|
||||
onEntryMouseLeave,
|
||||
),
|
||||
)}
|
||||
<g data-testid="radar-legend">
|
||||
{quadrants.map(quadrant => (
|
||||
<RadarLegendQuadrant
|
||||
key={quadrant.id}
|
||||
segments={segments}
|
||||
quadrant={quadrant}
|
||||
rings={rings}
|
||||
onEntryMouseEnter={onEntryMouseEnter}
|
||||
onEntryMouseLeave={onEntryMouseLeave}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import React from 'react';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import {
|
||||
Content,
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
import RadarComponent from '../components/RadarComponent';
|
||||
import { techRadarApiRef, TechRadarApi } from '../api';
|
||||
|
||||
const RadarPage: FC<{}> = () => {
|
||||
const RadarPage = (): JSX.Element => {
|
||||
const techRadarApi = useApi<TechRadarApi>(techRadarApiRef);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import GetBBoxPolyfill from '../../utils/polyfills/getBBox';
|
||||
|
||||
import RadarPlot, { Props } from './RadarPlot';
|
||||
|
||||
const minProps: Props = {
|
||||
width: 500,
|
||||
height: 200,
|
||||
radius: 50,
|
||||
quadrants: [{ id: 'languages', name: 'Languages' }],
|
||||
rings: [{ id: 'use', name: 'USE', color: '#93c47d' }],
|
||||
entries: [
|
||||
{
|
||||
id: 'typescript',
|
||||
title: 'TypeScript',
|
||||
quadrant: { id: 'languages', name: 'Languages' },
|
||||
moved: 0,
|
||||
ring: { id: 'use', name: 'USE', color: '#93c47d' },
|
||||
url: '#',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('RadarPlot', () => {
|
||||
beforeAll(() => {
|
||||
GetBBoxPolyfill.create(0, 0, 1000, 500);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
GetBBoxPolyfill.remove();
|
||||
});
|
||||
|
||||
it('should render', () => {
|
||||
const rendered = render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<svg>
|
||||
<RadarPlot {...minProps} />
|
||||
</svg>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
expect(rendered.getByTestId('radar-plot')).toBeInTheDocument();
|
||||
expect(rendered.getByTestId('radar-legend')).toBeInTheDocument();
|
||||
expect(rendered.getByTestId('radar-footer')).toBeInTheDocument();
|
||||
expect(rendered.getByTestId('radar-bubble')).toBeInTheDocument();
|
||||
expect(rendered.getAllByTestId('radar-entry')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import { Quadrant, Ring, Entry } from '../../utils/types';
|
||||
import React from 'react';
|
||||
import type { Quadrant, Ring, Entry } from '../../utils/types';
|
||||
|
||||
import RadarGrid from '../RadarGrid';
|
||||
import RadarEntry from '../RadarEntry';
|
||||
@@ -23,7 +23,7 @@ import RadarBubble from '../RadarBubble';
|
||||
import RadarFooter from '../RadarFooter';
|
||||
import RadarLegend from '../RadarLegend';
|
||||
|
||||
type Props = {
|
||||
export type Props = {
|
||||
width: number;
|
||||
height: number;
|
||||
radius: number;
|
||||
@@ -36,7 +36,7 @@ type Props = {
|
||||
};
|
||||
|
||||
// A component that draws the radar circle.
|
||||
const RadarPlot: FC<Props> = props => {
|
||||
const RadarPlot = (props: Props): JSX.Element => {
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
@@ -50,7 +50,7 @@ const RadarPlot: FC<Props> = props => {
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<g data-testid="radar-plot">
|
||||
<RadarLegend
|
||||
quadrants={quadrants}
|
||||
rings={rings}
|
||||
@@ -71,7 +71,7 @@ const RadarPlot: FC<Props> = props => {
|
||||
x={entry.x || 0}
|
||||
y={entry.y || 0}
|
||||
color={entry.color || ''}
|
||||
number={((entry && entry.idx) || 0) + 1}
|
||||
value={(entry?.index || 0) + 1}
|
||||
url={entry.url}
|
||||
moved={entry.moved}
|
||||
onMouseEnter={onEntryMouseEnter && (() => onEntryMouseEnter(entry))}
|
||||
@@ -80,9 +80,9 @@ const RadarPlot: FC<Props> = props => {
|
||||
))}
|
||||
<RadarBubble
|
||||
visible={!!activeEntry}
|
||||
text={activeEntry ? activeEntry.title : ''}
|
||||
x={activeEntry ? activeEntry.x || 0 : 0}
|
||||
y={activeEntry ? activeEntry.y || 0 : 0}
|
||||
text={activeEntry?.title || ''}
|
||||
x={activeEntry?.x || 0}
|
||||
y={activeEntry?.y || 0}
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
type WithLinkProps = {
|
||||
url?: string;
|
||||
className: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const WithLink = ({
|
||||
url,
|
||||
className,
|
||||
children,
|
||||
}: WithLinkProps): JSX.Element =>
|
||||
url ? (
|
||||
<a href={url} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
) : (
|
||||
<>{children}</>
|
||||
);
|
||||
@@ -17,7 +17,7 @@
|
||||
// Parameters for a ring; its index in an array determines how close to the center this ring is.
|
||||
export type Ring = {
|
||||
id: string;
|
||||
idx?: number;
|
||||
index?: number;
|
||||
name: string;
|
||||
color: string;
|
||||
outerRadius?: number;
|
||||
@@ -27,7 +27,7 @@ export type Ring = {
|
||||
// Parameters for a quadrant (there should be exactly 4 of course)
|
||||
export type Quadrant = {
|
||||
id: string;
|
||||
idx?: number;
|
||||
index?: number;
|
||||
name: string;
|
||||
legendX?: number;
|
||||
legendY?: number;
|
||||
@@ -45,9 +45,15 @@ export type Segment = {
|
||||
random: Function;
|
||||
};
|
||||
|
||||
export enum MovedState {
|
||||
Down = -1,
|
||||
NoChange = 0,
|
||||
Up = 1,
|
||||
}
|
||||
|
||||
export type Entry = {
|
||||
id: string;
|
||||
idx?: number;
|
||||
index?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
color?: string;
|
||||
@@ -61,7 +67,7 @@ export type Entry = {
|
||||
// An URL to a longer description as to why this entry is where it is
|
||||
url?: string;
|
||||
// How this entry has recently moved; -1 for "down", +1 for "up", 0 for not moved
|
||||
moved?: number;
|
||||
moved?: MovedState;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
import React from 'react';
|
||||
import { useShadowDom } from '..';
|
||||
import { useAsync } from 'react-use';
|
||||
import { useLocation, useParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Grid } from '@material-ui/core';
|
||||
import { Header, Content, ItemCard } from '@backstage/core';
|
||||
|
||||
import transformer, {
|
||||
addBaseUrl,
|
||||
rewriteDocLinks,
|
||||
@@ -25,9 +30,6 @@ import transformer, {
|
||||
modifyCssTransformer,
|
||||
} from '../transformers';
|
||||
import { docStorageURL } from '../../config';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import { Header, Content, ItemCard } from '@backstage/core';
|
||||
import { useLocation, useParams, useNavigate } from 'react-router-dom';
|
||||
import URLParser from '../urlParser';
|
||||
|
||||
const useFetch = (url: string) => {
|
||||
|
||||
Reference in New Issue
Block a user