diff --git a/packages/backend-common/src/logging/rootLogger.ts b/packages/backend-common/src/logging/rootLogger.ts index 11db57c1ed..fd2f3a1c8b 100644 --- a/packages/backend-common/src/logging/rootLogger.ts +++ b/packages/backend-common/src/logging/rootLogger.ts @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import * as winston from 'winston'; let rootLogger: winston.Logger = winston.createLogger({ diff --git a/packages/backend-common/src/middleware/errorHandler.test.ts b/packages/backend-common/src/middleware/errorHandler.test.ts index 022802dff8..e8a5a7f47a 100644 --- a/packages/backend-common/src/middleware/errorHandler.test.ts +++ b/packages/backend-common/src/middleware/errorHandler.test.ts @@ -34,6 +34,29 @@ describe('errorHandler', () => { expect(response.text).toBe('some message'); }); + it('doesnt try to send the response again if its already been sent', async () => { + const app = express(); + const mockSend = jest.fn(); + + app.use('/works_with_async_fail', (_, res) => { + res.status(200).send('hello'); + + // mutate the response object to test the middlware. + // it's hard to catch errors inside middleware from the outside. + // @ts-ignore + res.send = mockSend; + throw new Error('some message'); + }); + + app.use(errorHandler()); + const response = await request(app).get('/works_with_async_fail'); + + expect(response.status).toBe(200); + expect(response.text).toBe('hello'); + + expect(mockSend).not.toHaveBeenCalled(); + }); + it('takes code from http-errors library errors', async () => { const app = express(); app.use('/breaks', () => { diff --git a/packages/backend-common/src/middleware/errorHandler.ts b/packages/backend-common/src/middleware/errorHandler.ts index 14f6cfa8d7..ad8f170dcd 100644 --- a/packages/backend-common/src/middleware/errorHandler.ts +++ b/packages/backend-common/src/middleware/errorHandler.ts @@ -53,7 +53,10 @@ export function errorHandler( next: NextFunction, ) => { if (response.headersSent) { + // If the headers have already been sent, do not send the response again + // as this will throw an error in the backend. next(error); + return; } const status = getStatusCode(error); diff --git a/packages/core/package.json b/packages/core/package.json index 10a6c5d045..e49e4616ae 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -40,7 +40,7 @@ "classnames": "^2.2.6", "clsx": "^1.1.0", "lodash": "^4.17.15", - "material-table": "^1.58.0", + "material-table": "1.62.x", "prop-types": "^15.7.2", "rc-progress": "^3.0.0", "react": "^16.12.0", diff --git a/packages/core/src/layout/ItemCard/InfoCard.stories.tsx b/packages/core/src/layout/ItemCard/ItemCard.stories.tsx similarity index 68% rename from packages/core/src/layout/ItemCard/InfoCard.stories.tsx rename to packages/core/src/layout/ItemCard/ItemCard.stories.tsx index 87c5b0684e..c7ac2d3200 100644 --- a/packages/core/src/layout/ItemCard/InfoCard.stories.tsx +++ b/packages/core/src/layout/ItemCard/ItemCard.stories.tsx @@ -24,12 +24,30 @@ export default { const Wrapper: FC<{}> = ({ children }) => ( - {children} + + {children} + ); export const Default = () => ( - + + +); + +export const Tags = () => ( + + ); diff --git a/plugins/catalog-backend/scripts/mock-data b/plugins/catalog-backend/scripts/mock-data index 01675db363..76f97ccf3a 100755 --- a/plugins/catalog-backend/scripts/mock-data +++ b/plugins/catalog-backend/scripts/mock-data @@ -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 diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index c8c40d7b67..47f5a31158 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -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": { diff --git a/plugins/scaffolder-backend/scripts/mock-data b/plugins/scaffolder-backend/scripts/mock-data index 594bd76607..2424d15094 100755 --- a/plugins/scaffolder-backend/scripts/mock-data +++ b/plugins/scaffolder-backend/scripts/mock-data @@ -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 diff --git a/plugins/scaffolder-backend/src/scaffolder/index.ts b/plugins/scaffolder-backend/src/scaffolder/index.ts index 4cc3f21a9d..a1ee172184 100644 --- a/plugins/scaffolder-backend/src/scaffolder/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/index.ts @@ -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'; diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/index.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/index.ts new file mode 100644 index 0000000000..683d9c2750 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/index.ts @@ -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'; diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/logger.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/logger.ts new file mode 100644 index 0000000000..d2eaa13c3b --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/logger.ts @@ -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) => { + 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, + }; +}; diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts new file mode 100644 index 0000000000..501e72c078 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts @@ -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'); + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts new file mode 100644 index 0000000000..1c76ae252d --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts @@ -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(); + + create({ + entity, + values, + stages, + }: { + entity: TemplateEntityV1alpha1; + values: RequiredTemplateValues & Record; + 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 { + 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'; + } + } +} diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts new file mode 100644 index 0000000000..fd554e2033 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts @@ -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 = { + values: RequiredTemplateValues & Record; + 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 { + name: string; + handler(ctx: StageContext): Promise; +} + +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; + stages: StageInput[]; + }): Job; + + get(id: string): Job | undefined; + + run(job: Job): Promise; +}; diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/file.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/prepare/file.test.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/file.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/prepare/file.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts similarity index 97% rename from plugins/scaffolder-backend/src/scaffolder/prepare/github.test.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts index 44d53248d0..86377be6d0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/prepare/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts @@ -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: {} }, + {}, ); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts similarity index 89% rename from plugins/scaffolder-backend/src/scaffolder/prepare/github.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts index b0ced7db2b..f53151e55d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/prepare/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts @@ -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 { @@ -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? }); diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/helpers.test.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/prepare/helpers.test.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/helpers.test.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/helpers.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/prepare/helpers.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/helpers.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/prepare/index.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/index.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/preparers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/prepare/preparers.test.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.test.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/preparers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/prepare/preparers.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/preparers.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/prepare/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts similarity index 89% rename from plugins/scaffolder-backend/src/scaffolder/prepare/types.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts index a6e42c465f..adbc38ef58 100644 --- a/plugins/scaffolder-backend/src/scaffolder/prepare/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts @@ -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; + prepare( + template: TemplateEntityV1alpha1, + opts: { logger: Logger }, + ): Promise; }; export type PreparerBuilder = { diff --git a/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts similarity index 79% rename from plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.test.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts index 7c78badff1..994307e8b7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts @@ -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, }); diff --git a/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts similarity index 85% rename from plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts index a95eee7fb4..cef8a64b60 100644 --- a/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts @@ -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, diff --git a/plugins/scaffolder-backend/src/scaffolder/templater/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/templater/helpers.test.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.test.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/templater/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts similarity index 100% rename from plugins/scaffolder-backend/src/scaffolder/templater/helpers.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/templater/helpers.ts diff --git a/plugins/scaffolder-backend/src/scaffolder/templater/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/index.ts similarity index 93% rename from plugins/scaffolder-backend/src/scaffolder/templater/index.ts rename to plugins/scaffolder-backend/src/scaffolder/stages/templater/index.ts index 8885e65308..d0bbb0aa6c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/templater/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/index.ts @@ -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; logStream?: Writable; dockerClient: Docker; } diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index ab39864e76..0f43c3e0de 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -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); diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index a288268670..0ea28e38a9 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -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" diff --git a/plugins/tech-radar/src/api.ts b/plugins/tech-radar/src/api.ts index 3dec964ddb..856cf4ff3c 100644 --- a/plugins/tech-radar/src/api.ts +++ b/plugins/tech-radar/src/api.ts @@ -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; diff --git a/plugins/tech-radar/src/components/Radar/Radar.test.tsx b/plugins/tech-radar/src/components/Radar/Radar.test.tsx index 469f0d85cf..f0ad0d66ed 100644 --- a/plugins/tech-radar/src/components/Radar/Radar.test.tsx +++ b/plugins/tech-radar/src/components/Radar/Radar.test.tsx @@ -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' }], diff --git a/plugins/tech-radar/src/components/Radar/Radar.tsx b/plugins/tech-radar/src/components/Radar/Radar.tsx index 1db655730d..124e8bb68a 100644 --- a/plugins/tech-radar/src/components/Radar/Radar.tsx +++ b/plugins/tech-radar/src/components/Radar/Radar.tsx @@ -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 => { +const Radar = (props: Props): JSX.Element => { const { width, height, quadrants, rings, entries } = props; const radius = Math.min(width, height) / 2; - const [activeEntry, setActiveEntry] = useState(); + const [activeEntry, setActiveEntry] = useState(); const node = useRef(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 ( @@ -49,9 +49,9 @@ const Radar: FC = props => { entries={entries} quadrants={quadrants} rings={rings} - activeEntry={activeEntry || undefined} + activeEntry={activeEntry} onEntryMouseEnter={entry => setActiveEntry(entry)} - onEntryMouseLeave={() => setActiveEntry(null)} + onEntryMouseLeave={() => setActiveEntry(undefined)} /> ); diff --git a/plugins/tech-radar/src/components/Radar/utils.ts b/plugins/tech-radar/src/components/Radar/utils.ts index a8896ddf31..d7a826543c 100644 --- a/plugins/tech-radar/src/components/Radar/utils.ts +++ b/plugins/tech-radar/src/components/Radar/utils.ts @@ -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; }); }; diff --git a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx new file mode 100644 index 0000000000..d56ca743c2 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx @@ -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( + + + + + , + ); + + expect(rendered.getByText(minProps.text)).toBeInTheDocument(); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx index dcf9a37ed7..abaed92253 100644 --- a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx +++ b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx @@ -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(() => ({ }, })); -const RadarBubble: FC = props => { +const RadarBubble = (props: Props): JSX.Element => { const classes = useStyles(props); const { visible, text } = props; @@ -98,6 +98,7 @@ const RadarBubble: FC = props => { x={0} y={0} className={visible ? classes.visibleBubble : classes.bubble} + data-testid="radar-bubble" > diff --git a/plugins/tech-radar/src/components/RadarComponent.tsx b/plugins/tech-radar/src/components/RadarComponent.tsx index 981d1f1369..99190202b9 100644 --- a/plugins/tech-radar/src/components/RadarComponent.tsx +++ b/plugins/tech-radar/src/components/RadarComponent.tsx @@ -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(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 = props => { - const { loading, error, data } = useTechRadarLoader(props); +const RadarComponent = (props: TechRadarComponentProps): JSX.Element => { + const { loading, error, value: data } = useTechRadarLoader(props); return ( <> diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx new file mode 100644 index 0000000000..2d24f301d1 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx @@ -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( + + + + + , + ); + + 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(); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx index 6006979e67..29d35e01e2 100644 --- a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx @@ -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(() => ({ }, })); -const RadarEntry: FC = props => { +const makeBlip = (color: string, moved?: number) => { + const style = { fill: color }; + + let blip = ; + if (moved && moved > 0) { + blip = ; // triangle pointing up + } else if (moved && moved < 0) { + blip = ; // 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 => { onClick, } = props; - const style = { fill: color }; - - let blip; - if (moved && moved > 0) { - blip = ; // triangle pointing up - } else if (moved && moved < 0) { - blip = ; // triangle pointing down - } else { - blip = ; - } - - if (url) { - blip = ( - - {blip} - - ); - } + const blip = makeBlip(color, moved); return ( = props => { onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onClick={onClick} + data-testid="radar-entry" > - {blip} + + {blip} + - {number} + {value} ); diff --git a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx new file mode 100644 index 0000000000..77f21b6e63 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx @@ -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( + + + + + , + ); + const radarFooter = rendered.getByTestId('radar-footer'); + const { x, y } = minProps; + expect(radarFooter).toBeInTheDocument(); + expect(radarFooter.getAttribute('transform')).toBe(`translate(${x}, ${y})`); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx index 7d60a63f59..ced1ab8054 100644 --- a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx +++ b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx @@ -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(() => ({ }, })); -const RadarFooter: FC = props => { +const RadarFooter = (props: Props): JSX.Element => { const { x, y } = props; const classes = useStyles(props); return ( - + {'▲ moved up\u00a0\u00a0\u00a0\u00a0\u00a0▼ moved down'} ); diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx new file mode 100644 index 0000000000..3027cc4adb --- /dev/null +++ b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx @@ -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( + + + + + , + ); + + expect(rendered.getByTestId('radar-grid-x-line')).toBeInTheDocument(); + expect(rendered.getByTestId('radar-grid-y-line')).toBeInTheDocument(); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx index 514afbe958..eae85d4e64 100644 --- a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx +++ b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx @@ -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) => [ { x2={0} y2={radius} className={classes.axis} + data-testid="radar-grid-x-line" />, // Y axis { 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)}; }; diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx new file mode 100644 index 0000000000..68bfe892ea --- /dev/null +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx @@ -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( + + + + + , + ); + + expect(rendered.getByTestId('radar-legend')).toBeInTheDocument(); + expect(rendered.getAllByTestId('radar-quadrant')).toHaveLength(1); + expect(rendered.getAllByTestId('radar-ring')).toHaveLength(1); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx index 03146a8071..e319846d78 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx @@ -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(() => ({ +const useStyles = makeStyles(theme => ({ quadrant: { height: '100%', width: '100%', @@ -40,7 +41,7 @@ const useStyles = makeStyles(() => ({ 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(() => ({ 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(() => ({ }, })); -const RadarLegend: FC = 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 ( -
+

{ring.name}

{entries.length === 0 ? (

(empty)

) : (
    - {entries.map(entry => { - let node = {entry.title}; - - if (entry.url) { - node = ( - - {node} - - ); - } - - return ( -
  1. onEntryMouseEnter(entry)) - } - onMouseLeave={ - onEntryMouseLeave && (() => onEntryMouseLeave(entry)) - } - > - {node} -
  2. - ); - })} + {entries.map(entry => ( +
  3. onEntryMouseEnter(entry)) + } + onMouseLeave={ + onEntryMouseLeave && (() => onEntryMouseLeave(entry)) + } + > + + {entry.title} + +
  4. + ))}
)}
); }; - 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 ( = props => { y={quadrant.legendY} width={quadrant.legendWidth} height={quadrant.legendHeight} + data-testid="radar-quadrant" >

{quadrant.name}

- {rings.map(ring => - _renderRing( - ring, - _getSegment(segments, quadrant, ring), - onEntryMouseEnter, - onEntryMouseLeave, - ), - )} + {rings.map(ring => ( + + ))}
); }; - 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 => { onEntryMouseLeave, } = props; - const segments: Segments = _setupSegments(entries); + const segments: Segments = setupSegments(entries); return ( - - {quadrants.map(quadrant => - _renderQuadrant( - segments, - quadrant, - rings, - onEntryMouseEnter, - onEntryMouseLeave, - ), - )} + + {quadrants.map(quadrant => ( + + ))} ); }; diff --git a/plugins/tech-radar/src/components/RadarPage.tsx b/plugins/tech-radar/src/components/RadarPage.tsx index 4c7236e01f..1aefb72eca 100644 --- a/plugins/tech-radar/src/components/RadarPage.tsx +++ b/plugins/tech-radar/src/components/RadarPage.tsx @@ -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(techRadarApiRef); return ( diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx new file mode 100644 index 0000000000..5b17578478 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx @@ -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( + + + + + , + ); + + 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); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx index 48ad660dde..7f68ffe021 100644 --- a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx @@ -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 => { +const RadarPlot = (props: Props): JSX.Element => { const { width, height, @@ -50,7 +50,7 @@ const RadarPlot: FC = props => { } = props; return ( - + = 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 => { ))} diff --git a/plugins/tech-radar/src/utils/components.tsx b/plugins/tech-radar/src/utils/components.tsx new file mode 100644 index 0000000000..af5a7b05bd --- /dev/null +++ b/plugins/tech-radar/src/utils/components.tsx @@ -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 ? ( + + {children} + + ) : ( + <>{children} + ); diff --git a/plugins/tech-radar/src/utils/types.ts b/plugins/tech-radar/src/utils/types.ts index 14a0554a1f..f3933ee584 100644 --- a/plugins/tech-radar/src/utils/types.ts +++ b/plugins/tech-radar/src/utils/types.ts @@ -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; }; diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index e9092f1394..22662a787c 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -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) => { diff --git a/yarn.lock b/yarn.lock index f4fa34b993..8a8fd29dcd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -868,14 +868,6 @@ "@babel/plugin-transform-react-jsx-self" "^7.9.0" "@babel/plugin-transform-react-jsx-source" "^7.9.0" -"@babel/runtime-corejs2@^7.4.4": - version "7.9.2" - resolved "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.9.2.tgz#f11d074ff99b9b4319b5ecf0501f12202bf2bf4d" - integrity sha512-ayjSOxuK2GaSDJFCtLgHnYjuMyIpViNujWrZo8GUpN60/n7juzJKK5yOo6RFVb0zdU9ACJFK+MsZrUnj3OmXMw== - dependencies: - core-js "^2.6.5" - regenerator-runtime "^0.13.4" - "@babel/runtime-corejs3@^7.10.2": version "7.10.3" resolved "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.10.3.tgz#931ed6941d3954924a7aa967ee440e60c507b91a" @@ -6544,7 +6536,7 @@ core-js-pure@^3.0.0, core-js-pure@^3.0.1: resolved "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.6.4.tgz#4bf1ba866e25814f149d4e9aaa08c36173506e3a" integrity sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw== -core-js@^2.4.0, core-js@^2.6.5: +core-js@^2.4.0: version "2.6.11" resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== @@ -6701,10 +6693,10 @@ crypto-random-string@^2.0.0: resolved "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== -css-box-model@^1.1.2: - version "1.2.0" - resolved "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.0.tgz#3a26377b4162b3200d2ede4b064ec5b6a75186d0" - integrity sha512-lri0br+jSNV0kkkiGEp9y9y3Njq2PmpqbeGWRFQJuZteZzY9iC9GZhQ8Y4WpPwM/2YocjHePxy14igJY7YKzkA== +css-box-model@^1.2.0: + version "1.2.1" + resolved "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1" + integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw== dependencies: tiny-invariant "^1.0.6" @@ -12427,10 +12419,10 @@ markdown-to-jsx@^6.11.4: prop-types "^15.6.2" unquote "^1.1.0" -material-table@^1.58.0: - version "1.58.2" - resolved "https://registry.npmjs.org/material-table/-/material-table-1.58.2.tgz#dc0d19652848e6bb92f747d122bd7d4681cca6dc" - integrity sha512-s/m6ebyXFXmg07zxv1Fl6qPySKaiQhASXaOB3ubRKUFA1DkryUy3PGSEVWTjUYnRyi63kGq+N6b5nsokLR6m5A== +material-table@1.62.x: + version "1.62.0" + resolved "https://registry.npmjs.org/material-table/-/material-table-1.62.0.tgz#117793ebf16ab0fccbb6f8a670d849a7be0b5995" + integrity sha512-+3tnk32lXtkXeKM7k/hZ82jpSzlXU5CsWXqJHq4Tl0Un7ycjK2Kef6EMPqeE3i58vKNqbIvbrFf/ESH0D/Qwig== dependencies: "@date-io/date-fns" "^1.1.0" "@material-ui/pickers" "^3.2.2" @@ -12440,7 +12432,7 @@ material-table@^1.58.0: fast-deep-equal "2.0.1" filefy "0.1.10" prop-types "^15.6.2" - react-beautiful-dnd "11.0.3" + react-beautiful-dnd "^13.0.0" react-double-scrollbar "0.0.15" md5.js@^1.3.4: @@ -12479,7 +12471,7 @@ media-typer@0.3.0: resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= -memoize-one@^5.0.4: +memoize-one@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0" integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA== @@ -15128,7 +15120,7 @@ quick-lru@^1.0.0: resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-1.1.0.tgz#4360b17c61136ad38078397ff11416e186dcfbb8" integrity sha1-Q2CxfGETatOAeDl/8RQW4Ybc+7g= -raf-schd@^4.0.0: +raf-schd@^4.0.2: version "4.0.2" resolved "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.2.tgz#bd44c708188f2e84c810bf55fcea9231bcaed8a0" integrity sha512-VhlMZmGy6A6hrkJWHLNTGl5gtgMUm+xfGza6wbwnE914yeQ5Ybm18vgM734RZhMgfw4tacUrWseGZlpUrrakEQ== @@ -15211,19 +15203,18 @@ rc@^1.2.7, rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-beautiful-dnd@11.0.3: - version "11.0.3" - resolved "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-11.0.3.tgz#5678bb3e725d8b56cb7cf57f56e952105fc4f2af" - integrity sha512-2FX2SnOlKMmfn90xUHCav7cxRWXwY7FeRa6TzdxWeX7DdP5JTvVQcsWgiOkdbJSj+J+1q1nA9QO4/HQ52D0DAA== +react-beautiful-dnd@^13.0.0: + version "13.0.0" + resolved "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.0.0.tgz#f70cc8ff82b84bc718f8af157c9f95757a6c3b40" + integrity sha512-87It8sN0ineoC3nBW0SbQuTFXM6bUqM62uJGY4BtTf0yzPl8/3+bHMWkgIe0Z6m8e+gJgjWxefGRVfpE3VcdEg== dependencies: - "@babel/runtime-corejs2" "^7.4.4" - css-box-model "^1.1.2" - memoize-one "^5.0.4" - raf-schd "^4.0.0" - react-redux "^7.0.3" - redux "^4.0.1" - tiny-invariant "^1.0.4" - use-memo-one "^1.1.0" + "@babel/runtime" "^7.8.4" + css-box-model "^1.2.0" + memoize-one "^5.1.1" + raf-schd "^4.0.2" + react-redux "^7.1.1" + redux "^4.0.4" + use-memo-one "^1.1.1" react-clientside-effect@^1.2.2: version "1.2.2" @@ -15473,7 +15464,7 @@ react-popper@^1.3.6: typed-styles "^0.0.7" warning "^4.0.2" -react-redux@^7.0.3: +react-redux@^7.1.1: version "7.2.0" resolved "https://registry.npmjs.org/react-redux/-/react-redux-7.2.0.tgz#f970f62192b3981642fec46fd0db18a074fe879d" integrity sha512-EvCAZYGfOLqwV7gh849xy9/pt55rJXPwmYvI4lilPM5rUT/1NxuuN59ipdBksRVSvz0KInbPnp4IfoXJXCqiDA== @@ -15849,7 +15840,7 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" -redux@^4.0.1: +redux@^4.0.4: version "4.0.5" resolved "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== @@ -17948,7 +17939,7 @@ tiny-emitter@^2.0.0: resolved "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== -tiny-invariant@^1.0.2, tiny-invariant@^1.0.4, tiny-invariant@^1.0.6: +tiny-invariant@^1.0.2, tiny-invariant@^1.0.6: version "1.1.0" resolved "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== @@ -18587,7 +18578,7 @@ use-callback-ref@^1.2.1: resolved "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.2.1.tgz#898759ccb9e14be6c7a860abafa3ffbd826c89bb" integrity sha512-C3nvxh0ZpaOxs9RCnWwAJ+7bJPwQI8LHF71LzbQ3BvzH5XkdtlkMadqElGevg5bYBDFip4sAnD4m06zAKebg1w== -use-memo-one@^1.1.0: +use-memo-one@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.1.tgz#39e6f08fe27e422a7d7b234b5f9056af313bd22c" integrity sha512-oFfsyun+bP7RX8X2AskHNTxu+R3QdE/RC5IefMbqptmACAA/gfol1KDD5KRzPsGMa62sWxGZw+Ui43u6x4ddoQ== @@ -18674,6 +18665,11 @@ uuid@^8.0.0: resolved "https://registry.npmjs.org/uuid/-/uuid-8.1.0.tgz#6f1536eb43249f473abc6bd58ff983da1ca30d8d" integrity sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg== +uuid@^8.2.0: + version "8.2.0" + resolved "https://registry.npmjs.org/uuid/-/uuid-8.2.0.tgz#cb10dd6b118e2dada7d0cd9730ba7417c93d920e" + integrity sha512-CYpGiFTUrmI6OBMkAdjSDM0k5h8SkkiTP4WAjQgDgNB1S3Ou9VBEvr6q0Kv2H1mMk7IWfxYGpMH5sd5AvcIV2Q== + v8-compile-cache@^2.0.3: version "2.1.0" resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e"