From 34bb9d171432cc85de97929bf7bf9f60aa447c68 Mon Sep 17 00:00:00 2001 From: blam Date: Sat, 28 Nov 2020 22:11:56 +0100 Subject: [PATCH 001/297] feat: add thugboat --- .tugboat/config.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .tugboat/config.yml diff --git a/.tugboat/config.yml b/.tugboat/config.yml new file mode 100644 index 0000000000..ef037c0d28 --- /dev/null +++ b/.tugboat/config.yml @@ -0,0 +1,4 @@ +services: + backstage: + build: + dockerfile: ./packages/backend/Dockerfile From dbea1562dcd808d4350e66deb37f64ea7f47133d Mon Sep 17 00:00:00 2001 From: blam Date: Sat, 28 Nov 2020 22:21:56 +0100 Subject: [PATCH 002/297] chore: rework tugboat build --- .tugboat/config.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index ef037c0d28..612cbdb5af 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -1,4 +1,9 @@ services: backstage: - build: - dockerfile: ./packages/backend/Dockerfile + image: tugboatqa/node:lts + commands: + init: + - yarn + - yarn build + start: + - yarn start-backend From b303ee9fb170cc639efdc3b883263c3147ea4fc3 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 1 Dec 2020 13:11:40 +0100 Subject: [PATCH 003/297] chore: updating tugboat config to build and tsc --- .tugboat/config.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index 612cbdb5af..14cfd6dc85 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -3,7 +3,8 @@ services: image: tugboatqa/node:lts commands: init: - - yarn + - yarn install + - yarn tsc - yarn build start: - yarn start-backend From 4ee32a78f140f69f68d875cd610d74e0d31202f0 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 1 Dec 2020 20:08:47 +0100 Subject: [PATCH 004/297] chore: set as default --- .tugboat/config.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index 14cfd6dc85..b3195d6254 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -1,10 +1,11 @@ services: backstage: image: tugboatqa/node:lts + default: true commands: init: - yarn install - yarn tsc - yarn build start: - - yarn start-backend + - APP_CONFIG_backend_listen_port=80 yarn start-backend From e0f22774801788fd2584879c19eb2dfd923ebf46 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 2 Dec 2020 10:24:59 +0100 Subject: [PATCH 005/297] chore: fixing container port --- .tugboat/config.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index b3195d6254..ef696e28de 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -1,6 +1,7 @@ services: backstage: image: tugboatqa/node:lts + expose: 7000 default: true commands: init: @@ -8,4 +9,4 @@ services: - yarn tsc - yarn build start: - - APP_CONFIG_backend_listen_port=80 yarn start-backend + - yarn start-backend From 8caf5cb0eacc980ace3e295c94a35c9df4b0e12f Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 3 Dec 2020 00:15:30 +0100 Subject: [PATCH 006/297] chore: make the start-backend command backgrounded --- .tugboat/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index ef696e28de..3bc8865493 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -9,4 +9,4 @@ services: - yarn tsc - yarn build start: - - yarn start-backend + - yarn start-backend & From 83006ba83ab31387bb0e10055f756fdfee790f6a Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 3 Dec 2020 00:17:51 +0100 Subject: [PATCH 007/297] chore: fixing some base build preview things --- .tugboat/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index 3bc8865493..20f716785c 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -4,7 +4,7 @@ services: expose: 7000 default: true commands: - init: + build: - yarn install - yarn tsc - yarn build From 8400991239e256741af0681024676fcea3141408 Mon Sep 17 00:00:00 2001 From: nicolasm-dev <71887763+nicolasm-dev@users.noreply.github.com> Date: Thu, 21 Jan 2021 17:40:37 +0100 Subject: [PATCH 008/297] Hide scrollbar of sidebar --- packages/core/src/layout/Sidebar/Bar.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/core/src/layout/Sidebar/Bar.tsx b/packages/core/src/layout/Sidebar/Bar.tsx index d7318de27d..260593de95 100644 --- a/packages/core/src/layout/Sidebar/Bar.tsx +++ b/packages/core/src/layout/Sidebar/Bar.tsx @@ -39,6 +39,8 @@ const useStyles = makeStyles(theme => ({ padding: 0, background: theme.palette.navigation.background, overflowX: 'hidden', + msOverflowStyle: 'none', + scrollbarWidth: 'none', width: sidebarConfig.drawerWidthClosed, transition: theme.transitions.create('width', { easing: theme.transitions.easing.sharp, @@ -47,6 +49,9 @@ const useStyles = makeStyles(theme => ({ '& > *': { flexShrink: 0, }, + '&::-webkit-scrollbar': { + display: 'none', + }, }, drawerOpen: { width: sidebarConfig.drawerWidthOpen, From 21e624ba94116e8a364032e26b3efae733235c24 Mon Sep 17 00:00:00 2001 From: nicolasm-dev <71887763+nicolasm-dev@users.noreply.github.com> Date: Thu, 21 Jan 2021 17:40:37 +0100 Subject: [PATCH 009/297] Hide scrollbar of sidebar --- .changeset/lovely-panthers-peel.md | 8 ++++++++ packages/core/src/layout/Sidebar/Bar.tsx | 5 +++++ 2 files changed, 13 insertions(+) create mode 100644 .changeset/lovely-panthers-peel.md diff --git a/.changeset/lovely-panthers-peel.md b/.changeset/lovely-panthers-peel.md new file mode 100644 index 0000000000..0127a99a57 --- /dev/null +++ b/.changeset/lovely-panthers-peel.md @@ -0,0 +1,8 @@ +--- +'@backstage/core': minor +--- + +Closes #3556 +The scrollbar of collapsed sidebar is now hidden wihtout full screen. + +![image](https://user-images.githubusercontent.com/46953622/105390193-0bfd0080-5c19-11eb-8e86-2161bbe6e8d9.png) diff --git a/packages/core/src/layout/Sidebar/Bar.tsx b/packages/core/src/layout/Sidebar/Bar.tsx index d7318de27d..260593de95 100644 --- a/packages/core/src/layout/Sidebar/Bar.tsx +++ b/packages/core/src/layout/Sidebar/Bar.tsx @@ -39,6 +39,8 @@ const useStyles = makeStyles(theme => ({ padding: 0, background: theme.palette.navigation.background, overflowX: 'hidden', + msOverflowStyle: 'none', + scrollbarWidth: 'none', width: sidebarConfig.drawerWidthClosed, transition: theme.transitions.create('width', { easing: theme.transitions.easing.sharp, @@ -47,6 +49,9 @@ const useStyles = makeStyles(theme => ({ '& > *': { flexShrink: 0, }, + '&::-webkit-scrollbar': { + display: 'none', + }, }, drawerOpen: { width: sidebarConfig.drawerWidthOpen, From 6800da78d9237da704105d9d49797c51b31be4c6 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 21 Jan 2021 22:26:34 +0100 Subject: [PATCH 010/297] integration: Fix default branch API url for hosted bitbucket server --- .changeset/eleven-lamps-hide.md | 5 +++++ packages/integration/src/bitbucket/core.test.ts | 6 +++--- packages/integration/src/bitbucket/core.ts | 8 ++++++-- 3 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 .changeset/eleven-lamps-hide.md diff --git a/.changeset/eleven-lamps-hide.md b/.changeset/eleven-lamps-hide.md new file mode 100644 index 0000000000..809de25c82 --- /dev/null +++ b/.changeset/eleven-lamps-hide.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Fix default branch API url for custom hosted Bitbucket server diff --git a/packages/integration/src/bitbucket/core.test.ts b/packages/integration/src/bitbucket/core.test.ts index 39707976fe..1287ab4d66 100644 --- a/packages/integration/src/bitbucket/core.test.ts +++ b/packages/integration/src/bitbucket/core.test.ts @@ -116,7 +116,7 @@ describe('bitbucket core', () => { }; worker.use( rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default', + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', (_, res, ctx) => res( ctx.status(200), @@ -144,7 +144,7 @@ describe('bitbucket core', () => { }; worker.use( rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default', + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', (_, res, ctx) => res( ctx.status(200), @@ -231,7 +231,7 @@ describe('bitbucket core', () => { }; worker.use( rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default', + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', (_, res, ctx) => res( ctx.status(200), diff --git a/packages/integration/src/bitbucket/core.ts b/packages/integration/src/bitbucket/core.ts index ae61df497d..f5235d8189 100644 --- a/packages/integration/src/bitbucket/core.ts +++ b/packages/integration/src/bitbucket/core.ts @@ -31,9 +31,10 @@ export async function getBitbucketDefaultBranch( const { name: repoName, owner: project, resource } = parseGitUrl(url); const isHosted = resource === 'bitbucket.org'; + // Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp184 const branchUrl = isHosted ? `${config.apiBaseUrl}/repositories/${project}/${repoName}` - : `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/branches/default`; + : `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/default-branch`; const response = await fetch(branchUrl, getBitbucketRequestOptions(config)); if (!response.ok) { @@ -50,7 +51,10 @@ export async function getBitbucketDefaultBranch( defaultBranch = displayId; } if (!defaultBranch) { - throw new Error(`Failed to read default branch from ${branchUrl}`); + throw new Error( + `Failed to read default branch from ${branchUrl}. ` + + `Response ${response.status} ${response.json()}`, + ); } return defaultBranch; } From f5ed88501ba3c687a1e2f75ec340ae7e02c951fc Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Sun, 24 Jan 2021 16:54:01 +0100 Subject: [PATCH 011/297] backend: URL Reader readTree should not expect the extracted directory name If readTree can work without it receiving the top level directory name, it will be a relief. We won't have to deduce the directory name from the API responses or by any other means. --- .../src/reading/BitbucketUrlReader.ts | 24 +------------- .../src/reading/GithubUrlReader.ts | 28 +--------------- .../src/reading/GitlabUrlReader.ts | 26 +-------------- .../reading/tree/ReadTreeResponseFactory.ts | 9 +++--- .../reading/tree/TarArchiveResponse.test.ts | 32 ++++++------------- .../src/reading/tree/TarArchiveResponse.ts | 26 ++++++++++++--- .../reading/tree/ZipArchiveResponse.test.ts | 31 ++++++------------ .../src/reading/tree/ZipArchiveResponse.ts | 29 +++++++++++++---- packages/backend-common/src/reading/types.ts | 3 ++ 9 files changed, 72 insertions(+), 136 deletions(-) diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index e9727e04cf..bbccfe4794 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -121,31 +121,9 @@ export class BitbucketUrlReader implements UrlReader { throw new Error(message); } - // Get the filename of archive from the header of the response - const contentDispositionHeader = archiveBitbucketResponse.headers.get( - 'content-disposition', - ) as string; - if (!contentDispositionHeader) { - throw new Error( - `Failed to read tree from ${url}. ` + - 'Bitbucket API response for downloading archive does not contain content-disposition header ', - ); - } - const fileNameRegEx = new RegExp( - /^attachment; filename=(?.*).zip$/, - ); - const archiveFileName = contentDispositionHeader.match(fileNameRegEx) - ?.groups?.fileName; - if (!archiveFileName) { - throw new Error( - `Failed to read tree from ${url}. Bitbucket API response for downloading archive has an unexpected ` + - `format of content-disposition header ${contentDispositionHeader} `, - ); - } - return await this.treeResponseFactory.fromZipArchive({ stream: (archiveBitbucketResponse.body as unknown) as Readable, - path: `${archiveFileName}/${filepath}`, + subpath: filepath, etag: lastCommitShortHash, filter: options?.filter, }); diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index 6c7cefe2ef..4f1aa7c07b 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -166,37 +166,11 @@ export class GithubUrlReader implements UrlReader { throw new Error(message); } - // Get the filename of archive from the header of the response - const contentDispositionHeader = archive.headers.get( - 'content-disposition', - ) as string; - if (!contentDispositionHeader) { - throw new Error( - `Failed to read tree from ${url}. ` + - 'GitHub API response for downloading archive does not contain content-disposition header ', - ); - } - const fileNameRegEx = new RegExp( - /^attachment; filename=(?.*).tar.gz$/, - ); - const archiveFileName = contentDispositionHeader.match(fileNameRegEx) - ?.groups?.fileName; - if (!archiveFileName) { - throw new Error( - `Failed to read tree from ${url}. GitHub API response for downloading archive has an unexpected ` + - `format of content-disposition header ${contentDispositionHeader} `, - ); - } - - // The path includes the name of the directory inside the tarball and a sub path - // if requested in readTree. - const path = `${archiveFileName}/${filepath}`; - return await this.deps.treeResponseFactory.fromTarArchive({ // TODO(Rugvip): Underlying implementation of fetch will be node-fetch, we probably want // to stick to using that in exclusively backend code. stream: (archive.body as unknown) as Readable, - path, + subpath: filepath, etag: commitSha, filter: options?.filter, }); diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index 654f4f9a85..22316f6895 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -140,33 +140,9 @@ export class GitlabUrlReader implements UrlReader { throw new Error(message); } - // Get the filename of archive from the header of the response - const contentDispositionHeader = archiveGitLabResponse.headers.get( - 'content-disposition', - ) as string; - if (!contentDispositionHeader) { - throw new Error( - `Failed to read tree from ${url}. ` + - 'GitLab API response for downloading archive does not contain content-disposition header ', - ); - } - const fileNameRegEx = new RegExp( - /^attachment; filename="(?.*).zip"$/, - ); - const archiveFileName = contentDispositionHeader.match(fileNameRegEx) - ?.groups?.fileName; - if (!archiveFileName) { - throw new Error( - `Failed to read tree from ${url}. GitLab API response for downloading archive has an unexpected ` + - `format of content-disposition header ${contentDispositionHeader} `, - ); - } - - const path = filepath ? `${archiveFileName}/${filepath}/` : ''; - return await this.treeResponseFactory.fromZipArchive({ stream: (archiveGitLabResponse.body as unknown) as Readable, - path, + subpath: filepath, etag: commitSha, filter: options?.filter, }); diff --git a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts index 7332154d09..05ecdb0fc3 100644 --- a/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts +++ b/packages/backend-common/src/reading/tree/ReadTreeResponseFactory.ts @@ -24,8 +24,9 @@ import { ZipArchiveResponse } from './ZipArchiveResponse'; type FromArchiveOptions = { // A binary stream of a tar archive. stream: Readable; - // If set, the root of the tree will be set to the given directory path. - path?: string; + // If unset, the files at the root of the tree will be read. + // subpath must not contain the name of the top level directory. + subpath?: string; // etag of the blob etag: string; // Filter passed on from the ReadTreeOptions @@ -45,7 +46,7 @@ export class ReadTreeResponseFactory { async fromTarArchive(options: FromArchiveOptions): Promise { return new TarArchiveResponse( options.stream, - options.path ?? '', + options.subpath ?? '', this.workDir, options.etag, options.filter, @@ -55,7 +56,7 @@ export class ReadTreeResponseFactory { async fromZipArchive(options: FromArchiveOptions): Promise { return new ZipArchiveResponse( options.stream, - options.path ?? '', + options.subpath ?? '', this.workDir, options.etag, options.filter, diff --git a/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts b/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts index 2cbfc4a89e..2b76bea9e3 100644 --- a/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts +++ b/packages/backend-common/src/reading/tree/TarArchiveResponse.test.ts @@ -38,7 +38,7 @@ describe('TarArchiveResponse', () => { it('should read files', async () => { const stream = fs.createReadStream('/test-archive.tar.gz'); - const res = new TarArchiveResponse(stream, 'mock-main/', '/tmp', 'etag'); + const res = new TarArchiveResponse(stream, '', '/tmp', 'etag'); const files = await res.files(); expect(files).toEqual([ @@ -61,12 +61,8 @@ describe('TarArchiveResponse', () => { it('should read files with filter', async () => { const stream = fs.createReadStream('/test-archive.tar.gz'); - const res = new TarArchiveResponse( - stream, - 'mock-main/', - '/tmp', - 'etag', - path => path.endsWith('.yml'), + const res = new TarArchiveResponse(stream, '', '/tmp', 'etag', path => + path.endsWith('.yml'), ); const files = await res.files(); @@ -83,7 +79,7 @@ describe('TarArchiveResponse', () => { it('should read as archive and files', async () => { const stream = fs.createReadStream('/test-archive.tar.gz'); - const res = new TarArchiveResponse(stream, 'mock-main/', '/tmp', 'etag'); + const res = new TarArchiveResponse(stream, '', '/tmp', 'etag'); const buffer = await res.archive(); await expect(res.archive()).rejects.toThrow( @@ -115,24 +111,18 @@ describe('TarArchiveResponse', () => { const res = new TarArchiveResponse(stream, '', '/tmp', 'etag'); const dir = await res.dir(); - await expect( - fs.readFile(resolvePath(dir, 'mock-main/mkdocs.yml'), 'utf8'), + fs.readFile(resolvePath(dir, 'mkdocs.yml'), 'utf8'), ).resolves.toBe('site_name: Test\n'); await expect( - fs.readFile(resolvePath(dir, 'mock-main/docs/index.md'), 'utf8'), + fs.readFile(resolvePath(dir, 'docs/index.md'), 'utf8'), ).resolves.toBe('# Test\n'); }); it('should extract archive into directory with a subpath', async () => { const stream = fs.createReadStream('/test-archive.tar.gz'); - const res = new TarArchiveResponse( - stream, - 'mock-main/docs/', - '/tmp', - 'etag', - ); + const res = new TarArchiveResponse(stream, 'docs', '/tmp', 'etag'); const dir = await res.dir(); expect(dir).toMatch(/^[\/\\]tmp[\/\\].*$/); @@ -144,12 +134,8 @@ describe('TarArchiveResponse', () => { it('should extract archive into directory with a subpath and filter', async () => { const stream = fs.createReadStream('/test-archive.tar.gz'); - const res = new TarArchiveResponse( - stream, - 'mock-main/', - '/tmp', - 'etag', - path => path.endsWith('.yml'), + const res = new TarArchiveResponse(stream, '', '/tmp', 'etag', path => + path.endsWith('.yml'), ); const dir = await res.dir({ targetDir: '/tmp' }); diff --git a/packages/backend-common/src/reading/tree/TarArchiveResponse.ts b/packages/backend-common/src/reading/tree/TarArchiveResponse.ts index 5927eb75a1..96d3a5d495 100644 --- a/packages/backend-common/src/reading/tree/TarArchiveResponse.ts +++ b/packages/backend-common/src/reading/tree/TarArchiveResponse.ts @@ -30,6 +30,10 @@ import { const TarParseStream = (Parse as unknown) as { new (): ParseStream }; const pipeline = promisify(pipelineCb); +// Matches a directory name + one `/` at the start of any string, +// containing any character except `/` one or more times, and ending with a `/` +// e.g. Will match `dirA/` in `dirA/dirB/file.ext` +const directoryNameRegex = /^[^\/]+\//; /** * Wraps a tar archive stream into a tree response reader. @@ -78,14 +82,18 @@ export class TarArchiveResponse implements ReadTreeResponse { return; } + // File path relative to the root extracted directory. Will remove the + // top level dir name from the path since its name is hard to predetermine. + const relativePath = entry.path.replace(directoryNameRegex, ''); + if (this.subPath) { - if (!entry.path.startsWith(this.subPath)) { + if (!relativePath.startsWith(this.subPath)) { entry.resume(); return; } } - const path = entry.path.slice(this.subPath.length); + const path = relativePath.slice(this.subPath.length); if (this.filter) { if (!this.filter(path)) { entry.resume(); @@ -97,7 +105,10 @@ export class TarArchiveResponse implements ReadTreeResponse { await pipeline(entry, concatStream(resolve)); }); - files.push({ path, content: () => content }); + files.push({ + path, + content: () => content, + }); entry.resume(); }); @@ -138,7 +149,9 @@ export class TarArchiveResponse implements ReadTreeResponse { options?.targetDir ?? (await fs.mkdtemp(path.join(this.workDir, 'backstage-'))); - const strip = this.subPath ? this.subPath.split('/').length - 1 : 0; + // Equivalent of tar --strip-components=N + // When no subPath is given, remove just 1 top level directory + const strip = this.subPath ? this.subPath.split('/').length : 1; await pipeline( this.stream, @@ -146,7 +159,10 @@ export class TarArchiveResponse implements ReadTreeResponse { strip, cwd: dir, filter: path => { - if (this.subPath && !path.startsWith(this.subPath)) { + // File path relative to the root extracted directory. Will remove the + // top level dir name from the path since its name is hard to predetermine. + const relativePath = path.replace(directoryNameRegex, ''); + if (this.subPath && !relativePath.startsWith(this.subPath)) { return false; } if (this.filter) { diff --git a/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts b/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts index b42ec79d81..3bcaa5e0e3 100644 --- a/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts +++ b/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts @@ -38,7 +38,7 @@ describe('ZipArchiveResponse', () => { it('should read files', async () => { const stream = fs.createReadStream('/test-archive.zip'); - const res = new ZipArchiveResponse(stream, 'mock-main/', '/tmp', 'etag'); + const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag'); const files = await res.files(); expect(files).toEqual([ @@ -61,12 +61,8 @@ describe('ZipArchiveResponse', () => { it('should read files with filter', async () => { const stream = fs.createReadStream('/test-archive.zip'); - const res = new ZipArchiveResponse( - stream, - 'mock-main/', - '/tmp', - 'etag', - path => path.endsWith('.yml'), + const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag', path => + path.endsWith('.yml'), ); const files = await res.files(); @@ -83,7 +79,7 @@ describe('ZipArchiveResponse', () => { it('should read as archive and files', async () => { const stream = fs.createReadStream('/test-archive.zip'); - const res = new ZipArchiveResponse(stream, 'mock-main/', '/tmp', 'etag'); + const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag'); const buffer = await res.archive(); await expect(res.archive()).rejects.toThrow( @@ -117,22 +113,17 @@ describe('ZipArchiveResponse', () => { const dir = await res.dir(); await expect( - fs.readFile(resolvePath(dir, 'mock-main/mkdocs.yml'), 'utf8'), + fs.readFile(resolvePath(dir, 'mkdocs.yml'), 'utf8'), ).resolves.toBe('site_name: Test\n'); await expect( - fs.readFile(resolvePath(dir, 'mock-main/docs/index.md'), 'utf8'), + fs.readFile(resolvePath(dir, 'docs/index.md'), 'utf8'), ).resolves.toBe('# Test\n'); }); it('should extract archive into directory with a subpath', async () => { const stream = fs.createReadStream('/test-archive.zip'); - const res = new ZipArchiveResponse( - stream, - 'mock-main/docs/', - '/tmp', - 'etag', - ); + const res = new ZipArchiveResponse(stream, 'docs/', '/tmp', 'etag'); const dir = await res.dir(); expect(dir).toMatch(/^[\/\\]tmp[\/\\].*$/); @@ -144,12 +135,8 @@ describe('ZipArchiveResponse', () => { it('should extract archive into directory with a subpath and filter', async () => { const stream = fs.createReadStream('/test-archive.zip'); - const res = new ZipArchiveResponse( - stream, - 'mock-main/', - '/tmp', - 'etag', - path => path.endsWith('.yml'), + const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag', path => + path.endsWith('.yml'), ); const dir = await res.dir({ targetDir: '/tmp' }); diff --git a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts index 07d34faaa3..37ff32741f 100644 --- a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts +++ b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts @@ -25,6 +25,11 @@ import { ReadTreeResponseDirOptions, } from '../types'; +// Matches a directory name + one `/` at the start of any string, +// containing any character except / one or more times, and ending with a `/` +// e.g. Will match `dirA/` in `dirA/dirB/file.ext` +const directoryNameRegex = /^[^\/]+\//; + /** * Wraps a zip archive stream into a tree response reader. */ @@ -60,18 +65,26 @@ export class ZipArchiveResponse implements ReadTreeResponse { this.read = true; } - private getPath(entry: Entry): string { - return entry.path.slice(this.subPath.length); + // Will remove the top level dir name from the path since its name is hard to predetermine. + private stripTopDirectory(path: string): string { + return path.replace(directoryNameRegex, ''); + } + + // File path relative to the root extracted directory or a sub directory if subpath is set. + private getInnerPath(path: string): string { + return path.slice(this.subPath.length); } private shouldBeIncluded(entry: Entry): boolean { + const strippedPath = this.stripTopDirectory(entry.path); + if (this.subPath) { - if (!entry.path.startsWith(this.subPath)) { + if (!strippedPath.startsWith(this.subPath)) { return false; } } if (this.filter) { - return this.filter(this.getPath(entry)); + return this.filter(this.getInnerPath(entry.path)); } return true; } @@ -91,7 +104,7 @@ export class ZipArchiveResponse implements ReadTreeResponse { if (this.shouldBeIncluded(entry)) { files.push({ - path: this.getPath(entry), + path: this.getInnerPath(this.stripTopDirectory(entry.path)), content: () => entry.buffer(), }); } else { @@ -115,7 +128,7 @@ export class ZipArchiveResponse implements ReadTreeResponse { .pipe(unzipper.Parse()) .on('entry', (entry: Entry) => { if (entry.type === 'File' && this.shouldBeIncluded(entry)) { - archive.append(entry, { name: this.getPath(entry) }); + archive.append(entry, { name: this.getInnerPath(entry.path) }); } else { entry.autodrain(); } @@ -139,7 +152,9 @@ export class ZipArchiveResponse implements ReadTreeResponse { // Ignore directory entries since we handle that with the file entries // as a zip can have files with directories without directory entries if (entry.type === 'File' && this.shouldBeIncluded(entry)) { - const entryPath = this.getPath(entry); + const entryPath = this.getInnerPath( + this.stripTopDirectory(entry.path), + ); const dirname = path.dirname(entryPath); if (dirname) { await fs.mkdirp(path.join(dir, dirname)); diff --git a/packages/backend-common/src/reading/types.ts b/packages/backend-common/src/reading/types.ts index e98f760d8f..f9dfbe9aff 100644 --- a/packages/backend-common/src/reading/types.ts +++ b/packages/backend-common/src/reading/types.ts @@ -81,6 +81,9 @@ export type ReadTreeResponseDirOptions = { }; export type ReadTreeResponse = { + /** + * files() returns an array of all the files inside the tree and corresponding functions to read their content. + */ files(): Promise; archive(): Promise; From bd3c3cb960902c862cac3b10eed12c7971d33b9e Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Sun, 24 Jan 2021 17:25:24 +0100 Subject: [PATCH 012/297] Add tests for .dir() method in readTree response --- .../src/reading/AzureUrlReader.test.ts | 28 ++++++++++++- .../src/reading/BitbucketUrlReader.test.ts | 40 ++++++++++++++++++- .../src/reading/GithubUrlReader.test.ts | 40 ++++++++++++++++++- .../src/reading/GitlabUrlReader.test.ts | 40 ++++++++++++++++++- 4 files changed, 144 insertions(+), 4 deletions(-) diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts index 20f8feba42..30c086ea08 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.test.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import fs from 'fs'; +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; import path from 'path'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; @@ -139,6 +140,16 @@ describe('AzureUrlReader', () => { }); describe('readTree', () => { + beforeEach(() => { + mockFs({ + '/tmp': mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + const repoBuffer = fs.readFileSync( path.resolve('src', 'reading', '__fixtures__', 'mock-main.zip'), ); @@ -200,6 +211,21 @@ describe('AzureUrlReader', () => { expect(indexMarkdownFile.toString()).toBe('# Test\n'); }); + it('creates a directory with the wanted files', async () => { + const response = await processor.readTree( + 'https://dev.azure.com/organization/project/_git/repository', + ); + + const dir = await response.dir({ targetDir: '/tmp' }); + + await expect( + fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'), + ).resolves.toBe('site_name: Test\n'); + await expect( + fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'), + ).resolves.toBe('# Test\n'); + }); + it('throws a NotModifiedError when given a etag in options', async () => { const fnAzure = async () => { await processor.readTree( diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index 9661368b5e..9103ecc7f8 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -16,7 +16,8 @@ import { ConfigReader } from '@backstage/config'; import { msw } from '@backstage/test-utils'; -import fs from 'fs'; +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import path from 'path'; @@ -53,6 +54,16 @@ describe('BitbucketUrlReader', () => { }); describe('readTree', () => { + beforeEach(() => { + mockFs({ + '/tmp': mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + const worker = setupServer(); msw.setupDefaultHandlers(worker); @@ -155,6 +166,21 @@ describe('BitbucketUrlReader', () => { expect(mkDocsFile.toString()).toBe('site_name: Test\n'); }); + it('creates a directory with the wanted files', async () => { + const response = await bitbucketProcessor.readTree( + 'https://bitbucket.org/backstage/mock', + ); + + const dir = await response.dir({ targetDir: '/tmp' }); + + await expect( + fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'), + ).resolves.toBe('site_name: Test\n'); + await expect( + fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'), + ).resolves.toBe('# Test\n'); + }); + it('uses private bitbucket host', async () => { const response = await hostedBitbucketProcessor.readTree( 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs?at=some-branch', @@ -185,6 +211,18 @@ describe('BitbucketUrlReader', () => { expect(indexMarkdownFile.toString()).toBe('# Test\n'); }); + it('creates a directory with the wanted files with a subpath', async () => { + const response = await bitbucketProcessor.readTree( + 'https://bitbucket.org/backstage/mock/src/master/docs', + ); + + const dir = await response.dir({ targetDir: '/tmp' }); + + await expect( + fs.readFile(path.join(dir, 'index.md'), 'utf8'), + ).resolves.toBe('# Test\n'); + }); + it('throws a NotModifiedError when given a etag in options', async () => { const fnBitbucket = async () => { await bitbucketProcessor.readTree( diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts index 080e8b1d5b..975be85ccc 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.test.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts @@ -17,7 +17,8 @@ import { ConfigReader } from '@backstage/config'; import { GithubCredentialsProvider } from '@backstage/integration'; import { msw } from '@backstage/test-utils'; -import fs from 'fs'; +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import path from 'path'; @@ -107,6 +108,16 @@ describe('GithubUrlReader', () => { }); describe('readTree', () => { + beforeEach(() => { + mockFs({ + '/tmp': mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + const repoBuffer = fs.readFileSync( path.resolve( 'src', @@ -227,6 +238,21 @@ describe('GithubUrlReader', () => { expect(indexMarkdownFile.toString()).toBe('# Test\n'); }); + it('creates a directory with the wanted files', async () => { + const response = await githubProcessor.readTree( + 'https://github.com/backstage/mock', + ); + + const dir = await response.dir({ targetDir: '/tmp' }); + + await expect( + fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'), + ).resolves.toBe('site_name: Test\n'); + await expect( + fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'), + ).resolves.toBe('# Test\n'); + }); + it('should use the headers from the credentials provider to the fetch request', async () => { expect.assertions(2); @@ -293,6 +319,18 @@ describe('GithubUrlReader', () => { expect(indexMarkdownFile.toString()).toBe('# Test\n'); }); + it('creates a directory with the wanted files with subpath', async () => { + const response = await githubProcessor.readTree( + 'https://github.com/backstage/mock/tree/main/docs', + ); + + const dir = await response.dir({ targetDir: '/tmp' }); + + await expect( + fs.readFile(path.join(dir, 'index.md'), 'utf8'), + ).resolves.toBe('# Test\n'); + }); + it('throws a NotModifiedError when given a etag in options', async () => { const fnGithub = async () => { await githubProcessor.readTree('https://github.com/backstage/mock', { diff --git a/packages/backend-common/src/reading/GitlabUrlReader.test.ts b/packages/backend-common/src/reading/GitlabUrlReader.test.ts index c0736f769d..90acfd1ab9 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts @@ -16,7 +16,8 @@ import { ConfigReader } from '@backstage/config'; import { msw } from '@backstage/test-utils'; -import fs from 'fs'; +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import path from 'path'; @@ -153,6 +154,16 @@ describe('GitlabUrlReader', () => { }); describe('readTree', () => { + beforeEach(() => { + mockFs({ + '/tmp': mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + const archiveBuffer = fs.readFileSync( path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.zip'), ); @@ -254,6 +265,21 @@ describe('GitlabUrlReader', () => { expect(indexMarkdownFile.toString()).toBe('# Test\n'); }); + it('creates a directory with the wanted files', async () => { + const response = await gitlabProcessor.readTree( + 'https://gitlab.com/backstage/mock', + ); + + const dir = await response.dir({ targetDir: '/tmp' }); + + await expect( + fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'), + ).resolves.toBe('site_name: Test\n'); + await expect( + fs.readFile(path.join(dir, 'docs', 'index.md'), 'utf8'), + ).resolves.toBe('# Test\n'); + }); + it('returns the wanted files from hosted gitlab', async () => { worker.use( rest.get( @@ -296,6 +322,18 @@ describe('GitlabUrlReader', () => { expect(indexMarkdownFile.toString()).toBe('# Test\n'); }); + it('creates a directory with the wanted files with subpath', async () => { + const response = await gitlabProcessor.readTree( + 'https://gitlab.com/backstage/mock/tree/main/docs', + ); + + const dir = await response.dir({ targetDir: '/tmp' }); + + await expect( + fs.readFile(path.join(dir, 'index.md'), 'utf8'), + ).resolves.toBe('# Test\n'); + }); + it('throws a NotModifiedError when given a etag in options', async () => { const fnGitlab = async () => { await gitlabProcessor.readTree('https://gitlab.com/backstage/mock', { From 664dd08c95801870a9434ec8d4b76b979a3bd4e0 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Sun, 24 Jan 2021 19:03:39 +0100 Subject: [PATCH 013/297] backend-common: Add changeset about readTree archive directory name improvement --- .changeset/dull-ears-glow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dull-ears-glow.md diff --git a/.changeset/dull-ears-glow.md b/.changeset/dull-ears-glow.md new file mode 100644 index 0000000000..ea08b3b37e --- /dev/null +++ b/.changeset/dull-ears-glow.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +URL Reader's readTree: Fix bug with github.com URLs. From 38f710b366ffec2f3ef78d74d6fcc78de8a0bef0 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Mon, 25 Jan 2021 16:10:50 +0100 Subject: [PATCH 014/297] Fix commits url for bitbucket server --- .../src/reading/BitbucketUrlReader.ts | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index e9727e04cf..86dd7e32b0 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -161,13 +161,18 @@ export class BitbucketUrlReader implements UrlReader { } private async getLastCommitShortHash(url: string): Promise { - const { name: repoName, owner: project, ref } = parseGitUrl(url); + const { resource, name: repoName, owner: project, ref } = parseGitUrl(url); let branch = ref; if (!branch) { branch = await getBitbucketDefaultBranch(url, this.config); } - const commitsApiUrl = `${this.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}`; + + const isHosted = resource === 'bitbucket.org'; + // Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp222 + const commitsApiUrl = isHosted + ? `${this.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}` + : `${this.config.apiBaseUrl}/projects/${project}/repos/${repoName}/commits`; const commitsResponse = await fetch( commitsApiUrl, @@ -182,14 +187,26 @@ export class BitbucketUrlReader implements UrlReader { } const commits = await commitsResponse.json(); - if ( - commits && - commits.values && - commits.values.length > 0 && - commits.values[0].hash - ) { - return commits.values[0].hash.substring(0, 12); + if (isHosted) { + if ( + commits && + commits.values && + commits.values.length > 0 && + commits.values[0].hash + ) { + return commits.values[0].hash.substring(0, 12); + } + } else { + if ( + commits && + commits.values && + commits.values.length > 0 && + commits.values[0].id + ) { + return commits.values[0].id.substring(0, 12); + } } + throw new Error(`Failed to read response from ${commitsApiUrl}`); } } From 31e7b2a1fde62ed8e175345862cef1fee936a834 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Mon, 25 Jan 2021 16:26:02 +0100 Subject: [PATCH 015/297] Fix tests using wrong commits url for bitbucket server --- .../backend-common/src/reading/BitbucketUrlReader.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index 9661368b5e..6265718571 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -126,12 +126,12 @@ describe('BitbucketUrlReader', () => { ), ), rest.get( - 'https://api.bitbucket.mycompany.net/rest/api/1.0/repositories/backstage/mock/commits/some-branch', + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/commits', (_, res, ctx) => res( ctx.status(200), ctx.json({ - values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }], + values: [{ id: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }], }), ), ), From d65b34409aae8579631f1c12ac362afba59b3496 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Tue, 12 Jan 2021 16:44:30 -0500 Subject: [PATCH 016/297] add alert hooks --- .../src/alerts/KubernetesMigrationAlert.tsx | 156 +++++++++++++ .../src/alerts/ProjectGrowthAlert.tsx | 45 ++++ .../src/alerts/UnlabeledDataflowAlert.tsx | 43 ++++ .../index.ts} | 22 +- plugins/cost-insights/src/client.ts | 31 ++- .../ActionItemCard.test.tsx} | 8 +- .../components/ActionItems/ActionItemCard.tsx | 62 +++++ .../ActionItems/ActionItems.test.tsx | 86 +++++++ .../components/ActionItems/ActionItems.tsx | 132 +++++++++++ .../src/components/ActionItems/index.ts | 18 ++ .../AlertActionCardList/AlertActionCard.tsx | 45 ---- .../AlertInsights/AlertDialog.test.tsx | 169 ++++++++++++++ .../components/AlertInsights/AlertDialog.tsx | 210 +++++++++++++++++ .../AlertInsights/AlertInsights.test.tsx | 89 ++++++++ .../AlertInsights/AlertInsights.tsx | 212 ++++++++++++++++-- .../AlertInsights/AlertInsightsHeader.tsx | 5 +- .../AlertInsightsSection.test.tsx | 104 ++++++++- .../AlertInsights/AlertInsightsSection.tsx | 68 +++++- .../AlertInsightsSectionHeader.tsx | 39 ++-- .../AlertInsights/AlertStatusSummary.test.tsx | 58 +++++ .../AlertInsights/AlertStatusSummary.tsx | 116 ++++++++++ .../AlertStatusSummaryButton.tsx | 54 +++++ .../CostInsightsNavigation.tsx | 5 +- .../CostInsightsPage/CostInsightsPage.tsx | 84 ++++--- .../CostInsightsPage/CostInsightsPageRoot.tsx | 5 +- .../CostOverviewCard/CostOverviewCard.tsx | 4 +- .../MigrationAlertCard/MigrationAlertCard.tsx | 60 +++++ .../MigrationAlertCard/MigrationBarChart.tsx | 54 +++++ .../MigrationBarChartLegend.tsx | 58 +++++ .../index.ts | 2 +- .../ProductInsightsCard.test.tsx | 11 - .../ProductInsightsCard.tsx | 6 +- .../ProjectGrowthInstructionsPage.tsx | 2 +- .../src/forms/AlertAcceptForm.tsx | 57 +++++ .../src/forms/AlertDismissForm.tsx | 156 +++++++++++++ .../src/forms/AlertSnoozeForm.tsx | 115 ++++++++++ .../src/forms/MigrationDismissForm.tsx | 108 +++++++++ plugins/cost-insights/src/forms/index.ts | 21 ++ plugins/cost-insights/src/hooks/index.ts | 1 + plugins/cost-insights/src/hooks/useAlerts.tsx | 76 +++++++ plugins/cost-insights/src/hooks/useScroll.tsx | 63 +++--- plugins/cost-insights/src/index.ts | 2 +- plugins/cost-insights/src/types/Alert.ts | 128 ++++++++++- plugins/cost-insights/src/types/Duration.ts | 1 + plugins/cost-insights/src/utils/alerts.tsx | 58 ++--- plugins/cost-insights/src/utils/duration.ts | 13 +- plugins/cost-insights/src/utils/loading.ts | 1 + plugins/cost-insights/src/utils/styles.ts | 41 +++- plugins/cost-insights/src/utils/tests.tsx | 31 ++- 49 files changed, 2669 insertions(+), 266 deletions(-) create mode 100644 plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx create mode 100644 plugins/cost-insights/src/alerts/ProjectGrowthAlert.tsx create mode 100644 plugins/cost-insights/src/alerts/UnlabeledDataflowAlert.tsx rename plugins/cost-insights/src/{components/AlertActionCardList/AlertActionCardList.tsx => alerts/index.ts} (50%) rename plugins/cost-insights/src/components/{AlertActionCardList/AlertActionCard.test.tsx => ActionItems/ActionItemCard.test.tsx} (87%) create mode 100644 plugins/cost-insights/src/components/ActionItems/ActionItemCard.tsx create mode 100644 plugins/cost-insights/src/components/ActionItems/ActionItems.test.tsx create mode 100644 plugins/cost-insights/src/components/ActionItems/ActionItems.tsx create mode 100644 plugins/cost-insights/src/components/ActionItems/index.ts delete mode 100644 plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx create mode 100644 plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx create mode 100644 plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx create mode 100644 plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx create mode 100644 plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx create mode 100644 plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx create mode 100644 plugins/cost-insights/src/components/AlertInsights/AlertStatusSummaryButton.tsx create mode 100644 plugins/cost-insights/src/components/MigrationAlertCard/MigrationAlertCard.tsx create mode 100644 plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChart.tsx create mode 100644 plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChartLegend.tsx rename plugins/cost-insights/src/components/{AlertActionCardList => MigrationAlertCard}/index.ts (90%) create mode 100644 plugins/cost-insights/src/forms/AlertAcceptForm.tsx create mode 100644 plugins/cost-insights/src/forms/AlertDismissForm.tsx create mode 100644 plugins/cost-insights/src/forms/AlertSnoozeForm.tsx create mode 100644 plugins/cost-insights/src/forms/MigrationDismissForm.tsx create mode 100644 plugins/cost-insights/src/forms/index.ts create mode 100644 plugins/cost-insights/src/hooks/useAlerts.tsx diff --git a/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx b/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx new file mode 100644 index 0000000000..8940322223 --- /dev/null +++ b/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx @@ -0,0 +1,156 @@ +/* + * 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 pluralize from 'pluralize'; +import { MigrationAlertCard } from '../components/MigrationAlertCard'; +import { CostInsightsApi } from '../api'; +import { + Alert, + AlertForm, + AlertOptions, + AlertStatus, + AlertSnoozeFormData, + ChangeStatistic, + Entity, +} from '../types'; +import { MigrationDismissForm, MigrationDismissFormData } from '../forms'; + +export interface MigrationData { + startDate: string; + endDate: string; + change: ChangeStatistic; + services: Array; +} + +export interface MigrationAlert extends Alert { + api: CostInsightsApi; + data: MigrationData; +} + +/** + * The alert below is an example of an Alert implementation using event hooks. + * + * Alerts can be customized to be accepted, dismissed snoozed or any combination + * by defining a corresponding hook on the alert instance. + * + * For example, defining an onDismissed hook will render a dismiss button that, when clicked, will + * generate a dialog prompting the user to provide a reason for dismissing the alert. + * Dismiss form data will be passed to the hook, which must eventually return a new set of alerts. + * Errors thrown within hooks will generate a snackbar, which can be used to display a + * user-friendly error message. + * + * Cost Insights provides default forms for each hook, which can be overriden by providing a custom form component. + */ + +export class KubernetesMigrationAlert implements MigrationAlert { + api: CostInsightsApi; + data: MigrationData; + + subtitle = + 'Services running on Kubernetes are estimated to save 50% or more compared to Compute Engine.'; + + // Override default dismiss form with custom form component. + // SnoozeForm: AlertForm = MigrationSnoozeForm; + // AcceptForm: AlertForm = MigrationAcceptForm; + DismissForm: AlertForm< + MigrationAlert, + MigrationDismissFormData + > = MigrationDismissForm; + + constructor(api: CostInsightsApi, data: MigrationData) { + this.api = api; + this.data = data; + } + + get title() { + return `Consider migrating ${pluralize( + 'service', + this.data.services.length, + true, + )} to Kubernetes.`; + } + + get element() { + const subheader = `${pluralize( + 'Compute Engine role', + this.data.services.length, + true, + )}, sorted by cost`; + return ( + + ); + } + + /* Displays a custom dismiss form. */ + async onDismissed( + options: AlertOptions, + ): Promise { + const alerts = await this.api.getAlerts(options.group); + return new Promise(resolve => + setTimeout(resolve, 750, [ + ...alerts.slice(0, 2), + { + title: this.title, + subtitle: this.subtitle, + /** + * If a status property is defined, the alert will be filtered from the action items list + * but still appear grouped with other action items of the same status in the Hidden Action Items section. + */ + status: AlertStatus.Dismissed, + }, + ]), + ); + } + + /* Displays default accept form. */ + async onSnoozed( + options: AlertOptions, + ): Promise { + const alerts = await this.api.getAlerts(options.group); + return new Promise(resolve => + setTimeout(resolve, 750, [ + ...alerts.slice(0, 2), + { + title: this.title, + subtitle: this.subtitle, + status: AlertStatus.Snoozed, + }, + ]), + ); + } + + /* Displays default accept form. */ + async onAccepted(options: AlertOptions): Promise { + const alerts = await this.api.getAlerts(options.group); + return new Promise(resolve => + setTimeout(resolve, 750, [ + ...alerts.slice(0, 2), + { + title: this.title, + subtitle: this.subtitle, + status: AlertStatus.Accepted, + }, + ]), + ); + } +} diff --git a/plugins/cost-insights/src/alerts/ProjectGrowthAlert.tsx b/plugins/cost-insights/src/alerts/ProjectGrowthAlert.tsx new file mode 100644 index 0000000000..67eb1b5071 --- /dev/null +++ b/plugins/cost-insights/src/alerts/ProjectGrowthAlert.tsx @@ -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 React from 'react'; +import { ProjectGrowthAlertCard } from '../components/ProjectGrowthAlertCard'; +import { Alert, ProjectGrowthData } from '../types'; + +/** + * The alert below is an example of an Alert implementation; the CostInsightsApi permits returning + * any implementation of the Alert type, so adopters can create their own. The CostInsightsApi + * fetches alert data from the backend, then creates Alert classes with the data. + */ + +export class ProjectGrowthAlert implements Alert { + data: ProjectGrowthData; + + url = '/cost-insights/investigating-growth'; + subtitle = + 'Cost growth outpacing business growth is unsustainable long-term.'; + + constructor(data: ProjectGrowthData) { + this.data = data; + } + + get title() { + return `Investigate cost growth in project ${this.data.project}`; + } + + get element() { + return ; + } +} diff --git a/plugins/cost-insights/src/alerts/UnlabeledDataflowAlert.tsx b/plugins/cost-insights/src/alerts/UnlabeledDataflowAlert.tsx new file mode 100644 index 0000000000..e889e0e3d4 --- /dev/null +++ b/plugins/cost-insights/src/alerts/UnlabeledDataflowAlert.tsx @@ -0,0 +1,43 @@ +/* + * 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 { UnlabeledDataflowAlertCard } from '../components/UnlabeledDataflowAlertCard'; +import { Alert, AlertStatus, UnlabeledDataflowData } from '../types'; + +/** + * The alert below is an example of an Alert implementation; the CostInsightsApi permits returning + * any implementation of the Alert type, so adopters can create their own. The CostInsightsApi + * fetches alert data from the backend, then creates Alert classes with the data. + */ + +export class UnlabeledDataflowAlert implements Alert { + data: UnlabeledDataflowData; + status?: AlertStatus; + + url = '/cost-insights/labeling-jobs'; + title = 'Add labels to workflows'; + subtitle = + 'Labels show in billing data, enabling cost insights for each workflow.'; + + constructor(data: UnlabeledDataflowData) { + this.data = data; + } + + get element() { + return ; + } +} diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx b/plugins/cost-insights/src/alerts/index.ts similarity index 50% rename from plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx rename to plugins/cost-insights/src/alerts/index.ts index aaadbed09d..eb4a079e90 100644 --- a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx +++ b/plugins/cost-insights/src/alerts/index.ts @@ -13,22 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { Fragment } from 'react'; -import { Paper, Divider } from '@material-ui/core'; -import { AlertActionCard } from './AlertActionCard'; -import { Alert } from '../../types'; -type AlertActionCardList = { - alerts: Array; -}; - -export const AlertActionCardList = ({ alerts }: AlertActionCardList) => ( - - {alerts.map((alert, index) => ( - - - {index < alerts.length - 1 && } - - ))} - -); +export { ProjectGrowthAlert } from './ProjectGrowthAlert'; +export { UnlabeledDataflowAlert } from './UnlabeledDataflowAlert'; +export { KubernetesMigrationAlert } from './KubernetesMigrationAlert'; +export type { MigrationAlert } from './KubernetesMigrationAlert'; diff --git a/plugins/cost-insights/src/client.ts b/plugins/cost-insights/src/client.ts index 0ba57f76fa..837cd7a560 100644 --- a/plugins/cost-insights/src/client.ts +++ b/plugins/cost-insights/src/client.ts @@ -31,7 +31,8 @@ import { import { ProjectGrowthAlert, UnlabeledDataflowAlert, -} from '../src/utils/alerts'; + KubernetesMigrationAlert, +} from '../src/alerts'; import { trendlineOf, changeOf, @@ -174,6 +175,34 @@ export class ExampleCostInsightsClient implements CostInsightsApi { const alerts: Alert[] = await this.request({ group }, [ new ProjectGrowthAlert(projectGrowthData), new UnlabeledDataflowAlert(unlabeledDataflowData), + new KubernetesMigrationAlert(this, { + startDate: '2021-01-24', + endDate: '2020-02-24', + change: { + ratio: 0, + amount: 0, + }, + services: [ + { + id: 'service-a', + aggregation: [20_000, 10_000], + change: { + ratio: -1, + amount: -10_000, + }, + entities: {}, + }, + { + id: 'service-b', + aggregation: [30_000, 15_000], + change: { + ratio: -1, + amount: 15_000, + }, + entities: {}, + }, + ], + }), ]); return alerts; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx similarity index 87% rename from plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx rename to plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx index d896bf0d47..b72f050e5b 100644 --- a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.test.tsx +++ b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.test.tsx @@ -16,9 +16,9 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; -import { AlertActionCard } from './AlertActionCard'; +import { ActionItemCard } from './ActionItemCard'; import { MockScrollProvider } from '../../utils/tests'; -import { ProjectGrowthAlert } from '../../utils/alerts'; +import { ProjectGrowthAlert } from '../../alerts'; import { ProjectGrowthData } from '../../types'; const data: ProjectGrowthData = { @@ -31,11 +31,11 @@ const data: ProjectGrowthData = { }; const alert = new ProjectGrowthAlert(data); -describe('', () => { +describe('', () => { it('Renders an alert', async () => { const rendered = await renderInTestApp( - , + 1} /> , ); diff --git a/plugins/cost-insights/src/components/ActionItems/ActionItemCard.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.tsx new file mode 100644 index 0000000000..3c95fdaa2f --- /dev/null +++ b/plugins/cost-insights/src/components/ActionItems/ActionItemCard.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, { MouseEventHandler } from 'react'; +import classnames from 'classnames'; +import { Card, CardHeader } from '@material-ui/core'; +import { useScroll } from '../../hooks'; +import { Alert } from '../../types'; +import { useActionItemCardStyles as useStyles } from '../../utils/styles'; + +type ActionItemCardProps = { + alert: Alert; + number?: number; + avatar?: JSX.Element; + disableScroll?: boolean; +}; + +export const ActionItemCard = ({ + alert, + avatar, + number, + disableScroll = false, +}: ActionItemCardProps) => { + const classes = useStyles(); + const rootClasses = classnames(classes.root, { + [classes.activeRoot]: !disableScroll, + }); + const [, setScroll] = useScroll(); + + const onActionItemClick: MouseEventHandler = () => { + if (!disableScroll && number) { + setScroll(`alert-${number}`); + } + }; + + return ( + + + + ); +}; diff --git a/plugins/cost-insights/src/components/ActionItems/ActionItems.test.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItems.test.tsx new file mode 100644 index 0000000000..8c96e47f1a --- /dev/null +++ b/plugins/cost-insights/src/components/ActionItems/ActionItems.test.tsx @@ -0,0 +1,86 @@ +/* + * 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 { ActionItems } from './ActionItems'; +import { MockScrollProvider } from '../../utils/tests'; + +function renderInContext(children: JSX.Element) { + return render({children}); +} + +describe('', () => { + it('should not display status buttons if there no active alerts', () => { + const { queryByRole } = renderInContext( + , + ); + expect(queryByRole('button', { name: 'snoozed' })).not.toBeInTheDocument(); + expect(queryByRole('button', { name: 'accepted' })).not.toBeInTheDocument(); + expect( + queryByRole('button', { name: 'dismissed' }), + ).not.toBeInTheDocument(); + }); + + it('should display status buttons with correct badge number', () => { + const { getByText, getByRole, getAllByText } = renderInContext( + , + ); + expect(getAllByText('1')).toHaveLength(2); // should be a badge of 1 and action item number of 1 + expect(getByText('2')).toBeInTheDocument(); + expect(getByText('3')).toBeInTheDocument(); + expect(getByRole('button', { name: 'snoozed' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'accepted' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'dismissed' })).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/ActionItems/ActionItems.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItems.tsx new file mode 100644 index 0000000000..320ebb38aa --- /dev/null +++ b/plugins/cost-insights/src/components/ActionItems/ActionItems.tsx @@ -0,0 +1,132 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { Fragment, MouseEventHandler } from 'react'; +import { + Avatar, + Badge, + Box, + IconButtonProps, + IconButton, + Paper, + Divider, + Tooltip, +} from '@material-ui/core'; +import { default as SnoozeIcon } from '@material-ui/icons/AccessTime'; +import { default as AcceptIcon } from '@material-ui/icons/Check'; +import { default as DismissIcon } from '@material-ui/icons/Delete'; +import { ActionItemCard } from './ActionItemCard'; +import { Alert, AlertStatus } from '../../types'; +import { useScroll, ScrollType } from '../../hooks'; +import { useActionItemCardStyles as useStyles } from '../../utils/styles'; + +type ActionItemsProps = { + active: Alert[]; + snoozed: Alert[]; + accepted: Alert[]; + dismissed: Alert[]; +}; + +export const ActionItems = ({ + active, + snoozed, + accepted, + dismissed, +}: ActionItemsProps) => { + const classes = useStyles(); + const [, setScroll] = useScroll(); + + const isSnoozedButtonDisplayed = !!snoozed.length; + const isAcceptedButtonDisplayed = !!accepted.length; + const isDismissedButtonDisplayed = !!dismissed.length; + const isStatusButtonGroupDisplayed = !!active.length; + + const onStatusButtonClick: MouseEventHandler = () => + setScroll(ScrollType.AlertSummary); + + return ( + <> + + {active.map((alert, index) => ( + + {index + 1}} + /> + {index < active.length - 1 && } + + ))} + + {isStatusButtonGroupDisplayed && ( + + {isAcceptedButtonDisplayed && ( + } + amount={accepted.length} + onClick={onStatusButtonClick} + /> + )} + {isSnoozedButtonDisplayed && ( + } + onClick={onStatusButtonClick} + /> + )} + {isDismissedButtonDisplayed && ( + } + amount={dismissed.length} + onClick={onStatusButtonClick} + /> + )} + + )} + + ); +}; + +type AlertStatusButtonProps = { + title: string; + amount: number; + icon: JSX.Element; + onClick: MouseEventHandler; +} & IconButtonProps; + +const AlertStatusButton = ({ + title, + amount, + icon, + onClick, + ...buttonProps +}: AlertStatusButtonProps) => ( + + + {icon} + + +); diff --git a/plugins/cost-insights/src/components/ActionItems/index.ts b/plugins/cost-insights/src/components/ActionItems/index.ts new file mode 100644 index 0000000000..b63e34b0af --- /dev/null +++ b/plugins/cost-insights/src/components/ActionItems/index.ts @@ -0,0 +1,18 @@ +/* + * 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 { ActionItems } from './ActionItems'; +export { ActionItemCard } from './ActionItemCard'; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx deleted file mode 100644 index 78feade952..0000000000 --- a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCard.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import { Avatar, Card, CardHeader } from '@material-ui/core'; -import { useScroll } from '../../hooks'; -import { Alert } from '../../types'; -import { - useAlertActionCardHeader as useHeaderStyles, - useAlertActionCardStyles as useStyles, -} from '../../utils/styles'; - -type AlertActionCardProps = { - alert: Alert; - number: number; -}; - -export const AlertActionCard = ({ alert, number }: AlertActionCardProps) => { - const { scrollIntoView } = useScroll(`alert-${number}`); - const headerClasses = useHeaderStyles(); - const classes = useStyles(); - - return ( - - {number}} - title={alert.title} - subheader={alert.subtitle} - /> - - ); -}; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx new file mode 100644 index 0000000000..5785592aeb --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx @@ -0,0 +1,169 @@ +/* + * 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 { AlertDialog } from './AlertDialog'; +import { render } from '@testing-library/react'; +import { + Alert, + AlertFormProps, + AlertSnoozeOptions, + AlertDismissOptions, +} from '../../types'; + +type MockFormDataProps = AlertFormProps; + +const MockForm = React.forwardRef( + (props, ref) => ( +
+ You. Complete. Me. +
+ ), +); + +describe('', () => { + const snoozableAlert: Alert = { + title: 'title', + subtitle: 'test-subtitle', + onSnoozed: jest.fn(), + }; + + const dimissableAlert: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), + }; + + const customSnoozeAlert: Alert = { + title: 'title', + subtitle: 'subtitle', + onSnoozed: jest.fn(), + SnoozeForm: MockForm, + }; + + const customDismissAlert: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), + DismissForm: MockForm, + }; + + const customAcceptAlert: Alert = { + title: 'title', + subtitle: 'test-subtitle', + onAccepted: jest.fn(), + AcceptForm: MockForm, + }; + + it('Displays a default snooze form', () => { + const { getByText } = render( + , + ); + expect(getByText('For how long?')).toBeInTheDocument(); + expect(getByText('Snooze this action item?')).toBeInTheDocument(); + expect( + getByText('This action item will be snoozed for all of Ramones.'), + ).toBeInTheDocument(); + AlertSnoozeOptions.forEach(a => + expect(getByText(a.label)).toBeInTheDocument(), + ); + }); + + it('Displays a custom snooze form', () => { + const { getByText } = render( + , + ); + expect(getByText('You. Complete. Me.')).toBeInTheDocument(); + expect(getByText('Snooze this action item?')).toBeInTheDocument(); + expect( + getByText('This action item will be snoozed for all of Ramones.'), + ).toBeInTheDocument(); + }); + + it('Displays a default dismiss form', () => { + const { getByText } = render( + , + ); + expect(getByText('Dismiss this action item?')).toBeInTheDocument(); + expect( + getByText('This action item will be dismissed for all of Ramones.'), + ).toBeInTheDocument(); + AlertDismissOptions.forEach(a => + expect(getByText(a.label)).toBeInTheDocument(), + ); + }); + + it('Displays a custom dismiss form', () => { + const { getByText } = render( + , + ); + expect(getByText('Dismiss this action item?')).toBeInTheDocument(); + expect(getByText('You. Complete. Me.')).toBeInTheDocument(); + expect( + getByText('This action item will be dismissed for all of Ramones.'), + ).toBeInTheDocument(); + }); + + it('Displays a custom accept form', () => { + const { getByText } = render( + , + ); + expect(getByText('Accept this action item?')).toBeInTheDocument(); + expect(getByText('You. Complete. Me.')).toBeInTheDocument(); + expect( + getByText('This action item will be accepted for all of Ramones.'), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx new file mode 100644 index 0000000000..aa5b8c59fb --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx @@ -0,0 +1,210 @@ +/* + * 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, { useEffect, useRef, useState } from 'react'; +import { default as CloseIcon } from '@material-ui/icons/Close'; +import { + Box, + Button, + Divider, + Dialog, + DialogActions, + IconButton, + DialogContent, + Typography, +} from '@material-ui/core'; +import { + AlertAcceptForm, + AlertDismissForm, + AlertSnoozeForm, +} from '../../forms'; +import { useAlertDialogStyles as useStyles } from '../../utils/styles'; +import { choose } from '../../utils/alerts'; +import { Alert, Maybe } from '../../types'; + +const DEFAULT_FORM_ID = 'alert-form'; + +type AlertDialogProps = { + open: boolean; + group: string; + snoozed: Maybe; + accepted: Maybe; + dismissed: Maybe; + onClose: () => void; + onSubmit: (data: any) => void; +}; + +export const AlertDialog = ({ + open, + group, + snoozed, + accepted, + dismissed, + onClose, + onSubmit, +}: AlertDialogProps) => { + const classes = useStyles(); + const [isButtonDisabled, setDisabled] = useState(true); + const acceptRef = useRef>(null); + const snoozeRef = useRef>(null); + const dismissRef = useRef>(null); + + useEffect(() => { + if (open) { + setDisabled(true); + } else { + setDisabled(false); + } + }, [open]); + + function disableSubmit(isDisabled: boolean) { + setDisabled(isDisabled); + } + + function onDialogClose() { + onClose(); + setDisabled(true); + } + + const SnoozeForm = snoozed?.SnoozeForm ?? AlertSnoozeForm; + const AcceptForm = accepted?.AcceptForm ?? AlertAcceptForm; + const DismissForm = dismissed?.DismissForm ?? AlertDismissForm; + + const isSnoozeFormDisplayed = !!snoozed?.onSnoozed; + const isAcceptFormDisplayed = !!accepted?.onAccepted; + const isDismissFormDisplayed = !!dismissed?.onDismissed; + + const status = [ + isAcceptFormDisplayed, + isSnoozeFormDisplayed, + isDismissFormDisplayed, + ] as const; + + const TransitionProps = { + mountOnEnter: true, + unmountOnExit: true, + // Wait for child component to mount; avoid recycling refs. + onEntered() { + if (acceptRef.current) { + acceptRef.current.id = DEFAULT_FORM_ID; + } + if (snoozeRef.current) { + snoozeRef.current.id = DEFAULT_FORM_ID; + } + if (dismissRef.current) { + dismissRef.current.id = DEFAULT_FORM_ID; + } + }, + }; + + return ( + + + + + + + + + + + {choose(status, ['Accept', 'Snooze', 'Dismiss'])} this action + item? + + + + + This action item will be{' '} + {choose(status, ['accepted', 'snoozed', 'dismissed'])} for all of{' '} + {group}. + + + + + + + {choose(status, [ + accepted?.title, + snoozed?.title, + dismissed?.title, + ])} + + + + {choose(status, [ + accepted?.subtitle, + snoozed?.subtitle, + dismissed?.subtitle, + ])} + + + {isSnoozeFormDisplayed && ( + + )} + {isDismissFormDisplayed && ( + + )} + {isAcceptFormDisplayed && ( + + )} + + + + + + + ); +}; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx new file mode 100644 index 0000000000..cd8444cb1f --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx @@ -0,0 +1,89 @@ +/* + * 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, fireEvent, waitFor } from '@testing-library/react'; +import { AlertInsights } from './AlertInsights'; +import { + MockScrollProvider, + MockAlertsProvider, + MockLoadingProvider, +} from '../../utils/tests'; + +function renderInContext(children: JSX.Element) { + return render( + + + {children} + + , + ); +} + +describe('', () => { + it('should display the correct header if there are active action items', () => { + const { getByText, queryByText } = renderInContext( + , + ); + expect( + getByText( + 'This section outlines suggested action items your team can address to improve cloud costs.', + ), + ).toBeInTheDocument(); + expect(queryByText('Hidden Action Item')).not.toBeInTheDocument(); + }); + + it('should display alert summary if there are hidden action items', async () => { + const { getByText, getByRole } = renderInContext( + , + ); + + expect( + getByText( + "All of your team's action items are hidden. Maybe it's time to give them another look?", + ), + ).toBeInTheDocument(); + expect(getByText('Hidden Action Item')).toBeInTheDocument(); + + fireEvent.click(getByRole('button', { name: 'expand' })); + await waitFor(() => getByRole('img', { name: 'snoozed' })); + + expect(getByText('Vol. 4')).toBeInTheDocument(); + expect(getByText('Sabotage')).toBeInTheDocument(); + }); +}); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx index 4606906f04..1bcb684052 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx @@ -14,31 +14,203 @@ * limitations under the License. */ -import React from 'react'; -import { Grid } from '@material-ui/core'; -import { AlertInsightsSection } from './AlertInsightsSection'; +import React, { useCallback, useEffect, useState } from 'react'; +import pluralize from 'pluralize'; +import { Box, Grid, Snackbar } from '@material-ui/core'; +import { default as MuiAlert } from '@material-ui/lab/Alert'; +import { AlertDialog } from './AlertDialog'; +import { AlertStatusSummary } from './AlertStatusSummary'; +import { AlertStatusSummaryButton } from './AlertStatusSummaryButton'; import { AlertInsightsHeader } from './AlertInsightsHeader'; -import { Alert } from '../../types'; +import { AlertInsightsSection } from './AlertInsightsSection'; +import { + useAlerts, + useScroll, + useLoading, + ScrollType, + MapLoadingToProps, +} from '../../hooks'; +import { DefaultLoadingAction } from '../../utils/loading'; +import { Alert, AlertOptions, Maybe } from '../../types'; +import { sumOfAllAlerts } from '../../utils/alerts'; -const title = "Your team's action items"; -const subtitle = - 'This section outlines suggested action items your team can address to improve cloud costs.'; +type MapLoadingtoAlerts = (isLoading: boolean) => void; + +const mapLoadingToAlerts: MapLoadingToProps = ({ + dispatch, +}) => (isLoading: boolean) => + dispatch({ [DefaultLoadingAction.CostInsightsAlerts]: isLoading }); type AlertInsightsProps = { - alerts: Array; + group: string; + active: Alert[]; + snoozed: Alert[]; + accepted: Alert[]; + dismissed: Alert[]; }; -export const AlertInsights = ({ alerts }: AlertInsightsProps) => ( - - - - - - {alerts.map((alert, index) => ( - - +export const AlertInsights = ({ + group, + active, + snoozed, + accepted, + dismissed, +}: AlertInsightsProps) => { + const [alerts, setAlerts] = useAlerts(); + const [scroll, , ScrollAnchor] = useScroll(); + const dispatchLoadingAlerts = useLoading(mapLoadingToAlerts); + // Allow users to pass null values for data. + const [data, setData] = useState>(undefined); + const [error, setError] = useState>(null); + const [isDialogOpen, setDialogOpen] = useState(false); + const [isSummaryOpen, setSummaryOpen] = useState(false); + const [isSnackbarOpen, setSnackbarOpen] = useState(false); + + const closeDialog = useCallback(() => { + setData(undefined); + setDialogOpen(false); + setAlerts({ dismissed: null, snoozed: null, accepted: null }); + }, [setAlerts]); + + useEffect(() => { + async function callHandler( + options: AlertOptions, + callback: (options: AlertOptions) => Promise, + ) { + closeDialog(); + dispatchLoadingAlerts(true); + try { + const a: Alert[] = await callback(options); + setAlerts({ alerts: a }); + } catch (e) { + setError(e); + } finally { + dispatchLoadingAlerts(false); + } + } + + const options: AlertOptions = { data, group }; + const onSnoozed = alerts.snoozed?.onSnoozed?.bind(alerts.snoozed) ?? null; + const onAccepted = + alerts.accepted?.onAccepted?.bind(alerts.accepted) ?? null; + const onDismissed = + alerts.dismissed?.onDismissed?.bind(alerts.dismissed) ?? null; + + if (data !== undefined) { + if (onSnoozed) { + callHandler(options, onSnoozed); + } else if (onAccepted) { + callHandler(options, onAccepted); + } else if (onDismissed) { + callHandler(options, onDismissed); + } + } + }, [group, data, alerts, setAlerts, closeDialog, dispatchLoadingAlerts]); + + useEffect(() => { + if (scroll === ScrollType.AlertSummary) { + setSummaryOpen(true); + } + }, [scroll]); + + useEffect(() => { + if (error) { + setSnackbarOpen(true); + } else { + setSnackbarOpen(false); + } + }, [error]); + + useEffect(() => { + function toggleDialogOnStatusChange() { + const isAlertSnoozed = !!alerts.snoozed; + const isAlertAccepted = !!alerts.accepted; + const isAlertDismissed = !!alerts.dismissed; + + if (isAlertSnoozed || isAlertDismissed || isAlertAccepted) { + setDialogOpen(true); + } else { + setDialogOpen(false); + } + } + + toggleDialogOnStatusChange(); + }, [alerts.snoozed, alerts.dismissed, alerts.accepted]); + + function onSnackbarClose() { + setError(null); + } + + function onDialogSubmit(data: any) { + setData(data); + } + + function onSummaryButtonClick() { + setSummaryOpen(prevOpen => !prevOpen); + } + + const total = [accepted, snoozed, dismissed].reduce(sumOfAllAlerts, 0); + + const isAlertStatusSummaryDisplayed = !!total; + const isAlertInsightSectionDisplayed = !!active.length; + // AlertInsights will not display if there aren't any active or hidden items. + + return ( + + + + + {isAlertInsightSectionDisplayed && ( + + {active.map((alert, index) => ( + + + + ))} - ))} + )} + {isAlertStatusSummaryDisplayed && ( + + + + + {pluralize('Hidden Action Item', total)} + + + + + )} + + + + {error?.message} + + - -); + ); +}; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx index b58f6973ae..a8006b81d0 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsHeader.tsx @@ -30,10 +30,11 @@ export const AlertInsightsHeader = ({ subtitle, }: AlertInsightsHeaderProps) => { const classes = useStyles(); - const { ScrollAnchor } = useScroll(DefaultNavigation.AlertInsightsHeader); + const [, , ScrollAnchor] = useScroll(); + return ( - + {title}{' '} diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx index 54d350298c..f04e1d8b01 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx @@ -17,10 +17,10 @@ import React from 'react'; import { AlertInsightsSection } from './AlertInsightsSection'; import { render } from '@testing-library/react'; import { Alert } from '../../types'; -import { MockScrollProvider } from '../..'; +import { AlertState } from '../../hooks'; +import { MockScrollProvider, MockAlertsProvider } from '../../utils/tests'; const mockAlert: Alert = { - element:
, subtitle: 'Wherefore was I to this keen mockery born? When at your hands did I deserve this scorn?', title: 'Mock alert', @@ -29,26 +29,110 @@ const mockAlert: Alert = { describe('', () => { it('Renders alert without exploding', () => { - const { getByText } = render( - - - , + const { getByText, queryByText } = render( + + + + + , ); expect(getByText(mockAlert.title)).toBeInTheDocument(); expect(getByText(mockAlert.subtitle)).toBeInTheDocument(); expect(getByText('View Instructions')).toBeInTheDocument(); + expect(queryByText('Snooze')).not.toBeInTheDocument(); + expect(queryByText('Accept')).not.toBeInTheDocument(); + expect(queryByText('Dismiss')).not.toBeInTheDocument(); }); it('Hides instructions button if url is not provided', () => { - const alert = { + const alert: Alert = { ...mockAlert, url: undefined, }; const { queryByText } = render( - - - , + + + + + , ); expect(queryByText('View Instructions')).not.toBeInTheDocument(); }); + + it('Displays a snooze button if a hook is provided', () => { + const alert: Alert = { + ...mockAlert, + onSnoozed: jest.fn(), + }; + + const context: AlertState = { + alerts: [], + snoozed: alert, + dismissed: null, + accepted: null, + }; + + const { queryByText, getByText } = render( + + + + + , + ); + + expect(getByText('Snooze')).toBeInTheDocument(); + expect(queryByText('Accept')).not.toBeInTheDocument(); + expect(queryByText('Dismiss')).not.toBeInTheDocument(); + }); + + it('Displays a dismiss button if a hook is provided', () => { + const alert: Alert = { + ...mockAlert, + onDismissed: jest.fn(), + }; + const context: AlertState = { + alerts: [], + snoozed: null, + dismissed: alert, + accepted: null, + }; + + const { queryByText, getByText } = render( + + + + + , + ); + + expect(getByText('Dismiss')).toBeInTheDocument(); + expect(queryByText('Accept')).not.toBeInTheDocument(); + expect(queryByText('Snooze')).not.toBeInTheDocument(); + }); + + it('Displays an accept button if a hook is provided', () => { + const alert: Alert = { + ...mockAlert, + onAccepted: jest.fn(), + }; + + const context: AlertState = { + alerts: [], + snoozed: null, + dismissed: null, + accepted: alert, + }; + + const { queryByText, getByText } = render( + + + + + , + ); + + expect(getByText('Accept')).toBeInTheDocument(); + expect(queryByText('Snooze')).not.toBeInTheDocument(); + expect(queryByText('Dismiss')).not.toBeInTheDocument(); + }); }); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx index 7e371c8001..dda2d66f3f 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx @@ -15,7 +15,11 @@ */ import React from 'react'; import { Box, Button } from '@material-ui/core'; +import { default as SnoozeIcon } from '@material-ui/icons/AccessTime'; +import { default as AcceptIcon } from '@material-ui/icons/Check'; +import { default as DismissIcon } from '@material-ui/icons/Delete'; import { AlertInsightsSectionHeader } from './AlertInsightsSectionHeader'; +import { useAlerts } from '../../hooks'; import { Alert } from '../../types'; type AlertInsightsSectionProps = { @@ -27,18 +31,60 @@ export const AlertInsightsSection = ({ alert, number, }: AlertInsightsSectionProps) => { + const [, setAlerts] = useAlerts(); + + const isSnoozeButtonDisplayed = !!alert.onSnoozed; + const isAcceptButtonDisplayed = !!alert.onAccepted; + const isDismissButtonDisplayed = !!alert.onDismissed; + const isButtonGroupDisplayed = + isSnoozeButtonDisplayed || + isAcceptButtonDisplayed || + isDismissButtonDisplayed; + return ( - - - {alert.url && ( - - + + + {isButtonGroupDisplayed && ( + + {isAcceptButtonDisplayed && ( + + + + )} + {isSnoozeButtonDisplayed && ( + + + + )} + {isDismissButtonDisplayed && ( + + )} )} {alert.element} diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx index 3613e3adc8..c463a05609 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx @@ -15,34 +15,47 @@ */ import React from 'react'; -import { Avatar, Box, Grid, Typography } from '@material-ui/core'; +import { Avatar, Box, Button, Grid, Typography } from '@material-ui/core'; import { useAlertInsightsSectionStyles as useStyles } from '../../utils/styles'; import { useScroll } from '../../hooks'; +import { Alert } from '../../types'; type AlertInsightsSectionHeaderProps = { + alert: Alert; number: number; - title: string; - subtitle: string; }; export const AlertInsightsSectionHeader = ({ + alert, number, - title, - subtitle, }: AlertInsightsSectionHeaderProps) => { - const { ScrollAnchor } = useScroll(`alert-${number}`); + const [, , ScrollAnchor] = useScroll(); const classes = useStyles(); + + const isViewInstructionsButtonDisplayed = !!alert.url; + return ( - - + + - {number} - - - {title} - {subtitle} + + + {number} + + + {alert.title} + {alert.subtitle} + + + {isViewInstructionsButtonDisplayed && ( + + + + )} ); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx new file mode 100644 index 0000000000..830db19fcd --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.test.tsx @@ -0,0 +1,58 @@ +/* + * 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 { AlertStatusSummary } from './AlertStatusSummary'; +import { render } from '@testing-library/react'; +import { Alert, AlertStatus } from '../../types'; +import { MockScrollProvider } from '../../utils/tests'; + +const mockSnoozed: Alert = { + title: 'snoozed-title', + subtitle: 'snoozed-subtitle', + status: AlertStatus.Snoozed, +}; + +const mockAccepted: Alert = { + title: 'accepted-title', + subtitle: 'accepted-subtitle', + status: AlertStatus.Accepted, +}; + +const mockDismissed: Alert = { + title: 'dismissed-title', + subtitle: 'dismissed-subtitle', + status: AlertStatus.Dismissed, +}; + +describe('', () => { + it('should display alerts', () => { + const { getByText, getByRole } = render( + + + , + ); + [mockSnoozed, mockAccepted, mockDismissed].forEach(a => { + expect(getByText(a.title)).toBeInTheDocument(); + expect(getByText(a.subtitle)).toBeInTheDocument(); + expect(getByRole('img', { name: a.status })).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx new file mode 100644 index 0000000000..cb0a149511 --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx @@ -0,0 +1,116 @@ +/* + * 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, { Fragment } from 'react'; +import { Avatar, Box, Collapse, Divider } from '@material-ui/core'; +import { default as AcceptIcon } from '@material-ui/icons/Check'; +import { default as DismissIcon } from '@material-ui/icons/Delete'; +import { default as SnoozeIcon } from '@material-ui/icons/AccessTime'; +import { ActionItemCard } from '../ActionItems'; +import { Alert, AlertStatus } from '../../types'; +import { useActionItemCardStyles as useStyles } from '../../utils/styles'; + +type AlertStatusSummaryProps = { + open: boolean; + snoozed: Alert[]; + accepted: Alert[]; + dismissed: Alert[]; +}; + +export const AlertStatusSummary = ({ + open, + snoozed, + accepted, + dismissed, +}: AlertStatusSummaryProps) => { + const classes = useStyles(); + + const isSnoozedListDisplayed = !!snoozed.length; + const isAcceptedListDisplayed = !!accepted.length; + const isDismissedListDisplayed = !!dismissed.length; + + return ( + + {isAcceptedListDisplayed && ( + + {accepted.map((alert, index) => ( + + + {/* Icons indicate alert status. Do not hide from accesibility tree */} + + + } + /> + {index < accepted.length - 1 && } + + ))} + + )} + {isSnoozedListDisplayed && ( + + {snoozed.map((alert, index) => ( + + + + + } + /> + {index < snoozed.length - 1 && } + + ))} + + )} + {isDismissedListDisplayed && ( + + {dismissed.map((alert, index) => ( + + + + + } + /> + {index < dismissed.length - 1 && } + + ))} + + )} + + ); +}; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummaryButton.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummaryButton.tsx new file mode 100644 index 0000000000..ed8c7fabbb --- /dev/null +++ b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummaryButton.tsx @@ -0,0 +1,54 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, MouseEventHandler, PropsWithChildren } from 'react'; +import classnames from 'classnames'; +import { Button } from '@material-ui/core'; +import { default as ExpandMoreIcon } from '@material-ui/icons/ExpandMore'; +import { useAlertStatusSummaryButtonStyles as useStyles } from '../../utils/styles'; + +type AlertStatusSummaryButtonProps = { + onClick: MouseEventHandler; +}; + +export const AlertStatusSummaryButton = ({ + children, + onClick, +}: PropsWithChildren) => { + const classes = useStyles(); + const [clicked, setClicked] = useState(false); + const iconClassName = classnames(classes.icon, { + [classes.clicked]: clicked, + }); + + const handleOnClick: MouseEventHandler = e => { + setClicked(prevClicked => !prevClicked); + onClick(e); + }; + + return ( + + ); +}; diff --git a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx index a30b6c5a90..dd1652af46 100644 --- a/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx +++ b/plugins/cost-insights/src/components/CostInsightsNavigation/CostInsightsNavigation.tsx @@ -105,13 +105,14 @@ export const CostInsightsNavigation = React.memo( const NavigationMenuItem = ({ navigation, icon, title }: NavigationItem) => { const classes = useStyles(); - const { scrollIntoView } = useScroll(navigation); + const [, setScroll] = useScroll(); + return ( setScroll(navigation)} > {icon} { const classes = useSubtleTypographyStyles(); @@ -54,16 +68,24 @@ export const CostInsightsPage = () => { const config = useConfig(); const groups = useGroups(); const lastCompleteBillingDate = useLastCompleteBillingDate(); + const [alerts, setAlerts] = useAlerts(); const [currency, setCurrency] = useCurrency(); const [projects, setProjects] = useState>(null); const [products, setProducts] = useState>(null); const [dailyCost, setDailyCost] = useState>(null); const [metricData, setMetricData] = useState>(null); - const [alerts, setAlerts] = useState>(null); const [error, setError] = useState>(null); const { pageFilters, setPageFilters } = useFilters(p => p); + const snoozed = useMemo(() => alerts.alerts.filter(isSnoozed), [alerts]); + const accepted = useMemo(() => alerts.alerts.filter(isAccepted), [alerts]); + const dismissed = useMemo(() => alerts.alerts.filter(isDismissed), [alerts]); + const activeAlerts = useMemo(() => alerts.alerts.filter(isActive), [alerts]); + + const isActionItemsDisplayed = !!activeAlerts.length; + const isAlertInsightsDisplayed = !!alerts.alerts.length; + const { loadingActions, loadingGroups, @@ -120,7 +142,7 @@ export const CostInsightsPage = () => { : client.getGroupDailyCost(pageFilters.group, intervals), ]); setProjects(fetchedProjects); - setAlerts(fetchedAlerts); + setAlerts({ alerts: fetchedAlerts }); setMetricData(fetchedMetricData); setDailyCost(fetchedDailyCost); } else { @@ -145,6 +167,7 @@ export const CostInsightsPage = () => { loadingActions, loadingGroups, loadingBillingDate, + setAlerts, dispatchLoadingInsights, dispatchLoadingInitial, dispatchLoadingNone, @@ -177,8 +200,8 @@ export const CostInsightsPage = () => { ); } - // These should be defined, alerts can be an empty array but that's truthy - if (!dailyCost || !alerts) { + + if (!dailyCost) { return ( {`Error: Could not fetch cost insights data for team ${pageFilters.group}`} ); @@ -228,7 +251,7 @@ export const CostInsightsPage = () => { @@ -249,19 +272,22 @@ export const CostInsightsPage = () => { owner={pageFilters.group} groups={groups} hasCostData={!!dailyCost.aggregation.length} - alerts={alerts.length} + alerts={activeAlerts.length} /> - {!!alerts.length && ( - <> - - - - - - - - )} + + + + + + + + @@ -276,14 +302,20 @@ export const CostInsightsPage = () => { - - {!!alerts?.length && ( + + - + - )} - - {!alerts.length && } + + + {!isAlertInsightsDisplayed && } ( - + + + diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx index 39adc4bbf1..1539e431fd 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -49,7 +49,7 @@ export const CostOverviewCard = ({ const config = useConfig(); const [tabIndex, setTabIndex] = useState(0); - const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard); + const [, , ScrollAnchor] = useScroll(); const { setDuration, setProject, setMetric, ...filters } = useFilters( mapFiltersToProps, ); @@ -95,7 +95,7 @@ export const CostOverviewCard = ({ return ( - + {dailyCostData.groupedCosts && } diff --git a/plugins/cost-insights/src/components/MigrationAlertCard/MigrationAlertCard.tsx b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationAlertCard.tsx new file mode 100644 index 0000000000..f4d174d521 --- /dev/null +++ b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationAlertCard.tsx @@ -0,0 +1,60 @@ +/* + * 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 { Box } from '@material-ui/core'; +import { InfoCard } from '@backstage/core'; +import { MigrationBarChartLegend } from './MigrationBarChartLegend'; +import { MigrationBarChart } from './MigrationBarChart'; +import { MigrationData } from '../../types'; + +type MigrationAlertProps = { + data: MigrationData; + title: string; + subheader: string; + currentProduct: string; + comparedProduct: string; +}; + +export const MigrationAlertCard = ({ + data, + title, + subheader, + currentProduct, + comparedProduct, +}: MigrationAlertProps) => { + return ( + + + + + + + + + + + ); +}; diff --git a/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChart.tsx b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChart.tsx new file mode 100644 index 0000000000..60aa49b2bc --- /dev/null +++ b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChart.tsx @@ -0,0 +1,54 @@ +/* + * 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 { BarChart } from '../BarChart'; +import { + BarChartOptions, + CostInsightsTheme, + Entity, + ResourceData, +} from '../../types'; +import { useTheme } from '@material-ui/core'; + +type MigrationBarChartProps = { + currentProduct: string; + comparedProduct: string; + services: Array; +}; + +export const MigrationBarChart = ({ + currentProduct, + comparedProduct, + services, +}: MigrationBarChartProps) => { + const theme = useTheme(); + + const options: BarChartOptions = { + previousFill: theme.palette.magenta, + currentFill: theme.palette.yellow, + previousName: comparedProduct, + currentName: currentProduct, + }; + + const resources: ResourceData[] = services.map(service => ({ + name: service.id, + previous: service.aggregation[0], + current: service.aggregation[1], + })); + + return ; +}; diff --git a/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChartLegend.tsx b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChartLegend.tsx new file mode 100644 index 0000000000..a84e865e28 --- /dev/null +++ b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationBarChartLegend.tsx @@ -0,0 +1,58 @@ +/* + * 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 { Box, useTheme } from '@material-ui/core'; +import { CostGrowth } from '../CostGrowth'; +import { LegendItem } from '../LegendItem'; +import { ChangeStatistic, CostInsightsTheme, Duration } from '../../types'; +import { monthOf } from '../../utils/formatters'; + +export type MigrationBarChartLegendProps = { + change: ChangeStatistic; + startDate: string; + currentProduct: string; + comparedProduct: string; +}; + +export const MigrationBarChartLegend = ({ + currentProduct, + comparedProduct, + change, + startDate, +}: MigrationBarChartLegendProps) => { + const theme = useTheme(); + return ( + + + + {currentProduct} + + + + + {comparedProduct} + + + + + + + ); +}; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/index.ts b/plugins/cost-insights/src/components/MigrationAlertCard/index.ts similarity index 90% rename from plugins/cost-insights/src/components/AlertActionCardList/index.ts rename to plugins/cost-insights/src/components/MigrationAlertCard/index.ts index a0ce118482..4feb749c27 100644 --- a/plugins/cost-insights/src/components/AlertActionCardList/index.ts +++ b/plugins/cost-insights/src/components/MigrationAlertCard/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { AlertActionCardList } from './AlertActionCardList'; +export { MigrationAlertCard } from './MigrationAlertCard'; diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx index fc37bc5dd6..406affcd15 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx @@ -77,17 +77,6 @@ const renderProductInsightsCardInTestApp = async ( ); describe('', () => { - it('Renders the scroll anchors', async () => { - const rendered = await renderProductInsightsCardInTestApp( - mockProductCost, - MockComputeEngine, - Duration.P30D, - ); - expect( - rendered.queryByTestId(`scroll-test-compute-engine`), - ).toBeInTheDocument(); - }); - it('Should render the right subheader for products with cost data', async () => { const entity = { ...mockProductCost, diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx index 534ef490c3..828d229c94 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -60,7 +60,7 @@ export const ProductInsightsCard = ({ }: PropsWithChildren) => { const classes = useStyles(); const mountedRef = useRef(false); - const { ScrollAnchor } = useScroll(product.kind); + const [, , ScrollAnchor] = useScroll(); const [error, setError] = useState>(null); const dispatchLoading = useLoading(mapLoadingToProps); const lastCompleteBillingDate = useLastCompleteBillingDate(); @@ -107,7 +107,7 @@ export const ProductInsightsCard = ({ if (error || !entity) { return ( - + {error ? error.message @@ -123,7 +123,7 @@ export const ProductInsightsCard = ({ subheader={subheader} headerProps={headerProps} > - + {entities.length ? ( ; + +export const AlertAcceptForm = forwardRef< + HTMLFormElement, + AlertAcceptFormProps +>(({ onSubmit, disableSubmit }, ref) => { + const [checked, setChecked] = useState(false); + + const onFormSubmit: FormEventHandler = e => { + e.preventDefault(); + onSubmit(null); + }; + + const onChecked = (_: ChangeEvent, isChecked: boolean) => { + setChecked(isChecked); + disableSubmit(!isChecked); + }; + + return ( +
+ + + } + /> + +
+ ); +}); diff --git a/plugins/cost-insights/src/forms/AlertDismissForm.tsx b/plugins/cost-insights/src/forms/AlertDismissForm.tsx new file mode 100644 index 0000000000..1b3cbdb363 --- /dev/null +++ b/plugins/cost-insights/src/forms/AlertDismissForm.tsx @@ -0,0 +1,156 @@ +/* + * 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, { + ChangeEvent, + useEffect, + useState, + forwardRef, + FormEventHandler, +} from 'react'; +import { + Box, + Collapse, + FormControl, + FormControlLabel, + TextField, + Typography, + Radio, + RadioGroup, +} from '@material-ui/core'; +import { + Alert, + AlertFormProps, + AlertDismissReason, + AlertDismissOptions, + AlertDismissFormData, + Maybe, +} from '../types'; +import { useAlertDialogStyles as useStyles } from '../utils/styles'; + +export type AlertDismissFormProps = AlertFormProps; + +export const AlertDismissForm = forwardRef< + HTMLFormElement, + AlertDismissFormProps +>(({ onSubmit, disableSubmit }, ref) => { + const classes = useStyles(); + const [other, setOther] = useState>(null); + const [feedback, setFeedback] = useState>(null); + const [reason, setReason] = useState( + AlertDismissReason.Resolved, + ); + + const onFormSubmit: FormEventHandler = e => { + function submit() { + onSubmit({ + other: other, + reason: reason, + feedback: feedback, + }); + } + + e.preventDefault(); + if (reason) { + submit(); + } + }; + + const onReasonChange = (_: ChangeEvent, value: string) => { + setReason(value as AlertDismissReason); + }; + + const onOtherChange = (e: ChangeEvent) => { + return e.target.value + ? setOther(e.target.value as AlertDismissReason) + : setOther(null); + }; + + const onFeedbackChange = (e: ChangeEvent) => { + return e.target.value + ? setFeedback(e.target.value as AlertDismissReason) + : setFeedback(null); + }; + + useEffect(() => { + function validateDismissForm() { + if (reason === AlertDismissReason.Other) { + if (other) { + disableSubmit(false); + } else { + disableSubmit(true); + } + } else if (reason) { + disableSubmit(false); + } else { + disableSubmit(true); + } + } + + validateDismissForm(); + }, [reason, other, disableSubmit]); + + return ( +
+ + + Reason for dismissing? + + + + {AlertDismissOptions.map(option => ( + } + /> + ))} + + + + + + + + + Any other feedback you can provide? + + + +
+ ); +}); diff --git a/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx b/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx new file mode 100644 index 0000000000..51218fe844 --- /dev/null +++ b/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx @@ -0,0 +1,115 @@ +/* + * 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, { + ChangeEvent, + useEffect, + useState, + forwardRef, + FormEventHandler, +} from 'react'; +import dayjs from 'dayjs'; +import { + Box, + Collapse, + FormControl, + FormControlLabel, + RadioGroup, + Radio, + Typography, +} from '@material-ui/core'; +import { + Alert, + AlertFormProps, + Duration, + DEFAULT_DATE_FORMAT, + Maybe, + AlertSnoozeFormData, + AlertSnoozeOptions, +} from '../types'; +import { useAlertDialogStyles as useStyles } from '../utils/styles'; +import { intervalsOf } from '../utils/duration'; + +export type AlertSnoozeFormProps = AlertFormProps; + +export const AlertSnoozeForm = forwardRef< + HTMLFormElement, + AlertSnoozeFormProps +>(({ onSubmit, disableSubmit }, ref) => { + const classes = useStyles(); + const [error, setError] = useState>(null); + const [duration, setDuration] = useState>(Duration.P7D); + + const onFormSubmit: FormEventHandler = e => { + e.preventDefault(); + if (duration) { + const repeatInterval = 1; + const inclusiveEndDate = dayjs().format(DEFAULT_DATE_FORMAT); + onSubmit({ + intervals: intervalsOf(duration, inclusiveEndDate, repeatInterval), + }); + } else { + setError(new Error('Please select an option.')); + } + }; + + const onSnoozeDurationChange = ( + _: ChangeEvent, + value: string, + ) => { + setDuration(value as Duration); + }; + + useEffect(() => { + function clearErrorOnFormDataChange() { + disableSubmit(false); + setError(prevError => (prevError ? null : prevError)); + } + + clearErrorOnFormDataChange(); + }, [duration, disableSubmit]); + + const isErrorMessageDisplayed = !!error; + + return ( +
+ + + For how long? + + + {error?.message} + + + + {AlertSnoozeOptions.map(option => ( + } + /> + ))} + + + +
+ ); +}); diff --git a/plugins/cost-insights/src/forms/MigrationDismissForm.tsx b/plugins/cost-insights/src/forms/MigrationDismissForm.tsx new file mode 100644 index 0000000000..ce71af3a37 --- /dev/null +++ b/plugins/cost-insights/src/forms/MigrationDismissForm.tsx @@ -0,0 +1,108 @@ +/* + * 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, { + useEffect, + useState, + forwardRef, + FormEventHandler, + ChangeEvent, +} from 'react'; +import { + Checkbox, + FormControl, + FormControlLabel, + FormGroup, + Typography, +} from '@material-ui/core'; +import { AlertFormProps, Entity } from '../types'; +import { MigrationAlert } from '../alerts'; +import { findAlways } from '../utils/assert'; + +export type MigrationDismissFormData = { + services: Entity[]; +}; + +export type MigrationDismissFormProps = AlertFormProps< + MigrationAlert, + MigrationDismissFormData +>; + +export const MigrationDismissForm = forwardRef< + HTMLFormElement, + MigrationDismissFormProps +>(({ onSubmit, disableSubmit, alert }, ref) => { + const [services, setServices] = useState(alert.data.services); + + const onFormSubmit: FormEventHandler = e => { + /* Remember to prevent default form behavior */ + e.preventDefault(); + onSubmit({ services: services }); + }; + + const onCheckboxChange = ( + e: ChangeEvent, + checked: boolean, + ) => { + if (checked) { + const service = findAlways( + alert.data.services, + s => s.id === e.target.value, + ); + setServices(prevServices => prevServices.concat(service)); + } else { + setServices(prevServices => + prevServices.filter(p => p.id !== e.target.value), + ); + } + }; + + /* Submit button is disabled by default. Use props.disableSubmit to toggle disabled state. */ + useEffect(() => { + if (services.length) { + disableSubmit(false); + } else { + disableSubmit(true); + } + }, [services, disableSubmit]); + + return ( + /* All custom forms must accept a ref and implement an onSubmit handler. */ +
+ + + Or choose which services to dismiss this alert for. + + + {alert.data.services.map((service, index) => ( + p.id === service.id)} + onChange={onCheckboxChange} + /> + } + /> + ))} + + +
+ ); +}); diff --git a/plugins/cost-insights/src/forms/index.ts b/plugins/cost-insights/src/forms/index.ts new file mode 100644 index 0000000000..5b8384a293 --- /dev/null +++ b/plugins/cost-insights/src/forms/index.ts @@ -0,0 +1,21 @@ +/* + * 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 { AlertAcceptForm } from './AlertAcceptForm'; +export { AlertSnoozeForm } from './AlertSnoozeForm'; +export { AlertDismissForm } from './AlertDismissForm'; +export { MigrationDismissForm } from './MigrationDismissForm'; +export type { MigrationDismissFormData } from './MigrationDismissForm'; diff --git a/plugins/cost-insights/src/hooks/index.ts b/plugins/cost-insights/src/hooks/index.ts index 94c556763c..c1a4b3512c 100644 --- a/plugins/cost-insights/src/hooks/index.ts +++ b/plugins/cost-insights/src/hooks/index.ts @@ -16,6 +16,7 @@ export * from './useConfig'; export * from './useCurrency'; +export * from './useAlerts'; export * from './useFilters'; export * from './useCurrency'; export * from './useGroups'; diff --git a/plugins/cost-insights/src/hooks/useAlerts.tsx b/plugins/cost-insights/src/hooks/useAlerts.tsx new file mode 100644 index 0000000000..6bc311b09b --- /dev/null +++ b/plugins/cost-insights/src/hooks/useAlerts.tsx @@ -0,0 +1,76 @@ +/* + * 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, { + useReducer, + Dispatch, + SetStateAction, + createContext, + useContext, + PropsWithChildren, +} from 'react'; +import { Alert, Maybe } from '../types'; + +export type AlertsContextProps = { + alerts: AlertState; + setAlerts: Dispatch>>; +}; + +export const AlertsContext = createContext( + undefined, +); + +export type AlertState = { + alerts: Alert[]; + snoozed: Maybe; + accepted: Maybe; + dismissed: Maybe; +}; + +const initialState: AlertState = { + alerts: [], + snoozed: null, + accepted: null, + dismissed: null, +}; + +const reducer = ( + prevState: AlertState, + action: SetStateAction>, +): AlertState => ({ + ...prevState, + ...action, +}); + +export const AlertsProvider = ({ children }: PropsWithChildren<{}>) => { + const [alerts, setAlerts] = useReducer(reducer, initialState); + + return ( + + {children} + + ); +}; + +export function useAlerts() { + const context = useContext(AlertsContext); + return context + ? ([context.alerts, context.setAlerts] as const) + : assertNever(); +} + +function assertNever(): never { + throw new Error('useAlerts cannot be used outside AlertsContext provider'); +} diff --git a/plugins/cost-insights/src/hooks/useScroll.tsx b/plugins/cost-insights/src/hooks/useScroll.tsx index 0a1abbdad3..7f310d59b0 100644 --- a/plugins/cost-insights/src/hooks/useScroll.tsx +++ b/plugins/cost-insights/src/hooks/useScroll.tsx @@ -15,6 +15,7 @@ */ import React, { Dispatch, + ElementType, SetStateAction, useState, useContext, @@ -22,19 +23,14 @@ import React, { useRef, PropsWithChildren, } from 'react'; -import { CSSProperties } from '@material-ui/styles'; import { Maybe } from '../types'; export type ScrollTo = Maybe; export type ScrollContextProps = { - scrollTo: ScrollTo; - setScrollTo: Dispatch>; -}; - -export type ScrollUtils = { - ScrollAnchor: (props: Omit) => JSX.Element; - scrollIntoView: () => void; + scroll: ScrollTo; + setScroll: Dispatch>; + ScrollAnchor: ElementType; }; export interface ScrollAnchorProps extends ScrollIntoViewOptions { @@ -49,28 +45,14 @@ export const ScrollContext = React.createContext< export const ScrollAnchor = ({ id, - top, - left, - behavior, block, inline, + left = 0, + top = -20, + behavior = 'smooth', }: ScrollAnchorProps) => { const divRef = useRef(null); - const context = useContext(ScrollContext); - - if (!context) { - assertNever(); - } - - const { scrollTo, setScrollTo } = context; - - const styles: CSSProperties = { - position: 'absolute', - height: 0, - width: 0, - top: top || 0, - left: left || 0, - }; + const [scroll, setScroll] = useScroll(); useEffect(() => { function scrollIntoView() { @@ -80,39 +62,46 @@ export const ScrollAnchor = ({ inline: inline || 'nearest', }; - if (divRef.current && scrollTo === id) { + if (divRef.current && scroll === id) { divRef.current.scrollIntoView(options); - setScrollTo(null); + setScroll(null); } } scrollIntoView(); - }, [scrollTo, setScrollTo, id, behavior, block, inline]); + }, [scroll, setScroll, id, behavior, block, inline]); - return
; + return ( +
+ ); }; export const ScrollProvider = ({ children }: PropsWithChildren<{}>) => { - const [scrollTo, setScrollTo] = useState(null); + const [scroll, setScroll] = useState(null); return ( - + {children} ); }; -export function useScroll(id: ScrollTo): ScrollUtils { +export enum ScrollType { + AlertSummary = 'alert-status-summary', +} + +export function useScroll() { const context = useContext(ScrollContext); if (!context) { assertNever(); } - return { - ScrollAnchor: props => , - scrollIntoView: () => context.setScrollTo(id), - }; + return [context.scroll, context.setScroll, context.ScrollAnchor] as const; } function assertNever(): never { diff --git a/plugins/cost-insights/src/index.ts b/plugins/cost-insights/src/index.ts index 7cd0456ad1..13ce929401 100644 --- a/plugins/cost-insights/src/index.ts +++ b/plugins/cost-insights/src/index.ts @@ -17,8 +17,8 @@ export { plugin } from './plugin'; export * from './client'; export * from './api'; +export { ProjectGrowthAlert, UnlabeledDataflowAlert } from './alerts'; export * from './components'; export { useCurrency } from './hooks'; export * from './types'; export * from './utils/tests'; -export * from './utils/alerts'; diff --git a/plugins/cost-insights/src/types/Alert.ts b/plugins/cost-insights/src/types/Alert.ts index 18b86c8cb8..21e1a779a8 100644 --- a/plugins/cost-insights/src/types/Alert.ts +++ b/plugins/cost-insights/src/types/Alert.ts @@ -13,23 +13,147 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +import { ForwardRefExoticComponent, RefAttributes } from 'react'; import { ChangeStatistic } from './ChangeStatistic'; +import { Duration } from './Duration'; import { Maybe } from './Maybe'; /** * Generic alert type with required fields for display. The `element` field will be rendered in * the Cost Insights "Action Items" section. This should use data fetched in the CostInsightsApi * implementation to render an InfoCard or other visualization. + * + * The alert type exposes hooks which can be used to enable and access various events, + * such as when a user dismisses or snoozes an alert. Default forms and buttons + * will be rendered if a hook is defined. + * + * Each default form can be overridden with a custom component. It must be implemented using + * React.forwardRef. See https://reactjs.org/docs/forwarding-refs + * + * Errors thrown within hooks will generate a snackbar error notification. */ + export type Alert = { title: string; subtitle: string; + element?: JSX.Element; + status?: AlertStatus; url?: string; buttonText?: string; // Default: View Instructions - element: JSX.Element; + SnoozeForm?: AlertForm; + AcceptForm?: AlertForm; + DismissForm?: AlertForm; + onSnoozed?(options: AlertOptions): Promise; + onAccepted?(options: AlertOptions): Promise; + onDismissed?(options: AlertOptions): Promise; }; +export type AlertForm< + A extends Alert = any, + Data = any +> = ForwardRefExoticComponent< + AlertFormProps & RefAttributes +>; + +export interface AlertOptions { + data: T; + group: string; +} + +/** + * Default snooze form intervals are expressed using an ISO 8601 repeating interval string. + * For example, R1/P7D/2020-09-02 for 1 week or R1/P30D/2020-09-02 for 1 month. + * + * For example, if a user dismisses an alert on Monday January 01 for 1 week, + * it can be re-served on Monday, January 08. 7 calendar days from January 02, + * inclusive of the last day. + * + * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals + */ +export interface AlertSnoozeFormData { + intervals: string; +} + +export interface AlertDismissFormData { + other: Maybe; + reason: AlertDismissReason; + feedback: Maybe; +} + +// TODO: Convert enum to literal +export enum AlertStatus { + Snoozed = 'snoozed', + Accepted = 'accepted', + Dismissed = 'dismissed', +} + +export type AlertFormProps = { + alert: A; + onSubmit: (data: FormData) => void; + disableSubmit: (isDisabled: boolean) => void; +}; + +export interface AlertDismissOption { + label: string; + reason: string; +} + +export enum AlertDismissReason { + Other = 'other', + Resolved = 'resolved', + Expected = 'expected', + Seasonal = 'seasonal', + Migration = 'migration', + NotApplicable = 'not-applicable', +} + +export const AlertDismissOptions: AlertDismissOption[] = [ + { + reason: AlertDismissReason.Resolved, + label: 'This action item is now resolved.', + }, + { + reason: AlertDismissReason.Seasonal, + label: 'This is an expected increase at this time of year.', + }, + { + reason: AlertDismissReason.Migration, + label: 'This increase is from a migration in process.', + }, + { + reason: AlertDismissReason.Expected, + label: 'This is an expected increase due to our team’s priorities.', + }, + { + reason: AlertDismissReason.NotApplicable, + label: 'This action item doesn’t make sense for my team.', + }, + { + reason: AlertDismissReason.Other, + label: 'Other (please specify)', + }, +]; + +export type AlertSnoozeOption = { + label: string; + duration: Duration; +}; + +export const AlertSnoozeOptions: AlertSnoozeOption[] = [ + { + duration: Duration.P7D, + label: '1 Week', + }, + { + duration: Duration.P30D, + label: '1 Month', + }, + { + duration: Duration.P3M, + label: '1 Quarter', + }, +]; + export interface AlertCost { id: string; aggregation: [number, number]; diff --git a/plugins/cost-insights/src/types/Duration.ts b/plugins/cost-insights/src/types/Duration.ts index c0f03d5c27..e25f38e63d 100644 --- a/plugins/cost-insights/src/types/Duration.ts +++ b/plugins/cost-insights/src/types/Duration.ts @@ -21,6 +21,7 @@ * September 15. */ export enum Duration { + P7D = 'P7D', P30D = 'P30D', P90D = 'P90D', P3M = 'P3M', diff --git a/plugins/cost-insights/src/utils/alerts.tsx b/plugins/cost-insights/src/utils/alerts.tsx index fbb148f38a..b8dfddf79b 100644 --- a/plugins/cost-insights/src/utils/alerts.tsx +++ b/plugins/cost-insights/src/utils/alerts.tsx @@ -14,50 +14,22 @@ * limitations under the License. */ -import React from 'react'; -import { Alert, UnlabeledDataflowData, ProjectGrowthData } from '../types'; -import { UnlabeledDataflowAlertCard } from '../components/UnlabeledDataflowAlertCard'; -import { ProjectGrowthAlertCard } from '../components/ProjectGrowthAlertCard'; +import { Alert, AlertStatus } from '../types'; -/** - * The alerts below are examples of Alert implementation; the CostInsightsApi permits returning - * any implementation of the Alert type, so adopters can create their own. The CostInsightsApi - * fetches alert data from the backend, then creates Alert classes with the data. - */ +const createStatusHandler = (status?: string) => (alert: Alert) => + alert.status === status; +export const isActive = createStatusHandler(); +export const isSnoozed = createStatusHandler(AlertStatus.Snoozed); +export const isAccepted = createStatusHandler(AlertStatus.Accepted); +export const isDismissed = createStatusHandler(AlertStatus.Dismissed); -export class UnlabeledDataflowAlert implements Alert { - data: UnlabeledDataflowData; +export const sumOfAllAlerts = (sum: number, alerts: Alert[]) => + sum + alerts.length; - constructor(data: UnlabeledDataflowData) { - this.data = data; - } - - title = 'Add labels to workflows'; - subtitle = - 'Labels show in billing data, enabling cost insights for each workflow.'; - url = '/cost-insights/labeling-jobs'; - - get element() { - return ; - } -} - -export class ProjectGrowthAlert implements Alert { - data: ProjectGrowthData; - - constructor(data: ProjectGrowthData) { - this.data = data; - } - - get title() { - return `Investigate cost growth in project ${this.data.project}`; - } - - subtitle = - 'Cost growth outpacing business growth is unsustainable long-term.'; - url = '/cost-insights/investigating-growth'; - - get element() { - return ; - } +export function choose( + status: readonly [boolean, boolean, boolean], + values: [T, T, T], +): T | null { + const i = status.indexOf(true); + return i < 0 ? null : values[i]; } diff --git a/plugins/cost-insights/src/utils/duration.ts b/plugins/cost-insights/src/utils/duration.ts index 6eebead1d7..79ea150fc5 100644 --- a/plugins/cost-insights/src/utils/duration.ts +++ b/plugins/cost-insights/src/utils/duration.ts @@ -31,6 +31,7 @@ export function inclusiveStartDateOf( exclusiveEndDate: string, ): string { switch (duration) { + case Duration.P7D: case Duration.P30D: case Duration.P90D: return moment(exclusiveEndDate) @@ -53,6 +54,7 @@ export function exclusiveEndDateOf( inclusiveEndDate: string, ): string { switch (duration) { + case Duration.P7D: case Duration.P30D: case Duration.P90D: return moment(inclusiveEndDate) @@ -80,8 +82,15 @@ export function inclusiveEndDateOf( } // https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals -export function intervalsOf(duration: Duration, inclusiveEndDate: string) { - return `R2/${duration}/${exclusiveEndDateOf(duration, inclusiveEndDate)}`; +export function intervalsOf( + duration: Duration, + inclusiveEndDate: string, + repeating: number = 2, +) { + return `R${repeating}/${duration}/${exclusiveEndDateOf( + duration, + inclusiveEndDate, + )}`; } export function quarterEndDate(inclusiveEndDate: string): string { diff --git a/plugins/cost-insights/src/utils/loading.ts b/plugins/cost-insights/src/utils/loading.ts index 3328f23cfc..d15101075d 100644 --- a/plugins/cost-insights/src/utils/loading.ts +++ b/plugins/cost-insights/src/utils/loading.ts @@ -29,6 +29,7 @@ export enum DefaultLoadingAction { CostInsightsInitial = 'cost-insights-initial', CostInsightsPage = 'cost-insights-page', CostInsightsProducts = 'cost-insights-products', + CostInsightsAlerts = 'cost-insights-alerts', } export const INITIAL_LOADING_ACTIONS = [ diff --git a/plugins/cost-insights/src/utils/styles.ts b/plugins/cost-insights/src/utils/styles.ts index c04ceba8e3..acb900c490 100644 --- a/plugins/cost-insights/src/utils/styles.ts +++ b/plugins/cost-insights/src/utils/styles.ts @@ -479,8 +479,8 @@ export const useSelectStyles = makeStyles( }), ); -export const useAlertActionCardStyles = makeStyles( - (theme: BackstageTheme) => +export const useActionItemCardStyles = makeStyles( + (theme: CostInsightsTheme) => createStyles({ card: { boxShadow: 'none', @@ -489,15 +489,12 @@ export const useAlertActionCardStyles = makeStyles( backgroundColor: theme.palette.textVerySubtle, color: theme.palette.text.primary, }, - }), -); - -export const useAlertActionCardHeader = makeStyles( - (theme: CostInsightsTheme) => - createStyles({ root: { + minHeight: 80, paddingBottom: theme.spacing(2), borderRadius: theme.shape.borderRadius, + }, + activeRoot: { cursor: 'pointer', transition: theme.transitions.create('background', { duration: theme.transitions.duration.short, @@ -599,3 +596,31 @@ export const useEntityDialogStyles = makeStyles(theme => }, }), ); + +export const useAlertDialogStyles = makeStyles((theme: BackstageTheme) => + createStyles({ + content: { + padding: theme.spacing(0, 5, 2, 5), + }, + actions: { + padding: theme.spacing(2, 5), + }, + radio: { + margin: theme.spacing(-0.5, 0, -0.5, 0), + }, + icon: { + color: theme.palette.primary.dark, + margin: theme.spacing(2.5, 2.5, 0, 0), + padding: 0, + }, + }), +); + +export const useAlertStatusSummaryButtonStyles = makeStyles(() => ({ + icon: { + transform: 'transform 5s', + }, + clicked: { + transform: 'rotate(180deg)', + }, +})); diff --git a/plugins/cost-insights/src/utils/tests.tsx b/plugins/cost-insights/src/utils/tests.tsx index 852330acc3..13b38a49f2 100644 --- a/plugins/cost-insights/src/utils/tests.tsx +++ b/plugins/cost-insights/src/utils/tests.tsx @@ -22,6 +22,7 @@ import { IdentityApi, identityApiRef, } from '@backstage/core'; +import { AlertsContext, AlertsContextProps } from '../hooks/useAlerts'; import { LoadingContext, LoadingContextProps } from '../hooks/useLoading'; import { GroupsContext, GroupsContextProps } from '../hooks/useGroups'; import { FilterContext, FilterContextProps } from '../hooks/useFilters'; @@ -163,8 +164,9 @@ export type MockScrollProviderProps = PropsWithChildren<{}>; export const MockScrollProvider = ({ children }: MockScrollProviderProps) => { const defaultContext: ScrollContextProps = { - scrollTo: null, - setScrollTo: jest.fn(), + scroll: null, + setScroll: jest.fn(), + ScrollAnchor: jest.fn(() =>
), }; return ( @@ -231,3 +233,28 @@ export const MockCostInsightsApiProvider = ({ return {children}; }; + +export type MockAlertsProviderContextProps = PartialPropsWithChildren< + AlertsContextProps +>; + +export const MockAlertsProvider = ({ + children, + ...context +}: MockAlertsProviderContextProps) => { + const defaultContext: AlertsContextProps = { + alerts: { + alerts: [], + snoozed: null, + accepted: null, + dismissed: null, + }, + setAlerts: jest.fn(), + }; + + return ( + + {children} + + ); +}; From 19172f5a9b7e5b94b6c93bbe74bcc645fbdec789 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Mon, 25 Jan 2021 18:04:31 -0500 Subject: [PATCH 017/297] changeset --- .changeset/cost-insights-tricky-moles-grin.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cost-insights-tricky-moles-grin.md diff --git a/.changeset/cost-insights-tricky-moles-grin.md b/.changeset/cost-insights-tricky-moles-grin.md new file mode 100644 index 0000000000..c35769e03c --- /dev/null +++ b/.changeset/cost-insights-tricky-moles-grin.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': minor +--- + +add alert hooks From c81687ade11ae5f852a6482fa99d0135bfd93b37 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Mon, 25 Jan 2021 18:56:10 -0500 Subject: [PATCH 018/297] cleanup --- .../src/alerts/KubernetesMigrationAlert.tsx | 35 ++++++------- plugins/cost-insights/src/client.ts | 11 ++-- .../components/ActionItems/ActionItems.tsx | 52 +++++++++---------- .../AlertInsights/AlertDialog.test.tsx | 20 +++---- .../src/forms/AlertDismissForm.tsx | 8 +-- 5 files changed, 59 insertions(+), 67 deletions(-) diff --git a/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx b/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx index 8940322223..d7cc5ac79d 100644 --- a/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx +++ b/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx @@ -42,18 +42,19 @@ export interface MigrationAlert extends Alert { } /** - * The alert below is an example of an Alert implementation using event hooks. + * This is an example of an Alert implementation using optional event hooks. * - * Alerts can be customized to be accepted, dismissed snoozed or any combination - * by defining a corresponding hook on the alert instance. + * Event hooks can be used to enable users to dismiss, snooze, or accept an action item + * - or any combination thereof. Defining a hook will generate default UI - button, dialog and + * form. Cost Insights does not preserve client side alert state - each hook is expected to return a new set of alerts. * - * For example, defining an onDismissed hook will render a dismiss button that, when clicked, will - * generate a dialog prompting the user to provide a reason for dismissing the alert. - * Dismiss form data will be passed to the hook, which must eventually return a new set of alerts. - * Errors thrown within hooks will generate a snackbar, which can be used to display a - * user-friendly error message. + * Snoozed, accepted, etc. alerts should define a corresponding status property. Alerts will be aggregated + * by status in a collapsed view below Alert Insights section and a badge will appear in Action Items + * showing the total alerts of that status. * - * Cost Insights provides default forms for each hook, which can be overriden by providing a custom form component. + * Default forms can be overriden by providing a valid React form component. Form components + * must return valid form elements, and accept a ref and onSubmit event handler. See /forms + * for example implementations. Custom forms must implement a corresponding event hook. */ export class KubernetesMigrationAlert implements MigrationAlert { @@ -63,9 +64,7 @@ export class KubernetesMigrationAlert implements MigrationAlert { subtitle = 'Services running on Kubernetes are estimated to save 50% or more compared to Compute Engine.'; - // Override default dismiss form with custom form component. - // SnoozeForm: AlertForm = MigrationSnoozeForm; - // AcceptForm: AlertForm = MigrationAcceptForm; + // Override default dismiss form with a custom form component. DismissForm: AlertForm< MigrationAlert, MigrationDismissFormData @@ -101,7 +100,7 @@ export class KubernetesMigrationAlert implements MigrationAlert { ); } - /* Displays a custom dismiss form. */ + /* Fires when the onSubmit event is raised on a DismissAlert form. Displays a custom dismiss form. */ async onDismissed( options: AlertOptions, ): Promise { @@ -112,17 +111,13 @@ export class KubernetesMigrationAlert implements MigrationAlert { { title: this.title, subtitle: this.subtitle, - /** - * If a status property is defined, the alert will be filtered from the action items list - * but still appear grouped with other action items of the same status in the Hidden Action Items section. - */ status: AlertStatus.Dismissed, }, ]), ); } - /* Displays default accept form. */ + /* Fires when the onSubmit event is raised on an SnoozeAlert form. Displays default snooze form. */ async onSnoozed( options: AlertOptions, ): Promise { @@ -139,8 +134,8 @@ export class KubernetesMigrationAlert implements MigrationAlert { ); } - /* Displays default accept form. */ - async onAccepted(options: AlertOptions): Promise { + /* Fires when the onSubmit event is raised on an AcceptAlert form. Displays default accept form. */ + async onAccepted(options: AlertOptions): Promise { const alerts = await this.api.getAlerts(options.group); return new Promise(resolve => setTimeout(resolve, 750, [ diff --git a/plugins/cost-insights/src/client.ts b/plugins/cost-insights/src/client.ts index 837cd7a560..dd53d1f942 100644 --- a/plugins/cost-insights/src/client.ts +++ b/plugins/cost-insights/src/client.ts @@ -172,12 +172,13 @@ export class ExampleCostInsightsClient implements CostInsightsApi { ], }; + const today = dayjs(); const alerts: Alert[] = await this.request({ group }, [ new ProjectGrowthAlert(projectGrowthData), new UnlabeledDataflowAlert(unlabeledDataflowData), new KubernetesMigrationAlert(this, { - startDate: '2021-01-24', - endDate: '2020-02-24', + startDate: today.format(DEFAULT_DATE_FORMAT), + endDate: today.add(30, 'day').format(DEFAULT_DATE_FORMAT), change: { ratio: 0, amount: 0, @@ -187,7 +188,7 @@ export class ExampleCostInsightsClient implements CostInsightsApi { id: 'service-a', aggregation: [20_000, 10_000], change: { - ratio: -1, + ratio: -0.5, amount: -10_000, }, entities: {}, @@ -196,8 +197,8 @@ export class ExampleCostInsightsClient implements CostInsightsApi { id: 'service-b', aggregation: [30_000, 15_000], change: { - ratio: -1, - amount: 15_000, + ratio: -0.5, + amount: -15_000, }, entities: {}, }, diff --git a/plugins/cost-insights/src/components/ActionItems/ActionItems.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItems.tsx index 320ebb38aa..4fbe3470b1 100644 --- a/plugins/cost-insights/src/components/ActionItems/ActionItems.tsx +++ b/plugins/cost-insights/src/components/ActionItems/ActionItems.tsx @@ -32,6 +32,32 @@ import { Alert, AlertStatus } from '../../types'; import { useScroll, ScrollType } from '../../hooks'; import { useActionItemCardStyles as useStyles } from '../../utils/styles'; +type AlertStatusButtonProps = { + title: string; + amount: number; + icon: JSX.Element; + onClick: MouseEventHandler; +} & IconButtonProps; + +const AlertStatusButton = ({ + title, + amount, + icon, + onClick, + ...buttonProps +}: AlertStatusButtonProps) => ( + + + {icon} + + +); + type ActionItemsProps = { active: Alert[]; snoozed: Alert[]; @@ -104,29 +130,3 @@ export const ActionItems = ({ ); }; - -type AlertStatusButtonProps = { - title: string; - amount: number; - icon: JSX.Element; - onClick: MouseEventHandler; -} & IconButtonProps; - -const AlertStatusButton = ({ - title, - amount, - icon, - onClick, - ...buttonProps -}: AlertStatusButtonProps) => ( - - - {icon} - - -); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx index 5785592aeb..2e4cb53681 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx @@ -75,8 +75,8 @@ describe('', () => { snoozed={snoozableAlert} accepted={null} dismissed={null} - onClose={jest.fn} - onSubmit={jest.fn} + onClose={jest.fn()} + onSubmit={jest.fn()} />, ); expect(getByText('For how long?')).toBeInTheDocument(); @@ -97,8 +97,8 @@ describe('', () => { snoozed={customSnoozeAlert} accepted={null} dismissed={null} - onClose={jest.fn} - onSubmit={jest.fn} + onClose={jest.fn()} + onSubmit={jest.fn()} />, ); expect(getByText('You. Complete. Me.')).toBeInTheDocument(); @@ -116,8 +116,8 @@ describe('', () => { snoozed={null} accepted={null} dismissed={dimissableAlert} - onClose={jest.fn} - onSubmit={jest.fn} + onClose={jest.fn()} + onSubmit={jest.fn()} />, ); expect(getByText('Dismiss this action item?')).toBeInTheDocument(); @@ -137,8 +137,8 @@ describe('', () => { snoozed={null} accepted={null} dismissed={customDismissAlert} - onClose={jest.fn} - onSubmit={jest.fn} + onClose={jest.fn()} + onSubmit={jest.fn()} />, ); expect(getByText('Dismiss this action item?')).toBeInTheDocument(); @@ -156,8 +156,8 @@ describe('', () => { snoozed={null} accepted={customAcceptAlert} dismissed={null} - onClose={jest.fn} - onSubmit={jest.fn} + onClose={jest.fn()} + onSubmit={jest.fn()} />, ); expect(getByText('Accept this action item?')).toBeInTheDocument(); diff --git a/plugins/cost-insights/src/forms/AlertDismissForm.tsx b/plugins/cost-insights/src/forms/AlertDismissForm.tsx index 1b3cbdb363..4271785958 100644 --- a/plugins/cost-insights/src/forms/AlertDismissForm.tsx +++ b/plugins/cost-insights/src/forms/AlertDismissForm.tsx @@ -55,18 +55,14 @@ export const AlertDismissForm = forwardRef< ); const onFormSubmit: FormEventHandler = e => { - function submit() { + e.preventDefault(); + if (reason) { onSubmit({ other: other, reason: reason, feedback: feedback, }); } - - e.preventDefault(); - if (reason) { - submit(); - } }; const onReasonChange = (_: ChangeEvent, value: string) => { From 09771bdf4633b2226dfc0bdd0c32f52223af7d13 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Mon, 25 Jan 2021 20:44:41 -0500 Subject: [PATCH 019/297] fix import; don't make type public --- plugins/cost-insights/src/alerts/index.ts | 1 + .../src/components/MigrationAlertCard/MigrationAlertCard.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/cost-insights/src/alerts/index.ts b/plugins/cost-insights/src/alerts/index.ts index eb4a079e90..b55c012a60 100644 --- a/plugins/cost-insights/src/alerts/index.ts +++ b/plugins/cost-insights/src/alerts/index.ts @@ -18,3 +18,4 @@ export { ProjectGrowthAlert } from './ProjectGrowthAlert'; export { UnlabeledDataflowAlert } from './UnlabeledDataflowAlert'; export { KubernetesMigrationAlert } from './KubernetesMigrationAlert'; export type { MigrationAlert } from './KubernetesMigrationAlert'; +export type { MigrationData } from './KubernetesMigrationAlert'; diff --git a/plugins/cost-insights/src/components/MigrationAlertCard/MigrationAlertCard.tsx b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationAlertCard.tsx index f4d174d521..56d636d8aa 100644 --- a/plugins/cost-insights/src/components/MigrationAlertCard/MigrationAlertCard.tsx +++ b/plugins/cost-insights/src/components/MigrationAlertCard/MigrationAlertCard.tsx @@ -19,7 +19,7 @@ import { Box } from '@material-ui/core'; import { InfoCard } from '@backstage/core'; import { MigrationBarChartLegend } from './MigrationBarChartLegend'; import { MigrationBarChart } from './MigrationBarChart'; -import { MigrationData } from '../../types'; +import { MigrationData } from '../../alerts'; type MigrationAlertProps = { data: MigrationData; From a79134c7f1f30b47e4ec8c2c082a607db7a82278 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Mon, 25 Jan 2021 20:45:01 -0500 Subject: [PATCH 020/297] add duration --- plugins/cost-insights/src/utils/currency.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/cost-insights/src/utils/currency.ts b/plugins/cost-insights/src/utils/currency.ts index f1d67a14e4..8adbc4dcaa 100644 --- a/plugins/cost-insights/src/utils/currency.ts +++ b/plugins/cost-insights/src/utils/currency.ts @@ -20,6 +20,7 @@ export const rateOf = (cost: number, duration: Duration) => { switch (duration) { case Duration.P30D: return cost / 12; + case Duration.P7D: case Duration.P90D: case Duration.P3M: return cost / 4; From f19e4a5837fd99c0e832aff8fe5bbacd9fded7a0 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Mon, 25 Jan 2021 20:59:48 -0500 Subject: [PATCH 021/297] Update plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx Co-authored-by: Adam Harvey <33203301+adamdmharvey@users.noreply.github.com> --- plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx b/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx index d7cc5ac79d..7b775a4a96 100644 --- a/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx +++ b/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx @@ -52,7 +52,7 @@ export interface MigrationAlert extends Alert { * by status in a collapsed view below Alert Insights section and a badge will appear in Action Items * showing the total alerts of that status. * - * Default forms can be overriden by providing a valid React form component. Form components + * Default forms can be overridden by providing a valid React form component. Form components * must return valid form elements, and accept a ref and onSubmit event handler. See /forms * for example implementations. Custom forms must implement a corresponding event hook. */ From da661f836e6a730f2a157feb8ec367d7603c9ab0 Mon Sep 17 00:00:00 2001 From: Debajyoti Halder Date: Tue, 26 Jan 2021 12:29:48 +0530 Subject: [PATCH 022/297] Update eslint rules --- packages/cli/config/eslint.backend.js | 4 ++-- packages/cli/config/eslint.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/config/eslint.backend.js b/packages/cli/config/eslint.backend.js index 7acb538d38..6739d1c2ce 100644 --- a/packages/cli/config/eslint.backend.js +++ b/packages/cli/config/eslint.backend.js @@ -38,8 +38,8 @@ module.exports = { }, ignorePatterns: ['.eslintrc.js', '**/dist/**', '**/dist-types/**'], rules: { - '@typescript-eslint/no-shadow': 'off', - '@typescript-eslint/no-redeclare': 'off', + '@typescript-eslint/no-shadow': 'error', + '@typescript-eslint/no-redeclare': 'error', 'no-console': 0, // Permitted in console programs 'new-cap': ['error', { capIsNew: false }], // Because Express constructs things e.g. like 'const r = express.Router()' diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js index 044d737d15..c440e53bc0 100644 --- a/packages/cli/config/eslint.js +++ b/packages/cli/config/eslint.js @@ -45,9 +45,9 @@ module.exports = { }, ignorePatterns: ['.eslintrc.js', '**/dist/**', '**/dist-types/**'], rules: { - '@typescript-eslint/no-shadow': 'off', - '@typescript-eslint/no-redeclare': 'off', - 'no-undef': 'off', + '@typescript-eslint/no-shadow': 'error', + '@typescript-eslint/no-redeclare': 'error', + 'import/newline-after-import': 'error', 'import/no-duplicates': 'warn', 'import/no-extraneous-dependencies': [ From 5ad2d8b3be154037538244b61030d3230962bdfc Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 26 Jan 2021 20:14:27 +0100 Subject: [PATCH 023/297] chore: updating config for tugboat updating --- .tugboat/config.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index 20f716785c..065cc4d526 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -8,5 +8,9 @@ services: - yarn install - yarn tsc - yarn build + update: + - yarn install + - yarn tsc + - yarn build start: - yarn start-backend & From 99e53017d03b00fa1d10e4eabdb0e2e55df1df95 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 26 Jan 2021 20:19:30 +0100 Subject: [PATCH 024/297] chore: reworking some more steps to see if this is how you build --- .tugboat/config.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index 065cc4d526..4e393f2a6d 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -4,13 +4,13 @@ services: expose: 7000 default: true commands: - build: + init: - yarn install + build: - yarn tsc - yarn build update: - yarn install - - yarn tsc - - yarn build + - env start: - - yarn start-backend & + - node packages/backend --config app-config.yaml --config .tugboat/tugboat.app-config.production.yaml & From 59f9a5baad6294496af40b361528f5b98660da41 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 26 Jan 2021 20:44:57 +0100 Subject: [PATCH 025/297] feat: add in the tugboat config --- .tugboat/tugboat.app-config.production.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .tugboat/tugboat.app-config.production.yaml diff --git a/.tugboat/tugboat.app-config.production.yaml b/.tugboat/tugboat.app-config.production.yaml new file mode 100644 index 0000000000..f606574d89 --- /dev/null +++ b/.tugboat/tugboat.app-config.production.yaml @@ -0,0 +1,13 @@ +app: + title: Backstage Tugboat Preview + baseUrl: + $env: TUGBOAT_DEFAULT_SERVICE_URL + +backend: + baseUrl: + $env: TUGBOAT_DEFAULT_SERVICE_URL + cors: + origin: + $env: TUGBOAT_DEFAULT_SERVICE_URL + methods: [GET, POST, PUT, DELETE] + credentials: true From e877faa6eb5595605e5524f11168ce3926063099 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 26 Jan 2021 20:53:23 +0100 Subject: [PATCH 026/297] chore: fixing config --- .tugboat/config.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index 4e393f2a6d..aaca7f6d86 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -11,6 +11,7 @@ services: - yarn build update: - yarn install - - env + - yarn tsc + - yarn build start: - node packages/backend --config app-config.yaml --config .tugboat/tugboat.app-config.production.yaml & From e20b984a374ecef1de0dd3349f77c5bd67f7a322 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 26 Jan 2021 21:37:49 +0100 Subject: [PATCH 027/297] chore: updating base image --- .tugboat/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index aaca7f6d86..0c8c0329e3 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -1,6 +1,6 @@ services: backstage: - image: tugboatqa/node:lts + image: node:lts-alpine expose: 7000 default: true commands: From 027ee1a7cfa9a6bf7b168a9b793eefe0dc240d27 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Tue, 26 Jan 2021 17:03:14 -0500 Subject: [PATCH 028/297] add support for disabling alert dialog forms --- .../src/alerts/KubernetesMigrationAlert.tsx | 23 +- .../AlertInsights/AlertDialog.test.tsx | 297 +++++++++--------- .../components/AlertInsights/AlertDialog.tsx | 106 ++++--- plugins/cost-insights/src/types/Alert.ts | 6 +- plugins/cost-insights/src/utils/alerts.tsx | 4 +- 5 files changed, 239 insertions(+), 197 deletions(-) diff --git a/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx b/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx index 7b775a4a96..8c59d0c1a5 100644 --- a/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx +++ b/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx @@ -52,9 +52,15 @@ export interface MigrationAlert extends Alert { * by status in a collapsed view below Alert Insights section and a badge will appear in Action Items * showing the total alerts of that status. * - * Default forms can be overridden by providing a valid React form component. Form components - * must return valid form elements, and accept a ref and onSubmit event handler. See /forms - * for example implementations. Custom forms must implement a corresponding event hook. + * Customizing Alerts + * Default forms can be overridden in two ways - by setting a form property to null or defining a custom component. + * + * If a form property is set to null, the Dialog will not render a form. This can be useful in scenarios + * where data isn't needed from the user such as when a user accepts an action item's recommendation. + * + * If a form property is set to a React component, the Dialog will render the form component in place of the default form. + * Form components must return valid form elements, and accept a ref and onSubmit event handler. + * Custom forms must implement the corresponding event hook. See /forms for example implementations. */ export class KubernetesMigrationAlert implements MigrationAlert { @@ -64,7 +70,10 @@ export class KubernetesMigrationAlert implements MigrationAlert { subtitle = 'Services running on Kubernetes are estimated to save 50% or more compared to Compute Engine.'; - // Override default dismiss form with a custom form component. + // Dialog will not render a form if form property set to null. + AcceptForm = null; + + // Overrides default Dismiss form with a custom form component. DismissForm: AlertForm< MigrationAlert, MigrationDismissFormData @@ -100,7 +109,7 @@ export class KubernetesMigrationAlert implements MigrationAlert { ); } - /* Fires when the onSubmit event is raised on a DismissAlert form. Displays a custom dismiss form. */ + /* Fires when the onSubmit event is raised on a Dismiss form. Displays custom dismiss form. */ async onDismissed( options: AlertOptions, ): Promise { @@ -117,7 +126,7 @@ export class KubernetesMigrationAlert implements MigrationAlert { ); } - /* Fires when the onSubmit event is raised on an SnoozeAlert form. Displays default snooze form. */ + /* Fires when the onSubmit event is raised on a Snooze form. Displays default snooze form. */ async onSnoozed( options: AlertOptions, ): Promise { @@ -134,7 +143,7 @@ export class KubernetesMigrationAlert implements MigrationAlert { ); } - /* Fires when the onSubmit event is raised on an AcceptAlert form. Displays default accept form. */ + /* Fires when the Accept button is clicked. Dialog does not render a form. See KubernetesMigrationAlert.AcceptForm */ async onAccepted(options: AlertOptions): Promise { const alerts = await this.api.getAlerts(options.group); return new Promise(resolve => diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx index 2e4cb53681..72ff757cee 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx @@ -16,154 +16,169 @@ import React from 'react'; import { AlertDialog } from './AlertDialog'; import { render } from '@testing-library/react'; -import { - Alert, - AlertFormProps, - AlertSnoozeOptions, - AlertDismissOptions, -} from '../../types'; +import { Alert, AlertFormProps } from '../../types'; type MockFormDataProps = AlertFormProps; -const MockForm = React.forwardRef( - (props, ref) => ( +function createForm(title: string) { + return React.forwardRef((props, ref) => (
- You. Complete. Me. + You. {title}. Me.
- ), -); + )); +} +const snoozableAlert: Alert = { + title: 'title', + subtitle: 'test-subtitle', + onSnoozed: jest.fn(), +}; + +const dimissableAlert: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), +}; + +const acceptAlert: Alert = { + title: 'title', + subtitle: 'subtitle', + onAccepted: jest.fn(), +}; + +const customSnoozeAlert: Alert = { + title: 'title', + subtitle: 'subtitle', + onSnoozed: jest.fn(), + SnoozeForm: createForm('Snooze'), +}; + +const customDismissAlert: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), + DismissForm: createForm('Dismiss'), +}; + +const customAcceptAlert: Alert = { + title: 'title', + subtitle: 'test-subtitle', + onAccepted: jest.fn(), + AcceptForm: createForm('Accept'), +}; + +const nullAcceptAlert: Alert = { + title: 'title', + subtitle: 'test-subtitle', + onAccepted: jest.fn(), + AcceptForm: null, +}; + +const nullDismissAlert: Alert = { + title: 'title', + subtitle: 'test-subtitle', + onDismissed: jest.fn(), + DismissForm: null, +}; + +const nullSnoozeAlert: Alert = { + title: 'title', + subtitle: 'test-subtitle', + onSnoozed: jest.fn(), + SnoozeForm: null, +}; describe('', () => { - const snoozableAlert: Alert = { - title: 'title', - subtitle: 'test-subtitle', - onSnoozed: jest.fn(), - }; + describe.each` + accepted | dismissed | snoozed | action | text + ${acceptAlert} | ${null} | ${null} | ${['Accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'} + ${null} | ${dimissableAlert} | ${null} | ${['Dismiss', 'dismissed']} | ${'Reason for dismissing?'} + ${null} | ${null} | ${snoozableAlert} | ${['Snooze', 'snoozed']} | ${'For how long?'} + `( + 'Default forms', + ({ accepted, dismissed, snoozed, action: [action, actioned], text }) => { + it(`Displays a default ${action} form`, () => { + const { getByText } = render( + , + ); + expect(getByText(text)).toBeInTheDocument(); + expect(getByText(`${action} this action item?`)).toBeInTheDocument(); + expect( + getByText(`This action item will be ${actioned} for all of Ramones.`), + ).toBeInTheDocument(); + }); + }, + ); - const dimissableAlert: Alert = { - title: 'title', - subtitle: 'subtitle', - onDismissed: jest.fn(), - }; + describe.each` + accepted | dismissed | snoozed | action + ${customAcceptAlert} | ${null} | ${null} | ${['Accept', 'accepted']} + ${null} | ${customDismissAlert} | ${null} | ${['Dismiss', 'dismissed']} + ${null} | ${null} | ${customSnoozeAlert} | ${['Snooze', 'snoozed']} + `( + 'Custom forms', + ({ accepted, dismissed, snoozed, action: [Action, actioned] }) => { + it(`Displays a custom ${Action} form`, () => { + const { getByText } = render( + , + ); + expect(getByText(`You. ${Action}. Me.`)).toBeInTheDocument(); + expect(getByText(`${Action} this action item?`)).toBeInTheDocument(); + expect( + getByText(`This action item will be ${actioned} for all of Ramones.`), + ).toBeInTheDocument(); + }); + }, + ); - const customSnoozeAlert: Alert = { - title: 'title', - subtitle: 'subtitle', - onSnoozed: jest.fn(), - SnoozeForm: MockForm, - }; - - const customDismissAlert: Alert = { - title: 'title', - subtitle: 'subtitle', - onDismissed: jest.fn(), - DismissForm: MockForm, - }; - - const customAcceptAlert: Alert = { - title: 'title', - subtitle: 'test-subtitle', - onAccepted: jest.fn(), - AcceptForm: MockForm, - }; - - it('Displays a default snooze form', () => { - const { getByText } = render( - , - ); - expect(getByText('For how long?')).toBeInTheDocument(); - expect(getByText('Snooze this action item?')).toBeInTheDocument(); - expect( - getByText('This action item will be snoozed for all of Ramones.'), - ).toBeInTheDocument(); - AlertSnoozeOptions.forEach(a => - expect(getByText(a.label)).toBeInTheDocument(), - ); - }); - - it('Displays a custom snooze form', () => { - const { getByText } = render( - , - ); - expect(getByText('You. Complete. Me.')).toBeInTheDocument(); - expect(getByText('Snooze this action item?')).toBeInTheDocument(); - expect( - getByText('This action item will be snoozed for all of Ramones.'), - ).toBeInTheDocument(); - }); - - it('Displays a default dismiss form', () => { - const { getByText } = render( - , - ); - expect(getByText('Dismiss this action item?')).toBeInTheDocument(); - expect( - getByText('This action item will be dismissed for all of Ramones.'), - ).toBeInTheDocument(); - AlertDismissOptions.forEach(a => - expect(getByText(a.label)).toBeInTheDocument(), - ); - }); - - it('Displays a custom dismiss form', () => { - const { getByText } = render( - , - ); - expect(getByText('Dismiss this action item?')).toBeInTheDocument(); - expect(getByText('You. Complete. Me.')).toBeInTheDocument(); - expect( - getByText('This action item will be dismissed for all of Ramones.'), - ).toBeInTheDocument(); - }); - - it('Displays a custom accept form', () => { - const { getByText } = render( - , - ); - expect(getByText('Accept this action item?')).toBeInTheDocument(); - expect(getByText('You. Complete. Me.')).toBeInTheDocument(); - expect( - getByText('This action item will be accepted for all of Ramones.'), - ).toBeInTheDocument(); - }); + describe.each` + accepted | dismissed | snoozed | action | text + ${nullAcceptAlert} | ${null} | ${null} | ${['Accept', 'accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'} + ${null} | ${nullDismissAlert} | ${null} | ${['Dismiss', 'dismiss', 'dismissed']} | ${'Reason for dismissing?'} + ${null} | ${null} | ${nullSnoozeAlert} | ${['Snooze', 'snooze', 'snoozed']} | ${'For how long?'} + `( + 'Null forms', + ({ + accepted, + dismissed, + snoozed, + action: [Action, action, actioned], + text, + }) => { + it(`Does NOT display a ${Action} form`, () => { + const { getByText, getByRole, queryByText } = render( + , + ); + expect(queryByText(text)).not.toBeInTheDocument(); + expect(getByRole('button', { name: action })).toBeInTheDocument(); + expect(getByText(`${Action} this action item?`)).toBeInTheDocument(); + expect( + getByText(`This action item will be ${actioned} for all of Ramones.`), + ).toBeInTheDocument(); + }); + }, + ); }); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx index aa5b8c59fb..f7154602b9 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx @@ -33,7 +33,7 @@ import { } from '../../forms'; import { useAlertDialogStyles as useStyles } from '../../utils/styles'; import { choose } from '../../utils/alerts'; -import { Alert, Maybe } from '../../types'; +import { Alert, AlertForm, Maybe } from '../../types'; const DEFAULT_FORM_ID = 'alert-form'; @@ -79,20 +79,41 @@ export const AlertDialog = ({ setDisabled(true); } - const SnoozeForm = snoozed?.SnoozeForm ?? AlertSnoozeForm; - const AcceptForm = accepted?.AcceptForm ?? AlertAcceptForm; - const DismissForm = dismissed?.DismissForm ?? AlertDismissForm; + const SnoozeForm: Maybe = snoozed?.SnoozeForm ?? AlertSnoozeForm; + const AcceptForm: Maybe = accepted?.AcceptForm ?? AlertAcceptForm; + const DismissForm: Maybe = + dismissed?.DismissForm ?? AlertDismissForm; - const isSnoozeFormDisplayed = !!snoozed?.onSnoozed; - const isAcceptFormDisplayed = !!accepted?.onAccepted; - const isDismissFormDisplayed = !!dismissed?.onDismissed; + const isSnoozingEnabled = !!snoozed?.onSnoozed; + const isAcceptingEnabled = !!accepted?.onAccepted; + const isDismissingEnabled = !!dismissed?.onDismissed; + + const isSnoozeFormDisabled = snoozed?.SnoozeForm === null; + const isAcceptFormDisabled = accepted?.AcceptForm === null; + const isDismissFormDisabled = dismissed?.DismissForm === null; + const isFormDisabled = + isSnoozeFormDisabled || isAcceptFormDisabled || isDismissFormDisabled; const status = [ - isAcceptFormDisplayed, - isSnoozeFormDisplayed, - isDismissFormDisplayed, + isSnoozingEnabled, + isAcceptingEnabled, + isDismissingEnabled, ] as const; + const [Action, action, actioned] = + choose(status, [ + ['Snooze', 'snooze', 'snoozed'], + ['Accept', 'accept', 'accepted'], + ['Dismiss', 'dismiss', 'dismissed'], + ]) ?? []; + + const [title, subtitle] = + choose(status, [ + [snoozed?.title, snoozed?.subtitle], + [accepted?.title, accepted?.subtitle], + [dismissed?.title, dismissed?.subtitle], + ]) ?? []; + const TransitionProps = { mountOnEnter: true, unmountOnExit: true, @@ -122,24 +143,20 @@ export const AlertDialog = ({ - + - - {choose(status, ['Accept', 'Snooze', 'Dismiss'])} this action - item? - + {Action} this action item? - This action item will be{' '} - {choose(status, ['accepted', 'snoozed', 'dismissed'])} for all of{' '} - {group}. + This action item will be {actioned} for all of {group}. @@ -152,23 +169,11 @@ export const AlertDialog = ({ borderRadius={4} > - - {choose(status, [ - accepted?.title, - snoozed?.title, - dismissed?.title, - ])} - - - - {choose(status, [ - accepted?.subtitle, - snoozed?.subtitle, - dismissed?.subtitle, - ])} + {title} + {subtitle} - {isSnoozeFormDisplayed && ( + {isSnoozingEnabled && !isSnoozeFormDisabled && ( )} - {isDismissFormDisplayed && ( + {isDismissingEnabled && !isDismissFormDisabled && ( )} - {isAcceptFormDisplayed && ( + {isAcceptingEnabled && !isAcceptFormDisabled && ( - + {isFormDisabled ? ( + + ) : ( + + )} ); diff --git a/plugins/cost-insights/src/types/Alert.ts b/plugins/cost-insights/src/types/Alert.ts index 21e1a779a8..eb8919b71a 100644 --- a/plugins/cost-insights/src/types/Alert.ts +++ b/plugins/cost-insights/src/types/Alert.ts @@ -40,9 +40,9 @@ export type Alert = { status?: AlertStatus; url?: string; buttonText?: string; // Default: View Instructions - SnoozeForm?: AlertForm; - AcceptForm?: AlertForm; - DismissForm?: AlertForm; + SnoozeForm?: Maybe; + AcceptForm?: Maybe; + DismissForm?: Maybe; onSnoozed?(options: AlertOptions): Promise; onAccepted?(options: AlertOptions): Promise; onDismissed?(options: AlertOptions): Promise; diff --git a/plugins/cost-insights/src/utils/alerts.tsx b/plugins/cost-insights/src/utils/alerts.tsx index b8dfddf79b..e10740fbfe 100644 --- a/plugins/cost-insights/src/utils/alerts.tsx +++ b/plugins/cost-insights/src/utils/alerts.tsx @@ -29,7 +29,7 @@ export const sumOfAllAlerts = (sum: number, alerts: Alert[]) => export function choose( status: readonly [boolean, boolean, boolean], values: [T, T, T], -): T | null { +): T | undefined { const i = status.indexOf(true); - return i < 0 ? null : values[i]; + return values[i]; } From a7c0da02e8d8515e47880e12d6bbc73bbdb966f8 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sun, 17 Jan 2021 18:16:19 -0500 Subject: [PATCH 029/297] pass in the specified git branch ref when cloning a single branch --- packages/backend-common/src/scm/git.ts | 3 ++- .../scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/backend-common/src/scm/git.ts b/packages/backend-common/src/scm/git.ts index b0a1df6541..55a8746d6e 100644 --- a/packages/backend-common/src/scm/git.ts +++ b/packages/backend-common/src/scm/git.ts @@ -86,13 +86,14 @@ export class Git { return git.commit({ fs, dir, message, author, committer }); } - async clone({ url, dir }: { url: string; dir: string }): Promise { + async clone({ url, dir, ref }: { url: string; dir: string; ref?: string }): Promise { this.config.logger?.info(`Cloning repo {dir=${dir},url=${url}}`); return git.clone({ fs, http, url, dir, + ref, singleBranch: true, depth: 1, onProgress: this.onProgressHandler(), diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts index 1fca2ff0f9..7824687155 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts @@ -42,6 +42,7 @@ export class GitlabPreparer implements PreparerBase { const parsedGitLocation = parseGitUrl(location); const repositoryCheckoutUrl = parsedGitLocation.toString('https'); + const ref = parsedGitLocation.toString('ref'); const tempDir = await fs.promises.mkdtemp( path.join(workingDirectory, templateId), ); @@ -61,6 +62,7 @@ export class GitlabPreparer implements PreparerBase { await git.clone({ url: repositoryCheckoutUrl, + ref: ref, dir: tempDir, }); From 97df886e1c2ca1e2ba55bf1e6f04c2d4e8c16192 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Tue, 19 Jan 2021 10:32:15 -0500 Subject: [PATCH 030/297] KISS: no need for toString() --- .../scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts index 7824687155..0ce5466915 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts @@ -42,7 +42,7 @@ export class GitlabPreparer implements PreparerBase { const parsedGitLocation = parseGitUrl(location); const repositoryCheckoutUrl = parsedGitLocation.toString('https'); - const ref = parsedGitLocation.toString('ref'); + const ref = parsedGitLocation.ref; const tempDir = await fs.promises.mkdtemp( path.join(workingDirectory, templateId), ); From 26a3a6cf030d3d7e926a0b23d2adb5ef9cd4e2a6 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 21 Jan 2021 15:37:49 -0500 Subject: [PATCH 031/297] bump patch-level versions --- .changeset/breezy-meals-lie.md | 6 ++++++ packages/backend-common/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .changeset/breezy-meals-lie.md diff --git a/.changeset/breezy-meals-lie.md b/.changeset/breezy-meals-lie.md new file mode 100644 index 0000000000..d9bc3f3d22 --- /dev/null +++ b/.changeset/breezy-meals-lie.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-common': patch +'@backstage/plugin-scaffolder-backend': patch +--- + +include ref when cloning from GitLab diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index c698351615..350b7bd8f8 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.5.0", + "version": "0.5.1", "main": "src/index.ts", "types": "src/index.ts", "private": false, From 85cdcb2c6b6fb9ccfa85fef2945c0bafbb827a3f Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 21 Jan 2021 15:47:56 -0500 Subject: [PATCH 032/297] Prettier --- packages/backend-common/src/scm/git.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/backend-common/src/scm/git.ts b/packages/backend-common/src/scm/git.ts index 55a8746d6e..5b24d23b99 100644 --- a/packages/backend-common/src/scm/git.ts +++ b/packages/backend-common/src/scm/git.ts @@ -86,7 +86,15 @@ export class Git { return git.commit({ fs, dir, message, author, committer }); } - async clone({ url, dir, ref }: { url: string; dir: string; ref?: string }): Promise { + async clone({ + url, + dir, + ref, + }: { + url: string; + dir: string; + ref?: string; + }): Promise { this.config.logger?.info(`Cloning repo {dir=${dir},url=${url}}`); return git.clone({ fs, From c7de259c929c6feab8cf1fef1c76e7b3b8e6f5d4 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 21 Jan 2021 16:14:09 -0500 Subject: [PATCH 033/297] add ref parameter to clone --- .../src/scaffolder/stages/prepare/gitlab.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts index 19cbf793e4..8ea1d77859 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts @@ -89,6 +89,7 @@ describe('GitLabPreparer', () => { expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://gitlab.com/benjdlambert/backstage-graphql-template', dir: expect.any(String), + ref: expect.any(String), }); }); @@ -113,6 +114,7 @@ describe('GitLabPreparer', () => { expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://gitlab.com/benjdlambert/backstage-graphql-template', dir: expect.any(String), + ref: expect.any(String), }); }); From 9ba95e5085a8ee5b76fdc01387ea74223d11b23b Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 21 Jan 2021 16:40:21 -0500 Subject: [PATCH 034/297] undo manual package version bump done in error --- packages/backend-common/package.json | 2 +- plugins/scaffolder-backend/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 350b7bd8f8..c698351615 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.5.1", + "version": "0.5.0", "main": "src/index.ts", "types": "src/index.ts", "private": false, diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index cd7dab3444..914b151eeb 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.5.1", + "version": "0.5.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", From ea21f9c12b083af45b29a88c35ce3dc4cc115fe9 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Fri, 22 Jan 2021 13:18:21 -0500 Subject: [PATCH 035/297] pass in the specified git branch ref when cloning a single branch --- .../src/scaffolder/stages/prepare/github.test.ts | 2 ++ .../scaffolder-backend/src/scaffolder/stages/prepare/github.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts index b43545f5fc..4051e5d30d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts @@ -89,6 +89,7 @@ describe('GitHubPreparer', () => { expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://github.com/benjdlambert/backstage-graphql-template', dir: expect.any(String), + ref: expect.any(String), }); }); @@ -100,6 +101,7 @@ describe('GitHubPreparer', () => { expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://github.com/benjdlambert/backstage-graphql-template', dir: expect.any(String), + ref: expect.any(String), }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts index cfeff454e7..02cbb1c5cd 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts @@ -42,6 +42,7 @@ export class GithubPreparer implements PreparerBase { const parsedGitLocation = parseGitUrl(location); const repositoryCheckoutUrl = parsedGitLocation.toString('https'); + const ref = parsedGitLocation.ref; const tempDir = await fs.promises.mkdtemp( path.join(workingDirectory, templateId), ); @@ -63,6 +64,7 @@ export class GithubPreparer implements PreparerBase { await git.clone({ url: repositoryCheckoutUrl, + ref: ref, dir: tempDir, }); From 00d9d6ae454f61ff16335b65265ffb5ae331b7db Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Fri, 22 Jan 2021 15:01:01 -0500 Subject: [PATCH 036/297] Add missing ref fix that was applied to github and gitlab. --- .../src/scaffolder/stages/prepare/bitbucket.test.ts | 2 ++ .../src/scaffolder/stages/prepare/bitbucket.ts | 9 +++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts index ff74fffdd7..32822ae289 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts @@ -89,6 +89,7 @@ describe('BitbucketPreparer', () => { expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://bitbucket.org/backstage-project/backstage-repo', dir: expect.any(String), + ref: expect.any(String), }); }); @@ -113,6 +114,7 @@ describe('BitbucketPreparer', () => { expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://bitbucket.org/backstage-project/backstage-repo', dir: expect.any(String), + ref: expect.any(String), }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts index 5020a5f658..04f48e3005 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts @@ -49,15 +49,15 @@ export class BitbucketPreparer implements PreparerBase { const logger = opts.logger; const templateId = template.metadata.name; - const repo = parseGitUrl(location); - const repositoryCheckoutUrl = repo.toString('https'); - + const parsedGitLocation = parseGitUrl(location); + const repositoryCheckoutUrl = parsedGitLocation.toString('https'); + const ref = parsedGitLocation.ref; const tempDir = await fs.promises.mkdtemp( path.join(workingDirectory, templateId), ); const templateDirectory = path.join( - `${path.dirname(repo.filepath)}`, + `${path.dirname(parsedGitLocation.filepath)}`, template.spec.path ?? '.', ); @@ -73,6 +73,7 @@ export class BitbucketPreparer implements PreparerBase { await git.clone({ url: repositoryCheckoutUrl, + ref: ref, dir: tempDir, }); From b34d2514d716c010d4ea0bcb8d9ade2e19677f57 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Fri, 22 Jan 2021 15:29:20 -0500 Subject: [PATCH 037/297] improve the changset description --- .changeset/breezy-meals-lie.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.changeset/breezy-meals-lie.md b/.changeset/breezy-meals-lie.md index d9bc3f3d22..cbdd876614 100644 --- a/.changeset/breezy-meals-lie.md +++ b/.changeset/breezy-meals-lie.md @@ -3,4 +3,16 @@ '@backstage/plugin-scaffolder-backend': patch --- -include ref when cloning from GitLab +Honor the branch ref in the url when cloning. + +This fixes a bug in the scaffolder prepare stage where a non-default branch +was specified in the scaffolder URL but the default branch was cloned. +For example, even though the `other` branch is specified in this example, the +`master` branch was actually cloned: + +```yaml +catalog: + locations: + - type: url + target: https://github.com/backstage/backstage/blob/other/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml +``` From c05baec7fdcd599bc72d7d8023cbbbede8634d9c Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sat, 23 Jan 2021 14:12:18 -0500 Subject: [PATCH 038/297] Fix gitlab prepare fails with a 404. A GitLab URL not ending in .git requires a redirect which isomorphic-git doesn't follow, resulting in a 404. This incorporates @ruloweb's fix in #4134. --- .changeset/breezy-meals-lie.md | 2 ++ .../src/scaffolder/stages/prepare/gitlab.test.ts | 4 ++-- .../src/scaffolder/stages/prepare/gitlab.ts | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.changeset/breezy-meals-lie.md b/.changeset/breezy-meals-lie.md index cbdd876614..4ca4f1ac35 100644 --- a/.changeset/breezy-meals-lie.md +++ b/.changeset/breezy-meals-lie.md @@ -16,3 +16,5 @@ catalog: - type: url target: https://github.com/backstage/backstage/blob/other/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml ``` + +This also fixes a 404 in the prepare stage for GitLab URLs. diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts index 8ea1d77859..0b77fd813b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts @@ -87,7 +87,7 @@ describe('GitLabPreparer', () => { await preparer.prepare(mockEntity, { logger: getVoidLogger() }); expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: 'https://gitlab.com/benjdlambert/backstage-graphql-template', + url: 'https://gitlab.com/benjdlambert/backstage-graphql-template.git', dir: expect.any(String), ref: expect.any(String), }); @@ -112,7 +112,7 @@ describe('GitLabPreparer', () => { await preparer.prepare(mockEntity, { logger: getVoidLogger() }); expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: 'https://gitlab.com/benjdlambert/backstage-graphql-template', + url: 'https://gitlab.com/benjdlambert/backstage-graphql-template.git', dir: expect.any(String), ref: expect.any(String), }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts index 0ce5466915..4368c1c242 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts @@ -41,6 +41,7 @@ export class GitlabPreparer implements PreparerBase { const templateId = template.metadata.name; const parsedGitLocation = parseGitUrl(location); + parsedGitLocation.git_suffix = true; const repositoryCheckoutUrl = parsedGitLocation.toString('https'); const ref = parsedGitLocation.ref; const tempDir = await fs.promises.mkdtemp( From 119c6386fda3e4262dadf60355749f4ceadbd4d5 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sat, 23 Jan 2021 17:21:39 -0500 Subject: [PATCH 039/297] Revert "undo manual package version bump done in error" This reverts commit 44c5ec6feb5109ae97c1a66b885da3c6440de0d5. --- packages/backend-common/package.json | 2 +- plugins/scaffolder-backend/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index c698351615..350b7bd8f8 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.5.0", + "version": "0.5.1", "main": "src/index.ts", "types": "src/index.ts", "private": false, diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 914b151eeb..cd7dab3444 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.5.0", + "version": "0.5.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", From aae1dca6c6f4da6011c0999fa4e0743fa6838468 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sat, 23 Jan 2021 17:23:47 -0500 Subject: [PATCH 040/297] Revert "bump patch-level versions" This reverts commit 96c99d75ae6523c049e6f51a3f72e7d47591a324. --- packages/backend-common/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 350b7bd8f8..c698351615 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.5.1", + "version": "0.5.0", "main": "src/index.ts", "types": "src/index.ts", "private": false, From ea59a2b363b32fe42d1d096608ceea90dc827c0e Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sat, 23 Jan 2021 17:42:26 -0500 Subject: [PATCH 041/297] Azure branch support --- .../src/scaffolder/stages/prepare/azure.test.ts | 4 +++- .../scaffolder-backend/src/scaffolder/stages/prepare/azure.ts | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts index 8dd72c35e9..89ca8e2f8f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts @@ -45,7 +45,7 @@ describe('AzurePreparer', () => { metadata: { annotations: { [LOCATION_ANNOTATION]: - 'url:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml', + 'url:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.yaml&version=GBmaster', }, name: 'graphql-starter', title: 'GraphQL Service', @@ -112,6 +112,7 @@ describe('AzurePreparer', () => { url: 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', dir: expect.any(String), + ref: 'master', }); }); @@ -124,6 +125,7 @@ describe('AzurePreparer', () => { url: 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', dir: expect.any(String), + ref: 'master', }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts index 8b204fa799..67f089965d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts @@ -42,6 +42,7 @@ export class AzurePreparer implements PreparerBase { const parsedGitLocation = parseGitUrl(location); const repositoryCheckoutUrl = parsedGitLocation.toString('https'); + const ref = parsedGitLocation.ref; const tempDir = await fs.promises.mkdtemp( path.join(workingDirectory, templateId), ); @@ -63,6 +64,7 @@ export class AzurePreparer implements PreparerBase { await git.clone({ url: repositoryCheckoutUrl, + ref: ref, dir: tempDir, }); From d275c3eb6e9ce671cb49ec8da7c97d6b0a1b9c9d Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Tue, 26 Jan 2021 19:05:35 -0500 Subject: [PATCH 042/297] Update ActionItems.test.tsx --- .../src/components/ActionItems/ActionItems.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/cost-insights/src/components/ActionItems/ActionItems.test.tsx b/plugins/cost-insights/src/components/ActionItems/ActionItems.test.tsx index 8c96e47f1a..677ba99c6a 100644 --- a/plugins/cost-insights/src/components/ActionItems/ActionItems.test.tsx +++ b/plugins/cost-insights/src/components/ActionItems/ActionItems.test.tsx @@ -24,7 +24,7 @@ function renderInContext(children: JSX.Element) { } describe('', () => { - it('should not display status buttons if there no active alerts', () => { + it('should not display status buttons if there are no active alerts', () => { const { queryByRole } = renderInContext( , ); From 92d39a0c9fa9c32c9f02d65f1f343f2fb07fcf33 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 26 Jan 2021 16:42:41 +0100 Subject: [PATCH 043/297] Refactor scaffolder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Patrik Oldsberg --- .../src/scaffolder/jobs/processor.ts | 51 ++++++++++- .../src/scaffolder/jobs/types.ts | 1 + .../src/scaffolder/stages/prepare/azure.ts | 41 ++++----- .../scaffolder/stages/prepare/bitbucket.ts | 51 ++++------- .../src/scaffolder/stages/prepare/file.ts | 33 ++----- .../src/scaffolder/stages/prepare/github.ts | 43 ++++------ .../src/scaffolder/stages/prepare/gitlab.ts | 41 ++++----- .../src/scaffolder/stages/prepare/types.ts | 19 ++-- .../src/scaffolder/stages/publish/github.ts | 8 +- .../src/scaffolder/stages/publish/types.ts | 2 +- .../stages/templater/cookiecutter.ts | 44 +++++----- .../scaffolder/stages/templater/templaters.ts | 20 ++--- .../src/scaffolder/stages/templater/types.ts | 7 +- .../scaffolder-backend/src/service/router.ts | 86 ++++++++++--------- 14 files changed, 218 insertions(+), 229 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts index b932db7fe1..08423872f2 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts @@ -13,13 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import os from 'os'; +import fs from 'fs-extra'; import { Processor, Job, StageContext, StageInput } from './types'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import * as uuid from 'uuid'; import Docker from 'dockerode'; +import path from 'path'; import { TemplaterValues, TemplaterBase } from '../stages/templater'; import { PreparerBuilder } from '../stages/prepare'; import { makeLogStream } from './logger'; +import { Logger } from 'winston'; +import { Config } from '@backstage/config'; export type JobProcessorArguments = { preparers: PreparerBuilder; @@ -33,7 +39,45 @@ export type JobAndDirectoryTuple = { }; export class JobProcessor implements Processor { - private jobs = new Map(); + private readonly workingDirectory: string; + private readonly jobs: Map; + + static async fromConfig({ + config, + logger, + }: { + config: Config; + logger: Logger; + }) { + let workingDirectory: string; + if (config.has('backend.workingDirectory')) { + workingDirectory = config.getString('backend.workingDirectory'); + try { + // Check if working directory exists and is writable + await fs.promises.access( + workingDirectory, + fs.constants.F_OK | fs.constants.W_OK, + ); + logger.info(`using working directory: ${workingDirectory}`); + } catch (err) { + logger.error( + `working directory ${workingDirectory} ${ + err.code === 'ENOENT' ? 'does not exist' : 'is not writable' + }`, + ); + throw err; + } + } else { + workingDirectory = os.tmpdir(); + } + + return new JobProcessor(workingDirectory); + } + + constructor(workingDirectory: string) { + this.workingDirectory = workingDirectory; + this.jobs = new Map(); + } create({ entity, @@ -52,6 +96,7 @@ export class JobProcessor implements Processor { values, logger, logStream: stream, + workspacePath: path.join(this.workingDirectory, id), }; const job: Job = { @@ -80,6 +125,8 @@ export class JobProcessor implements Processor { throw new Error("Job is not in a 'PENDING' state"); } + await fs.mkdir(job.context.workspacePath); + job.status = 'STARTED'; try { @@ -134,6 +181,8 @@ export class JobProcessor implements Processor { // 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'; + } finally { + await fs.remove(job.context.workspacePath); } } } diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts index 4d0369708c..e45e6c8b96 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/types.ts @@ -26,6 +26,7 @@ export type StageContext = { entity: TemplateEntityV1alpha1; logger: Logger; logStream: Writable; + workspacePath: string; } & T; export type ProcessorStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED'; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts index 8b204fa799..cca6baa0b1 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts @@ -13,11 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import os from 'os'; import fs from 'fs-extra'; import path from 'path'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { parseLocationAnnotation } from '../helpers'; import { Git } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; import parseGitUrl from 'git-url-parse'; @@ -30,25 +27,13 @@ export class AzurePreparer implements PreparerBase { constructor(private readonly config: { token?: string }) {} - async prepare( - template: TemplateEntityV1alpha1, - opts: PreparerOptions, - ): Promise { - const { location } = parseLocationAnnotation(template); - const workingDirectory = opts.workingDirectory ?? os.tmpdir(); - const logger = opts.logger; - - const templateId = template.metadata.name; - - const parsedGitLocation = parseGitUrl(location); - const repositoryCheckoutUrl = parsedGitLocation.toString('https'); - const tempDir = await fs.promises.mkdtemp( - path.join(workingDirectory, templateId), - ); - - const templateDirectory = path.join( - `${path.dirname(parsedGitLocation.filepath)}`, - template.spec.path ?? '.', + async prepare({ url, workspacePath, logger }: PreparerOptions) { + const parsedGitUrl = parseGitUrl(url); + const checkoutPath = path.join(workspacePath, 'checkout'); + const targetPath = path.join(workspacePath, 'template'); + const fullPathToTemplate = path.resolve( + checkoutPath, + parsedGitUrl.filepath, ); // Username can be anything but the empty string according to: @@ -62,10 +47,16 @@ export class AzurePreparer implements PreparerBase { : Git.fromAuth({ logger }); await git.clone({ - url: repositoryCheckoutUrl, - dir: tempDir, + url: parsedGitUrl.toString('https'), + dir: checkoutPath, }); - return path.resolve(tempDir, templateDirectory); + await fs.move(fullPathToTemplate, targetPath); + + try { + await fs.rmdir(path.join(targetPath, '.git')); + } catch { + // Ignore intentionally + } } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts index 5020a5f658..ad8bbca0b8 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.ts @@ -13,11 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import os from 'os'; import fs from 'fs-extra'; import path from 'path'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { parseLocationAnnotation } from '../helpers'; import { Git } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; import { BitbucketIntegrationConfig } from '@backstage/integration'; @@ -40,43 +37,29 @@ export class BitbucketPreparer implements PreparerBase { }, ) {} - async prepare( - template: TemplateEntityV1alpha1, - opts: PreparerOptions, - ): Promise { - const { location } = parseLocationAnnotation(template); - const workingDirectory = opts.workingDirectory ?? os.tmpdir(); - const logger = opts.logger; - const templateId = template.metadata.name; - - const repo = parseGitUrl(location); - const repositoryCheckoutUrl = repo.toString('https'); - - const tempDir = await fs.promises.mkdtemp( - path.join(workingDirectory, templateId), + async prepare({ url, workspacePath, logger }: PreparerOptions) { + const parsedGitUrl = parseGitUrl(url); + const checkoutPath = path.join(workspacePath, 'checkout'); + const targetPath = path.join(workspacePath, 'template'); + const fullPathToTemplate = path.resolve( + checkoutPath, + parsedGitUrl.filepath, ); - const templateDirectory = path.join( - `${path.dirname(repo.filepath)}`, - template.spec.path ?? '.', - ); - - const checkoutLocation = path.resolve(tempDir, templateDirectory); - - const auth = this.getAuth(); - const git = auth - ? Git.fromAuth({ - ...auth, - logger, - }) - : Git.fromAuth({ logger }); + const git = Git.fromAuth({ logger, ...this.getAuth() }); await git.clone({ - url: repositoryCheckoutUrl, - dir: tempDir, + url: parsedGitUrl.toString('https'), + dir: checkoutPath, }); - return checkoutLocation; + await fs.move(fullPathToTemplate, targetPath); + + try { + await fs.rmdir(path.join(targetPath, '.git')); + } catch { + // Ignore intentionally + } } private getAuth(): { username: string; password: string } | undefined { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts index 08129c7397..cbf81bdb06 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts @@ -13,43 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import os from 'os'; import fs from 'fs-extra'; import path from 'path'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { parseLocationAnnotation } from '../helpers'; import { InputError } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; export class FilePreparer implements PreparerBase { - async prepare( - template: TemplateEntityV1alpha1, - opts: PreparerOptions, - ): Promise { - const { protocol, location } = parseLocationAnnotation(template); - const workingDirectory = opts?.workingDirectory ?? os.tmpdir(); - - if (protocol !== 'file') { - throw new InputError( - `Wrong location protocol: ${protocol}, should be 'file'`, - ); + async prepare({ url, workspacePath }: PreparerOptions) { + if (!url.startsWith('file:///')) { + throw new InputError(`Wrong location protocol, should be 'file', ${url}`); } - const templateId = template.metadata.name; - const tempDir = await fs.promises.mkdtemp( - path.join(workingDirectory, templateId), - ); + const checkoutDir = path.join(workspacePath, 'checkout'); + await fs.ensureDir(checkoutDir); - const parentDirectory = path.resolve( - path.dirname(location), - template.spec.path ?? '.', - ); + const templatePath = url.slice('file://'.length); - await fs.copy(parentDirectory, tempDir, { - filter: src => src !== location, + await fs.copy(templatePath, checkoutDir, { recursive: true, }); - - return tempDir; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts index cfeff454e7..4273336d56 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.ts @@ -13,11 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import os from 'os'; import fs from 'fs-extra'; import path from 'path'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { parseLocationAnnotation } from '../helpers'; import { Git } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; import parseGitUrl from 'git-url-parse'; @@ -30,29 +27,15 @@ export class GithubPreparer implements PreparerBase { constructor(private readonly config: { token?: string }) {} - async prepare( - template: TemplateEntityV1alpha1, - opts: PreparerOptions, - ): Promise { - const { location } = parseLocationAnnotation(template); - const workingDirectory = opts.workingDirectory ?? os.tmpdir(); - const logger = opts.logger; - - const templateId = template.metadata.name; - - const parsedGitLocation = parseGitUrl(location); - const repositoryCheckoutUrl = parsedGitLocation.toString('https'); - const tempDir = await fs.promises.mkdtemp( - path.join(workingDirectory, templateId), + async prepare({ url, workspacePath, logger }: PreparerOptions) { + const parsedGitUrl = parseGitUrl(url); + const checkoutPath = path.join(workspacePath, 'checkout'); + const targetPath = path.join(workspacePath, 'template'); + const fullPathToTemplate = path.resolve( + checkoutPath, + parsedGitUrl.filepath, ); - const templateDirectory = path.join( - `${path.dirname(parsedGitLocation.filepath)}`, - template.spec.path ?? '.', - ); - - const checkoutLocation = path.resolve(tempDir, templateDirectory); - const git = this.config.token ? Git.fromAuth({ username: this.config.token, @@ -62,10 +45,16 @@ export class GithubPreparer implements PreparerBase { : Git.fromAuth({ logger }); await git.clone({ - url: repositoryCheckoutUrl, - dir: tempDir, + url: parsedGitUrl.toString('https'), + dir: checkoutPath, }); - return checkoutLocation; + await fs.move(fullPathToTemplate, targetPath); + + try { + await fs.rmdir(path.join(targetPath, '.git')); + } catch { + // Ignore intentionally + } } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts index 1fca2ff0f9..98901577d5 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.ts @@ -14,13 +14,10 @@ * limitations under the License. */ import { Git } from '@backstage/backend-common'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { GitLabIntegrationConfig } from '@backstage/integration'; import fs from 'fs-extra'; import parseGitUrl from 'git-url-parse'; -import os from 'os'; import path from 'path'; -import { parseLocationAnnotation } from '../helpers'; import { PreparerBase, PreparerOptions } from './types'; export class GitlabPreparer implements PreparerBase { @@ -30,25 +27,13 @@ export class GitlabPreparer implements PreparerBase { constructor(private readonly config: { token?: string }) {} - async prepare( - template: TemplateEntityV1alpha1, - opts: PreparerOptions, - ): Promise { - const { location } = parseLocationAnnotation(template); - const logger = opts.logger; - const workingDirectory = opts.workingDirectory ?? os.tmpdir(); - - const templateId = template.metadata.name; - - const parsedGitLocation = parseGitUrl(location); - const repositoryCheckoutUrl = parsedGitLocation.toString('https'); - const tempDir = await fs.promises.mkdtemp( - path.join(workingDirectory, templateId), - ); - - const templateDirectory = path.join( - `${path.dirname(parsedGitLocation.filepath)}`, - template.spec.path ?? '.', + async prepare({ url, workspacePath, logger }: PreparerOptions) { + const parsedGitUrl = parseGitUrl(url); + const checkoutPath = path.join(workspacePath, 'checkout'); + const targetPath = path.join(workspacePath, 'template'); + const fullPathToTemplate = path.resolve( + checkoutPath, + parsedGitUrl.filepath, ); const git = this.config.token @@ -60,10 +45,16 @@ export class GitlabPreparer implements PreparerBase { : Git.fromAuth({ logger }); await git.clone({ - url: repositoryCheckoutUrl, - dir: tempDir, + url: parsedGitUrl.toString('https'), + dir: checkoutPath, }); - return path.resolve(tempDir, templateDirectory); + await fs.move(fullPathToTemplate, targetPath); + + try { + await fs.rmdir(path.join(targetPath, '.git')); + } catch { + // Ignore intentionally + } } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts index 17ffea5557..6a2b7808f1 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/types.ts @@ -13,24 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { Logger } from 'winston'; export type PreparerOptions = { - workingDirectory?: string; + /** + * Full URL to the directory containg template data + */ + url: string; + /** + * The workspace path that will eventually be the the root of the new repo + */ + workspacePath: string; logger: Logger; }; export interface PreparerBase { /** - * Given an Entity definition from the Service Catalog, go and prepare a directory - * with contents from the remote location in temporary storage and return the path - * @param template The template entity from the Service Catalog + * Prepare a directory with contents from the remote location */ - prepare( - template: TemplateEntityV1alpha1, - opts?: PreparerOptions, - ): Promise; + prepare(opts: PreparerOptions): Promise; } export type PreparerBuilder = { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts index 31d94d5f53..13aab7ff34 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.ts @@ -19,8 +19,10 @@ import { initRepoAndPush } from './helpers'; import { GitHubIntegrationConfig } from '@backstage/integration'; import parseGitUrl from 'git-url-parse'; import { Octokit } from '@octokit/rest'; +import path from 'path'; export type RepoVisibilityOptions = 'private' | 'internal' | 'public'; + export class GithubPublisher implements PublisherBase { static async fromConfig( config: GitHubIntegrationConfig, @@ -41,6 +43,7 @@ export class GithubPublisher implements PublisherBase { repoVisibility, }); } + constructor( private readonly config: { token: string; @@ -51,7 +54,7 @@ export class GithubPublisher implements PublisherBase { async publish({ values, - directory, + workspacePath, logger, }: PublisherOptions): Promise { const { owner, name } = parseGitUrl(values.storePath); @@ -66,7 +69,7 @@ export class GithubPublisher implements PublisherBase { }); await initRepoAndPush({ - dir: directory, + dir: path.join(workspacePath, 'result'), remoteUrl, auth: { username: this.config.token, @@ -79,7 +82,6 @@ export class GithubPublisher implements PublisherBase { /\.git$/, '/blob/master/catalog-info.yaml', ); - return { remoteUrl, catalogInfoUrl }; } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts index fbf656206d..3ae92b8b7d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/types.ts @@ -32,7 +32,7 @@ export type PublisherBase = { export type PublisherOptions = { values: TemplaterValues; - directory: string; + workspacePath: string; logger: Logger; }; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts index 7f7fd3494f..d19112561a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts @@ -19,8 +19,6 @@ import { runDockerContainer, runCommand } from './helpers'; import { TemplaterBase, TemplaterRunOptions } from '.'; import path from 'path'; -import { TemplaterRunResult } from './types'; - const commandExists = require('command-exists-promise'); export class CookieCutter implements TemplaterBase { @@ -38,28 +36,32 @@ export class CookieCutter implements TemplaterBase { } } - public async run(options: TemplaterRunOptions): Promise { + public async run({ + workspacePath, + dockerClient, + values, + logStream, + }: TemplaterRunOptions): Promise { + const templateDir = path.join(workspacePath, 'template'); + const intermediateDir = path.join(workspacePath, 'intermediate'); + const resultDir = path.join(workspacePath, 'result'); + // First lets grab the default cookiecutter.json file - const cookieCutterJson = await this.fetchTemplateCookieCutter( - options.directory, - ); + const cookieCutterJson = await this.fetchTemplateCookieCutter(templateDir); const cookieInfo = { ...cookieCutterJson, - ...options.values, + ...values, }; - await fs.writeJSON(`${options.directory}/cookiecutter.json`, cookieInfo); - - const templateDir = options.directory; - const resultDir = await fs.promises.mkdtemp(`${options.directory}-result`); + await fs.writeJSON(`${templateDir}/cookiecutter.json`, cookieInfo); const cookieCutterInstalled = await commandExists('cookiecutter'); if (cookieCutterInstalled) { await runCommand({ command: 'cookiecutter', - args: ['--no-input', '-o', resultDir, templateDir, '--verbose'], - logStream: options.logStream, + args: ['--no-input', '-o', intermediateDir, templateDir, '--verbose'], + logStream, }); } else { await runDockerContainer({ @@ -73,22 +75,20 @@ export class CookieCutter implements TemplaterBase { '--verbose', ], templateDir, - resultDir, - logStream: options.logStream, - dockerClient: options.dockerClient, + resultDir: intermediateDir, + logStream, + dockerClient, }); } - // if cookiecutter was successful, resultDir will contain + // if cookiecutter was successful, intermediateDir will contain // exactly one directory. - const [generated] = await fs.readdir(resultDir); + const [generated] = await fs.readdir(intermediateDir); if (generated === undefined) { - throw new Error('Cookie Cutter did not generate anything'); + throw new Error('No data generated by cookiecutter'); } - return { - resultDir: path.resolve(resultDir, generated), - }; + await fs.move(path.join(intermediateDir, generated), resultDir); } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.ts index 549b898af8..9535d28837 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.ts @@ -20,26 +20,20 @@ import { TemplaterBuilder, } from './types'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { getTemplaterKey } from './helpers'; - export class Templaters implements TemplaterBuilder { - private preparerMap = new Map(); + private templaterMap = new Map(); register(templaterKey: SupportedTemplatingKey, templater: TemplaterBase) { - this.preparerMap.set(templaterKey, templater); + this.templaterMap.set(templaterKey, templater); } - get(template: TemplateEntityV1alpha1): TemplaterBase { - const templaterKey = getTemplaterKey(template); - const preparer = this.preparerMap.get(templaterKey); + get(templaterId: string): TemplaterBase { + const templater = this.templaterMap.get(templaterId); - if (!preparer) { - throw new Error( - `No templater registered for template: "${templaterKey}"`, - ); + if (!templater) { + throw new Error(`No templater registered for template: "${templaterId}"`); } - return preparer; + return templater; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts index 812fb972d5..fa14788d22 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/types.ts @@ -15,7 +15,6 @@ */ import type { Writable } from 'stream'; import Docker from 'dockerode'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import gitUrlParse from 'git-url-parse'; /** @@ -46,7 +45,7 @@ export type TemplaterRunResult = { * client to run any templater on top of your directory. */ export type TemplaterRunOptions = { - directory: string; + workspacePath: string; values: TemplaterValues; logStream?: Writable; dockerClient: Docker; @@ -54,7 +53,7 @@ export type TemplaterRunOptions = { export type TemplaterBase = { // runs the templating with the values and returns the directory to push the VCS - run(opts: TemplaterRunOptions): Promise; + run(opts: TemplaterRunOptions): Promise; }; export type TemplaterConfig = { @@ -71,5 +70,5 @@ export type SupportedTemplatingKey = 'cookiecutter' | string; */ export type TemplaterBuilder = { register(protocol: SupportedTemplatingKey, templater: TemplaterBase): void; - get(template: TemplateEntityV1alpha1): TemplaterBase; + get(templater: string): TemplaterBase; }; diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 115efc2ec4..4fc9b9a911 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -15,7 +15,6 @@ */ import { Config } from '@backstage/config'; -import fs from 'fs-extra'; import Docker from 'dockerode'; import express from 'express'; import Router from 'express-promise-router'; @@ -33,6 +32,7 @@ import { import { CatalogEntityClient } from '../lib/catalog'; import { validate, ValidatorResult } from 'jsonschema'; import parseGitUrl from 'git-url-parse'; +import { PreparerBase } from '../scaffolder/stages/prepare'; export interface RouterOptions { preparers: PreparerBuilder; @@ -62,27 +62,7 @@ export async function createRouter( } = options; const logger = parentLogger.child({ plugin: 'scaffolder' }); - const jobProcessor = new JobProcessor(); - - let workingDirectory: string; - if (config.has('backend.workingDirectory')) { - workingDirectory = config.getString('backend.workingDirectory'); - try { - // Check if working directory exists and is writable - await fs.promises.access( - workingDirectory, - fs.constants.F_OK | fs.constants.W_OK, - ); - logger.info(`using working directory: ${workingDirectory}`); - } catch (err) { - logger.error( - `working directory ${workingDirectory} ${ - err.code === 'ENOENT' ? 'does not exist' : 'is not writable' - }`, - ); - throw err; - } - } + const jobProcessor = await JobProcessor.fromConfig({ config, logger }); router .get('/v1/job/:jobId', ({ params }, res) => { @@ -128,51 +108,79 @@ export async function createRouter( res.status(400).json({ errors: validationResult.errors }); return; } + const job = jobProcessor.create({ entity: template, values, stages: [ { name: 'Prepare the skeleton', - handler: async ctx => { - const { protocol, location: pullPath } = parseLocationAnnotation( - ctx.entity, - ); + async handler(ctx) { + const { + protocol, + location: templateEntityLocation, + } = parseLocationAnnotation(ctx.entity); - const preparer = + if (protocol === 'file') { + const preparer: PreparerBase = + protocol === 'file' + ? new FilePreparer() + : preparers.get(templateEntityLocation); + + const url = new URL( + template.spec.path || '.', + templateEntityLocation, + ) + .toString() + .replace(/\/$/, ''); + + await preparer.prepare({ + url, + logger: ctx.logger, + workspacePath: ctx.workspacePath, + }); + return; + } + + const preparer: PreparerBase = protocol === 'file' ? new FilePreparer() - : preparers.get(pullPath); + : preparers.get(templateEntityLocation); - const skeletonDir = await preparer.prepare(ctx.entity, { + const url = new URL( + template.spec.path || '.', + templateEntityLocation, + ) + .toString() + .replace(/\/$/, ''); + + await preparer.prepare({ + url, logger: ctx.logger, - workingDirectory, + workspacePath: ctx.workspacePath, }); - return { skeletonDir }; }, }, { name: 'Run the templater', - handler: async (ctx: StageContext<{ skeletonDir: string }>) => { - const templater = templaters.get(ctx.entity); - const { resultDir } = await templater.run({ - directory: ctx.skeletonDir, + async handler(ctx) { + const templater = templaters.get(ctx.entity.spec.templater); + await templater.run({ + workspacePath: ctx.workspacePath, dockerClient, logStream: ctx.logStream, values: ctx.values, }); - - return { resultDir }; }, }, { name: 'Publish template', - handler: async (ctx: StageContext<{ resultDir: string }>) => { + handler: async ctx => { const publisher = publishers.get(ctx.values.storePath); ctx.logger.info('Will now store the template'); const result = await publisher.publish({ values: ctx.values, - directory: ctx.resultDir, + workspacePath: ctx.workspacePath, logger: ctx.logger, }); return result; From add5ce1350946fe0c35c22ec6dcff175d9a0eb9c Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 27 Jan 2021 08:59:41 +0100 Subject: [PATCH 044/297] cleanup filepreparer usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: blam Co-authored-by: Patrik Oldsberg --- .../scaffolder-backend/src/service/router.ts | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 4fc9b9a911..a3e7202f95 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -17,12 +17,12 @@ import { Config } from '@backstage/config'; import Docker from 'dockerode'; import express from 'express'; +import { resolve as resolvePath } from 'path'; import Router from 'express-promise-router'; import { Logger } from 'winston'; import { JobProcessor, PreparerBuilder, - StageContext, TemplaterBuilder, TemplaterValues, PublisherBuilder, @@ -32,7 +32,6 @@ import { import { CatalogEntityClient } from '../lib/catalog'; import { validate, ValidatorResult } from 'jsonschema'; import parseGitUrl from 'git-url-parse'; -import { PreparerBase } from '../scaffolder/stages/prepare'; export interface RouterOptions { preparers: PreparerBuilder; @@ -122,30 +121,22 @@ export async function createRouter( } = parseLocationAnnotation(ctx.entity); if (protocol === 'file') { - const preparer: PreparerBase = - protocol === 'file' - ? new FilePreparer() - : preparers.get(templateEntityLocation); + const preparer = new FilePreparer(); - const url = new URL( - template.spec.path || '.', + const path = resolvePath( templateEntityLocation, - ) - .toString() - .replace(/\/$/, ''); + template.spec.path || '.', + ); await preparer.prepare({ - url, + url: `file://${path}`, logger: ctx.logger, workspacePath: ctx.workspacePath, }); return; } - const preparer: PreparerBase = - protocol === 'file' - ? new FilePreparer() - : preparers.get(templateEntityLocation); + const preparer = preparers.get(templateEntityLocation); const url = new URL( template.spec.path || '.', From 85365cc23de2a7a5e5f71a249913217370949cb7 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 27 Jan 2021 09:12:36 +0100 Subject: [PATCH 045/297] publish repo from result/ directory Update tests --- .../scaffolder/stages/publish/azure.test.ts | 4 ++-- .../src/scaffolder/stages/publish/azure.ts | 5 +++-- .../stages/publish/bitbucket.test.ts | 8 ++++---- .../scaffolder/stages/publish/bitbucket.ts | 5 +++-- .../scaffolder/stages/publish/github.test.ts | 20 +++++++++---------- .../scaffolder/stages/publish/gitlab.test.ts | 8 ++++---- .../src/scaffolder/stages/publish/gitlab.ts | 6 +++--- 7 files changed, 29 insertions(+), 27 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts index e4410e9838..81901809e4 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts @@ -53,7 +53,7 @@ describe('Azure Publisher', () => { storePath: 'https://dev.azure.com/organisation/project/_git/repo', owner: 'bob', }, - directory: '/tmp/test', + workspacePath: '/tmp/test', logger, }); @@ -74,7 +74,7 @@ describe('Azure Publisher', () => { 'project', ); expect(helpers.initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: '/tmp/test/result', remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', auth: { username: 'notempty', password: 'fake-azure-token' }, logger, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts index b2ac486d77..4190ba8abb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.ts @@ -21,6 +21,7 @@ import { initRepoAndPush } from './helpers'; import { AzureIntegrationConfig } from '@backstage/integration'; import parseGitUrl from 'git-url-parse'; import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api'; +import path from 'path'; export class AzurePublisher implements PublisherBase { static async fromConfig(config: AzureIntegrationConfig) { @@ -34,7 +35,7 @@ export class AzurePublisher implements PublisherBase { async publish({ values, - directory, + workspacePath, logger, }: PublisherOptions): Promise { const { owner, name, organization, resource } = parseGitUrl( @@ -56,7 +57,7 @@ export class AzurePublisher implements PublisherBase { const catalogInfoUrl = `${remoteUrl}?path=%2Fcatalog-info.yaml`; await initRepoAndPush({ - dir: directory, + dir: path.join(workspacePath, 'result'), remoteUrl, auth: { username: 'notempty', diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts index f75eea939a..3b6ec9da89 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts @@ -69,7 +69,7 @@ describe('Bitbucket Publisher', () => { storePath: 'https://bitbucket.org/project/repo', owner: 'bob', }, - directory: '/tmp/test', + workspacePath: '/tmp/test', logger: logger, }); @@ -80,7 +80,7 @@ describe('Bitbucket Publisher', () => { }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: '/tmp/test/result', remoteUrl: 'https://bitbucket.org/project/repo', auth: { username: 'fake-user', password: 'fake-token' }, logger: logger, @@ -127,7 +127,7 @@ describe('Bitbucket Publisher', () => { storePath: 'https://bitbucket.mycompany.com/project/repo', owner: 'bob', }, - directory: '/tmp/test', + workspacePath: '/tmp/test', logger: logger, }); @@ -138,7 +138,7 @@ describe('Bitbucket Publisher', () => { }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: '/tmp/test/result', remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo', auth: { username: 'x-token-auth', password: 'fake-token' }, logger: logger, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts index a0b867507d..2a274144ff 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts @@ -19,6 +19,7 @@ import { initRepoAndPush } from './helpers'; import fetch from 'cross-fetch'; import { BitbucketIntegrationConfig } from '@backstage/integration'; import parseGitUrl from 'git-url-parse'; +import path from 'path'; // TODO(blam): We should probably start to use a bitbucket client here that we can change // the baseURL to point at on-prem or public bitbucket versions like we do for @@ -45,7 +46,7 @@ export class BitbucketPublisher implements PublisherBase { async publish({ values, - directory, + workspacePath, logger, }: PublisherOptions): Promise { const { owner: project, name } = parseGitUrl(values.storePath); @@ -58,7 +59,7 @@ export class BitbucketPublisher implements PublisherBase { }); await initRepoAndPush({ - dir: directory, + dir: path.join(workspacePath, 'result'), remoteUrl: result.remoteUrl, auth: { username: this.config.username ? this.config.username : 'x-token-auth', diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts index 9a788b960d..faa8c49124 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts @@ -64,7 +64,7 @@ describe('GitHub Publisher', () => { owner: 'bob', access: 'blam/team', }, - directory: '/tmp/test', + workspacePath: '/tmp/test', logger, }); @@ -89,7 +89,7 @@ describe('GitHub Publisher', () => { permission: 'admin', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: '/tmp/test/result', remoteUrl: 'https://github.com/backstage/backstage.git', auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, @@ -122,7 +122,7 @@ describe('GitHub Publisher', () => { owner: 'bob', access: 'blam', }, - directory: '/tmp/test', + workspacePath: '/tmp/test', logger, }); @@ -140,7 +140,7 @@ describe('GitHub Publisher', () => { expect(mockGithubClient.repos.addCollaborator).not.toHaveBeenCalled(); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: '/tmp/test/result', remoteUrl: 'https://github.com/backstage/backstage.git', auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, @@ -175,7 +175,7 @@ describe('GitHub Publisher', () => { access: 'bob', description: 'description', }, - directory: '/tmp/test', + workspacePath: '/tmp/test', logger, }); @@ -198,7 +198,7 @@ describe('GitHub Publisher', () => { permission: 'admin', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: '/tmp/test/result', remoteUrl: 'https://github.com/backstage/backstage.git', auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, @@ -233,7 +233,7 @@ describe('GitHub Publisher', () => { storePath: 'https://github.com/blam/test', owner: 'bob', }, - directory: '/tmp/test', + workspacePath: '/tmp/test', logger, }); @@ -249,7 +249,7 @@ describe('GitHub Publisher', () => { visibility: 'internal', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: '/tmp/test/result', remoteUrl: 'https://github.com/backstage/backstage.git', auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, @@ -283,7 +283,7 @@ describe('GitHub Publisher', () => { storePath: 'https://github.com/blam/test', owner: 'bob', }, - directory: '/tmp/test', + workspacePath: '/tmp/test', logger, }); @@ -299,7 +299,7 @@ describe('GitHub Publisher', () => { private: true, }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: '/tmp/test/result', remoteUrl: 'https://github.com/backstage/backstage.git', auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts index 11b32a6de5..99ddbbb2b7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts @@ -68,7 +68,7 @@ describe('GitLab Publisher', () => { storePath: 'https://gitlab.com/blam/test', owner: 'bob', }, - directory: '/tmp/test', + workspacePath: '/tmp/test', logger, }); @@ -85,7 +85,7 @@ describe('GitLab Publisher', () => { name: 'test', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: '/tmp/test/result', remoteUrl: 'mockclone', auth: { username: 'oauth2', password: 'fake-token' }, logger, @@ -111,7 +111,7 @@ describe('GitLab Publisher', () => { storePath: 'https://gitlab.com/blam/test', owner: 'bob', }, - directory: '/tmp/test', + workspacePath: '/tmp/test', logger, }); @@ -125,7 +125,7 @@ describe('GitLab Publisher', () => { name: 'test', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test', + dir: '/tmp/test/result', remoteUrl: 'mockclone', auth: { username: 'oauth2', password: 'fake-token' }, logger, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts index 24149ab2f3..ace5791b1f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts @@ -19,7 +19,7 @@ import { Gitlab } from '@gitbeaker/node'; import { Gitlab as GitlabClient } from '@gitbeaker/core'; import { initRepoAndPush } from './helpers'; import parseGitUrl from 'git-url-parse'; - +import path from 'path'; import { GitLabIntegrationConfig } from '@backstage/integration'; export class GitlabPublisher implements PublisherBase { @@ -38,7 +38,7 @@ export class GitlabPublisher implements PublisherBase { async publish({ values, - directory, + workspacePath, logger, }: PublisherOptions): Promise { const { owner, name } = parseGitUrl(values.storePath); @@ -49,7 +49,7 @@ export class GitlabPublisher implements PublisherBase { }); await initRepoAndPush({ - dir: directory, + dir: path.join(workspacePath, 'result'), remoteUrl, auth: { username: 'oauth2', From 4f6d779b78195f6ad86f303cd6c3e79933344b47 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 27 Jan 2021 09:42:50 +0100 Subject: [PATCH 046/297] chore: run the built main for now, --- .tugboat/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index 0c8c0329e3..af30015047 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -14,4 +14,4 @@ services: - yarn tsc - yarn build start: - - node packages/backend --config app-config.yaml --config .tugboat/tugboat.app-config.production.yaml & + - node packages/backend/dist/main --config app-config.yaml --config .tugboat/tugboat.app-config.production.yaml & From f683dfbee5af261cd344022d75c30d119775fdbc Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 27 Jan 2021 10:02:19 +0100 Subject: [PATCH 047/297] chore: fixing config again --- .tugboat/config.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index af30015047..d130f60554 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -1,6 +1,6 @@ services: backstage: - image: node:lts-alpine + image: tugboatqa/node:lts expose: 7000 default: true commands: @@ -14,4 +14,5 @@ services: - yarn tsc - yarn build start: - - node packages/backend/dist/main --config app-config.yaml --config .tugboat/tugboat.app-config.production.yaml & + # This should be production, but we need to run backend:bundle like the dockerfile does. + - yarn start-backend --config app-config.yaml --config .tugboat/tugboat.app-config.production.yaml & From 233d519ca19ac180495a58a6a40d39e9aeebda09 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 27 Jan 2021 10:23:53 +0100 Subject: [PATCH 048/297] chore: think this is what it's supposed to look like haha --- .tugboat/config.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index d130f60554..bdef387f3f 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -8,11 +8,9 @@ services: - yarn install build: - yarn tsc - - yarn build + - yarn build --config app-config.yaml --config .tugboat/tugboat.app-config.production.yaml update: - yarn install - - yarn tsc - - yarn build start: # This should be production, but we need to run backend:bundle like the dockerfile does. - yarn start-backend --config app-config.yaml --config .tugboat/tugboat.app-config.production.yaml & From 732701e4a7972d424cdd9b0cb75140261c359819 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 27 Jan 2021 10:42:31 +0100 Subject: [PATCH 049/297] chore: Remove the build-config flag --- .tugboat/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index bdef387f3f..4694800e50 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -8,7 +8,7 @@ services: - yarn install build: - yarn tsc - - yarn build --config app-config.yaml --config .tugboat/tugboat.app-config.production.yaml + - yarn build update: - yarn install start: From 15932ee95b808a0e3b961ab7417aae4b22b7eadd Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 27 Jan 2021 11:17:12 +0100 Subject: [PATCH 050/297] chore: try different config --- .tugboat/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index 4694800e50..e06b95d7ef 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -13,4 +13,4 @@ services: - yarn install start: # This should be production, but we need to run backend:bundle like the dockerfile does. - - yarn start-backend --config app-config.yaml --config .tugboat/tugboat.app-config.production.yaml & + - node packages/backend/dist/main --config app-config.yaml --config .tugboat/tugboat.app-config.production.yaml & From 7e7e24c61c7efbaca584ab1827d61d9296a24c5b Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 27 Jan 2021 11:25:24 +0100 Subject: [PATCH 051/297] Pass working directory --- .../src/scaffolder/jobs/processor.test.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts index eaae937337..0492400db6 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts @@ -70,7 +70,7 @@ describe('JobProcessor', () => { describe('create', () => { it('creates should create a new job with a unique id', async () => { - const processor = new JobProcessor(); + const processor = new JobProcessor('/tmp'); const job = processor.create({ entity: mockEntity, @@ -84,7 +84,7 @@ describe('JobProcessor', () => { }); it('should setup the correct context for the job', async () => { - const processor = new JobProcessor(); + const processor = new JobProcessor('/tmp'); const job = processor.create({ entity: mockEntity, @@ -97,7 +97,7 @@ describe('JobProcessor', () => { }); it('should set the status as pending', async () => { - const processor = new JobProcessor(); + const processor = new JobProcessor('/tmp'); const job = processor.create({ entity: mockEntity, @@ -120,7 +120,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor(); + const processor = new JobProcessor('/tmp'); const job = processor.create({ entity: mockEntity, @@ -139,12 +139,12 @@ describe('JobProcessor', () => { describe('get', () => { it('return undefined for when the job does not exist', () => { - const processor = new JobProcessor(); + const processor = new JobProcessor('/tmp'); 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 processor = new JobProcessor('/tmp'); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -156,7 +156,7 @@ describe('JobProcessor', () => { }); describe('process', () => { it('throws an error when the status of the job is not in pending state', async () => { - const processor = new JobProcessor(); + const processor = new JobProcessor('/tmp'); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -182,7 +182,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor(); + const processor = new JobProcessor('/tmp'); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -208,7 +208,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor(); + const processor = new JobProcessor('/tmp'); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -244,7 +244,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor(); + const processor = new JobProcessor('/tmp'); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -283,7 +283,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor(); + const processor = new JobProcessor('/tmp'); const job = processor.create({ entity: mockEntity, values: mockValues, From 46211fa5bea80aa108d3cfc03dc9a945ea23e7a7 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 27 Jan 2021 11:26:59 +0100 Subject: [PATCH 052/297] Update prepare test --- .../stages/prepare/bitbucket.test.ts | 100 ++++------------ .../scaffolder/stages/prepare/file.test.ts | 71 ++++------- .../scaffolder/stages/prepare/github.test.ts | 108 +++++------------ .../scaffolder/stages/prepare/gitlab.test.ts | 112 ++++-------------- 4 files changed, 99 insertions(+), 292 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts index ff74fffdd7..84e72eafb8 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts @@ -14,21 +14,13 @@ * limitations under the License. */ -jest.doMock('fs-extra', () => ({ - promises: { - mkdtemp: jest.fn(dir => `${dir}-static`), - }, -})); - +import fs from 'fs-extra'; import { BitbucketPreparer } from './bitbucket'; -import { - TemplateEntityV1alpha1, - LOCATION_ANNOTATION, -} from '@backstage/catalog-model'; import { getVoidLogger, Git } from '@backstage/backend-common'; +jest.mock('fs-extra'); + describe('BitbucketPreparer', () => { - let mockEntity: TemplateEntityV1alpha1; const logger = getVoidLogger(); const mockGitClient = { clone: jest.fn(), @@ -38,44 +30,6 @@ describe('BitbucketPreparer', () => { beforeEach(() => { jest.clearAllMocks(); - mockEntity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - [LOCATION_ANNOTATION]: - 'bitbucket:https://bitbucket.org/backstage-project/backstage-repo', - }, - 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: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; }); const preparer = BitbucketPreparer.fromConfig({ @@ -84,12 +38,20 @@ describe('BitbucketPreparer', () => { appPassword: 'fake-password', }); + const prepareOptions = { + url: 'https://bitbucket.org/backstage-project/backstage-repo', + logger, + workspacePath: '/tmp', + }; + it('calls the clone command with the correct arguments for a repository', async () => { - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + await preparer.prepare(prepareOptions); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://bitbucket.org/backstage-project/backstage-repo', - dir: expect.any(String), + dir: '/tmp/checkout', }); + expect(fs.move).toHaveBeenCalledWith('/tmp/checkout', '/tmp/template'); + expect(fs.rmdir).toHaveBeenCalledWith('/tmp/template/.git'); }); it('calls the clone command with the correct arguments if an app password is provided for a repository', async () => { @@ -98,7 +60,7 @@ describe('BitbucketPreparer', () => { username: 'fake-user', appPassword: 'fake-password', }); - await preparer.prepare(mockEntity, { logger }); + await preparer.prepare(prepareOptions); expect(Git.fromAuth).toHaveBeenCalledWith({ logger, @@ -108,22 +70,22 @@ describe('BitbucketPreparer', () => { }); it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { - delete mockEntity.spec.path; - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + await preparer.prepare(prepareOptions); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://bitbucket.org/backstage-project/backstage-repo', - dir: expect.any(String), + dir: '/tmp/checkout', }); }); - it('return the temp directory with the path to the folder if it is specified', async () => { - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), + it('moves a template subdirectory to checkout if specified', async () => { + await preparer.prepare({ + url: 'https://bitbucket.org/foo/bar/src/master/1/2/3', + logger, + workspacePath: '/tmp', }); - - expect(response.split('\\').join('/')).toMatch( - /\/template\/test\/1\/2\/3$/, + expect(fs.move).toHaveBeenCalledWith( + '/tmp/checkout/1/2/3', + '/tmp/template', ); }); @@ -133,7 +95,7 @@ describe('BitbucketPreparer', () => { token: 'fake-token', }); - await preparer.prepare(mockEntity, { logger }); + await preparer.prepare(prepareOptions); expect(Git.fromAuth).toHaveBeenCalledWith({ logger, @@ -141,16 +103,4 @@ describe('BitbucketPreparer', () => { password: 'fake-token', }); }); - - it('return the working directory with the path to the folder if it is specified', async () => { - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - workingDirectory: '/workDir', - logger: getVoidLogger(), - }); - - expect(response.split('\\').join('/')).toMatch( - /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, - ); - }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts index 2b328f420b..6e7f70ac3d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts @@ -14,57 +14,34 @@ * limitations under the License. */ -import os from 'os'; -import fs from 'fs-extra'; -import YAML from 'yaml'; -import { FilePreparer } from './file'; -import path from 'path'; -import { - TemplateEntityV1alpha1, - LOCATION_ANNOTATION, -} from '@backstage/catalog-model'; import { getVoidLogger } from '@backstage/backend-common'; +import fs from 'fs-extra'; +import { FilePreparer } from './file'; -const setupTest = async (fixturePath: string) => { - const locationForTemplateYaml = path.resolve( - path.dirname( - require.resolve('@backstage/plugin-scaffolder-backend/package'), - ), - 'fixtures', - fixturePath, - ); - - const [parsedDocument] = YAML.parseAllDocuments( - await fs.readFile(locationForTemplateYaml, 'utf-8'), - ); - - const template: TemplateEntityV1alpha1 = parsedDocument.toJSON(); - template.metadata.annotations = { - [LOCATION_ANNOTATION]: `file:${locationForTemplateYaml}`, - }; - - const filePreparer = new FilePreparer(); - const resultDir = await filePreparer.prepare(template, { - logger: getVoidLogger(), - workingDirectory: os.tmpdir(), - }); - - return { filePreparer, template, resultDir }; -}; +jest.mock('fs-extra'); describe('File preparer', () => { - it('excludes the yaml file from the temp folder', async () => { - const { resultDir } = await setupTest('test-simple-template/template.yaml'); - expect(fs.existsSync(`${resultDir}/template.yaml`)).toBe(false); - }); + const logger = getVoidLogger(); + const preparer = new FilePreparer(); + it('prepares templates from a file path', async () => { + await preparer.prepare({ + url: 'file:///path/to/template', + logger, + workspacePath: '/tmp', + }); + expect(fs.copy).toHaveBeenCalledWith('/path/to/template', '/tmp/checkout', { + recursive: true, + }); + expect(fs.ensureDir).toHaveBeenCalledWith('/tmp/checkout'); - it('resolves relative path from the template', async () => { - const { resultDir } = await setupTest('test-simple-template/template.yaml'); - expect(fs.existsSync(`${resultDir}/expected_file.ts`)).toBe(true); - }); - - it('resolves relative path from the nested template', async () => { - const { resultDir } = await setupTest('test-nested-template/template.yaml'); - expect(fs.existsSync(`${resultDir}/expected_file.ts`)).toBe(true); + await expect( + preparer.prepare({ + url: 'file://not/full/path', + logger, + workspacePath: '/tmp', + }), + ).rejects.toThrow( + "Wrong location protocol, should be 'file', file://not/full/path", + ); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts index b43545f5fc..ac3be609fa 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts @@ -14,12 +14,7 @@ * limitations under the License. */ -jest.doMock('fs-extra', () => ({ - promises: { - mkdtemp: jest.fn(dir => `${dir}-static`), - }, -})); - +import fs from 'fs-extra'; import { GithubPreparer } from './github'; import { TemplateEntityV1alpha1, @@ -27,6 +22,8 @@ import { } from '@backstage/catalog-model'; import { getVoidLogger, Git } from '@backstage/backend-common'; +jest.mock('fs-extra'); + describe('GitHubPreparer', () => { let mockEntity: TemplateEntityV1alpha1; const mockGitClient = { @@ -38,44 +35,6 @@ describe('GitHubPreparer', () => { beforeEach(() => { jest.clearAllMocks(); - mockEntity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - [LOCATION_ANNOTATION]: - '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: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; }); const preparer = GithubPreparer.fromConfig({ @@ -84,58 +43,47 @@ describe('GitHubPreparer', () => { }); it('calls the clone command with the correct arguments for a repository', async () => { - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + await preparer.prepare({ + url: + 'https://github.com/benjdlambert/backstage-graphql-template/blob/master/templates/graphql-starter/template', + logger, + workspacePath: '/tmp', + }); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://github.com/benjdlambert/backstage-graphql-template', dir: expect.any(String), }); + expect(fs.move).toHaveBeenCalledWith( + '/tmp/checkout/templates/graphql-starter/template', + '/tmp/template', + ); + expect(fs.rmdir).toHaveBeenCalledWith('/tmp/template/.git'); }); it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { - delete mockEntity.spec.path; - - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + await preparer.prepare({ + url: + 'https://github.com/benjdlambert/backstage-graphql-template/blob/master', + logger, + workspacePath: '/tmp', + }); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://github.com/benjdlambert/backstage-graphql-template', dir: expect.any(String), }); - }); - - it('return the temp directory with the path to the folder if it is specified', async () => { - const preparer = GithubPreparer.fromConfig({ - host: 'github.com', - token: 'fake-token', - }); - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), - }); - expect(response.split('\\').join('/')).toMatch( - /\/template\/test\/1\/2\/3$/, - ); - }); - - it('return the working directory with the path to the folder if it is specified', async () => { - const preparer = GithubPreparer.fromConfig({ - host: 'github.com', - token: 'fake-token', - }); - - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - workingDirectory: '/workDir', - logger: getVoidLogger(), - }); - - expect(response.split('\\').join('/')).toMatch( - /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, - ); + expect(fs.move).toHaveBeenCalledWith('/tmp/checkout', '/tmp/template'); + expect(fs.rmdir).toHaveBeenCalledWith('/tmp/template/.git'); }); it('calls the clone command with token', async () => { - await preparer.prepare(mockEntity, { logger }); + await preparer.prepare({ + url: + 'https://github.com/benjdlambert/backstage-graphql-template/blob/master', + logger, + workspacePath: '/tmp', + }); expect(Git.fromAuth).toHaveBeenCalledWith({ logger, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts index 19cbf793e4..8bb602fb34 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts @@ -13,60 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -jest.doMock('fs-extra', () => ({ - promises: { - mkdtemp: jest.fn(dir => `${dir}-static`), - }, -})); - +import fs from 'fs-extra'; import { GitlabPreparer } from './gitlab'; -import { - TemplateEntityV1alpha1, - LOCATION_ANNOTATION, -} from '@backstage/catalog-model'; import { getVoidLogger, Git } from '@backstage/backend-common'; -const mockTemplate = (): TemplateEntityV1alpha1 => ({ - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - [LOCATION_ANNOTATION]: - 'url:https://gitlab.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 practices with GraphQL\n', - uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', - etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', - generation: 1, - }, - spec: { - type: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, -}); +jest.mock('fs-extra'); describe('GitLabPreparer', () => { - let mockEntity: TemplateEntityV1alpha1; const mockGitClient = { clone: jest.fn(), }; @@ -81,61 +34,40 @@ describe('GitLabPreparer', () => { host: 'gitlab.com', token: 'fake-token', }); - it(`calls the clone command with the correct arguments for a repository`, async () => { - mockEntity = mockTemplate(); - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + it(`calls the clone command with the correct arguments for a repository`, async () => { + await preparer.prepare({ + url: + 'https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master', + logger, + workspacePath: '/tmp', + }); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://gitlab.com/benjdlambert/backstage-graphql-template', - dir: expect.any(String), + dir: '/tmp/checkout', }); - }); - - it(`calls the clone command with the correct arguments if an access token is provided in integrations for a repository`, async () => { - mockEntity = mockTemplate(); - - await preparer.prepare(mockEntity, { logger }); expect(Git.fromAuth).toHaveBeenCalledWith({ logger, username: 'oauth2', password: 'fake-token', }); + + expect(fs.move).toHaveBeenCalledWith('/tmp/checkout', '/tmp/template'); + expect(fs.rmdir).toHaveBeenCalledWith('/tmp/template/.git'); }); - it(`calls the clone command with the correct arguments for a repository when no path is provided`, async () => { - mockEntity = mockTemplate(); - delete mockEntity.spec.path; - - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); - - expect(mockGitClient.clone).toHaveBeenCalledWith({ - url: 'https://gitlab.com/benjdlambert/backstage-graphql-template', - dir: expect.any(String), + it(`clones the template from a sub directory if specified`, async () => { + await preparer.prepare({ + url: + 'https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master/1/2/3', + logger, + workspacePath: '/tmp', }); - }); - - it(`return the temp directory with the path to the folder if it is specified`, async () => { - mockEntity = mockTemplate(); - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), - }); - expect(response.split('\\').join('/')).toMatch( - /\/template\/test\/1\/2\/3$/, - ); - }); - - it('return the working directory with the path to the folder if it is specified', async () => { - mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { - workingDirectory: '/workDir', - logger: getVoidLogger(), - }); - - expect(response.split('\\').join('/')).toMatch( - /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, + expect(fs.move).toHaveBeenCalledWith( + '/tmp/checkout/1/2/3', + '/tmp/template', ); }); }); From 1aa7817271f79fd20c168d6c362607de8a82edc1 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 27 Jan 2021 11:31:27 +0100 Subject: [PATCH 053/297] chore: hopefully getting something to boot now? --- .tugboat/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index e06b95d7ef..831ea6a9b6 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -13,4 +13,4 @@ services: - yarn install start: # This should be production, but we need to run backend:bundle like the dockerfile does. - - node packages/backend/dist/main --config app-config.yaml --config .tugboat/tugboat.app-config.production.yaml & + - yarn start-backend --config $PWD/app-config.yaml --config $PWD/.tugboat/tugboat.app-config.production.yaml & From 04c6781e6ca4673fc4e9ff6698e8451f67e30081 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Wed, 27 Jan 2021 11:42:20 +0100 Subject: [PATCH 054/297] Extra care for tmp directory on Windows --- packages/backend-common/src/reading/AzureUrlReader.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts index 30c086ea08..c937f8a40c 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.test.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import * as os from 'os'; import fs from 'fs-extra'; import mockFs from 'mock-fs'; import path from 'path'; @@ -32,6 +33,8 @@ const treeResponseFactory = ReadTreeResponseFactory.create({ config: new ConfigReader({}), }); +const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + describe('AzureUrlReader', () => { const worker = setupServer(); msw.setupDefaultHandlers(worker); @@ -142,7 +145,7 @@ describe('AzureUrlReader', () => { describe('readTree', () => { beforeEach(() => { mockFs({ - '/tmp': mockFs.directory(), + [tmpDir]: mockFs.directory(), }); }); @@ -216,7 +219,7 @@ describe('AzureUrlReader', () => { 'https://dev.azure.com/organization/project/_git/repository', ); - const dir = await response.dir({ targetDir: '/tmp' }); + const dir = await response.dir({ targetDir: tmpDir }); await expect( fs.readFile(path.join(dir, 'mkdocs.yml'), 'utf8'), From 4944dcf0a7ee7e423c657d426edc7bcd6e67f9bb Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 27 Jan 2021 11:43:20 +0100 Subject: [PATCH 055/297] joinGitUrlPath Co-authored-by: Patrik Oldsberg --- .../src/scaffolder/stages/helpers.ts | 19 +++++++++++++++++++ .../scaffolder-backend/src/service/router.ts | 9 ++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts index f8d8229298..784e72ca87 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.ts @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import { posix as posixPath } from 'path'; import { TemplateEntityV1alpha1, LOCATION_ANNOTATION, @@ -53,3 +55,20 @@ export const parseLocationAnnotation = ( location, }; }; + +export function joinGitUrlPath(repoUrl: string, path?: string): string { + const parsed = new URL(repoUrl); + + if (parsed.hostname.endsWith('azure.com')) { + const templatePath = posixPath.normalize( + posixPath.join( + posixPath.dirname(parsed.searchParams.get('path') || '/'), + path || '.', + ), + ); + parsed.searchParams.set('path', templatePath); + return parsed.toString(); + } + + return new URL(path || '.', repoUrl).toString().replace(/\/$/, ''); +} diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index a3e7202f95..9098e5d05e 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -27,6 +27,7 @@ import { TemplaterValues, PublisherBuilder, parseLocationAnnotation, + joinGitUrlPath, FilePreparer, } from '../scaffolder'; import { CatalogEntityClient } from '../lib/catalog'; @@ -138,12 +139,10 @@ export async function createRouter( const preparer = preparers.get(templateEntityLocation); - const url = new URL( - template.spec.path || '.', + const url = joinGitUrlPath( templateEntityLocation, - ) - .toString() - .replace(/\/$/, ''); + template.spec.path, + ); await preparer.prepare({ url, From 766e6526ded114ca148dad96a0501356f83a1f71 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 27 Jan 2021 11:44:37 +0100 Subject: [PATCH 056/297] add joinGitUrlPath tests --- .../src/scaffolder/stages/helpers.test.ts | 76 ++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts index be6d215e5b..5c809d229e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/helpers.test.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { parseLocationAnnotation } from './helpers'; +import { parseLocationAnnotation, joinGitUrlPath } from './helpers'; import { TemplateEntityV1alpha1, LOCATION_ANNOTATION, @@ -73,7 +73,7 @@ describe('Helpers', () => { metadata: { annotations: { [LOCATION_ANNOTATION]: - ':https://github.com/benjdlambert/backstage-graphql-template/blob/master/template.yaml', + ':https://github.com/o/r/blob/master/template.yaml', }, name: 'graphql-starter', title: 'GraphQL Service', @@ -250,4 +250,76 @@ describe('Helpers', () => { }); }); }); + + describe('joinGitUrlPath', () => { + it.each([ + [ + 'https://github.com/o/r/blob/master/template.yaml', + 'template', + 'https://github.com/o/r/blob/master/template', + ], + [ + 'https://dev.azure.com/o/p/_git/template-repo?path=%2Ftemplate.yaml', + undefined, + 'https://dev.azure.com/o/p/_git/template-repo?path=%2F', + ], + [ + 'https://dev.azure.com/o/p/_git/template-repo?path=%2Ftemplate.yaml', + 'a', + 'https://dev.azure.com/o/p/_git/template-repo?path=%2Fa', + ], + [ + 'https://dev.azure.com/o/p/_git/template-repo?path=%2Fa%2Ftemplate.yaml', + 'b', + 'https://dev.azure.com/o/p/_git/template-repo?path=%2Fa%2Fb', + ], + [ + 'https://github.com/o/r/blob/master/template.yaml', + undefined, + 'https://github.com/o/r/blob/master', + ], + [ + 'https://github.com/o/r/blob/master/template.yaml', + 'template', + 'https://github.com/o/r/blob/master/template', + ], + [ + 'https://github.com/o/r/blob/master/templates/graphql-starter/template.yaml', + 'template', + 'https://github.com/o/r/blob/master/templates/graphql-starter/template', + ], + [ + 'https://gitlab.com/o/r/-/blob/master/template.yaml', + undefined, + 'https://gitlab.com/o/r/-/blob/master', + ], + [ + 'https://gitlab.com/o/r/-/blob/master/template.yaml', + 'template', + 'https://gitlab.com/o/r/-/blob/master/template', + ], + [ + 'https://gitlab.com/o/r/-/blob/master/a/b/c/template.yaml', + '../../c', + 'https://gitlab.com/o/r/-/blob/master/a/c', + ], + [ + 'https://bitbucket.org/p/r/src/master/a/b/template.yaml', + undefined, + 'https://bitbucket.org/p/r/src/master/a/b', + ], + [ + 'https://bitbucket.org/p/r/src/master/a/b/template.yaml', + 'c', + 'https://bitbucket.org/p/r/src/master/a/b/c', + ], + [ + 'https://bitbucket.org/p/r/src/master/a/b/template.yaml', + '../c', + 'https://bitbucket.org/p/r/src/master/a/c', + ], + ])('should join git url %s with path %s', (url, path, result) => { + expect(joinGitUrlPath(url, path)).toBe(result); + }); + }); }); From 6b541f10f921be82f9d20f3a16e917ace2f98801 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 27 Jan 2021 11:45:51 +0100 Subject: [PATCH 057/297] Update azure tests --- .../scaffolder/stages/prepare/azure.test.ts | 123 +++++------------- .../src/scaffolder/stages/prepare/azure.ts | 2 +- 2 files changed, 34 insertions(+), 91 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts index 8dd72c35e9..1319c2b499 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts @@ -14,19 +14,12 @@ * limitations under the License. */ -jest.doMock('fs-extra', () => ({ - promises: { - mkdtemp: jest.fn(dir => `${dir}-static`), - }, -})); - +import fs from 'fs-extra'; import { AzurePreparer } from './azure'; -import { - TemplateEntityV1alpha1, - LOCATION_ANNOTATION, -} from '@backstage/catalog-model'; import { getVoidLogger, Git } from '@backstage/backend-common'; +jest.mock('fs-extra'); + describe('AzurePreparer', () => { const mockGitClient = { clone: jest.fn(), @@ -36,119 +29,69 @@ describe('AzurePreparer', () => { jest.spyOn(Git, 'fromAuth').mockReturnValue(mockGitClient as any); - let mockEntity: TemplateEntityV1alpha1; - beforeEach(() => { - jest.clearAllMocks(); - mockEntity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - [LOCATION_ANNOTATION]: - 'url:https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=%2Ftemplate.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: 'website', - templater: 'cookiecutter', - path: './template', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; - }); - const preparer = AzurePreparer.fromConfig({ host: 'dev.azure.com', token: 'fake-azure-token', }); - // TODO(blam): Here's a test that will fail when the deprecation is complete - it('calls the clone command with deprecated token', async () => { - await preparer.prepare(mockEntity, { logger }); - - expect(Git.fromAuth).toHaveBeenCalledWith({ - logger, - password: 'fake-azure-token', - username: 'notempty', - }); - }); + const prepareOptions = { + url: + 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', + workspacePath: '/tmp', + logger, + }; it('calls the clone command with token from integrations config', async () => { - await preparer.prepare(mockEntity, { logger }); + await preparer.prepare(prepareOptions); expect(Git.fromAuth).toHaveBeenCalledWith({ logger, password: 'fake-azure-token', username: 'notempty', }); + expect(fs.move).toHaveBeenCalledWith('/tmp/checkout', '/tmp/template'); + expect(fs.rmdir).toHaveBeenCalledWith('/tmp/template/.git'); }); it('calls the clone command with the correct arguments for a repository', async () => { - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + await preparer.prepare(prepareOptions); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', - dir: expect.any(String), + dir: '/tmp/checkout', }); }); it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { - delete mockEntity.spec.path; - - await preparer.prepare(mockEntity, { logger: getVoidLogger() }); + await preparer.prepare({ + url: + 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', + workspacePath: '/tmp', + logger, + }); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', - dir: expect.any(String), + dir: '/tmp/checkout', }); + expect(fs.move).toHaveBeenCalledWith('/tmp/checkout', '/tmp/template'); }); - it('return the temp directory with the path to the folder if it is specified', async () => { - mockEntity.spec.path = './template/test/1/2/3'; - - const response = await preparer.prepare(mockEntity, { - logger: getVoidLogger(), + it('moves the template from path if it is specified', async () => { + const path = './template/test/1/2/3'; + await preparer.prepare({ + url: `https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=${encodeURIComponent( + path, + )}`, + logger, + workspacePath: '/tmp', }); - expect(response.split('\\').join('/')).toMatch( - /\/template\/test\/1\/2\/3$/, - ); - }); - - it('return the working directory with the path to the folder if it is specified', async () => { - mockEntity.spec.path = './template/test/1/2/3'; - - const response = await preparer.prepare(mockEntity, { - workingDirectory: '/workDir', - logger: getVoidLogger(), - }); - - expect(response.split('\\').join('/')).toMatch( - /\/workDir\/graphql-starter-static\/template\/test\/1\/2\/3$/, + expect(fs.move).toHaveBeenCalledWith( + '/tmp/checkout/template/test/1/2/3', + '/tmp/template', ); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts index cca6baa0b1..e3fd5fe68b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.ts @@ -33,7 +33,7 @@ export class AzurePreparer implements PreparerBase { const targetPath = path.join(workspacePath, 'template'); const fullPathToTemplate = path.resolve( checkoutPath, - parsedGitUrl.filepath, + parsedGitUrl.filepath ?? '', ); // Username can be anything but the empty string according to: From 45f49bd12c0d1255b19850ce7cfd72df6c3042c9 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 27 Jan 2021 13:48:57 +0100 Subject: [PATCH 058/297] chore: performance! --- .tugboat/config.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index 831ea6a9b6..7ee1a775e9 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -7,8 +7,7 @@ services: init: - yarn install build: - - yarn tsc - - yarn build + - yarn workspace example-app build update: - yarn install start: From e3d850a9e460ef2c131c4bc47d5d736db95e8d72 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 27 Jan 2021 13:56:37 +0100 Subject: [PATCH 059/297] chore: try skip yarn for tty problems --- .tugboat/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index 7ee1a775e9..be952d601c 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -12,4 +12,4 @@ services: - yarn install start: # This should be production, but we need to run backend:bundle like the dockerfile does. - - yarn start-backend --config $PWD/app-config.yaml --config $PWD/.tugboat/tugboat.app-config.production.yaml & + - cd packages/backend && node_modules/.bin/backstage-cli backend:dev --config ../../app-config.yaml --config ../../.tugboat/tugboat.app-config.production.yaml & From f1d1ef4635523577f640bdcc406df22ef17aed17 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 27 Jan 2021 14:35:11 +0100 Subject: [PATCH 060/297] chore: updating config --- .tugboat/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index be952d601c..2678f39540 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -12,4 +12,4 @@ services: - yarn install start: # This should be production, but we need to run backend:bundle like the dockerfile does. - - cd packages/backend && node_modules/.bin/backstage-cli backend:dev --config ../../app-config.yaml --config ../../.tugboat/tugboat.app-config.production.yaml & + - cd packages/backend && node_modules/.bin/backstage-cli backend:dev --config ../../app-config.yaml --config ../../.tugboat/tugboat.app-config.production.yaml From 43290ea99126444cb09723c65e53cc6489210f76 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Tue, 26 Jan 2021 17:35:09 -0500 Subject: [PATCH 061/297] update version of git-url-parse to set ref for Azure DevOps --- packages/backend-common/package.json | 2 +- plugins/scaffolder-backend/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index c698351615..e556a75062 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -43,7 +43,7 @@ "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.1", - "git-url-parse": "^11.4.3", + "git-url-parse": "^11.4.4", "helmet": "^4.0.0", "isomorphic-git": "^1.8.0", "knex": "^0.21.6", diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index cd7dab3444..e118a1d7f9 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -48,7 +48,7 @@ "express": "^4.17.1", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.0", - "git-url-parse": "^11.4.3", + "git-url-parse": "^11.4.4", "globby": "^11.0.0", "helmet": "^4.0.0", "isomorphic-git": "^1.8.0", From 3179c98c85c0240dc4842ff6bde1682bd6a1eeca Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 27 Jan 2021 14:49:04 +0100 Subject: [PATCH 062/297] chore: updating config again --- .tugboat/config.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index 2678f39540..2be043cd78 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -5,11 +5,11 @@ services: default: true commands: init: - - yarn install + - mkdir -p /etc/service/node + - echo "#!/bin/sh" > /etc/service/node/run + - echo "yarn --cwd ${TUGBOAT_ROOT} start-backend --config ${TUGBOAT_ROOT}/app-config.yaml --config ${TUGBOAT_ROOT}/.tugboat/tugboat.app-config.production.yaml" >> /etc/service/node/run + - chmod +x /etc/service/node/run build: - yarn workspace example-app build update: - yarn install - start: - # This should be production, but we need to run backend:bundle like the dockerfile does. - - cd packages/backend && node_modules/.bin/backstage-cli backend:dev --config ../../app-config.yaml --config ../../.tugboat/tugboat.app-config.production.yaml From 06af096c49bf31d2a0b11d3eb898f5455367e3c7 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 27 Jan 2021 15:15:52 +0100 Subject: [PATCH 063/297] Set workspace path based on platform --- .../scaffolder/stages/prepare/azure.test.ts | 28 +++++++++++-------- .../stages/prepare/bitbucket.test.ts | 22 +++++++++------ 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts index 1319c2b499..08b3c325ae 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts @@ -15,6 +15,8 @@ */ import fs from 'fs-extra'; +import os from 'os'; +import { resolve } from 'path'; import { AzurePreparer } from './azure'; import { getVoidLogger, Git } from '@backstage/backend-common'; @@ -34,23 +36,25 @@ describe('AzurePreparer', () => { token: 'fake-azure-token', }); + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const checkoutPath = resolve(workspacePath, 'checkout'); + const templatePath = resolve(workspacePath, 'template'); const prepareOptions = { url: 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', - workspacePath: '/tmp', + workspacePath, logger, }; it('calls the clone command with token from integrations config', async () => { await preparer.prepare(prepareOptions); - expect(Git.fromAuth).toHaveBeenCalledWith({ logger, password: 'fake-azure-token', username: 'notempty', }); - expect(fs.move).toHaveBeenCalledWith('/tmp/checkout', '/tmp/template'); - expect(fs.rmdir).toHaveBeenCalledWith('/tmp/template/.git'); + expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); + expect(fs.rmdir).toHaveBeenCalledWith(resolve(templatePath, '.git')); }); it('calls the clone command with the correct arguments for a repository', async () => { @@ -59,7 +63,7 @@ describe('AzurePreparer', () => { expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', - dir: '/tmp/checkout', + dir: checkoutPath, }); }); @@ -67,31 +71,31 @@ describe('AzurePreparer', () => { await preparer.prepare({ url: 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', - workspacePath: '/tmp', + workspacePath, logger, }); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', - dir: '/tmp/checkout', + dir: checkoutPath, }); - expect(fs.move).toHaveBeenCalledWith('/tmp/checkout', '/tmp/template'); + expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); }); it('moves the template from path if it is specified', async () => { - const path = './template/test/1/2/3'; + const path = './subdir'; await preparer.prepare({ url: `https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=${encodeURIComponent( path, )}`, logger, - workspacePath: '/tmp', + workspacePath, }); expect(fs.move).toHaveBeenCalledWith( - '/tmp/checkout/template/test/1/2/3', - '/tmp/template', + resolve(checkoutPath, 'subdir'), + templatePath, ); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts index 84e72eafb8..2f93346470 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts @@ -17,6 +17,8 @@ import fs from 'fs-extra'; import { BitbucketPreparer } from './bitbucket'; import { getVoidLogger, Git } from '@backstage/backend-common'; +import { resolve } from 'path'; +import os from 'os'; jest.mock('fs-extra'); @@ -38,20 +40,24 @@ describe('BitbucketPreparer', () => { appPassword: 'fake-password', }); + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const checkoutPath = resolve(workspacePath, 'checkout'); + const templatePath = resolve(workspacePath, 'template'); + const prepareOptions = { url: 'https://bitbucket.org/backstage-project/backstage-repo', logger, - workspacePath: '/tmp', + workspacePath, }; it('calls the clone command with the correct arguments for a repository', async () => { await preparer.prepare(prepareOptions); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://bitbucket.org/backstage-project/backstage-repo', - dir: '/tmp/checkout', + dir: checkoutPath, }); - expect(fs.move).toHaveBeenCalledWith('/tmp/checkout', '/tmp/template'); - expect(fs.rmdir).toHaveBeenCalledWith('/tmp/template/.git'); + expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); + expect(fs.rmdir).toHaveBeenCalledWith(resolve(templatePath, '.git')); }); it('calls the clone command with the correct arguments if an app password is provided for a repository', async () => { @@ -73,7 +79,7 @@ describe('BitbucketPreparer', () => { await preparer.prepare(prepareOptions); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://bitbucket.org/backstage-project/backstage-repo', - dir: '/tmp/checkout', + dir: checkoutPath, }); }); @@ -81,11 +87,11 @@ describe('BitbucketPreparer', () => { await preparer.prepare({ url: 'https://bitbucket.org/foo/bar/src/master/1/2/3', logger, - workspacePath: '/tmp', + workspacePath, }); expect(fs.move).toHaveBeenCalledWith( - '/tmp/checkout/1/2/3', - '/tmp/template', + resolve(checkoutPath, '1', '2', '3'), + templatePath, ); }); From 80366b60ad3226544d9a305537d18881957f83dd Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 27 Jan 2021 16:48:19 +0100 Subject: [PATCH 064/297] Use windows friendly temp dir --- .../scaffolder/stages/prepare/github.test.ts | 29 ++++++++++--------- .../scaffolder/stages/prepare/gitlab.test.ts | 20 ++++++++----- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts index ac3be609fa..7b97975ebf 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts @@ -15,17 +15,18 @@ */ import fs from 'fs-extra'; +import os from 'os'; +import { resolve } from 'path'; import { GithubPreparer } from './github'; -import { - TemplateEntityV1alpha1, - LOCATION_ANNOTATION, -} from '@backstage/catalog-model'; import { getVoidLogger, Git } from '@backstage/backend-common'; jest.mock('fs-extra'); describe('GitHubPreparer', () => { - let mockEntity: TemplateEntityV1alpha1; + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const checkoutPath = resolve(workspacePath, 'checkout'); + const templatePath = resolve(workspacePath, 'template'); + const mockGitClient = { clone: jest.fn(), }; @@ -47,16 +48,16 @@ describe('GitHubPreparer', () => { url: 'https://github.com/benjdlambert/backstage-graphql-template/blob/master/templates/graphql-starter/template', logger, - workspacePath: '/tmp', + workspacePath, }); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://github.com/benjdlambert/backstage-graphql-template', - dir: expect.any(String), + dir: checkoutPath, }); expect(fs.move).toHaveBeenCalledWith( - '/tmp/checkout/templates/graphql-starter/template', - '/tmp/template', + resolve(checkoutPath, 'templates', 'graphql-starter', 'template'), + templatePath, ); expect(fs.rmdir).toHaveBeenCalledWith('/tmp/template/.git'); }); @@ -66,15 +67,15 @@ describe('GitHubPreparer', () => { url: 'https://github.com/benjdlambert/backstage-graphql-template/blob/master', logger, - workspacePath: '/tmp', + workspacePath, }); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://github.com/benjdlambert/backstage-graphql-template', - dir: expect.any(String), + dir: checkoutPath, }); - expect(fs.move).toHaveBeenCalledWith('/tmp/checkout', '/tmp/template'); - expect(fs.rmdir).toHaveBeenCalledWith('/tmp/template/.git'); + expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); + expect(fs.rmdir).toHaveBeenCalledWith(resolve(templatePath, '.git')); }); it('calls the clone command with token', async () => { @@ -82,7 +83,7 @@ describe('GitHubPreparer', () => { url: 'https://github.com/benjdlambert/backstage-graphql-template/blob/master', logger, - workspacePath: '/tmp', + workspacePath, }); expect(Git.fromAuth).toHaveBeenCalledWith({ diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts index 8bb602fb34..35da164c3c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts @@ -14,12 +14,18 @@ * limitations under the License. */ import fs from 'fs-extra'; +import os from 'os'; +import { resolve } from 'path'; import { GitlabPreparer } from './gitlab'; import { getVoidLogger, Git } from '@backstage/backend-common'; jest.mock('fs-extra'); describe('GitLabPreparer', () => { + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const checkoutPath = resolve(workspacePath, 'checkout'); + const templatePath = resolve(workspacePath, 'template'); + const mockGitClient = { clone: jest.fn(), }; @@ -40,12 +46,12 @@ describe('GitLabPreparer', () => { url: 'https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master', logger, - workspacePath: '/tmp', + workspacePath, }); expect(mockGitClient.clone).toHaveBeenCalledWith({ url: 'https://gitlab.com/benjdlambert/backstage-graphql-template', - dir: '/tmp/checkout', + dir: checkoutPath, }); expect(Git.fromAuth).toHaveBeenCalledWith({ @@ -54,8 +60,8 @@ describe('GitLabPreparer', () => { password: 'fake-token', }); - expect(fs.move).toHaveBeenCalledWith('/tmp/checkout', '/tmp/template'); - expect(fs.rmdir).toHaveBeenCalledWith('/tmp/template/.git'); + expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); + expect(fs.rmdir).toHaveBeenCalledWith(resolve(templatePath, '.git')); }); it(`clones the template from a sub directory if specified`, async () => { @@ -63,11 +69,11 @@ describe('GitLabPreparer', () => { url: 'https://gitlab.com/benjdlambert/backstage-graphql-template/-/blob/master/1/2/3', logger, - workspacePath: '/tmp', + workspacePath, }); expect(fs.move).toHaveBeenCalledWith( - '/tmp/checkout/1/2/3', - '/tmp/template', + resolve(checkoutPath, '1', '2', '3'), + templatePath, ); }); }); From e346f96540de9107f7775b5f8523e766023552f7 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Wed, 27 Jan 2021 14:17:19 -0500 Subject: [PATCH 065/297] forgot to push yarn.lock --- yarn.lock | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/yarn.lock b/yarn.lock index 7c743bc5b3..1dcbe1733d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14180,6 +14180,13 @@ git-url-parse@^11.4.3: dependencies: git-up "^4.0.0" +git-url-parse@^11.4.4: + version "11.4.4" + resolved "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.4.4.tgz#5d747debc2469c17bc385719f7d0427802d83d77" + integrity sha512-Y4o9o7vQngQDIU9IjyCmRJBin5iYjI5u9ZITnddRZpD7dcCFQj2sL2XuMNbLRE4b4B/4ENPsp2Q8P44fjAZ0Pw== + dependencies: + git-up "^4.0.0" + gitconfiglocal@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/gitconfiglocal/-/gitconfiglocal-1.0.0.tgz#41d045f3851a5ea88f03f24ca1c6178114464b9b" From 8180fb76d8ea9b294bdfe595d45a4bedc6a23397 Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Wed, 27 Jan 2021 15:32:29 -0500 Subject: [PATCH 066/297] memoize alerts, not context --- .../CostInsightsPage/CostInsightsPage.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx index e7377eca10..f974fa0ddd 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx @@ -78,10 +78,18 @@ export const CostInsightsPage = () => { const { pageFilters, setPageFilters } = useFilters(p => p); - const snoozed = useMemo(() => alerts.alerts.filter(isSnoozed), [alerts]); - const accepted = useMemo(() => alerts.alerts.filter(isAccepted), [alerts]); - const dismissed = useMemo(() => alerts.alerts.filter(isDismissed), [alerts]); - const activeAlerts = useMemo(() => alerts.alerts.filter(isActive), [alerts]); + const snoozed = useMemo(() => alerts.alerts.filter(isSnoozed), [ + alerts.alerts, + ]); + const accepted = useMemo(() => alerts.alerts.filter(isAccepted), [ + alerts.alerts, + ]); + const dismissed = useMemo(() => alerts.alerts.filter(isDismissed), [ + alerts.alerts, + ]); + const activeAlerts = useMemo(() => alerts.alerts.filter(isActive), [ + alerts.alerts, + ]); const isActionItemsDisplayed = !!activeAlerts.length; const isAlertInsightsDisplayed = !!alerts.alerts.length; From 83a879c4cb4debe5531074be22acc5b430fc27e5 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 28 Jan 2021 00:01:55 +0100 Subject: [PATCH 067/297] chore: add a simple github action workflow --- .github/workflows/tugboat.yml | 57 +++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/tugboat.yml diff --git a/.github/workflows/tugboat.yml b/.github/workflows/tugboat.yml new file mode 100644 index 0000000000..4c52ce99d6 --- /dev/null +++ b/.github/workflows/tugboat.yml @@ -0,0 +1,57 @@ +name: Tugboat E2E Tests +on: deployment_status + +jobs: + run: + # When the deployment event is success + if: github.event.deployment_status.state == 'success' + name: Run test suite against tugboat + runs-on: ubuntu-latest + steps: + # Set an initial commit status message to indicate that the tests are + # running. + - name: set pending status + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} + debug: true + script: | + return github.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + state: 'pending', + context: 'Nightwatch.js tests', + description: 'Running tests', + target_url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + }); + - uses: actions/checkout@v1 + - uses: actions/setup-node@v1 + with: + node-version: '14' + - name: get deployment status + id: get-status-env + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} + result-encoding: string + script: | + const result = await github.repos.getDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: context.payload.deployment.id, + status_id: context.payload.deployment_status.id, + headers: { + 'Accept': 'application/vnd.github.ant-man-preview+json' + }, + }); + console.log(result); + return result.data.environment_url; + - name: echo tugboat preview url + run: | + echo ${{ steps.get-status-env.outputs.result }} + # The first time you hit a Tugboat URL it can take a while to load, so + # we visit it once here to prime it. Otherwise the very first test + # will often timeout. + curl ${{ steps.get-status-env.outputs.result }} + From 3148751354df0cb62324d65ef64c66f90e1c53dc Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 28 Jan 2021 00:12:52 +0100 Subject: [PATCH 068/297] chore: update workfloew --- .github/workflows/tugboat.yml | 107 ++++++++++++++++++---------------- 1 file changed, 57 insertions(+), 50 deletions(-) diff --git a/.github/workflows/tugboat.yml b/.github/workflows/tugboat.yml index 4c52ce99d6..da67be58b4 100644 --- a/.github/workflows/tugboat.yml +++ b/.github/workflows/tugboat.yml @@ -2,56 +2,63 @@ name: Tugboat E2E Tests on: deployment_status jobs: - run: - # When the deployment event is success + run-tests: + # Only run after a successful Tugboat deployment. if: github.event.deployment_status.state == 'success' - name: Run test suite against tugboat + name: Run tests against Tugboat runs-on: ubuntu-latest - steps: - # Set an initial commit status message to indicate that the tests are - # running. - - name: set pending status - uses: actions/github-script@v3 - with: - github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} - debug: true - script: | - return github.repos.createCommitStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - sha: context.sha, - state: 'pending', - context: 'Nightwatch.js tests', - description: 'Running tests', - target_url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - }); - - uses: actions/checkout@v1 - - uses: actions/setup-node@v1 - with: - node-version: '14' - - name: get deployment status - id: get-status-env - uses: actions/github-script@v3 - with: - github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} - result-encoding: string - script: | - const result = await github.repos.getDeploymentStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - deployment_id: context.payload.deployment.id, - status_id: context.payload.deployment_status.id, - headers: { - 'Accept': 'application/vnd.github.ant-man-preview+json' - }, - }); - console.log(result); - return result.data.environment_url; - - name: echo tugboat preview url - run: | - echo ${{ steps.get-status-env.outputs.result }} - # The first time you hit a Tugboat URL it can take a while to load, so - # we visit it once here to prime it. Otherwise the very first test - # will often timeout. - curl ${{ steps.get-status-env.outputs.result }} + steps: + # Set an initial commit status message to indicate that the tests are + # running. + - name: set pending status + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} + debug: true + script: | + return github.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + state: 'pending', + context: 'Nightwatch.js tests', + description: 'Running tests', + target_url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + }); + - uses: actions/checkout@v1 + - uses: actions/setup-node@v1 + with: + node-version: '14' + + # This is required because the environment_url param that Tugboat uses + # to tell us where the preview is located isn't supported unless you + # specify the custom Accept header when getting the deployment_status, + # and GitHub actions doesn't do that by default. So instead we have to + # load the status object manually and get the data we need. + # https://developer.github.com/changes/2016-04-06-deployment-and-deployment-status-enhancements/ + - name: get deployment status + id: get-status-env + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} + result-encoding: string + script: | + const result = await github.repos.getDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: context.payload.deployment.id, + status_id: context.payload.deployment_status.id, + headers: { + 'Accept': 'application/vnd.github.ant-man-preview+json' + }, + }); + console.log(result); + return result.data.environment_url; + - name: echo tugboat preview url + run: | + echo ${{ steps.get-status-env.outputs.result }} + # The first time you hit a Tugboat URL it can take a while to load, so + # we visit it once here to prime it. Otherwise the very first test + # will often timeout. + curl ${{ steps.get-status-env.outputs.result }} From fca9de6c93b53395a994bfa2271f3e5ba84d7c3f Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 28 Jan 2021 00:24:28 +0100 Subject: [PATCH 069/297] chore: update workflow again --- .github/workflows/tugboat.yml | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tugboat.yml b/.github/workflows/tugboat.yml index da67be58b4..53fcd2e873 100644 --- a/.github/workflows/tugboat.yml +++ b/.github/workflows/tugboat.yml @@ -1,7 +1,29 @@ name: Tugboat E2E Tests on: deployment_status - jobs: + set-pending: + if: github.event.deployment_status.state != 'success' && github.event.deployment_status.state != 'failed' + name: Run tests against Tugboat + runs-on: ubuntu-latest + steps: + # Set an initial commit status message to indicate that the tests are + # running. + - name: set pending status + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} + debug: true + script: | + return github.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + state: 'pending', + context: 'Backstage Tugboat E2E Tests', + description: 'Running tests', + target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" + }); + run-tests: # Only run after a successful Tugboat deployment. if: github.event.deployment_status.state == 'success' @@ -21,9 +43,9 @@ jobs: repo: context.repo.repo, sha: context.sha, state: 'pending', - context: 'Nightwatch.js tests', + context: 'Backstage Tugboat E2E Tests', description: 'Running tests', - target_url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" }); - uses: actions/checkout@v1 @@ -57,8 +79,8 @@ jobs: return result.data.environment_url; - name: echo tugboat preview url run: | - echo ${{ steps.get-status-env.outputs.result }} + echo ${{steps.get-status-env.outputs.result}} # The first time you hit a Tugboat URL it can take a while to load, so # we visit it once here to prime it. Otherwise the very first test # will often timeout. - curl ${{ steps.get-status-env.outputs.result }} + curl ${{steps.get-status-env.outputs.result}} From a4716e47276475f2424471df7fd126bcc9f484bb Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 28 Jan 2021 00:50:44 +0100 Subject: [PATCH 070/297] chore: fixing deployments for real --- .github/workflows/tugboat.yml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tugboat.yml b/.github/workflows/tugboat.yml index 53fcd2e873..cce4df01de 100644 --- a/.github/workflows/tugboat.yml +++ b/.github/workflows/tugboat.yml @@ -3,7 +3,7 @@ on: deployment_status jobs: set-pending: if: github.event.deployment_status.state != 'success' && github.event.deployment_status.state != 'failed' - name: Run tests against Tugboat + name: Set pending waiting for Tugboat runs-on: ubuntu-latest steps: # Set an initial commit status message to indicate that the tests are @@ -20,14 +20,14 @@ jobs: sha: context.sha, state: 'pending', context: 'Backstage Tugboat E2E Tests', - description: 'Running tests', + description: 'Waiting for Tugboat to complete deployment', target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" }); run-tests: # Only run after a successful Tugboat deployment. if: github.event.deployment_status.state == 'success' - name: Run tests against Tugboat + name: Run tests against Tugboat deployment runs-on: ubuntu-latest steps: # Set an initial commit status message to indicate that the tests are @@ -44,7 +44,7 @@ jobs: sha: context.sha, state: 'pending', context: 'Backstage Tugboat E2E Tests', - description: 'Running tests', + description: 'Running against tugboat preview', target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" }); @@ -79,8 +79,4 @@ jobs: return result.data.environment_url; - name: echo tugboat preview url run: | - echo ${{steps.get-status-env.outputs.result}} - # The first time you hit a Tugboat URL it can take a while to load, so - # we visit it once here to prime it. Otherwise the very first test - # will often timeout. curl ${{steps.get-status-env.outputs.result}} From 408962dcead8301ae3885a7769264f117ea83fe0 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 28 Jan 2021 01:17:52 +0100 Subject: [PATCH 071/297] chore: last github workflow plz --- .github/workflows/tugboat.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/workflows/tugboat.yml b/.github/workflows/tugboat.yml index cce4df01de..3d6980c81e 100644 --- a/.github/workflows/tugboat.yml +++ b/.github/workflows/tugboat.yml @@ -80,3 +80,32 @@ jobs: - name: echo tugboat preview url run: | curl ${{steps.get-status-env.outputs.result}} + # Update the commit status with a fail or success. + - name: set status + if: ${{ success() }} + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} + script: | + return github.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + state: "success", + context: 'Backstage Tugboat E2E Tests', + target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" + }); + - name: set status + if: ${{ failure() }} || ${{ cancelled() }} + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} + script: | + return github.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + state: "error", + context: 'Backstage Tugboat E2E Tests', + target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" + }); From f2b7817d4f9cca4740da984b819a905573349531 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 Jan 2021 04:48:11 +0000 Subject: [PATCH 072/297] chore(deps): bump core-js from 3.6.5 to 3.8.3 Bumps [core-js](https://github.com/zloirock/core-js) from 3.6.5 to 3.8.3. - [Release notes](https://github.com/zloirock/core-js/releases) - [Changelog](https://github.com/zloirock/core-js/blob/master/CHANGELOG.md) - [Commits](https://github.com/zloirock/core-js/compare/v3.6.5...v3.8.3) Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6b634511b4..aefde4902f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10787,9 +10787,9 @@ core-js-pure@^3.0.0, core-js-pure@^3.0.1: integrity sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw== core-js@3, core-js@^3.0.1, core-js@^3.0.4, core-js@^3.5.0, core-js@^3.6.0, core-js@^3.6.5: - version "3.6.5" - resolved "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a" - integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA== + version "3.8.3" + resolved "https://registry.npmjs.org/core-js/-/core-js-3.8.3.tgz#c21906e1f14f3689f93abcc6e26883550dd92dd0" + integrity sha512-KPYXeVZYemC2TkNEkX/01I+7yd+nX3KddKwZ1Ww7SKWdI2wQprSgLmrTddT8nw92AjEklTsPBoSdQBhbI1bQ6Q== core-js@^2.4.0, core-js@^2.5.7, core-js@^2.6.10, core-js@^2.6.5: version "2.6.11" From 60ace107797e949afdb3fb320b49c6e2d2edc4b8 Mon Sep 17 00:00:00 2001 From: Nigel Wright Date: Thu, 28 Jan 2021 20:19:30 +1300 Subject: [PATCH 073/297] Fix: added a check for invalid timezone --- .../src/layout/HomepageTimer/HomepageTimer.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx b/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx index 59b7f3fb3d..219665599c 100644 --- a/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx +++ b/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx @@ -39,18 +39,28 @@ function getTimes(configApi: ConfigApi) { for (const clock of clockConfigs) { if (clock.has('label') && clock.has('timezone')) { + let label = clock.getString('label'); + const options = { timeZone: clock.getString('timezone'), ...timeFormat, }; - const time = d.toLocaleTimeString(lang, options); - const label = clock.getString('label'); + try { + new Date().toLocaleString(lang, options); + } catch (e) { + // eslint-disable-next-line no-console + console.warn( + `The timezone ${options.timeZone} is invalid. Defaulting to America/Los Angeles`, + ); + options.timeZone = 'America/Los_Angeles'; + label = 'Los Angeles'; + } + const time = d.toLocaleTimeString(lang, options); clocks.push({ time, label }); } } - return clocks; } From 459027920dcddc56b68e7b7354aa82b4a7b38a11 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 28 Jan 2021 09:06:42 +0100 Subject: [PATCH 074/297] Fix cookiecutter tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- .../stages/templater/cookiecutter.test.ts | 173 ++++++++---------- 1 file changed, 73 insertions(+), 100 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts index 656abd9613..f3c716e8a7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts @@ -13,48 +13,29 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -jest.mock('./helpers', () => ({ - runDockerContainer: jest.fn(), - runCommand: jest.fn(), -})); -jest.mock('command-exists-promise', () => jest.fn()); + +const runDockerContainer = jest.fn(); +const runCommand = jest.fn(); +const commandExists = jest.fn(); + +jest.mock('./helpers', () => ({ runDockerContainer, runCommand })); +jest.mock('command-exists-promise', () => commandExists); +jest.mock('fs-extra'); import { CookieCutter } from './cookiecutter'; import fs from 'fs-extra'; -import os from 'os'; -import path from 'path'; -import { RunDockerContainerOptions, RunCommandOptions } from './helpers'; import { PassThrough } from 'stream'; import Docker from 'dockerode'; import parseGitUrl from 'git-url-parse'; -const commandExists = require('command-exists-promise'); - describe('CookieCutter Templater', () => { - const cookie = new CookieCutter(); const mockDocker = {} as Docker; - const { - runDockerContainer, - }: { - runDockerContainer: jest.Mock; - } = require('./helpers'); - jest - .spyOn(fs, 'readdir') - .mockImplementation(() => Promise.resolve(['newthing'])); - - beforeEach(async () => { + beforeEach(() => { jest.clearAllMocks(); }); - 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 entity', async () => { - const tempdir = await mkTemp(); - const values = { owner: 'blobby', storePath: 'https://github.com/org/repo', @@ -65,22 +46,30 @@ describe('CookieCutter Templater', () => { }, }; - await cookie.run({ directory: tempdir, values, dockerClient: mockDocker }); + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); - const cookieCutterJson = await fs.readJSON(`${tempdir}/cookiecutter.json`); + const templater = new CookieCutter(); + await templater.run({ + workspacePath: 'tempdir', + values, + dockerClient: mockDocker, + }); - expect(cookieCutterJson).toEqual( - expect.objectContaining(JSON.parse(JSON.stringify(values))), + expect(fs.writeJson).toBeCalledWith( + 'tempdir/template/cookiecutter.json', + expect.objectContaining(values), ); }); it('should merge any value that is in the cookiecutter.json path already', async () => { - const tempdir = await mkTemp(); const existingJson = { _copy_without_render: ['./github/workflows/*'], }; - await fs.writeJSON(`${tempdir}/cookiecutter.json`, existingJson); + jest + .spyOn(fs, 'readJSON') + .mockImplementationOnce(() => Promise.resolve(existingJson)); + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); const values = { owner: 'blobby', @@ -91,11 +80,14 @@ describe('CookieCutter Templater', () => { }, }; - await cookie.run({ directory: tempdir, values, dockerClient: mockDocker }); + const templater = new CookieCutter(); + await templater.run({ + workspacePath: 'tempdir', + values, + dockerClient: mockDocker, + }); - const cookieCutterJson = await fs.readJSON(`${tempdir}/cookiecutter.json`); - - expect(cookieCutterJson).toEqual({ + expect(fs.writeJSON).toBeCalledWith('tempdir/template/cookiecutter.json', { ...existingJson, ...values, destination: { @@ -105,9 +97,9 @@ describe('CookieCutter Templater', () => { }); it('should throw an error if the cookiecutter json is malformed and not missing', async () => { - const tempdir = await mkTemp(); - - await fs.writeFile(`${tempdir}/cookiecutter.json`, "{'"); + jest.spyOn(fs, 'readJSON').mockImplementationOnce(() => { + throw new Error('BAM'); + }); const values = { owner: 'blobby', @@ -117,14 +109,17 @@ describe('CookieCutter Templater', () => { }, }; + const templater = new CookieCutter(); await expect( - cookie.run({ directory: tempdir, values, dockerClient: mockDocker }), - ).rejects.toThrow(/Unexpected token ' in JSON at position 1/); + templater.run({ + workspacePath: 'tempdir', + values, + dockerClient: mockDocker, + }), + ).rejects.toThrow('BAM'); }); it('should run the correct docker container with the correct bindings for the volumes', async () => { - const tempdir = await mkTemp(); - const values = { owner: 'blobby', storePath: 'https://github.com/org/repo', @@ -134,7 +129,14 @@ describe('CookieCutter Templater', () => { }, }; - await cookie.run({ directory: tempdir, values, dockerClient: mockDocker }); + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); + + const templater = new CookieCutter(); + await templater.run({ + workspacePath: 'tempdir', + values, + dockerClient: mockDocker, + }); expect(runDockerContainer).toHaveBeenCalledWith({ imageName: 'spotify/backstage-cookiecutter', @@ -146,39 +148,16 @@ describe('CookieCutter Templater', () => { '/template', '--verbose', ], - templateDir: tempdir, - resultDir: expect.stringContaining(`${tempdir}-result`), + templateDir: 'tempdir/template', + resultDir: 'tempdir/intermediate', logStream: undefined, dockerClient: mockDocker, }); }); - it('should return the result path to the end templated folder', async () => { - const tempdir = await mkTemp(); - - const values = { - owner: 'blobby', - storePath: 'https://github.com/org/repo', - component_id: 'newthing', - destination: { - git: parseGitUrl('https://github.com/org/repo'), - }, - }; - - const { resultDir } = await cookie.run({ - directory: tempdir, - values, - dockerClient: mockDocker, - }); - - expect(resultDir.startsWith(`${tempdir}-result`)).toBeTruthy(); - }); - it('should pass through the streamer to the run docker helper', async () => { const stream = new PassThrough(); - const tempdir = await mkTemp(); - const values = { owner: 'blobby', storePath: 'https://github.com/org/repo', @@ -188,8 +167,11 @@ describe('CookieCutter Templater', () => { }, }; - await cookie.run({ - directory: tempdir, + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); + + const templater = new CookieCutter(); + await templater.run({ + workspacePath: 'tempdir', values, logStream: stream, dockerClient: mockDocker, @@ -205,29 +187,17 @@ describe('CookieCutter Templater', () => { '/template', '--verbose', ], - templateDir: tempdir, - resultDir: expect.stringContaining(`${tempdir}-result`), + templateDir: 'tempdir/template', + resultDir: 'tempdir/intermediate', logStream: stream, dockerClient: mockDocker, }); }); describe('when cookiecutter is available', () => { - beforeAll(() => { - commandExists.mockImplementation(() => () => true); - }); - it('use the binary', async () => { - const { - runCommand, - }: { - runCommand: jest.Mock; - } = require('./helpers'); - const stream = new PassThrough(); - const tempdir = await mkTemp(); - const values = { owner: 'blobby', storePath: 'https://github.com/org/repo', @@ -237,8 +207,12 @@ describe('CookieCutter Templater', () => { }, }; - await cookie.run({ - directory: tempdir, + jest.spyOn(fs, 'readdir').mockResolvedValueOnce(['newthing']); + commandExists.mockImplementationOnce(() => () => true); + + const templater = new CookieCutter(); + await templater.run({ + workspacePath: 'tempdir', values, logStream: stream, dockerClient: mockDocker, @@ -249,8 +223,8 @@ describe('CookieCutter Templater', () => { args: expect.arrayContaining([ '--no-input', '-o', - tempdir, - expect.stringContaining(`${tempdir}-result`), + 'tempdir/intermediate', + 'tempdir/template', '--verbose', ]), logStream: stream, @@ -259,18 +233,17 @@ describe('CookieCutter Templater', () => { }); describe('when nothing was generated', () => { - beforeEach(() => { - jest.spyOn(fs, 'readdir').mockImplementation(() => Promise.resolve([])); - }); - it('throws an error', async () => { const stream = new PassThrough(); - const tempdir = await mkTemp(); + jest + .spyOn(fs, 'readdir') + .mockImplementationOnce(() => Promise.resolve([])); - return expect( - cookie.run({ - directory: tempdir, + const templater = new CookieCutter(); + await expect( + templater.run({ + workspacePath: 'tempdir', values: { owner: 'blobby', storePath: 'https://github.com/org/repo', @@ -281,7 +254,7 @@ describe('CookieCutter Templater', () => { logStream: stream, dockerClient: mockDocker, }), - ).rejects.toThrow(/Cookie Cutter did not generate anything/); + ).rejects.toThrow(/No data generated by cookiecutter/); }); }); }); From 502849fe8ea5a801253bb6b11af92bacd71acf33 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 28 Jan 2021 09:14:43 +0100 Subject: [PATCH 075/297] Fix paths tests for windows --- .../scaffolder/stages/prepare/file.test.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts index 6e7f70ac3d..408653aee0 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts @@ -17,28 +17,37 @@ import { getVoidLogger } from '@backstage/backend-common'; import fs from 'fs-extra'; import { FilePreparer } from './file'; +import os from 'os'; +import { resolve } from 'path'; jest.mock('fs-extra'); describe('File preparer', () => { - const logger = getVoidLogger(); - const preparer = new FilePreparer(); it('prepares templates from a file path', async () => { + const logger = getVoidLogger(); + const preparer = new FilePreparer(); + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const checkoutPath = resolve(workspacePath, 'checkout'); + await preparer.prepare({ url: 'file:///path/to/template', logger, - workspacePath: '/tmp', + workspacePath, }); - expect(fs.copy).toHaveBeenCalledWith('/path/to/template', '/tmp/checkout', { - recursive: true, - }); - expect(fs.ensureDir).toHaveBeenCalledWith('/tmp/checkout'); + expect(fs.copy).toHaveBeenCalledWith( + resolve('/path', 'to', 'template'), + checkoutPath, + { + recursive: true, + }, + ); + expect(fs.ensureDir).toHaveBeenCalledWith(checkoutPath); await expect( preparer.prepare({ url: 'file://not/full/path', logger, - workspacePath: '/tmp', + workspacePath, }), ).rejects.toThrow( "Wrong location protocol, should be 'file', file://not/full/path", From b77a9852059e80b019698e9636b331332048e2f8 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 28 Jan 2021 09:33:55 +0100 Subject: [PATCH 076/297] Update templater tests --- .../stages/templater/templaters.test.ts | 92 +------------------ 1 file changed, 2 insertions(+), 90 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts index 9ef20b7740..209210c075 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/templaters.test.ts @@ -14,52 +14,13 @@ * limitations under the License. */ import { Templaters } from '.'; -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { CookieCutter } from './cookiecutter'; describe('Templaters', () => { - const mockTemplate: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: { - 'backstage.io/managed-by-location': - 'file:/Users/bingo/backstage/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml', - }, - name: 'react-ssr-template', - title: 'React SSR Template', - description: - 'Next.js application skeleton for creating isomorphic web applications.', - uid: '7357f4c5-aa58-4a1e-9670-18931eef771f', - etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw', - generation: 1, - }, - spec: { - templater: 'cookiecutter', - path: '.', - type: 'website', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; it('should throw an error when the templater is not registered', () => { const templaters = new Templaters(); - expect(() => templaters.get(mockTemplate)).toThrow( + expect(() => templaters.get('cookiecutter')).toThrow( expect.objectContaining({ message: 'No templater registered for template: "cookiecutter"', }), @@ -71,55 +32,6 @@ describe('Templaters', () => { templaters.register('cookiecutter', templater); - expect(templaters.get(mockTemplate)).toBe(templater); - }); - - it('should throw an error if the templater does not exist in the entity', () => { - const brokenTemplate: TemplateEntityV1alpha1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Template', - metadata: { - annotations: {}, - name: 'react-ssr-template', - title: 'React SSR Template', - description: - 'Next.js application skeleton for creating isomorphic web applications.', - uid: '7357f4c5-aa58-4a1e-9670-18931eef771f', - etag: 'YWUxZWQyY2EtZDkxMC00MDM0LWI0ODAtMDgwMWY0YzdlMWIw', - generation: 1, - }, - spec: { - type: 'website', - path: '.', - templater: '', - schema: { - $schema: 'http://json-schema.org/draft-07/schema#', - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string', - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string', - title: 'Store path', - description: 'GitHub store path in org/repo format', - }, - }, - }, - }, - }; - - const templaters = new Templaters(); - - expect(() => templaters.get(brokenTemplate)).toThrow( - expect.objectContaining({ - name: 'InputError', - message: expect.stringContaining( - 'Template does not have a required templating key', - ), - }), - ); + expect(templaters.get('cookiecutter')).toBe(templater); }); }); From 99460a64371c4b2604e151088f86463fb6635c2b Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 28 Jan 2021 10:45:16 +0100 Subject: [PATCH 077/297] chore: updating workflow --- .github/workflows/tugboat.yml | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/.github/workflows/tugboat.yml b/.github/workflows/tugboat.yml index 3d6980c81e..fdb2e1ddc4 100644 --- a/.github/workflows/tugboat.yml +++ b/.github/workflows/tugboat.yml @@ -80,7 +80,20 @@ jobs: - name: echo tugboat preview url run: | curl ${{steps.get-status-env.outputs.result}} - # Update the commit status with a fail or success. + - name: set status + if: ${{ failure() }} + uses: actions/github-script@v3 + with: + github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} + script: | + return github.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + state: "error", + context: 'Backstage Tugboat E2E Tests', + target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" + }); - name: set status if: ${{ success() }} uses: actions/github-script@v3 @@ -95,17 +108,3 @@ jobs: context: 'Backstage Tugboat E2E Tests', target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" }); - - name: set status - if: ${{ failure() }} || ${{ cancelled() }} - uses: actions/github-script@v3 - with: - github-token: ${{secrets.GH_SERVICE_ACCOUNT_TOKEN}} - script: | - return github.repos.createCommitStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - sha: context.sha, - state: "error", - context: 'Backstage Tugboat E2E Tests', - target_url: "https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}" - }); From cda741034da2dc5ec22099e1d0c56854c201a0ef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 28 Jan 2021 10:46:08 +0000 Subject: [PATCH 078/297] Version Packages --- .changeset/breezy-meals-lie.md | 20 ---------- .changeset/chatty-cooks-begin.md | 6 --- .changeset/clean-rocks-ring.md | 18 --------- .changeset/cost-insights-curvy-dingos-live.md | 5 --- .changeset/cost-insights-yellow-trees-love.md | 6 --- .changeset/dull-ears-glow.md | 5 --- .changeset/eleven-lamps-hide.md | 5 --- .changeset/empty-coats-film.md | 12 ------ .changeset/fast-flowers-tickle.md | 31 -------------- .changeset/five-games-grin.md | 5 --- .changeset/forty-jobs-occur.md | 8 ---- .changeset/fuzzy-points-whisper.md | 5 --- .changeset/green-boats-attend.md | 5 --- .changeset/happy-crabs-punch.md | 12 ------ .changeset/healthy-cameras-suffer.md | 5 --- .changeset/honest-jokes-rush.md | 5 --- .changeset/loud-terms-kiss.md | 5 --- .changeset/lucky-guests-mate.md | 5 --- .changeset/stale-tools-confess.md | 5 --- .changeset/techdocs-dirty-rivers-hope.md | 5 --- .changeset/tender-parrots-itch.md | 5 --- packages/app/CHANGELOG.md | 21 ++++++++++ packages/app/package.json | 16 ++++---- packages/backend-common/CHANGELOG.md | 30 ++++++++++++++ packages/backend-common/package.json | 8 ++-- packages/backend/CHANGELOG.md | 22 ++++++++++ packages/backend/package.json | 20 +++++----- packages/catalog-client/package.json | 2 +- packages/catalog-model/package.json | 2 +- packages/cli/CHANGELOG.md | 27 +++++++++++++ packages/cli/package.json | 6 +-- packages/config-loader/CHANGELOG.md | 18 +++++++++ packages/config-loader/package.json | 2 +- packages/core-api/package.json | 2 +- packages/core/package.json | 2 +- packages/create-app/CHANGELOG.md | 40 +++++++++++++++++++ packages/create-app/package.json | 20 +++++----- packages/dev-utils/package.json | 4 +- packages/integration/CHANGELOG.md | 7 ++++ packages/integration/package.json | 4 +- packages/techdocs-common/CHANGELOG.md | 13 ++++++ packages/techdocs-common/package.json | 8 ++-- packages/test-utils/package.json | 2 +- packages/theme/package.json | 2 +- plugins/api-docs/package.json | 4 +- plugins/app-backend/CHANGELOG.md | 13 ++++++ plugins/app-backend/package.json | 8 ++-- plugins/auth-backend/CHANGELOG.md | 12 ++++++ plugins/auth-backend/package.json | 6 +-- plugins/catalog-backend/CHANGELOG.md | 11 +++++ plugins/catalog-backend/package.json | 6 +-- plugins/catalog-graphql/package.json | 4 +- plugins/catalog-import/CHANGELOG.md | 11 +++++ plugins/catalog-import/package.json | 8 ++-- plugins/catalog/CHANGELOG.md | 13 ++++++ plugins/catalog/package.json | 6 +-- plugins/circleci/package.json | 4 +- plugins/cloudbuild/package.json | 4 +- plugins/cost-insights/CHANGELOG.md | 11 +++++ plugins/cost-insights/package.json | 4 +- plugins/explore/package.json | 2 +- plugins/fossa/package.json | 2 +- plugins/gcp-projects/package.json | 2 +- plugins/github-actions/package.json | 6 +-- plugins/gitops-profiles/package.json | 2 +- plugins/graphiql/package.json | 2 +- plugins/graphql/package.json | 4 +- plugins/jenkins/package.json | 4 +- plugins/kafka-backend/CHANGELOG.md | 38 ++++++++++++++++++ plugins/kafka-backend/package.json | 6 +-- plugins/kafka/CHANGELOG.md | 37 +++++++++++++++++ plugins/kafka/package.json | 6 +-- plugins/kubernetes-backend/CHANGELOG.md | 10 +++++ plugins/kubernetes-backend/package.json | 6 +-- plugins/kubernetes/CHANGELOG.md | 9 +++++ plugins/kubernetes/package.json | 6 +-- plugins/lighthouse/package.json | 4 +- plugins/newrelic/package.json | 2 +- plugins/org/package.json | 4 +- plugins/pagerduty/package.json | 2 +- plugins/proxy-backend/package.json | 4 +- plugins/register-component/package.json | 4 +- plugins/rollbar-backend/package.json | 4 +- plugins/rollbar/package.json | 4 +- plugins/scaffolder-backend/CHANGELOG.md | 28 +++++++++++++ plugins/scaffolder-backend/package.json | 8 ++-- plugins/scaffolder/CHANGELOG.md | 9 +++++ plugins/scaffolder/package.json | 6 +-- plugins/search/package.json | 4 +- plugins/sentry/package.json | 4 +- plugins/sonarqube/CHANGELOG.md | 6 +++ plugins/sonarqube/package.json | 4 +- plugins/tech-radar/package.json | 2 +- plugins/techdocs-backend/package.json | 6 +-- plugins/techdocs/package.json | 6 +-- plugins/user-settings/package.json | 2 +- plugins/welcome/package.json | 2 +- 97 files changed, 523 insertions(+), 315 deletions(-) delete mode 100644 .changeset/breezy-meals-lie.md delete mode 100644 .changeset/chatty-cooks-begin.md delete mode 100644 .changeset/clean-rocks-ring.md delete mode 100644 .changeset/cost-insights-curvy-dingos-live.md delete mode 100644 .changeset/cost-insights-yellow-trees-love.md delete mode 100644 .changeset/dull-ears-glow.md delete mode 100644 .changeset/eleven-lamps-hide.md delete mode 100644 .changeset/empty-coats-film.md delete mode 100644 .changeset/fast-flowers-tickle.md delete mode 100644 .changeset/five-games-grin.md delete mode 100644 .changeset/forty-jobs-occur.md delete mode 100644 .changeset/fuzzy-points-whisper.md delete mode 100644 .changeset/green-boats-attend.md delete mode 100644 .changeset/happy-crabs-punch.md delete mode 100644 .changeset/healthy-cameras-suffer.md delete mode 100644 .changeset/honest-jokes-rush.md delete mode 100644 .changeset/loud-terms-kiss.md delete mode 100644 .changeset/lucky-guests-mate.md delete mode 100644 .changeset/stale-tools-confess.md delete mode 100644 .changeset/techdocs-dirty-rivers-hope.md delete mode 100644 .changeset/tender-parrots-itch.md diff --git a/.changeset/breezy-meals-lie.md b/.changeset/breezy-meals-lie.md deleted file mode 100644 index 4ca4f1ac35..0000000000 --- a/.changeset/breezy-meals-lie.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@backstage/backend-common': patch -'@backstage/plugin-scaffolder-backend': patch ---- - -Honor the branch ref in the url when cloning. - -This fixes a bug in the scaffolder prepare stage where a non-default branch -was specified in the scaffolder URL but the default branch was cloned. -For example, even though the `other` branch is specified in this example, the -`master` branch was actually cloned: - -```yaml -catalog: - locations: - - type: url - target: https://github.com/backstage/backstage/blob/other/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml -``` - -This also fixes a 404 in the prepare stage for GitLab URLs. diff --git a/.changeset/chatty-cooks-begin.md b/.changeset/chatty-cooks-begin.md deleted file mode 100644 index e32d22d532..0000000000 --- a/.changeset/chatty-cooks-begin.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/plugin-kubernetes': patch -'@backstage/plugin-kubernetes-backend': patch ---- - -Add AWS auth provider for Kubernetes diff --git a/.changeset/clean-rocks-ring.md b/.changeset/clean-rocks-ring.md deleted file mode 100644 index 706f3640c2..0000000000 --- a/.changeset/clean-rocks-ring.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@backstage/cli': minor ---- - -We've bumped the `@eslint-typescript` packages to the latest, which now add some additional rules that might cause lint failures. -The main one which could become an issue is the [no-use-before-define](https://eslint.org/docs/rules/no-use-before-define) rule. - -Every plugin and app has the ability to override these rules if you want to ignore them for now. - -You can reset back to the default behaviour by using the following in your own `.eslint.js` - -```js -rules: { - 'no-use-before-define': 'off' -} -``` - -Because of the nature of this change, we're unable to provide a grace period for the update :( diff --git a/.changeset/cost-insights-curvy-dingos-live.md b/.changeset/cost-insights-curvy-dingos-live.md deleted file mode 100644 index f15d3b0492..0000000000 --- a/.changeset/cost-insights-curvy-dingos-live.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-cost-insights': patch ---- - -Allow expand functionality to top panel product chart tooltip. diff --git a/.changeset/cost-insights-yellow-trees-love.md b/.changeset/cost-insights-yellow-trees-love.md deleted file mode 100644 index 19eea0dfe2..0000000000 --- a/.changeset/cost-insights-yellow-trees-love.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/plugin-cost-insights': minor ---- - -Add support for additional breakdowns of daily cost data. -This changes the type of Cost.groupedCosts returned by CostInsightsApi.getGroupDailyCost. diff --git a/.changeset/dull-ears-glow.md b/.changeset/dull-ears-glow.md deleted file mode 100644 index ea08b3b37e..0000000000 --- a/.changeset/dull-ears-glow.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -URL Reader's readTree: Fix bug with github.com URLs. diff --git a/.changeset/eleven-lamps-hide.md b/.changeset/eleven-lamps-hide.md deleted file mode 100644 index 809de25c82..0000000000 --- a/.changeset/eleven-lamps-hide.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/integration': patch ---- - -Fix default branch API url for custom hosted Bitbucket server diff --git a/.changeset/empty-coats-film.md b/.changeset/empty-coats-film.md deleted file mode 100644 index 02b0225102..0000000000 --- a/.changeset/empty-coats-film.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@backstage/backend-common': patch -'@backstage/integration': patch -'@backstage/techdocs-common': patch -'@backstage/plugin-catalog': patch -'@backstage/plugin-catalog-backend': patch -'@backstage/plugin-catalog-import': patch -'@backstage/plugin-scaffolder': patch -'@backstage/plugin-scaffolder-backend': patch ---- - -Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. diff --git a/.changeset/fast-flowers-tickle.md b/.changeset/fast-flowers-tickle.md deleted file mode 100644 index 3eacb6f1a0..0000000000 --- a/.changeset/fast-flowers-tickle.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@backstage/plugin-kafka': minor -'@backstage/plugin-kafka-backend': minor ---- - -Added support for multiple Kafka clusters and multiple consumers per component. -Note that this introduces several breaking changes. - -1. Configuration in `app-config.yaml` has changed to support the ability to configure multiple clusters. This means you are required to update the configs in the following way: - -```diff -kafka: - clientId: backstage -- brokers: -- - localhost:9092 -+ clusters: -+ - name: prod -+ brokers: -+ - localhost:9092 -``` - -2. Configuration of services has changed as well to support multiple clusters: - -```diff - annotations: -- kafka.apache.org/consumer-groups: consumer -+ kafka.apache.org/consumer-groups: prod/consumer -``` - -3. Kafka Backend API has changed, so querying offsets of a consumer group is now done with the following query path: - `/consumers/${clusterId}/${consumerGroup}/offsets` diff --git a/.changeset/five-games-grin.md b/.changeset/five-games-grin.md deleted file mode 100644 index 492c92880c..0000000000 --- a/.changeset/five-games-grin.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/cli': patch ---- - -Add `--lax` option to `config:print` and `config:check`, which causes all environment variables to be assumed to be set. diff --git a/.changeset/forty-jobs-occur.md b/.changeset/forty-jobs-occur.md deleted file mode 100644 index b1620a7535..0000000000 --- a/.changeset/forty-jobs-occur.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@backstage/plugin-catalog': patch ---- - -Add `EntityRefLinks` that shows one or multiple entity ref links. - -Change the about card and catalog table to use `EntityRefLinks` due to the -nature of relations to support multiple relations per type. diff --git a/.changeset/fuzzy-points-whisper.md b/.changeset/fuzzy-points-whisper.md deleted file mode 100644 index d1af2b2dcf..0000000000 --- a/.changeset/fuzzy-points-whisper.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-auth-backend': patch ---- - -Fixed serialization issue with caching of public keys in AWS ALB auth provider diff --git a/.changeset/green-boats-attend.md b/.changeset/green-boats-attend.md deleted file mode 100644 index ae2324291e..0000000000 --- a/.changeset/green-boats-attend.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend': patch ---- - -Support supplying a custom catalog descriptor file parser diff --git a/.changeset/happy-crabs-punch.md b/.changeset/happy-crabs-punch.md deleted file mode 100644 index 4c68dbea1a..0000000000 --- a/.changeset/happy-crabs-punch.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@backstage/config-loader': patch ---- - -Added support for environment variable substitutions in string configuration values using a `${VAR}` placeholder. All environment variables must be available, or the entire expression will be evaluated to `undefined`. To escape a substitution, use `$${...}`, which will end up as `${...}`. - -For example: - -```yaml -app: - baseUrl: https://${BASE_HOST} -``` diff --git a/.changeset/healthy-cameras-suffer.md b/.changeset/healthy-cameras-suffer.md deleted file mode 100644 index 13d74011dc..0000000000 --- a/.changeset/healthy-cameras-suffer.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-kubernetes': patch ---- - -Improve error reporting for plugin misconfiguration. diff --git a/.changeset/honest-jokes-rush.md b/.changeset/honest-jokes-rush.md deleted file mode 100644 index 31ec6202da..0000000000 --- a/.changeset/honest-jokes-rush.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-auth-backend': patch ---- - -Use .text instead of .json for ALB key response diff --git a/.changeset/loud-terms-kiss.md b/.changeset/loud-terms-kiss.md deleted file mode 100644 index da50e56e3a..0000000000 --- a/.changeset/loud-terms-kiss.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-sonarqube': patch ---- - -Ask the SonarQube server for all support metrics prior to querying them for a project. diff --git a/.changeset/lucky-guests-mate.md b/.changeset/lucky-guests-mate.md deleted file mode 100644 index fd733aac75..0000000000 --- a/.changeset/lucky-guests-mate.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/config-loader': minor ---- - -Removed support for the deprecated `$data` placeholder. diff --git a/.changeset/stale-tools-confess.md b/.changeset/stale-tools-confess.md deleted file mode 100644 index 10f2203010..0000000000 --- a/.changeset/stale-tools-confess.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-auth-backend': patch ---- - -Fix AWS ALB issuer check diff --git a/.changeset/techdocs-dirty-rivers-hope.md b/.changeset/techdocs-dirty-rivers-hope.md deleted file mode 100644 index 3f49afa529..0000000000 --- a/.changeset/techdocs-dirty-rivers-hope.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/techdocs-common': patch ---- - -Add rate limiter for concurrent execution of file uploads in AWS and Google publishers diff --git a/.changeset/tender-parrots-itch.md b/.changeset/tender-parrots-itch.md deleted file mode 100644 index 00757b9b22..0000000000 --- a/.changeset/tender-parrots-itch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/config-loader': minor ---- - -Enable further processing of configuration files included using the `$include` placeholder. Meaning that for example for example `$env` includes will be processed as usual in included files. diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index d6157f560f..d39f705546 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,26 @@ # example-app +## 0.2.13 + +### Patch Changes + +- Updated dependencies [681111228] +- Updated dependencies [12a56cdfe] +- Updated dependencies [8b7ef9f8b] +- Updated dependencies [fac91bcc5] +- Updated dependencies [9dd057662] +- Updated dependencies [234e7d985] +- Updated dependencies [ef7957be4] +- Updated dependencies [0b1182346] +- Updated dependencies [a6e3b9596] + - @backstage/plugin-kubernetes@0.3.7 + - @backstage/cli@0.5.0 + - @backstage/plugin-cost-insights@0.6.0 + - @backstage/plugin-catalog@0.2.14 + - @backstage/plugin-catalog-import@0.3.6 + - @backstage/plugin-scaffolder@0.4.1 + - @backstage/plugin-kafka@0.2.0 + ## 0.2.12 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index 7734dfc244..36fe7651e6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,18 +1,18 @@ { "name": "example-app", - "version": "0.2.12", + "version": "0.2.13", "private": true, "bundled": true, "dependencies": { "@backstage/catalog-model": "^0.7.0", - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/core": "^0.5.0", "@backstage/plugin-api-docs": "^0.4.3", - "@backstage/plugin-catalog": "^0.2.12", - "@backstage/plugin-catalog-import": "^0.3.5", + "@backstage/plugin-catalog": "^0.2.14", + "@backstage/plugin-catalog-import": "^0.3.6", "@backstage/plugin-circleci": "^0.2.6", "@backstage/plugin-cloudbuild": "^0.2.7", - "@backstage/plugin-cost-insights": "^0.5.7", + "@backstage/plugin-cost-insights": "^0.6.0", "@backstage/plugin-explore": "^0.2.3", "@backstage/plugin-gcp-projects": "^0.2.3", "@backstage/plugin-github-actions": "^0.3.0", @@ -20,14 +20,14 @@ "@backstage/plugin-graphiql": "^0.2.6", "@backstage/plugin-org": "^0.3.4", "@backstage/plugin-jenkins": "^0.3.6", - "@backstage/plugin-kafka": "^0.1.1", - "@backstage/plugin-kubernetes": "^0.3.6", + "@backstage/plugin-kafka": "^0.2.0", + "@backstage/plugin-kubernetes": "^0.3.7", "@backstage/plugin-lighthouse": "^0.2.8", "@backstage/plugin-newrelic": "^0.2.3", "@backstage/plugin-pagerduty": "0.2.6", "@backstage/plugin-register-component": "^0.2.7", "@backstage/plugin-rollbar": "^0.2.8", - "@backstage/plugin-scaffolder": "^0.4.0", + "@backstage/plugin-scaffolder": "^0.4.1", "@backstage/plugin-sentry": "^0.3.3", "@backstage/plugin-search": "^0.2.6", "@backstage/plugin-tech-radar": "^0.3.3", diff --git a/packages/backend-common/CHANGELOG.md b/packages/backend-common/CHANGELOG.md index 84265c9897..3cb57f9056 100644 --- a/packages/backend-common/CHANGELOG.md +++ b/packages/backend-common/CHANGELOG.md @@ -1,5 +1,35 @@ # @backstage/backend-common +## 0.5.1 + +### Patch Changes + +- 26a3a6cf0: Honor the branch ref in the url when cloning. + + This fixes a bug in the scaffolder prepare stage where a non-default branch + was specified in the scaffolder URL but the default branch was cloned. + For example, even though the `other` branch is specified in this example, the + `master` branch was actually cloned: + + ```yaml + catalog: + locations: + - type: url + target: https://github.com/backstage/backstage/blob/other/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml + ``` + + This also fixes a 404 in the prepare stage for GitLab URLs. + +- 664dd08c9: URL Reader's readTree: Fix bug with github.com URLs. +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- Updated dependencies [6800da78d] +- Updated dependencies [9dd057662] +- Updated dependencies [ef7957be4] +- Updated dependencies [ef7957be4] +- Updated dependencies [ef7957be4] + - @backstage/integration@0.3.1 + - @backstage/config-loader@0.5.0 + ## 0.5.0 ### Minor Changes diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index e556a75062..78077c2ce7 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.5.0", + "version": "0.5.1", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -31,8 +31,8 @@ "dependencies": { "@backstage/cli-common": "^0.1.1", "@backstage/config": "^0.1.2", - "@backstage/config-loader": "^0.4.1", - "@backstage/integration": "^0.3.0", + "@backstage/config-loader": "^0.5.0", + "@backstage/integration": "^0.3.1", "@types/cors": "^2.8.6", "@types/express": "^4.17.6", "archiver": "^5.0.2", @@ -66,7 +66,7 @@ } }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/test-utils": "^0.1.5", "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md index 1d840d8761..215e4e7296 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,27 @@ # example-backend +## 0.2.13 + +### Patch Changes + +- Updated dependencies [26a3a6cf0] +- Updated dependencies [681111228] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] +- Updated dependencies [234e7d985] +- Updated dependencies [d7b1d317f] +- Updated dependencies [a91aa6bf2] +- Updated dependencies [39b05b9ae] +- Updated dependencies [4eaa06057] + - @backstage/backend-common@0.5.1 + - @backstage/plugin-scaffolder-backend@0.5.2 + - @backstage/plugin-kubernetes-backend@0.2.6 + - @backstage/plugin-catalog-backend@0.5.5 + - @backstage/plugin-kafka-backend@0.2.0 + - @backstage/plugin-auth-backend@0.2.12 + - example-app@0.2.13 + - @backstage/plugin-app-backend@0.3.5 + ## 0.2.12 ### Patch Changes diff --git a/packages/backend/package.json b/packages/backend/package.json index 269089a400..aa00407952 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.2.12", + "version": "0.2.13", "main": "dist/index.cjs.js", "types": "src/index.ts", "license": "Apache-2.0", @@ -27,24 +27,24 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", - "@backstage/plugin-app-backend": "^0.3.4", - "@backstage/plugin-auth-backend": "^0.2.11", - "@backstage/plugin-catalog-backend": "^0.5.4", + "@backstage/plugin-app-backend": "^0.3.5", + "@backstage/plugin-auth-backend": "^0.2.12", + "@backstage/plugin-catalog-backend": "^0.5.5", "@backstage/plugin-graphql-backend": "^0.1.5", - "@backstage/plugin-kubernetes-backend": "^0.2.5", - "@backstage/plugin-kafka-backend": "^0.1.1", + "@backstage/plugin-kubernetes-backend": "^0.2.6", + "@backstage/plugin-kafka-backend": "^0.2.0", "@backstage/plugin-proxy-backend": "^0.2.4", "@backstage/plugin-rollbar-backend": "^0.1.7", - "@backstage/plugin-scaffolder-backend": "^0.5.0", + "@backstage/plugin-scaffolder-backend": "^0.5.2", "@backstage/plugin-techdocs-backend": "^0.5.4", "@gitbeaker/node": "^28.0.2", "@octokit/rest": "^18.0.12", "azure-devops-node-api": "^10.1.1", "dockerode": "^3.2.1", - "example-app": "^0.2.12", + "example-app": "^0.2.13", "express": "^4.17.1", "express-promise-router": "^3.0.3", "knex": "^0.21.6", @@ -54,7 +54,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5" diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index 0b68446463..c1cbd6157f 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -34,7 +34,7 @@ "cross-fetch": "^3.0.6" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/jest": "^26.0.7", "msw": "^0.21.2" }, diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index e03ce8c465..0b05f489a1 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -38,7 +38,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/express": "^4.17.6", "@types/jest": "^26.0.7", "@types/lodash": "^4.14.151", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 7998d408e0..29cce97ae9 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,32 @@ # @backstage/cli +## 0.5.0 + +### Minor Changes + +- 12a56cdfe: We've bumped the `@eslint-typescript` packages to the latest, which now add some additional rules that might cause lint failures. + The main one which could become an issue is the [no-use-before-define](https://eslint.org/docs/rules/no-use-before-define) rule. + + Every plugin and app has the ability to override these rules if you want to ignore them for now. + + You can reset back to the default behaviour by using the following in your own `.eslint.js` + + ```js + rules: { + 'no-use-before-define': 'off' + } + ``` + + Because of the nature of this change, we're unable to provide a grace period for the update :( + +### Patch Changes + +- ef7957be4: Add `--lax` option to `config:print` and `config:check`, which causes all environment variables to be assumed to be set. +- Updated dependencies [ef7957be4] +- Updated dependencies [ef7957be4] +- Updated dependencies [ef7957be4] + - @backstage/config-loader@0.5.0 + ## 0.4.7 ### Patch Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index a98534ecb6..ad45a33d93 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "0.4.7", + "version": "0.5.0", "private": false, "publishConfig": { "access": "public" @@ -30,7 +30,7 @@ "dependencies": { "@backstage/cli-common": "^0.1.1", "@backstage/config": "^0.1.2", - "@backstage/config-loader": "^0.4.1", + "@backstage/config-loader": "^0.5.0", "@hot-loader/react-dom": "^16.13.0", "@lerna/package-graph": "^3.18.5", "@lerna/project": "^3.18.0", @@ -113,7 +113,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/config": "^0.1.2", "@backstage/core": "^0.5.0", "@backstage/dev-utils": "^0.1.8", diff --git a/packages/config-loader/CHANGELOG.md b/packages/config-loader/CHANGELOG.md index a5e34ad91e..2079c85096 100644 --- a/packages/config-loader/CHANGELOG.md +++ b/packages/config-loader/CHANGELOG.md @@ -1,5 +1,23 @@ # @backstage/config-loader +## 0.5.0 + +### Minor Changes + +- ef7957be4: Removed support for the deprecated `$data` placeholder. +- ef7957be4: Enable further processing of configuration files included using the `$include` placeholder. Meaning that for example for example `$env` includes will be processed as usual in included files. + +### Patch Changes + +- ef7957be4: Added support for environment variable substitutions in string configuration values using a `${VAR}` placeholder. All environment variables must be available, or the entire expression will be evaluated to `undefined`. To escape a substitution, use `${...}`, which will end up as `${...}`. + + For example: + + ```yaml + app: + baseUrl: https://${BASE_HOST} + ``` + ## 0.4.1 ### Patch Changes diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 476c57b014..45ceb36e19 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/config-loader", "description": "Config loading functionality used by Backstage backend, and CLI", - "version": "0.4.1", + "version": "0.5.0", "private": false, "publishConfig": { "access": "public", diff --git a/packages/core-api/package.json b/packages/core-api/package.json index 85f9d3a921..bbea58e846 100644 --- a/packages/core-api/package.json +++ b/packages/core-api/package.json @@ -42,7 +42,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.4.5", + "@backstage/cli": "^0.5.0", "@backstage/test-utils": "^0.1.6", "@backstage/test-utils-core": "^0.1.1", "@testing-library/jest-dom": "^5.10.1", diff --git a/packages/core/package.json b/packages/core/package.json index a937502247..325689e179 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -65,7 +65,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index e33d8cc633..d2faa14876 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,45 @@ # @backstage/create-app +## 1.0.0 + +### Patch Changes + +- Updated dependencies [26a3a6cf0] +- Updated dependencies [12a56cdfe] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] +- Updated dependencies [ef7957be4] +- Updated dependencies [0b1182346] +- Updated dependencies [d7b1d317f] +- Updated dependencies [a91aa6bf2] +- Updated dependencies [39b05b9ae] +- Updated dependencies [4eaa06057] + - @backstage/backend-common@0.5.1 + - @backstage/plugin-scaffolder-backend@0.5.2 + - @backstage/cli@0.5.0 + - @backstage/plugin-catalog@0.2.14 + - @backstage/plugin-catalog-backend@0.5.5 + - @backstage/plugin-catalog-import@0.3.6 + - @backstage/plugin-scaffolder@0.4.1 + - @backstage/plugin-auth-backend@0.2.12 + - @backstage/catalog-model@0.7.0 + - @backstage/core@0.5.0 + - @backstage/test-utils@0.1.6 + - @backstage/theme@0.2.2 + - @backstage/plugin-api-docs@0.4.3 + - @backstage/plugin-app-backend@0.3.5 + - @backstage/plugin-circleci@0.2.6 + - @backstage/plugin-explore@0.2.3 + - @backstage/plugin-github-actions@0.3.0 + - @backstage/plugin-lighthouse@0.2.8 + - @backstage/plugin-proxy-backend@0.2.4 + - @backstage/plugin-rollbar-backend@0.1.7 + - @backstage/plugin-search@0.2.6 + - @backstage/plugin-tech-radar@0.3.3 + - @backstage/plugin-techdocs@0.5.4 + - @backstage/plugin-techdocs-backend@0.5.4 + - @backstage/plugin-user-settings@0.2.4 + ## 0.3.6 ### Patch Changes diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 60dd150566..448b320346 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/create-app", "description": "Create app package for Backstage", - "version": "0.3.6", + "version": "1.0.0", "private": false, "publishConfig": { "access": "public" @@ -44,26 +44,26 @@ "ts-node": "^8.6.2" }, "peerDependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/config": "^0.1.2", "@backstage/core": "^0.5.0", "@backstage/plugin-api-docs": "^0.4.3", - "@backstage/plugin-app-backend": "^0.3.4", - "@backstage/plugin-auth-backend": "^0.2.11", - "@backstage/plugin-catalog": "^0.2.13", - "@backstage/plugin-catalog-backend": "^0.5.4", - "@backstage/plugin-catalog-import": "^0.3.5", + "@backstage/plugin-app-backend": "^0.3.5", + "@backstage/plugin-auth-backend": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", + "@backstage/plugin-catalog-backend": "^0.5.5", + "@backstage/plugin-catalog-import": "^0.3.6", "@backstage/plugin-circleci": "^0.2.6", "@backstage/plugin-explore": "^0.2.3", "@backstage/plugin-github-actions": "^0.3.0", "@backstage/plugin-lighthouse": "^0.2.8", "@backstage/plugin-proxy-backend": "^0.2.4", "@backstage/plugin-rollbar-backend": "^0.1.7", - "@backstage/plugin-scaffolder": "^0.4.0", + "@backstage/plugin-scaffolder": "^0.4.1", "@backstage/plugin-search": "^0.2.6", - "@backstage/plugin-scaffolder-backend": "^0.5.1", + "@backstage/plugin-scaffolder-backend": "^0.5.2", "@backstage/plugin-tech-radar": "^0.3.3", "@backstage/plugin-techdocs": "^0.5.4", "@backstage/plugin-techdocs-backend": "^0.5.4", diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 629bc7c87e..93a5247c82 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -31,7 +31,7 @@ "dependencies": { "@backstage/core": "^0.5.0", "@backstage/catalog-model": "^0.7.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/test-utils": "^0.1.5", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", @@ -47,7 +47,7 @@ "react-router-dom": "6.0.0-beta.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, diff --git a/packages/integration/CHANGELOG.md b/packages/integration/CHANGELOG.md index 804fc0c63e..11eb164535 100644 --- a/packages/integration/CHANGELOG.md +++ b/packages/integration/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/integration +## 0.3.1 + +### Patch Changes + +- 6800da78d: Fix default branch API url for custom hosted Bitbucket server +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. + ## 0.3.0 ### Minor Changes diff --git a/packages/integration/package.json b/packages/integration/package.json index 6c96a8562b..06a83ea883 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/integration", - "version": "0.3.0", + "version": "0.3.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -37,7 +37,7 @@ "luxon": "^1.25.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/test-utils": "^0.1.5", "@types/jest": "^26.0.7", "@types/luxon": "^1.25.0", diff --git a/packages/techdocs-common/CHANGELOG.md b/packages/techdocs-common/CHANGELOG.md index 7bff39d3c4..568df3157c 100644 --- a/packages/techdocs-common/CHANGELOG.md +++ b/packages/techdocs-common/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/techdocs-common +## 0.3.6 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- db2328c88: Add rate limiter for concurrent execution of file uploads in AWS and Google publishers +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [6800da78d] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + - @backstage/integration@0.3.1 + ## 0.3.5 ### Patch Changes diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index dd4e4d8c35..64c5c19425 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/techdocs-common", "description": "Common functionalities for TechDocs, to be shared between techdocs-backend plugin and techdocs-cli", - "version": "0.3.5", + "version": "0.3.6", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -37,10 +37,10 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1.0", - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", - "@backstage/integration": "^0.3.0", + "@backstage/integration": "^0.3.1", "@google-cloud/storage": "^5.6.0", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", @@ -59,7 +59,7 @@ }, "devDependencies": { "@aws-sdk/types": "3.1.0", - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/fs-extra": "^9.0.5", "@types/git-url-parse": "^9.0.0", "@types/js-yaml": "^3.12.5", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 8c5a7c1f39..10f0e89c14 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -45,7 +45,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@backstage/cli": "^0.4.3", + "@backstage/cli": "^0.5.0", "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, diff --git a/packages/theme/package.json b/packages/theme/package.json index 54f43a1443..24edf81bb6 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -31,7 +31,7 @@ "@material-ui/core": "^4.11.0" }, "devDependencies": { - "@backstage/cli": "^0.4.1" + "@backstage/cli": "^0.5.0" }, "files": [ "dist" diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index d9c0dc0968..5e3c42bfdf 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -32,7 +32,7 @@ "@asyncapi/react-component": "^0.18.2", "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-icons/font": "^1.0.2", "@material-ui/core": "^4.11.0", @@ -49,7 +49,7 @@ "swagger-ui-react": "^3.37.2" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/app-backend/CHANGELOG.md b/plugins/app-backend/CHANGELOG.md index 90a4eac95d..514e32633a 100644 --- a/plugins/app-backend/CHANGELOG.md +++ b/plugins/app-backend/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-app-backend +## 0.3.5 + +### Patch Changes + +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] +- Updated dependencies [ef7957be4] +- Updated dependencies [ef7957be4] +- Updated dependencies [ef7957be4] + - @backstage/backend-common@0.5.1 + - @backstage/config-loader@0.5.0 + ## 0.3.4 ### Patch Changes diff --git a/plugins/app-backend/package.json b/plugins/app-backend/package.json index f55da9042a..f397ea1475 100644 --- a/plugins/app-backend/package.json +++ b/plugins/app-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-app-backend", - "version": "0.3.4", + "version": "0.3.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,8 +29,8 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", - "@backstage/config-loader": "^0.4.0", + "@backstage/backend-common": "^0.5.1", + "@backstage/config-loader": "^0.5.0", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", "express": "^4.17.1", @@ -40,7 +40,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/supertest": "^2.0.8", "msw": "^0.20.5", "supertest": "^4.0.2" diff --git a/plugins/auth-backend/CHANGELOG.md b/plugins/auth-backend/CHANGELOG.md index 0df0fa1938..e3ff6aaa43 100644 --- a/plugins/auth-backend/CHANGELOG.md +++ b/plugins/auth-backend/CHANGELOG.md @@ -1,5 +1,17 @@ # @backstage/plugin-auth-backend +## 0.2.12 + +### Patch Changes + +- d7b1d317f: Fixed serialization issue with caching of public keys in AWS ALB auth provider +- 39b05b9ae: Use .text instead of .json for ALB key response +- 4eaa06057: Fix AWS ALB issuer check +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + ## 0.2.11 ### Patch Changes diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 27cb725faa..b2198165a5 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-auth-backend", - "version": "0.2.11", + "version": "0.2.12", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-client": "^0.3.5", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", @@ -65,7 +65,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", "@types/express-session": "^1.17.2", diff --git a/plugins/catalog-backend/CHANGELOG.md b/plugins/catalog-backend/CHANGELOG.md index 77620c1cea..0e446bf0f7 100644 --- a/plugins/catalog-backend/CHANGELOG.md +++ b/plugins/catalog-backend/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-catalog-backend +## 0.5.5 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- a91aa6bf2: Support supplying a custom catalog descriptor file parser +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + ## 0.5.4 ### Patch Changes diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index eaf1f69938..264bda0556 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-backend", - "version": "0.5.4", + "version": "0.5.5", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ "dependencies": { "@aws-sdk/client-organizations": "^3.2.0", "@azure/msal-node": "^1.0.0-beta.3", - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@octokit/graphql": "^4.5.8", @@ -57,7 +57,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/test-utils": "^0.1.6", "@types/core-js": "^2.5.4", "@types/git-url-parse": "^9.0.0", diff --git a/plugins/catalog-graphql/package.json b/plugins/catalog-graphql/package.json index 4533a541f0..c81c29fc3e 100644 --- a/plugins/catalog-graphql/package.json +++ b/plugins/catalog-graphql/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@graphql-modules/core": "^0.7.17", @@ -42,7 +42,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/test-utils": "^0.1.5", "@graphql-codegen/cli": "^1.17.7", "@graphql-codegen/typescript": "^1.17.7", diff --git a/plugins/catalog-import/CHANGELOG.md b/plugins/catalog-import/CHANGELOG.md index 2d75ae6af4..c6e4995c70 100644 --- a/plugins/catalog-import/CHANGELOG.md +++ b/plugins/catalog-import/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-catalog-import +## 0.3.6 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- Updated dependencies [6800da78d] +- Updated dependencies [9dd057662] +- Updated dependencies [0b1182346] + - @backstage/integration@0.3.1 + - @backstage/plugin-catalog@0.2.14 + ## 0.3.5 ### Patch Changes diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index 2f9784a23e..5a4dbf8798 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog-import", - "version": "0.3.5", + "version": "0.3.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,8 +32,8 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/integration": "^0.3.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/integration": "^0.3.1", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -49,7 +49,7 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/catalog/CHANGELOG.md b/plugins/catalog/CHANGELOG.md index efacf6fbad..3920d8696d 100644 --- a/plugins/catalog/CHANGELOG.md +++ b/plugins/catalog/CHANGELOG.md @@ -1,5 +1,18 @@ # @backstage/plugin-catalog +## 0.2.14 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- 0b1182346: Add `EntityRefLinks` that shows one or multiple entity ref links. + + Change the about card and catalog table to use `EntityRefLinks` due to the + nature of relations to support multiple relations per type. + +- Updated dependencies [9dd057662] + - @backstage/plugin-scaffolder@0.4.1 + ## 0.2.13 ### Patch Changes diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 0887930726..90bdd2f3c7 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-catalog", - "version": "0.2.13", + "version": "0.2.14", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -33,7 +33,7 @@ "@backstage/catalog-client": "^0.3.5", "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-scaffolder": "^0.4.0", + "@backstage/plugin-scaffolder": "^0.4.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -51,7 +51,7 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@microsoft/microsoft-graph-types": "^1.25.0", diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index fbc6605419..ddae98a566 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -50,7 +50,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index 0826ac972a..9a05a652b9 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -32,7 +32,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/cost-insights/CHANGELOG.md b/plugins/cost-insights/CHANGELOG.md index b666c66864..a979f1476b 100644 --- a/plugins/cost-insights/CHANGELOG.md +++ b/plugins/cost-insights/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/plugin-cost-insights +## 0.6.0 + +### Minor Changes + +- fac91bcc5: Add support for additional breakdowns of daily cost data. + This changes the type of Cost.groupedCosts returned by CostInsightsApi.getGroupDailyCost. + +### Patch Changes + +- 8b7ef9f8b: Allow expand functionality to top panel product chart tooltip. + ## 0.5.7 ### Patch Changes diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index bd573d36de..72a9e92633 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cost-insights", - "version": "0.5.7", + "version": "0.6.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -55,7 +55,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/explore/package.json b/plugins/explore/package.json index e4acc34435..c6668c9c4d 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -42,7 +42,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/fossa/package.json b/plugins/fossa/package.json index 27f6ad0966..b69039fa00 100644 --- a/plugins/fossa/package.json +++ b/plugins/fossa/package.json @@ -43,7 +43,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index 71f1d83c99..085e08e2a3 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -41,7 +41,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index c3b05b0369..7b7cf539f9 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -34,8 +34,8 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/integration": "^0.3.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/integration": "^0.3.1", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -50,7 +50,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index 2b98f0179d..447c3cbb81 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -42,7 +42,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index 1b95db6e39..850b29108e 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -43,7 +43,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/graphql/package.json b/plugins/graphql/package.json index f5214a3210..99d369b1a7 100644 --- a/plugins/graphql/package.json +++ b/plugins/graphql/package.json @@ -29,7 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/config": "^0.1.2", "@backstage/plugin-catalog-graphql": "^0.2.6", "@graphql-modules/core": "^0.7.17", @@ -45,7 +45,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/supertest": "^2.0.8", "eslint-plugin-graphql": "^4.0.0", "msw": "^0.20.5", diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index c511553699..a99b9937b1 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.13", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/kafka-backend/CHANGELOG.md b/plugins/kafka-backend/CHANGELOG.md index f8dbdbd00b..1e4701af16 100644 --- a/plugins/kafka-backend/CHANGELOG.md +++ b/plugins/kafka-backend/CHANGELOG.md @@ -1,5 +1,43 @@ # @backstage/plugin-kafka-backend +## 0.2.0 + +### Minor Changes + +- 234e7d985: Added support for multiple Kafka clusters and multiple consumers per component. + Note that this introduces several breaking changes. + + 1. Configuration in `app-config.yaml` has changed to support the ability to configure multiple clusters. This means you are required to update the configs in the following way: + + ```diff + kafka: + clientId: backstage + - brokers: + - - localhost:9092 + + clusters: + + - name: prod + + brokers: + + - localhost:9092 + ``` + + 2. Configuration of services has changed as well to support multiple clusters: + + ```diff + annotations: + - kafka.apache.org/consumer-groups: consumer + + kafka.apache.org/consumer-groups: prod/consumer + ``` + + 3. Kafka Backend API has changed, so querying offsets of a consumer group is now done with the following query path: + `/consumers/${clusterId}/${consumerGroup}/offsets` + +### Patch Changes + +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + ## 0.1.1 ### Patch Changes diff --git a/plugins/kafka-backend/package.json b/plugins/kafka-backend/package.json index 0c7eff2193..311464b47d 100644 --- a/plugins/kafka-backend/package.json +++ b/plugins/kafka-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kafka-backend", - "version": "0.1.1", + "version": "0.2.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -31,7 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", @@ -42,7 +42,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/jest-when": "^2.7.2", "@types/lodash": "^4.14.151", "jest-when": "^3.1.0", diff --git a/plugins/kafka/CHANGELOG.md b/plugins/kafka/CHANGELOG.md index 01479f8186..92f49a1d35 100644 --- a/plugins/kafka/CHANGELOG.md +++ b/plugins/kafka/CHANGELOG.md @@ -1,5 +1,42 @@ # @backstage/plugin-kafka +## 0.2.0 + +### Minor Changes + +- 234e7d985: Added support for multiple Kafka clusters and multiple consumers per component. + Note that this introduces several breaking changes. + + 1. Configuration in `app-config.yaml` has changed to support the ability to configure multiple clusters. This means you are required to update the configs in the following way: + + ```diff + kafka: + clientId: backstage + - brokers: + - - localhost:9092 + + clusters: + + - name: prod + + brokers: + + - localhost:9092 + ``` + + 2. Configuration of services has changed as well to support multiple clusters: + + ```diff + annotations: + - kafka.apache.org/consumer-groups: consumer + + kafka.apache.org/consumer-groups: prod/consumer + ``` + + 3. Kafka Backend API has changed, so querying offsets of a consumer group is now done with the following query path: + `/consumers/${clusterId}/${consumerGroup}/offsets` + +### Patch Changes + +- Updated dependencies [9dd057662] +- Updated dependencies [0b1182346] + - @backstage/plugin-catalog@0.2.14 + ## 0.1.1 ### Patch Changes diff --git a/plugins/kafka/package.json b/plugins/kafka/package.json index 306b65a653..eef14439fd 100644 --- a/plugins/kafka/package.json +++ b/plugins/kafka/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kafka", - "version": "0.1.1", + "version": "0.2.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -22,7 +22,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -33,7 +33,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/kubernetes-backend/CHANGELOG.md b/plugins/kubernetes-backend/CHANGELOG.md index 279cb8a5ba..08a3a66657 100644 --- a/plugins/kubernetes-backend/CHANGELOG.md +++ b/plugins/kubernetes-backend/CHANGELOG.md @@ -1,5 +1,15 @@ # @backstage/plugin-kubernetes-backend +## 0.2.6 + +### Patch Changes + +- 681111228: Add AWS auth provider for Kubernetes +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + ## 0.2.5 ### Patch Changes diff --git a/plugins/kubernetes-backend/package.json b/plugins/kubernetes-backend/package.json index eeb91de777..e69a82b738 100644 --- a/plugins/kubernetes-backend/package.json +++ b/plugins/kubernetes-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes-backend", - "version": "0.2.5", + "version": "0.2.6", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,7 +32,7 @@ }, "dependencies": { "@aws-sdk/credential-provider-node": "^3.3.0", - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@kubernetes/client-node": "^0.13.2", @@ -51,7 +51,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/aws4": "^1.5.1", "supertest": "^4.0.2" }, diff --git a/plugins/kubernetes/CHANGELOG.md b/plugins/kubernetes/CHANGELOG.md index c1f09038ba..f16728e1fe 100644 --- a/plugins/kubernetes/CHANGELOG.md +++ b/plugins/kubernetes/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-kubernetes +## 0.3.7 + +### Patch Changes + +- 681111228: Add AWS auth provider for Kubernetes +- a6e3b9596: Improve error reporting for plugin misconfiguration. +- Updated dependencies [681111228] + - @backstage/plugin-kubernetes-backend@0.2.6 + ## 0.3.6 ### Patch Changes diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 96d700c3bb..1d985c0d10 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-kubernetes", - "version": "0.3.6", + "version": "0.3.7", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -34,7 +34,7 @@ "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@backstage/core": "^0.5.0", - "@backstage/plugin-kubernetes-backend": "^0.2.5", + "@backstage/plugin-kubernetes-backend": "^0.2.6", "@backstage/theme": "^0.2.2", "@kubernetes/client-node": "^0.13.2", "@material-ui/core": "^4.11.0", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 162da40fd0..3463842bc5 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -34,7 +34,7 @@ "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -46,7 +46,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/newrelic/package.json b/plugins/newrelic/package.json index 57f146521b..2f43c9cbeb 100644 --- a/plugins/newrelic/package.json +++ b/plugins/newrelic/package.json @@ -41,7 +41,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/org/package.json b/plugins/org/package.json index 976caffdbb..fbb3bd1afb 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -22,7 +22,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -33,7 +33,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index 174f62d5e7..0e87bdc2d7 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -44,7 +44,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/proxy-backend/package.json b/plugins/proxy-backend/package.json index 94825e8691..ae828cb2ec 100644 --- a/plugins/proxy-backend/package.json +++ b/plugins/proxy-backend/package.json @@ -28,7 +28,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", "express": "^4.17.1", @@ -42,7 +42,7 @@ "yup": "^0.29.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/http-proxy-middleware": "^0.19.3", "@types/supertest": "^2.0.8", "@types/uuid": "^8.0.0", diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index 99f7931c2b..29b629de9b 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -32,7 +32,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -45,7 +45,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/rollbar-backend/package.json b/plugins/rollbar-backend/package.json index 39a2b17369..522f5a3b4c 100644 --- a/plugins/rollbar-backend/package.json +++ b/plugins/rollbar-backend/package.json @@ -30,7 +30,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/config": "^0.1.2", "@types/express": "^4.17.6", "axios": "^0.21.1", @@ -47,7 +47,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@types/supertest": "^2.0.8", "supertest": "^4.0.2" }, diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index c37ca53a82..f3a21709db 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -47,7 +47,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index bcf43cf229..353e2321cf 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,33 @@ # @backstage/plugin-scaffolder-backend +## 0.5.2 + +### Patch Changes + +- 26a3a6cf0: Honor the branch ref in the url when cloning. + + This fixes a bug in the scaffolder prepare stage where a non-default branch + was specified in the scaffolder URL but the default branch was cloned. + For example, even though the `other` branch is specified in this example, the + `master` branch was actually cloned: + + ```yaml + catalog: + locations: + - type: url + target: https://github.com/backstage/backstage/blob/other/plugins/scaffolder-backend/sample-templates/docs-template/template.yaml + ``` + + This also fixes a 404 in the prepare stage for GitLab URLs. + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- Updated dependencies [26a3a6cf0] +- Updated dependencies [664dd08c9] +- Updated dependencies [6800da78d] +- Updated dependencies [9dd057662] + - @backstage/backend-common@0.5.1 + - @backstage/integration@0.3.1 + ## 0.5.1 ### Patch Changes diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index e118a1d7f9..57b62be2cd 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.5.1", + "version": "0.5.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -29,10 +29,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", - "@backstage/integration": "^0.3.0", + "@backstage/integration": "^0.3.1", "@gitbeaker/core": "^28.0.2", "@gitbeaker/node": "^28.0.2", "@octokit/rest": "^18.0.12", @@ -59,7 +59,7 @@ "yaml": "^1.10.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/test-utils": "^0.1.5", "@types/fs-extra": "^9.0.1", "@types/supertest": "^2.0.8", diff --git a/plugins/scaffolder/CHANGELOG.md b/plugins/scaffolder/CHANGELOG.md index 6d435a144c..11a9cfb75c 100644 --- a/plugins/scaffolder/CHANGELOG.md +++ b/plugins/scaffolder/CHANGELOG.md @@ -1,5 +1,14 @@ # @backstage/plugin-scaffolder +## 0.4.1 + +### Patch Changes + +- 9dd057662: Upgrade [git-url-parse](https://www.npmjs.com/package/git-url-parse) to [v11.4.4](https://github.com/IonicaBizau/git-url-parse/pull/125) which fixes parsing an Azure DevOps branch ref. +- Updated dependencies [9dd057662] +- Updated dependencies [0b1182346] + - @backstage/plugin-catalog@0.2.14 + ## 0.4.0 ### Minor Changes diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 483dc548ea..d81d176232 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder", - "version": "0.4.0", + "version": "0.4.1", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -32,7 +32,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -51,7 +51,7 @@ "swr": "^0.3.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/search/package.json b/plugins/search/package.json index 41e295aba4..9409a416a0 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -30,7 +30,7 @@ }, "dependencies": { "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/catalog-model": "^0.7.0", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", @@ -43,7 +43,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index 0bcc49ce35..52343ad332 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -46,7 +46,7 @@ "timeago.js": "^4.0.2" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/sonarqube/CHANGELOG.md b/plugins/sonarqube/CHANGELOG.md index 375b5a8312..c3be5e7415 100644 --- a/plugins/sonarqube/CHANGELOG.md +++ b/plugins/sonarqube/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/plugin-sonarqube +## 0.1.9 + +### Patch Changes + +- 49a67732f: Ask the SonarQube server for all support metrics prior to querying them for a project. + ## 0.1.8 ### Patch Changes diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index 0f7eca0f9a..b6701f9849 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-sonarqube", - "version": "0.1.8", + "version": "0.1.9", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -46,7 +46,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index d9f627ac8c..9014d52931 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -43,7 +43,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index eb3d70dee7..e6af442d20 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -30,10 +30,10 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/backend-common": "^0.5.0", + "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", - "@backstage/techdocs-common": "^0.3.5", + "@backstage/techdocs-common": "^0.3.6", "@types/dockerode": "^3.2.1", "@types/express": "^4.17.6", "cross-fetch": "^3.0.6", @@ -45,7 +45,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index ac439b8623..2c1e0e5911 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -33,10 +33,10 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.12", + "@backstage/plugin-catalog": "^0.2.14", "@backstage/test-utils": "^0.1.6", "@backstage/theme": "^0.2.2", - "@backstage/techdocs-common": "^0.3.5", + "@backstage/techdocs-common": "^0.3.6", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", @@ -49,7 +49,7 @@ "sanitize-html": "^1.27.0" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index 2e6f5e3e95..78790a891e 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -41,7 +41,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index 440e8c2d47..9482e34743 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -41,7 +41,7 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", "@testing-library/jest-dom": "^5.10.1", From 4237f8aa82ff1b5eab6a870f8e120610050c0fdd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 Jan 2021 11:21:30 +0100 Subject: [PATCH 079/297] backend-common: add integration test for UrlReader --- .../src/reading/integration.test.ts | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 packages/backend-common/src/reading/integration.test.ts diff --git a/packages/backend-common/src/reading/integration.test.ts b/packages/backend-common/src/reading/integration.test.ts new file mode 100644 index 0000000000..e8ebd1dd8d --- /dev/null +++ b/packages/backend-common/src/reading/integration.test.ts @@ -0,0 +1,155 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { getVoidLogger } from '../logging'; +import { UrlReaders } from './UrlReaders'; + +const reader = UrlReaders.default({ + logger: getVoidLogger(), + config: new ConfigReader({ + // The tokens in this config provide read only access to the backstage-verification repos + integrations: { + github: [ + { + host: 'github.com', + token: `${86}af${617}d9c3c8bf958b37a${630691452765}bb0b0a`, + }, + ], + gitlab: [ + { + host: 'gitlab.com', + token: 'tveGtSHDBJM9ZRHZNRfm', + }, + ], + bitbucket: [ + { + host: 'bitbucket.org', + username: 'backstage-verification', + appPassword: 'H79MAAhtbZwCafkVTrrQ', + }, + ], + azure: [ + { + host: 'dev.azure.com', + // lasts until 2022-01-27 + token: 'bhs5cbukiuxrkc3ftuyt5h3eqewtkj37lmf3jx5aoajivq3f5jmq', + }, + ], + }, + }), +}); + +function withRetries(count: number, fn: () => Promise) { + return async () => { + let error; + for (let i = 0; i < count; i++) { + try { + await fn(); + return; + } catch (err) { + error = err; + } + } + throw error; + }; +} + +describe('UrlReaders', () => { + it( + 'should read data from azure', + withRetries(3, async () => { + const data = await reader.read( + 'https://dev.azure.com/backstage-verification/test-templates/_git/test-templates?path=%2Ftemplate.yaml', + ); + expect(data.toString()).toContain('test-template-azure'); + + const res = await reader.readTree( + 'https://dev.azure.com/backstage-verification/test-templates/_git/test-templates?path=%2F{{cookiecutter.name}}', + ); + const files = await res.files(); + expect(files).toEqual([ + { + path: 'catalog-info.yaml', + content: expect.any(Function), + }, + ]); + }), + ); + + it( + 'should read data from gitlab', + withRetries(3, async () => { + const data = await reader.read( + 'https://gitlab.com/backstage-verification/test-templates/-/blob/master/template.yaml', + ); + expect(data.toString()).toContain('test-template-gitlab'); + + const res = await reader.readTree( + 'https://gitlab.com/backstage-verification/test-templates/-/tree/master/{{cookiecutter.name}}', + ); + const files = await res.files(); + expect(files).toEqual([ + { + path: 'catalog-info.yaml', + content: expect.any(Function), + }, + ]); + }), + ); + + it( + 'should read data from bitbucket', + withRetries(3, async () => { + const data = await reader.read( + 'https://bitbucket.org/backstage-verification/test-template/src/master/template.yaml', + ); + expect(data.toString()).toContain('test-template-bitbucket'); + + const res = await reader.readTree( + 'https://bitbucket.org/backstage-verification/test-template/src/master/{{cookiecutter.name}}', + ); + const files = await res.files(); + expect(files).toEqual([ + { + path: 'catalog-info.yaml', + content: expect.any(Function), + }, + ]); + }), + ); + + it( + 'should read data from github', + withRetries(3, async () => { + const data = await reader.read( + 'https://github.com/backstage-verification/test-templates/blob/master/template.yaml', + ); + expect(data.toString()).toContain('test-template-github'); + + const res = await reader.readTree( + 'https://github.com/backstage-verification/test-templates/tree/master/{{cookiecutter.name}}', + ); + const files = await res.files(); + expect(files).toEqual([ + { + path: 'catalog-info.yaml', + content: expect.any(Function), + }, + ]); + }), + ); +}); From df26c76ab1f16f9f742de49284d8bc3255778138 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 28 Jan 2021 13:15:06 +0100 Subject: [PATCH 080/297] chore: bump just patch --- packages/create-app/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 448b320346..475b61dde8 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/create-app", "description": "Create app package for Backstage", - "version": "1.0.0", + "version": "0.3.7", "private": false, "publishConfig": { "access": "public" From 5b06ca7171683ef7a408dec73f26bf2b6a039190 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 28 Jan 2021 13:37:25 +0100 Subject: [PATCH 081/297] techdocs: Use URL Reader in example component --- packages/create-app/templates/default-app/catalog-info.yaml.hbs | 2 +- plugins/github-actions/examples/sample.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/create-app/templates/default-app/catalog-info.yaml.hbs b/packages/create-app/templates/default-app/catalog-info.yaml.hbs index 0f8e4f5aeb..d66da552b7 100644 --- a/packages/create-app/templates/default-app/catalog-info.yaml.hbs +++ b/packages/create-app/templates/default-app/catalog-info.yaml.hbs @@ -6,7 +6,7 @@ metadata: # Example for optional annotations # annotations: # github.com/project-slug: backstage/backstage - # backstage.io/techdocs-ref: github:https://github.com/backstage/backstage.git + # backstage.io/techdocs-ref: url:https://github.com/backstage/backstage spec: type: website owner: john@example.com diff --git a/plugins/github-actions/examples/sample.yaml b/plugins/github-actions/examples/sample.yaml index beda3c58ed..a765bd395d 100644 --- a/plugins/github-actions/examples/sample.yaml +++ b/plugins/github-actions/examples/sample.yaml @@ -5,7 +5,7 @@ metadata: description: backstage.io annotations: github.com/project-slug: 'backstage/backstage' - backstage.io/techdocs-ref: github:https://github.com/backstage/backstage.git + backstage.io/techdocs-ref: url:https://github.com/backstage/backstage spec: type: website lifecycle: production From c42cbd10c1252d0abe4d57b1c0ed42d0878215fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 28 Jan 2021 14:25:55 +0100 Subject: [PATCH 082/297] Update CHANGELOG.md --- packages/create-app/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index d2faa14876..6c449b33b5 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,6 +1,6 @@ # @backstage/create-app -## 1.0.0 +## 0.3.7 ### Patch Changes From 4bc89c555a68fd8c0827c22217b25e64f1a736fe Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 28 Jan 2021 14:54:48 +0100 Subject: [PATCH 083/297] TechDocs: Dir preparer should also use URL Reader --- app-config.yaml | 2 +- .../techdocs-common/src/stages/prepare/dir.ts | 18 +++++++++++------- .../src/stages/prepare/preparers.ts | 13 +++++++++---- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index c150ef5902..010c9b9710 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -208,7 +208,7 @@ catalog: # Backstage example components - type: file target: ../catalog-model/examples/all-components.yaml - # Example component for github-actions + # Example component for github-actions and TechDocs - type: file target: ../../plugins/github-actions/examples/sample.yaml # Example component for TechDocs diff --git a/packages/techdocs-common/src/stages/prepare/dir.ts b/packages/techdocs-common/src/stages/prepare/dir.ts index 537f670edc..ab277fda9a 100644 --- a/packages/techdocs-common/src/stages/prepare/dir.ts +++ b/packages/techdocs-common/src/stages/prepare/dir.ts @@ -18,17 +18,19 @@ import { Entity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import path from 'path'; import { parseReferenceAnnotation, checkoutGitRepository } from '../../helpers'; -import { InputError } from '@backstage/backend-common'; +import { UrlReader, InputError } from '@backstage/backend-common'; import parseGitUrl from 'git-url-parse'; import { Logger } from 'winston'; export class DirectoryPreparer implements PreparerBase { - private readonly config: Config; - private readonly logger: Logger; - - constructor(config: Config, logger: Logger) { + constructor( + private readonly config: Config, + private readonly logger: Logger, + private readonly reader: UrlReader, + ) { this.config = config; this.logger = logger; + this.reader = reader; } private async resolveManagedByLocationToDir(entity: Entity) { @@ -41,9 +43,12 @@ export class DirectoryPreparer implements PreparerBase { `Building docs for entity with type 'dir' and managed-by-location '${type}'`, ); switch (type) { + case 'url': { + const response = await this.reader.readTree(target); + return await response.dir(); + } case 'github': case 'gitlab': - case 'url': case 'azure/api': { const parsedGitLocation = parseGitUrl(target); const repoLocation = await checkoutGitRepository( @@ -56,7 +61,6 @@ export class DirectoryPreparer implements PreparerBase { path.join(repoLocation, parsedGitLocation.filepath), ); } - case 'file': return path.dirname(target); default: diff --git a/packages/techdocs-common/src/stages/prepare/preparers.ts b/packages/techdocs-common/src/stages/prepare/preparers.ts index 4625d7b0e4..de825d5d41 100644 --- a/packages/techdocs-common/src/stages/prepare/preparers.ts +++ b/packages/techdocs-common/src/stages/prepare/preparers.ts @@ -35,17 +35,22 @@ export class Preparers implements PreparerBuilder { ): Promise { const preparers = new Preparers(); - const directoryPreparer = new DirectoryPreparer(config, logger); + const urlPreparer = new UrlPreparer(reader, logger); + preparers.register('url', urlPreparer); + + /** + * Dir preparer is a syntactic sugar for users to define techdocs-ref annotation. + * When using dir preparer, the docs will be fetched using URL Reader. + */ + const directoryPreparer = new DirectoryPreparer(config, logger, reader); preparers.register('dir', directoryPreparer); + // Common git preparers will be deprecated soon. const commonGitPreparer = new CommonGitPreparer(config, logger); preparers.register('github', commonGitPreparer); preparers.register('gitlab', commonGitPreparer); preparers.register('azure/api', commonGitPreparer); - const urlPreparer = new UrlPreparer(reader, logger); - preparers.register('url', urlPreparer); - return preparers; } From f680c6cd219b72f4771bb96578825fe07a9d395b Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 28 Jan 2021 15:12:41 +0100 Subject: [PATCH 084/297] TechDocs: Update docs to use URL Reader over dir preparer --- docs/features/techdocs/creating-and-publishing.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/features/techdocs/creating-and-publishing.md b/docs/features/techdocs/creating-and-publishing.md index 187fe97aed..b32bda1e15 100644 --- a/docs/features/techdocs/creating-and-publishing.md +++ b/docs/features/techdocs/creating-and-publishing.md @@ -66,7 +66,9 @@ Update your component's entity description by adding the following lines to its ```yaml metadata: annotations: - backstage.io/techdocs-ref: dir:./ + backstage.io/techdocs-ref: url:https://github.com/org/repo + # Or + # backstage.io/techdocs-ref: url:https://github.com/org/repo/tree/branchName/subFolder ``` Create a `/docs` folder in the root of the project with at least an `index.md` From f0320190d795e6a50ae1644349f995dbca7ae4b0 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 28 Jan 2021 15:13:36 +0100 Subject: [PATCH 085/297] TechDocs: Add changeset for dir preparer improvement --- .changeset/techdocs-chilly-steaks-brush.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/techdocs-chilly-steaks-brush.md diff --git a/.changeset/techdocs-chilly-steaks-brush.md b/.changeset/techdocs-chilly-steaks-brush.md new file mode 100644 index 0000000000..7fd4763c63 --- /dev/null +++ b/.changeset/techdocs-chilly-steaks-brush.md @@ -0,0 +1,5 @@ +--- +'@backstage/techdocs-common': patch +--- + +dir preparer will use URL Reader in its implementation. From 03a2d95c71da258fffd07a2b3121aa59583d828c Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 28 Jan 2021 15:27:39 +0100 Subject: [PATCH 086/297] TechDocs: Update tests with mock url reader --- .../src/stages/prepare/dir.test.ts | 24 +++++++++++++++---- .../src/service/standaloneServer.ts | 11 ++++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/techdocs-common/src/stages/prepare/dir.test.ts b/packages/techdocs-common/src/stages/prepare/dir.test.ts index d082b1367b..630608e817 100644 --- a/packages/techdocs-common/src/stages/prepare/dir.test.ts +++ b/packages/techdocs-common/src/stages/prepare/dir.test.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { getVoidLogger } from '@backstage/backend-common'; +import { getVoidLogger, UrlReader } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { DirectoryPreparer } from './dir'; import { checkoutGitRepository } from '../../helpers'; @@ -46,10 +46,18 @@ const createMockEntity = (annotations: {}) => { }; const mockConfig = new ConfigReader({}); +const mockUrlReader: jest.Mocked = { + read: jest.fn(), + readTree: jest.fn(), +}; describe('directory preparer', () => { it('should merge managed-by-location and techdocs-ref when techdocs-ref is relative', async () => { - const directoryPreparer = new DirectoryPreparer(mockConfig, logger); + const directoryPreparer = new DirectoryPreparer( + mockConfig, + logger, + mockUrlReader, + ); const mockEntity = createMockEntity({ 'backstage.io/managed-by-location': @@ -63,7 +71,11 @@ describe('directory preparer', () => { }); it('should merge managed-by-location and techdocs-ref when techdocs-ref is absolute', async () => { - const directoryPreparer = new DirectoryPreparer(mockConfig, logger); + const directoryPreparer = new DirectoryPreparer( + mockConfig, + logger, + mockUrlReader, + ); const mockEntity = createMockEntity({ 'backstage.io/managed-by-location': @@ -77,7 +89,11 @@ describe('directory preparer', () => { }); it('should merge managed-by-location and techdocs-ref when managed-by-location is a git repository', async () => { - const directoryPreparer = new DirectoryPreparer(mockConfig, logger); + const directoryPreparer = new DirectoryPreparer( + mockConfig, + logger, + mockUrlReader, + ); const mockEntity = createMockEntity({ 'backstage.io/managed-by-location': diff --git a/plugins/techdocs-backend/src/service/standaloneServer.ts b/plugins/techdocs-backend/src/service/standaloneServer.ts index 721c10a1ec..d91e2f08a2 100644 --- a/plugins/techdocs-backend/src/service/standaloneServer.ts +++ b/plugins/techdocs-backend/src/service/standaloneServer.ts @@ -17,6 +17,7 @@ import { createServiceBuilder, SingleHostDiscovery, + UrlReader, } from '@backstage/backend-common'; import { Server } from 'http'; import { Logger } from 'winston'; @@ -49,10 +50,18 @@ export async function startStandaloneServer( }, }); const discovery = SingleHostDiscovery.fromConfig(config); + const mockUrlReader: jest.Mocked = { + read: jest.fn(), + readTree: jest.fn(), + }; logger.debug('Creating application...'); const preparers = new Preparers(); - const directoryPreparer = new DirectoryPreparer(config, logger); + const directoryPreparer = new DirectoryPreparer( + config, + logger, + mockUrlReader, + ); preparers.register('dir', directoryPreparer); const generators = new Generators(); From cdea0baf1c142a696516f21f1d0c115e824dc593 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 28 Jan 2021 15:33:47 +0100 Subject: [PATCH 087/297] Add changeset --- .changeset/wicked-beds-buy.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/wicked-beds-buy.md diff --git a/.changeset/wicked-beds-buy.md b/.changeset/wicked-beds-buy.md new file mode 100644 index 0000000000..2f0586bf93 --- /dev/null +++ b/.changeset/wicked-beds-buy.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +The scaffolder is updated to generate a unique workspace directory inside the temp folder which gets cleaned up afterwards. + +prepare/template/publish steps is refactored to operate on known directories(`checkout/`, `template/`, `result/`) inside the generated temp directory. +Updates preparers to take the template url instead of the entire template. This is all in preparation f + +Fix broken configuration GitHub actions in Create React App template. From 327c94da3920c55fc7b06840028ec10a75be960b Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 28 Jan 2021 15:34:35 +0100 Subject: [PATCH 088/297] Fix CRA templating The GitHub actions setup was never templated correctly. Use known directories for templating --- .../scaffolder/stages/templater/cra/index.ts | 102 +++++++++++++----- .../cra/templates/.github/workflows/main.yml | 41 ------- 2 files changed, 74 insertions(+), 69 deletions(-) delete mode 100644 plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/templates/.github/workflows/main.yml diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts index f5863feb10..5e957e0504 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/index.ts @@ -17,25 +17,30 @@ import fs from 'fs-extra'; import { runDockerContainer } from '../helpers'; import { TemplaterBase, TemplaterRunOptions } from '..'; import path from 'path'; -import { TemplaterRunResult } from '../types'; import * as yaml from 'yaml'; -import { resolvePackagePath } from '@backstage/backend-common'; // TODO(blam): Replace with the universal import from github-actions after a release // As it will break the E2E without it const GITHUB_ACTIONS_ANNOTATION = 'github.com/project-slug'; export class CreateReactAppTemplater implements TemplaterBase { - public async run(options: TemplaterRunOptions): Promise { + public async run({ + workspacePath, + values, + logStream, + dockerClient, + }: TemplaterRunOptions): Promise { const { component_id: componentName, use_typescript: withTypescript, use_github_actions: withGithubActions, description, owner, - } = options.values; + } = values; + const intermediateDir = path.join(workspacePath, 'template'); + await fs.ensureDir(intermediateDir); - const resultDir = await fs.promises.mkdtemp(`${options.directory}-result`); + const resultDir = path.join(workspacePath, 'result'); await runDockerContainer({ imageName: 'node:lts-alpine', @@ -44,35 +49,80 @@ export class CreateReactAppTemplater implements TemplaterBase { componentName as string, withTypescript ? ' --template typescript' : '', ], - templateDir: options.directory, - resultDir, - logStream: options.logStream, - dockerClient: options.dockerClient, + templateDir: intermediateDir, + resultDir: intermediateDir, + logStream: logStream, + dockerClient: dockerClient, createOptions: { Entrypoint: ['npx'], WorkingDir: '/result', }, }); - const extraAnnotations: Record = {}; - const finalDir = path.resolve( - resultDir, - options.values.component_id as string, - ); + // if cookiecutter was successful, intermediateDir will contain + // exactly one directory. + const [generated] = await fs.readdir(intermediateDir); + if (generated === undefined) { + throw new Error('No data generated by cookiecutter'); + } + + await fs.move(path.join(intermediateDir, generated), resultDir); + + const extraAnnotations: Record = {}; if (withGithubActions) { - await fs.promises.mkdir(`${finalDir}/.github`); - await fs.promises.mkdir(`${finalDir}/.github/workflows`); - await fs.promises.copyFile( - `${resolvePackagePath( - '@backstage/plugin-scaffolder-backend', - )}/templates/.github/workflows/main.yml`, - `${finalDir}/.github/workflows/main.yml`, + await fs.mkdir(`${resultDir}/.github`); + await fs.mkdir(`${resultDir}/.github/workflows`); + const githubActionsYaml = ` +name: CRA Build + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [12.x] + + steps: + - name: checkout code + uses: actions/checkout@v1 + - name: get yarn cache + id: yarn-cache + run: echo "::set-output name=dir::$(yarn cache dir)" + - uses: actions/cache@v2 + with: + path: \${{ steps.yarn-cache.outputs.dir }} + key: \${{ runner.os }}-yarn-\${{ hashFiles('**/yarn.lock') }} + restore-keys: | + \${{ runner.os }}-yarn- + - name: use node.js \${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: \${{ matrix.node-version }} + - name: yarn install, build, and test + working-directory: . + run: | + yarn install + yarn build + yarn test + env: + CI: true + `; + await fs.writeFile( + `${resultDir}/.github/workflows/main.yml`, + githubActionsYaml, ); extraAnnotations[ GITHUB_ACTIONS_ANNOTATION - ] = `${options.values?.destination?.git?.owner}/${options.values?.destination?.git?.name}`; + ] = `${values?.destination?.git?.owner}/${values?.destination?.git?.name}`; } const componentInfo = { @@ -92,13 +142,9 @@ export class CreateReactAppTemplater implements TemplaterBase { }, }; - await fs.promises.writeFile( - `${finalDir}/catalog-info.yaml`, + await fs.writeFile( + `${resultDir}/catalog-info.yaml`, yaml.stringify(componentInfo), ); - - return { - resultDir: finalDir, - }; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/templates/.github/workflows/main.yml b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/templates/.github/workflows/main.yml deleted file mode 100644 index 65cae9abbb..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cra/templates/.github/workflows/main.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: CRA Build - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [12.x] - - steps: - - name: checkout code - uses: actions/checkout@v1 - - name: get yarn cache - id: yarn-cache - run: echo "::set-output name=dir::$(yarn cache dir)" - - uses: actions/cache@v2 - with: - path: ${{ steps.yarn-cache.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - name: use node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: yarn install, build, and test - working-directory: . - run: | - yarn install - yarn build - yarn test - env: - CI: true From d904fd5b3df939108f11e264489d2c1a160cf931 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 28 Jan 2021 15:44:36 +0100 Subject: [PATCH 089/297] Fix changeset wording --- .changeset/wicked-beds-buy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/wicked-beds-buy.md b/.changeset/wicked-beds-buy.md index 2f0586bf93..daa8fffeab 100644 --- a/.changeset/wicked-beds-buy.md +++ b/.changeset/wicked-beds-buy.md @@ -5,6 +5,6 @@ The scaffolder is updated to generate a unique workspace directory inside the temp folder which gets cleaned up afterwards. prepare/template/publish steps is refactored to operate on known directories(`checkout/`, `template/`, `result/`) inside the generated temp directory. -Updates preparers to take the template url instead of the entire template. This is all in preparation f +Updates preparers to take the template url instead of the entire template. This is done primarly to allow for backwards compatability between v1 and v2 scaffolder templates. Fix broken configuration GitHub actions in Create React App template. From 359ebd6814d47cfd2d7970925d211d03252e2613 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 28 Jan 2021 16:00:21 +0100 Subject: [PATCH 090/297] fix typos --- .changeset/wicked-beds-buy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/wicked-beds-buy.md b/.changeset/wicked-beds-buy.md index daa8fffeab..3218269ff0 100644 --- a/.changeset/wicked-beds-buy.md +++ b/.changeset/wicked-beds-buy.md @@ -5,6 +5,6 @@ The scaffolder is updated to generate a unique workspace directory inside the temp folder which gets cleaned up afterwards. prepare/template/publish steps is refactored to operate on known directories(`checkout/`, `template/`, `result/`) inside the generated temp directory. -Updates preparers to take the template url instead of the entire template. This is done primarly to allow for backwards compatability between v1 and v2 scaffolder templates. +Updates preparers to take the template url instead of the entire template. This is done primarily to allow for backwards compatibility between v1 and v2 scaffolder templates. Fix broken configuration GitHub actions in Create React App template. From ea890cde8dda386f0246521b109f1e34737cbe00 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 28 Jan 2021 16:01:23 +0100 Subject: [PATCH 091/297] Fix router tests --- plugins/scaffolder-backend/src/service/router.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index 59d5f4696e..31f62febd2 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -23,6 +23,8 @@ jest.doMock('fs-extra', () => ({ F_OK: 0, W_OK: 1, }, + mkdir: jest.fn(), + remove: jest.fn(), })); import { getVoidLogger } from '@backstage/backend-common'; @@ -116,9 +118,10 @@ describe('createRouter - working directory', () => { }, }); - expect(mockPrepare).toBeCalledWith(expect.anything(), { + expect(mockPrepare).toBeCalledWith({ logger: expect.anything(), - workingDirectory: '/path', + workspacePath: expect.stringContaining('path'), + url: expect.anything(), }); }); @@ -143,8 +146,10 @@ describe('createRouter - working directory', () => { }, }); - expect(mockPrepare).toBeCalledWith(expect.anything(), { + expect(mockPrepare).toBeCalledWith({ logger: expect.anything(), + workspacePath: expect.anything(), + url: expect.anything(), }); }); }); From 90c8f20b9ff0caaef0bcb3c9591a6b1fa137972c Mon Sep 17 00:00:00 2001 From: Niall McCullagh Date: Thu, 28 Jan 2021 14:19:24 +0000 Subject: [PATCH 092/297] fix(tech-radar): fix mapping of moved and url atrributes to fix display As part of the refactor to include entry history the moved and url attributes were no longer being mapped. This resulted in the radar not indicating if an entry had moved, and the bubble not being clickable. --- .changeset/shaggy-dingos-suffer.md | 5 +++++ plugins/tech-radar/src/components/RadarComponent.tsx | 4 +++- plugins/tech-radar/src/sampleData.ts | 6 +++--- 3 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 .changeset/shaggy-dingos-suffer.md diff --git a/.changeset/shaggy-dingos-suffer.md b/.changeset/shaggy-dingos-suffer.md new file mode 100644 index 0000000000..66c520bb3e --- /dev/null +++ b/.changeset/shaggy-dingos-suffer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-tech-radar': patch +--- + +Fix mapping RadarEntry and Entry for moved and url attributes diff --git a/plugins/tech-radar/src/components/RadarComponent.tsx b/plugins/tech-radar/src/components/RadarComponent.tsx index 583a7c91e0..759fec62c3 100644 --- a/plugins/tech-radar/src/components/RadarComponent.tsx +++ b/plugins/tech-radar/src/components/RadarComponent.tsx @@ -59,7 +59,7 @@ const RadarComponent = (props: TechRadarComponentProps): JSX.Element => { ring: loaderResponse!.rings.find( r => r.id === entry.timeline[0].ringId, )!, - history: entry.timeline.map(e => { + timeline: entry.timeline.map(e => { return { date: e.date, ring: loaderResponse!.rings.find(a => a.id === e.ringId)!, @@ -67,6 +67,8 @@ const RadarComponent = (props: TechRadarComponentProps): JSX.Element => { moved: e.moved, }; }), + moved: entry.timeline[0].moved, + url: entry.url, }; }); }; diff --git a/plugins/tech-radar/src/sampleData.ts b/plugins/tech-radar/src/sampleData.ts index 914bc30931..613e7b3cdc 100644 --- a/plugins/tech-radar/src/sampleData.ts +++ b/plugins/tech-radar/src/sampleData.ts @@ -53,7 +53,7 @@ entries.push({ entries.push({ timeline: [ { - moved: 0, + moved: -1, ringId: 'use', date: new Date('2020-08-06'), description: @@ -69,7 +69,7 @@ entries.push({ entries.push({ timeline: [ { - moved: 0, + moved: 1, ringId: 'use', date: new Date('2020-08-06'), }, @@ -88,7 +88,7 @@ entries.push({ date: new Date('2020-08-06'), }, ], - url: '#', + url: 'https://reactjs.org/', key: 'react', id: 'react', title: 'React', From 9c7efe153567c0545190128e64d92428c9c21115 Mon Sep 17 00:00:00 2001 From: Niall McCullagh Date: Thu, 28 Jan 2021 16:59:05 +0000 Subject: [PATCH 093/297] fix(tech-radar): allow entries on radar legend to be clickable --- .changeset/shaggy-dingos-suffer.md | 1 + plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/shaggy-dingos-suffer.md b/.changeset/shaggy-dingos-suffer.md index 66c520bb3e..c1e200b701 100644 --- a/.changeset/shaggy-dingos-suffer.md +++ b/.changeset/shaggy-dingos-suffer.md @@ -3,3 +3,4 @@ --- Fix mapping RadarEntry and Entry for moved and url attributes +Fix clicking of links in the radar legend diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx index e319846d78..e10993b010 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx @@ -71,12 +71,12 @@ const useStyles = makeStyles(theme => ({ 'font-feature-settings': 'pnum', }, entry: { - pointerEvents: 'none', + pointerEvents: 'visiblePainted', userSelect: 'none', fontSize: '11px', }, entryLink: { - pointerEvents: 'none', + pointerEvents: 'visiblePainted', }, })); From 605629dc74f5198348f89405224a1384976ea27c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 Jan 2021 18:06:47 +0100 Subject: [PATCH 094/297] integration: forward errors from gitlab project lookups --- packages/integration/src/gitlab/core.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/integration/src/gitlab/core.ts b/packages/integration/src/gitlab/core.ts index 29dcc60bac..c1d43c3a46 100644 --- a/packages/integration/src/gitlab/core.ts +++ b/packages/integration/src/gitlab/core.ts @@ -147,10 +147,15 @@ export async function getProjectId( repoIDLookup.toString(), getGitLabRequestOptions(config), ); - const projectIDJson = await response.json(); - const projectID = Number(projectIDJson.id); + const data = await response.json(); - return projectID; + if (!response.ok) { + throw new Error( + `GitLab Error '${data.error}', ${data.error_description}`, + ); + } + + return Number(data.id); } catch (e) { throw new Error(`Could not get GitLab project ID for: ${target}, ${e}`); } From 064c513e1af9ef25ba559dbb3fb6d8e4ce9c3610 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 Jan 2021 18:10:12 +0100 Subject: [PATCH 095/297] add changeset --- .changeset/moody-buckets-visit.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/moody-buckets-visit.md diff --git a/.changeset/moody-buckets-visit.md b/.changeset/moody-buckets-visit.md new file mode 100644 index 0000000000..07a960620b --- /dev/null +++ b/.changeset/moody-buckets-visit.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Properly forward errors that occur when looking up GitLab project IDs. From 6e612ce252a62f421581da433f3de6c823f98ed9 Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Wed, 27 Jan 2021 21:38:02 -0500 Subject: [PATCH 096/297] feat: add entity metadata links --- .changeset/fuzzy-pumpkins-tie.md | 5 + .../software-catalog/descriptor-format.md | 28 + packages/catalog-model/examples/acme/org.yaml | 3 + .../examples/apis/streetlights-api.yaml | 4 + .../examples/apis/swapi-graphql.yaml | 1173 +---------------- .../components/petstore-component.yaml | 4 + .../examples/domains/artists-domain.yaml | 3 + packages/catalog-model/src/entity/Entity.ts | 25 + .../policies/FieldFormatEntityPolicy.test.ts | 9 + .../policies/FieldFormatEntityPolicy.ts | 6 + 10 files changed, 93 insertions(+), 1167 deletions(-) create mode 100644 .changeset/fuzzy-pumpkins-tie.md diff --git a/.changeset/fuzzy-pumpkins-tie.md b/.changeset/fuzzy-pumpkins-tie.md new file mode 100644 index 0000000000..9b4a928c7c --- /dev/null +++ b/.changeset/fuzzy-pumpkins-tie.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': minor +--- + +Adds a new optional `links` metadata field to the Entity class within the `catalog-model` package (as discussed in [[RFC] Entity Links](https://github.com/backstage/backstage/issues/3787)). This PR adds support for the entity links only. Follow up PR's will introduce the UI component to display them. diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 2b173c4b3e..64d2638681 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -314,6 +314,34 @@ This field is optional, and currently has no special semantics. Each tag must be sequences of `[a-z0-9]` separated by `-`, at most 63 characters in total. +### `links` [optional] + +A list of external hyperlinks related to the entity. Links can provide +additional contextual information that may be located outside of backstage +itself. For example, an admin dashboard or external CMS page. + +Users may add links to descriptor YAML files to provide additional reference +information to external content & resources. Links are not intended to drive any +additional functionality within backstage, which is best left to `annotations` +and `labels`. It is recommended to use links only when an equivalent well-known +`annotation` does not cover a similar use case. + +Fields of a link are: + +| Field | Type | Description | +| ------- | ------ | ------------------------------------------------------------------------------------ | +| `url` | String | [Required] A `url` in a standard `uri` format (e.g. `https://example.com/some/page`) | +| `title` | String | [Optional] A user friendly display name for the link. | +| `icon` | String | [Optional] A key representing a visual icon to be displayed in the UI. | + +_NOTE_: The `icon` field value is meant to be a semantic key that will map to a +specific icon that may be provided by an icon library (e.g. `material-ui` +icons). These keys should a sequence of `[a-z0-9A-Z]` and possibly separated by +one of `[-_.]`. Backstage may support some basic icons out of the box, but the +backstage integrator will ultimately be left to provide the appropriate icon +component mappings. A generic fallback icon would be provided if a mapping +cannot be resolved. + ## Common to All Kinds: Relations The `relations` root field is a read-only list of relations, between the current diff --git a/packages/catalog-model/examples/acme/org.yaml b/packages/catalog-model/examples/acme/org.yaml index 05afc265c0..35dfa2633c 100644 --- a/packages/catalog-model/examples/acme/org.yaml +++ b/packages/catalog-model/examples/acme/org.yaml @@ -3,6 +3,9 @@ kind: Group metadata: name: acme-corp description: The acme-corp organization + links: + - url: http://www.acme.com/ + name: Website spec: type: organization profile: diff --git a/packages/catalog-model/examples/apis/streetlights-api.yaml b/packages/catalog-model/examples/apis/streetlights-api.yaml index 725811d0cc..4e0dbb1396 100644 --- a/packages/catalog-model/examples/apis/streetlights-api.yaml +++ b/packages/catalog-model/examples/apis/streetlights-api.yaml @@ -5,6 +5,10 @@ metadata: description: The Smartylighting Streetlights API allows you to remotely manage the city lights. tags: - mqtt + links: + - url: https://github.com/asyncapi/asyncapi/blob/master/examples/1.2.0/streetlights.yml + title: Source Code + icon: code spec: type: asyncapi lifecycle: production diff --git a/packages/catalog-model/examples/apis/swapi-graphql.yaml b/packages/catalog-model/examples/apis/swapi-graphql.yaml index 0c1f7af5a4..85b0bda3a3 100644 --- a/packages/catalog-model/examples/apis/swapi-graphql.yaml +++ b/packages/catalog-model/examples/apis/swapi-graphql.yaml @@ -3,1174 +3,13 @@ kind: API metadata: name: starwars-graphql description: SWAPI GraphQL Schema + links: + - url: https://github.com/graphql/swapi-graphql + title: GitHub Repo + icon: github spec: type: graphql lifecycle: production owner: team-b - definition: | - schema { - query: Root - } - - """A single film.""" - type Film implements Node { - """The title of this film.""" - title: String - - """The episode number of this film.""" - episodeID: Int - - """The opening paragraphs at the beginning of this film.""" - openingCrawl: String - - """The name of the director of this film.""" - director: String - - """The name(s) of the producer(s) of this film.""" - producers: [String] - - """The ISO 8601 date format of film release at original creator country.""" - releaseDate: String - speciesConnection(after: String, first: Int, before: String, last: Int): FilmSpeciesConnection - starshipConnection(after: String, first: Int, before: String, last: Int): FilmStarshipsConnection - vehicleConnection(after: String, first: Int, before: String, last: Int): FilmVehiclesConnection - characterConnection(after: String, first: Int, before: String, last: Int): FilmCharactersConnection - planetConnection(after: String, first: Int, before: String, last: Int): FilmPlanetsConnection - - """The ISO 8601 date format of the time that this resource was created.""" - created: String - - """The ISO 8601 date format of the time that this resource was edited.""" - edited: String - - """The ID of an object""" - id: ID! - } - - """A connection to a list of items.""" - type FilmCharactersConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [FilmCharactersEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - characters: [Person] - } - - """An edge in a connection.""" - type FilmCharactersEdge { - """The item at the end of the edge""" - node: Person - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type FilmPlanetsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [FilmPlanetsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - planets: [Planet] - } - - """An edge in a connection.""" - type FilmPlanetsEdge { - """The item at the end of the edge""" - node: Planet - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type FilmsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [FilmsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - films: [Film] - } - - """An edge in a connection.""" - type FilmsEdge { - """The item at the end of the edge""" - node: Film - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type FilmSpeciesConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [FilmSpeciesEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - species: [Species] - } - - """An edge in a connection.""" - type FilmSpeciesEdge { - """The item at the end of the edge""" - node: Species - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type FilmStarshipsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [FilmStarshipsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - starships: [Starship] - } - - """An edge in a connection.""" - type FilmStarshipsEdge { - """The item at the end of the edge""" - node: Starship - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type FilmVehiclesConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [FilmVehiclesEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - vehicles: [Vehicle] - } - - """An edge in a connection.""" - type FilmVehiclesEdge { - """The item at the end of the edge""" - node: Vehicle - - """A cursor for use in pagination""" - cursor: String! - } - - """An object with an ID""" - interface Node { - """The id of the object.""" - id: ID! - } - - """Information about pagination in a connection.""" - type PageInfo { - """When paginating forwards, are there more items?""" - hasNextPage: Boolean! - - """When paginating backwards, are there more items?""" - hasPreviousPage: Boolean! - - """When paginating backwards, the cursor to continue.""" - startCursor: String - - """When paginating forwards, the cursor to continue.""" - endCursor: String - } - - """A connection to a list of items.""" - type PeopleConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [PeopleEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - people: [Person] - } - - """An edge in a connection.""" - type PeopleEdge { - """The item at the end of the edge""" - node: Person - - """A cursor for use in pagination""" - cursor: String! - } - - """An individual person or character within the Star Wars universe.""" - type Person implements Node { - """The name of this person.""" - name: String - - """ - The birth year of the person, using the in-universe standard of BBY or ABY - - Before the Battle of Yavin or After the Battle of Yavin. The Battle of Yavin is - a battle that occurs at the end of Star Wars episode IV: A New Hope. - """ - birthYear: String - - """ - The eye color of this person. Will be "unknown" if not known or "n/a" if the - person does not have an eye. - """ - eyeColor: String - - """ - The gender of this person. Either "Male", "Female" or "unknown", - "n/a" if the person does not have a gender. - """ - gender: String - - """ - The hair color of this person. Will be "unknown" if not known or "n/a" if the - person does not have hair. - """ - hairColor: String - - """The height of the person in centimeters.""" - height: Int - - """The mass of the person in kilograms.""" - mass: Float - - """The skin color of this person.""" - skinColor: String - - """A planet that this person was born on or inhabits.""" - homeworld: Planet - filmConnection(after: String, first: Int, before: String, last: Int): PersonFilmsConnection - - """The species that this person belongs to, or null if unknown.""" - species: Species - starshipConnection(after: String, first: Int, before: String, last: Int): PersonStarshipsConnection - vehicleConnection(after: String, first: Int, before: String, last: Int): PersonVehiclesConnection - - """The ISO 8601 date format of the time that this resource was created.""" - created: String - - """The ISO 8601 date format of the time that this resource was edited.""" - edited: String - - """The ID of an object""" - id: ID! - } - - """A connection to a list of items.""" - type PersonFilmsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [PersonFilmsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - films: [Film] - } - - """An edge in a connection.""" - type PersonFilmsEdge { - """The item at the end of the edge""" - node: Film - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type PersonStarshipsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [PersonStarshipsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - starships: [Starship] - } - - """An edge in a connection.""" - type PersonStarshipsEdge { - """The item at the end of the edge""" - node: Starship - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type PersonVehiclesConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [PersonVehiclesEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - vehicles: [Vehicle] - } - - """An edge in a connection.""" - type PersonVehiclesEdge { - """The item at the end of the edge""" - node: Vehicle - - """A cursor for use in pagination""" - cursor: String! - } - - """ - A large mass, planet or planetoid in the Star Wars Universe, at the time of - 0 ABY. - """ - type Planet implements Node { - """The name of this planet.""" - name: String - - """The diameter of this planet in kilometers.""" - diameter: Int - - """ - The number of standard hours it takes for this planet to complete a single - rotation on its axis. - """ - rotationPeriod: Int - - """ - The number of standard days it takes for this planet to complete a single orbit - of its local star. - """ - orbitalPeriod: Int - - """ - A number denoting the gravity of this planet, where "1" is normal or 1 standard - G. "2" is twice or 2 standard Gs. "0.5" is half or 0.5 standard Gs. - """ - gravity: String - - """The average population of sentient beings inhabiting this planet.""" - population: Float - - """The climates of this planet.""" - climates: [String] - - """The terrains of this planet.""" - terrains: [String] - - """ - The percentage of the planet surface that is naturally occuring water or bodies - of water. - """ - surfaceWater: Float - residentConnection(after: String, first: Int, before: String, last: Int): PlanetResidentsConnection - filmConnection(after: String, first: Int, before: String, last: Int): PlanetFilmsConnection - - """The ISO 8601 date format of the time that this resource was created.""" - created: String - - """The ISO 8601 date format of the time that this resource was edited.""" - edited: String - - """The ID of an object""" - id: ID! - } - - """A connection to a list of items.""" - type PlanetFilmsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [PlanetFilmsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - films: [Film] - } - - """An edge in a connection.""" - type PlanetFilmsEdge { - """The item at the end of the edge""" - node: Film - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type PlanetResidentsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [PlanetResidentsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - residents: [Person] - } - - """An edge in a connection.""" - type PlanetResidentsEdge { - """The item at the end of the edge""" - node: Person - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type PlanetsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [PlanetsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - planets: [Planet] - } - - """An edge in a connection.""" - type PlanetsEdge { - """The item at the end of the edge""" - node: Planet - - """A cursor for use in pagination""" - cursor: String! - } - - type Root { - allFilms(after: String, first: Int, before: String, last: Int): FilmsConnection - film(id: ID, filmID: ID): Film - allPeople(after: String, first: Int, before: String, last: Int): PeopleConnection - person(id: ID, personID: ID): Person - allPlanets(after: String, first: Int, before: String, last: Int): PlanetsConnection - planet(id: ID, planetID: ID): Planet - allSpecies(after: String, first: Int, before: String, last: Int): SpeciesConnection - species(id: ID, speciesID: ID): Species - allStarships(after: String, first: Int, before: String, last: Int): StarshipsConnection - starship(id: ID, starshipID: ID): Starship - allVehicles(after: String, first: Int, before: String, last: Int): VehiclesConnection - vehicle(id: ID, vehicleID: ID): Vehicle - - """Fetches an object given its ID""" - node( - """The ID of an object""" - id: ID! - ): Node - } - - """A type of person or character within the Star Wars Universe.""" - type Species implements Node { - """The name of this species.""" - name: String - - """The classification of this species, such as "mammal" or "reptile".""" - classification: String - - """The designation of this species, such as "sentient".""" - designation: String - - """The average height of this species in centimeters.""" - averageHeight: Float - - """The average lifespan of this species in years, null if unknown.""" - averageLifespan: Int - - """ - Common eye colors for this species, null if this species does not typically - have eyes. - """ - eyeColors: [String] - - """ - Common hair colors for this species, null if this species does not typically - have hair. - """ - hairColors: [String] - - """ - Common skin colors for this species, null if this species does not typically - have skin. - """ - skinColors: [String] - - """The language commonly spoken by this species.""" - language: String - - """A planet that this species originates from.""" - homeworld: Planet - personConnection(after: String, first: Int, before: String, last: Int): SpeciesPeopleConnection - filmConnection(after: String, first: Int, before: String, last: Int): SpeciesFilmsConnection - - """The ISO 8601 date format of the time that this resource was created.""" - created: String - - """The ISO 8601 date format of the time that this resource was edited.""" - edited: String - - """The ID of an object""" - id: ID! - } - - """A connection to a list of items.""" - type SpeciesConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [SpeciesEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - species: [Species] - } - - """An edge in a connection.""" - type SpeciesEdge { - """The item at the end of the edge""" - node: Species - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type SpeciesFilmsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [SpeciesFilmsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - films: [Film] - } - - """An edge in a connection.""" - type SpeciesFilmsEdge { - """The item at the end of the edge""" - node: Film - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type SpeciesPeopleConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [SpeciesPeopleEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - people: [Person] - } - - """An edge in a connection.""" - type SpeciesPeopleEdge { - """The item at the end of the edge""" - node: Person - - """A cursor for use in pagination""" - cursor: String! - } - - """A single transport craft that has hyperdrive capability.""" - type Starship implements Node { - """The name of this starship. The common name, such as "Death Star".""" - name: String - - """ - The model or official name of this starship. Such as "T-65 X-wing" or "DS-1 - Orbital Battle Station". - """ - model: String - - """ - The class of this starship, such as "Starfighter" or "Deep Space Mobile - Battlestation" - """ - starshipClass: String - - """The manufacturers of this starship.""" - manufacturers: [String] - - """The cost of this starship new, in galactic credits.""" - costInCredits: Float - - """The length of this starship in meters.""" - length: Float - - """The number of personnel needed to run or pilot this starship.""" - crew: String - - """The number of non-essential people this starship can transport.""" - passengers: String - - """ - The maximum speed of this starship in atmosphere. null if this starship is - incapable of atmosphering flight. - """ - maxAtmospheringSpeed: Int - - """The class of this starships hyperdrive.""" - hyperdriveRating: Float - - """ - The Maximum number of Megalights this starship can travel in a standard hour. - A "Megalight" is a standard unit of distance and has never been defined before - within the Star Wars universe. This figure is only really useful for measuring - the difference in speed of starships. We can assume it is similar to AU, the - distance between our Sun (Sol) and Earth. - """ - MGLT: Int - - """The maximum number of kilograms that this starship can transport.""" - cargoCapacity: Float - - """ - The maximum length of time that this starship can provide consumables for its - entire crew without having to resupply. - """ - consumables: String - pilotConnection(after: String, first: Int, before: String, last: Int): StarshipPilotsConnection - filmConnection(after: String, first: Int, before: String, last: Int): StarshipFilmsConnection - - """The ISO 8601 date format of the time that this resource was created.""" - created: String - - """The ISO 8601 date format of the time that this resource was edited.""" - edited: String - - """The ID of an object""" - id: ID! - } - - """A connection to a list of items.""" - type StarshipFilmsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [StarshipFilmsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - films: [Film] - } - - """An edge in a connection.""" - type StarshipFilmsEdge { - """The item at the end of the edge""" - node: Film - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type StarshipPilotsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [StarshipPilotsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - pilots: [Person] - } - - """An edge in a connection.""" - type StarshipPilotsEdge { - """The item at the end of the edge""" - node: Person - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type StarshipsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [StarshipsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - starships: [Starship] - } - - """An edge in a connection.""" - type StarshipsEdge { - """The item at the end of the edge""" - node: Starship - - """A cursor for use in pagination""" - cursor: String! - } - - """A single transport craft that does not have hyperdrive capability""" - type Vehicle implements Node { - """ - The name of this vehicle. The common name, such as "Sand Crawler" or "Speeder - bike". - """ - name: String - - """ - The model or official name of this vehicle. Such as "All-Terrain Attack - Transport". - """ - model: String - - """The class of this vehicle, such as "Wheeled" or "Repulsorcraft".""" - vehicleClass: String - - """The manufacturers of this vehicle.""" - manufacturers: [String] - - """The cost of this vehicle new, in Galactic Credits.""" - costInCredits: Float - - """The length of this vehicle in meters.""" - length: Float - - """The number of personnel needed to run or pilot this vehicle.""" - crew: String - - """The number of non-essential people this vehicle can transport.""" - passengers: String - - """The maximum speed of this vehicle in atmosphere.""" - maxAtmospheringSpeed: Int - - """The maximum number of kilograms that this vehicle can transport.""" - cargoCapacity: Float - - """ - The maximum length of time that this vehicle can provide consumables for its - entire crew without having to resupply. - """ - consumables: String - pilotConnection(after: String, first: Int, before: String, last: Int): VehiclePilotsConnection - filmConnection(after: String, first: Int, before: String, last: Int): VehicleFilmsConnection - - """The ISO 8601 date format of the time that this resource was created.""" - created: String - - """The ISO 8601 date format of the time that this resource was edited.""" - edited: String - - """The ID of an object""" - id: ID! - } - - """A connection to a list of items.""" - type VehicleFilmsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [VehicleFilmsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - films: [Film] - } - - """An edge in a connection.""" - type VehicleFilmsEdge { - """The item at the end of the edge""" - node: Film - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type VehiclePilotsConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [VehiclePilotsEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - pilots: [Person] - } - - """An edge in a connection.""" - type VehiclePilotsEdge { - """The item at the end of the edge""" - node: Person - - """A cursor for use in pagination""" - cursor: String! - } - - """A connection to a list of items.""" - type VehiclesConnection { - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """A list of edges.""" - edges: [VehiclesEdge] - - """ - A count of the total number of objects in this connection, ignoring pagination. - This allows a client to fetch the first five objects by passing "5" as the - argument to "first", then fetch the total count so it could display "5 of 83", - for example. - """ - totalCount: Int - - """ - A list of all of the objects returned in the connection. This is a convenience - field provided for quickly exploring the API; rather than querying for - "{ edges { node } }" when no edge data is needed, this field can be be used - instead. Note that when clients like Relay need to fetch the "cursor" field on - the edge to enable efficient pagination, this shortcut cannot be used, and the - full "{ edges { node } }" version should be used instead. - """ - vehicles: [Vehicle] - } - - """An edge in a connection.""" - type VehiclesEdge { - """The item at the end of the edge""" - node: Vehicle - - """A cursor for use in pagination""" - cursor: String! - } + definition: + $text: https://github.com/graphql/swapi-graphql/blob/master/schema.graphql diff --git a/packages/catalog-model/examples/components/petstore-component.yaml b/packages/catalog-model/examples/components/petstore-component.yaml index acbb2f82b0..48286f9b74 100644 --- a/packages/catalog-model/examples/components/petstore-component.yaml +++ b/packages/catalog-model/examples/components/petstore-component.yaml @@ -3,6 +3,10 @@ kind: Component metadata: name: petstore description: Petstore + links: + - url: https://github.com/swagger-api/swagger-petstore + title: GitHub Repo + icon: github spec: type: service lifecycle: experimental diff --git a/packages/catalog-model/examples/domains/artists-domain.yaml b/packages/catalog-model/examples/domains/artists-domain.yaml index 7bcc4329dd..7ac4ba66a1 100644 --- a/packages/catalog-model/examples/domains/artists-domain.yaml +++ b/packages/catalog-model/examples/domains/artists-domain.yaml @@ -3,5 +3,8 @@ kind: Domain metadata: name: artists description: Everything related to artists + links: + - url: http://example.com/domain/readme + title: Artists Domain Readme spec: owner: team-a diff --git a/packages/catalog-model/src/entity/Entity.ts b/packages/catalog-model/src/entity/Entity.ts index ac43137546..7abe1f9c0c 100644 --- a/packages/catalog-model/src/entity/Entity.ts +++ b/packages/catalog-model/src/entity/Entity.ts @@ -125,6 +125,11 @@ export type EntityMeta = JsonObject & { * various ways. */ tags?: string[]; + + /** + * A list of external hyperlinks related to the entity. + */ + links?: EntityLink[]; }; /** @@ -161,3 +166,23 @@ export type EntityRelationSpec = { */ target: EntityName; }; + +/** + * A link to external information that is related to the entity. + */ +export type EntityLink = { + /** + * The url to the external site, document, etc. + */ + url: string; + + /** + * An optional descriptive title for the link. + */ + title?: string; + + /** + * An optional semantic key that represents a visual icon. + */ + icon?: string; +}; diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts index edf04a64cc..52a8207cc8 100644 --- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts @@ -38,6 +38,10 @@ describe('FieldFormatEntityPolicy', () => { tags: - java - data-service + links: + - url: https://example.org + title: Website + icon: website spec: custom: stuff `); @@ -110,4 +114,9 @@ describe('FieldFormatEntityPolicy', () => { data.metadata.tags.push('Hello World'); await expect(policy.enforce(data)).rejects.toThrow(/tags.*"Hello World"/i); }); + + it('rejects bad link icon value', async () => { + data.metadata.links.push({ url: 'https://foo', icon: 'some icon' }); + await expect(policy.enforce(data)).rejects.toThrow(/links.*"some icon"/i); + }); }); diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts index 0aabbd9e48..09ccca6506 100644 --- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts @@ -134,6 +134,12 @@ export class FieldFormatEntityPolicy implements EntityPolicy { require(`tags.${i}`, tags[i], this.validators.isValidTag); } + const links = entity.metadata.links ?? []; + + for (let i = 0; i < links.length; ++i) { + optional(`links.${i}`, links[i]?.icon, this.validators.isValidEntityName); + } + return entity; } } From b47136bd349dfdefe7b4d31295a5ac448b3787f3 Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Thu, 28 Jan 2021 05:34:57 -0500 Subject: [PATCH 097/297] add entity links example json --- docs/features/software-catalog/descriptor-format.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 64d2638681..1134fbb61a 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -54,6 +54,11 @@ software catalog API. "labels": { "system": "public-websites" }, + "links": [{ + "url": "https://admin.example-org.com", + "title": "Admin Dashboard", + "icon": "dashboard" + }], "tags": ["java"], "name": "artist-web", "uid": "2152f463-549d-4d8d-a94d-ce2b7676c6e2" From 83b94626572e324fadb9c3d1c434aeb35f1a3342 Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Thu, 28 Jan 2021 05:43:14 -0500 Subject: [PATCH 098/297] update entity links catalog examples --- packages/catalog-model/examples/acme/org.yaml | 2 + .../examples/apis/petstore-api.yaml | 7 + .../examples/apis/swapi-graphql.yaml | 1169 ++++++++++++++++- .../components/artist-lookup-component.yaml | 6 + .../examples/domains/artists-domain.yaml | 7 +- 5 files changed, 1187 insertions(+), 4 deletions(-) diff --git a/packages/catalog-model/examples/acme/org.yaml b/packages/catalog-model/examples/acme/org.yaml index 35dfa2633c..f0a5003f7c 100644 --- a/packages/catalog-model/examples/acme/org.yaml +++ b/packages/catalog-model/examples/acme/org.yaml @@ -6,6 +6,8 @@ metadata: links: - url: http://www.acme.com/ name: Website + - url: https://meta.wikimedia.org/wiki/ + name: Intranet spec: type: organization profile: diff --git a/packages/catalog-model/examples/apis/petstore-api.yaml b/packages/catalog-model/examples/apis/petstore-api.yaml index a314b4e255..4c18b16a16 100644 --- a/packages/catalog-model/examples/apis/petstore-api.yaml +++ b/packages/catalog-model/examples/apis/petstore-api.yaml @@ -6,6 +6,13 @@ metadata: tags: - store - rest + links: + - url: https://github.com/swagger-api/swagger-petstore + title: GitHub Repo + icon: github + - url: https://github.com/OAI/OpenAPI-Specification/blob/master/examples/v3.0/petstore.yaml + title: API Spec + code: code spec: type: openapi lifecycle: experimental diff --git a/packages/catalog-model/examples/apis/swapi-graphql.yaml b/packages/catalog-model/examples/apis/swapi-graphql.yaml index 85b0bda3a3..f3d924fc53 100644 --- a/packages/catalog-model/examples/apis/swapi-graphql.yaml +++ b/packages/catalog-model/examples/apis/swapi-graphql.yaml @@ -11,5 +11,1170 @@ spec: type: graphql lifecycle: production owner: team-b - definition: - $text: https://github.com/graphql/swapi-graphql/blob/master/schema.graphql + definition: | + schema { + query: Root + } + + """A single film.""" + type Film implements Node { + """The title of this film.""" + title: String + + """The episode number of this film.""" + episodeID: Int + + """The opening paragraphs at the beginning of this film.""" + openingCrawl: String + + """The name of the director of this film.""" + director: String + + """The name(s) of the producer(s) of this film.""" + producers: [String] + + """The ISO 8601 date format of film release at original creator country.""" + releaseDate: String + speciesConnection(after: String, first: Int, before: String, last: Int): FilmSpeciesConnection + starshipConnection(after: String, first: Int, before: String, last: Int): FilmStarshipsConnection + vehicleConnection(after: String, first: Int, before: String, last: Int): FilmVehiclesConnection + characterConnection(after: String, first: Int, before: String, last: Int): FilmCharactersConnection + planetConnection(after: String, first: Int, before: String, last: Int): FilmPlanetsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! + } + + """A connection to a list of items.""" + type FilmCharactersConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmCharactersEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + characters: [Person] + } + + """An edge in a connection.""" + type FilmCharactersEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type FilmPlanetsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmPlanetsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + planets: [Planet] + } + + """An edge in a connection.""" + type FilmPlanetsEdge { + """The item at the end of the edge""" + node: Planet + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type FilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] + } + + """An edge in a connection.""" + type FilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type FilmSpeciesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmSpeciesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + species: [Species] + } + + """An edge in a connection.""" + type FilmSpeciesEdge { + """The item at the end of the edge""" + node: Species + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type FilmStarshipsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmStarshipsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] + } + + """An edge in a connection.""" + type FilmStarshipsEdge { + """The item at the end of the edge""" + node: Starship + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type FilmVehiclesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [FilmVehiclesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] + } + + """An edge in a connection.""" + type FilmVehiclesEdge { + """The item at the end of the edge""" + node: Vehicle + + """A cursor for use in pagination""" + cursor: String! + } + + """An object with an ID""" + interface Node { + """The id of the object.""" + id: ID! + } + + """Information about pagination in a connection.""" + type PageInfo { + """When paginating forwards, are there more items?""" + hasNextPage: Boolean! + + """When paginating backwards, are there more items?""" + hasPreviousPage: Boolean! + + """When paginating backwards, the cursor to continue.""" + startCursor: String + + """When paginating forwards, the cursor to continue.""" + endCursor: String + } + + """A connection to a list of items.""" + type PeopleConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PeopleEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + people: [Person] + } + + """An edge in a connection.""" + type PeopleEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! + } + + """An individual person or character within the Star Wars universe.""" + type Person implements Node { + """The name of this person.""" + name: String + + """ + The birth year of the person, using the in-universe standard of BBY or ABY - + Before the Battle of Yavin or After the Battle of Yavin. The Battle of Yavin is + a battle that occurs at the end of Star Wars episode IV: A New Hope. + """ + birthYear: String + + """ + The eye color of this person. Will be "unknown" if not known or "n/a" if the + person does not have an eye. + """ + eyeColor: String + + """ + The gender of this person. Either "Male", "Female" or "unknown", + "n/a" if the person does not have a gender. + """ + gender: String + + """ + The hair color of this person. Will be "unknown" if not known or "n/a" if the + person does not have hair. + """ + hairColor: String + + """The height of the person in centimeters.""" + height: Int + + """The mass of the person in kilograms.""" + mass: Float + + """The skin color of this person.""" + skinColor: String + + """A planet that this person was born on or inhabits.""" + homeworld: Planet + filmConnection(after: String, first: Int, before: String, last: Int): PersonFilmsConnection + + """The species that this person belongs to, or null if unknown.""" + species: Species + starshipConnection(after: String, first: Int, before: String, last: Int): PersonStarshipsConnection + vehicleConnection(after: String, first: Int, before: String, last: Int): PersonVehiclesConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! + } + + """A connection to a list of items.""" + type PersonFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PersonFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] + } + + """An edge in a connection.""" + type PersonFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type PersonStarshipsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PersonStarshipsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] + } + + """An edge in a connection.""" + type PersonStarshipsEdge { + """The item at the end of the edge""" + node: Starship + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type PersonVehiclesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PersonVehiclesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] + } + + """An edge in a connection.""" + type PersonVehiclesEdge { + """The item at the end of the edge""" + node: Vehicle + + """A cursor for use in pagination""" + cursor: String! + } + + """ + A large mass, planet or planetoid in the Star Wars Universe, at the time of + 0 ABY. + """ + type Planet implements Node { + """The name of this planet.""" + name: String + + """The diameter of this planet in kilometers.""" + diameter: Int + + """ + The number of standard hours it takes for this planet to complete a single + rotation on its axis. + """ + rotationPeriod: Int + + """ + The number of standard days it takes for this planet to complete a single orbit + of its local star. + """ + orbitalPeriod: Int + + """ + A number denoting the gravity of this planet, where "1" is normal or 1 standard + G. "2" is twice or 2 standard Gs. "0.5" is half or 0.5 standard Gs. + """ + gravity: String + + """The average population of sentient beings inhabiting this planet.""" + population: Float + + """The climates of this planet.""" + climates: [String] + + """The terrains of this planet.""" + terrains: [String] + + """ + The percentage of the planet surface that is naturally occuring water or bodies + of water. + """ + surfaceWater: Float + residentConnection(after: String, first: Int, before: String, last: Int): PlanetResidentsConnection + filmConnection(after: String, first: Int, before: String, last: Int): PlanetFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! + } + + """A connection to a list of items.""" + type PlanetFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PlanetFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] + } + + """An edge in a connection.""" + type PlanetFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type PlanetResidentsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PlanetResidentsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + residents: [Person] + } + + """An edge in a connection.""" + type PlanetResidentsEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type PlanetsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [PlanetsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + planets: [Planet] + } + + """An edge in a connection.""" + type PlanetsEdge { + """The item at the end of the edge""" + node: Planet + + """A cursor for use in pagination""" + cursor: String! + } + + type Root { + allFilms(after: String, first: Int, before: String, last: Int): FilmsConnection + film(id: ID, filmID: ID): Film + allPeople(after: String, first: Int, before: String, last: Int): PeopleConnection + person(id: ID, personID: ID): Person + allPlanets(after: String, first: Int, before: String, last: Int): PlanetsConnection + planet(id: ID, planetID: ID): Planet + allSpecies(after: String, first: Int, before: String, last: Int): SpeciesConnection + species(id: ID, speciesID: ID): Species + allStarships(after: String, first: Int, before: String, last: Int): StarshipsConnection + starship(id: ID, starshipID: ID): Starship + allVehicles(after: String, first: Int, before: String, last: Int): VehiclesConnection + vehicle(id: ID, vehicleID: ID): Vehicle + + """Fetches an object given its ID""" + node( + """The ID of an object""" + id: ID! + ): Node + } + + """A type of person or character within the Star Wars Universe.""" + type Species implements Node { + """The name of this species.""" + name: String + + """The classification of this species, such as "mammal" or "reptile".""" + classification: String + + """The designation of this species, such as "sentient".""" + designation: String + + """The average height of this species in centimeters.""" + averageHeight: Float + + """The average lifespan of this species in years, null if unknown.""" + averageLifespan: Int + + """ + Common eye colors for this species, null if this species does not typically + have eyes. + """ + eyeColors: [String] + + """ + Common hair colors for this species, null if this species does not typically + have hair. + """ + hairColors: [String] + + """ + Common skin colors for this species, null if this species does not typically + have skin. + """ + skinColors: [String] + + """The language commonly spoken by this species.""" + language: String + + """A planet that this species originates from.""" + homeworld: Planet + personConnection(after: String, first: Int, before: String, last: Int): SpeciesPeopleConnection + filmConnection(after: String, first: Int, before: String, last: Int): SpeciesFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! + } + + """A connection to a list of items.""" + type SpeciesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [SpeciesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + species: [Species] + } + + """An edge in a connection.""" + type SpeciesEdge { + """The item at the end of the edge""" + node: Species + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type SpeciesFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [SpeciesFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] + } + + """An edge in a connection.""" + type SpeciesFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type SpeciesPeopleConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [SpeciesPeopleEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + people: [Person] + } + + """An edge in a connection.""" + type SpeciesPeopleEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! + } + + """A single transport craft that has hyperdrive capability.""" + type Starship implements Node { + """The name of this starship. The common name, such as "Death Star".""" + name: String + + """ + The model or official name of this starship. Such as "T-65 X-wing" or "DS-1 + Orbital Battle Station". + """ + model: String + + """ + The class of this starship, such as "Starfighter" or "Deep Space Mobile + Battlestation" + """ + starshipClass: String + + """The manufacturers of this starship.""" + manufacturers: [String] + + """The cost of this starship new, in galactic credits.""" + costInCredits: Float + + """The length of this starship in meters.""" + length: Float + + """The number of personnel needed to run or pilot this starship.""" + crew: String + + """The number of non-essential people this starship can transport.""" + passengers: String + + """ + The maximum speed of this starship in atmosphere. null if this starship is + incapable of atmosphering flight. + """ + maxAtmospheringSpeed: Int + + """The class of this starships hyperdrive.""" + hyperdriveRating: Float + + """ + The Maximum number of Megalights this starship can travel in a standard hour. + A "Megalight" is a standard unit of distance and has never been defined before + within the Star Wars universe. This figure is only really useful for measuring + the difference in speed of starships. We can assume it is similar to AU, the + distance between our Sun (Sol) and Earth. + """ + MGLT: Int + + """The maximum number of kilograms that this starship can transport.""" + cargoCapacity: Float + + """ + The maximum length of time that this starship can provide consumables for its + entire crew without having to resupply. + """ + consumables: String + pilotConnection(after: String, first: Int, before: String, last: Int): StarshipPilotsConnection + filmConnection(after: String, first: Int, before: String, last: Int): StarshipFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! + } + + """A connection to a list of items.""" + type StarshipFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [StarshipFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] + } + + """An edge in a connection.""" + type StarshipFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type StarshipPilotsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [StarshipPilotsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + pilots: [Person] + } + + """An edge in a connection.""" + type StarshipPilotsEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type StarshipsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [StarshipsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + starships: [Starship] + } + + """An edge in a connection.""" + type StarshipsEdge { + """The item at the end of the edge""" + node: Starship + + """A cursor for use in pagination""" + cursor: String! + } + + """A single transport craft that does not have hyperdrive capability""" + type Vehicle implements Node { + """ + The name of this vehicle. The common name, such as "Sand Crawler" or "Speeder + bike". + """ + name: String + + """ + The model or official name of this vehicle. Such as "All-Terrain Attack + Transport". + """ + model: String + + """The class of this vehicle, such as "Wheeled" or "Repulsorcraft".""" + vehicleClass: String + + """The manufacturers of this vehicle.""" + manufacturers: [String] + + """The cost of this vehicle new, in Galactic Credits.""" + costInCredits: Float + + """The length of this vehicle in meters.""" + length: Float + + """The number of personnel needed to run or pilot this vehicle.""" + crew: String + + """The number of non-essential people this vehicle can transport.""" + passengers: String + + """The maximum speed of this vehicle in atmosphere.""" + maxAtmospheringSpeed: Int + + """The maximum number of kilograms that this vehicle can transport.""" + cargoCapacity: Float + + """ + The maximum length of time that this vehicle can provide consumables for its + entire crew without having to resupply. + """ + consumables: String + pilotConnection(after: String, first: Int, before: String, last: Int): VehiclePilotsConnection + filmConnection(after: String, first: Int, before: String, last: Int): VehicleFilmsConnection + + """The ISO 8601 date format of the time that this resource was created.""" + created: String + + """The ISO 8601 date format of the time that this resource was edited.""" + edited: String + + """The ID of an object""" + id: ID! + } + + """A connection to a list of items.""" + type VehicleFilmsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [VehicleFilmsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + films: [Film] + } + + """An edge in a connection.""" + type VehicleFilmsEdge { + """The item at the end of the edge""" + node: Film + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type VehiclePilotsConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [VehiclePilotsEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + pilots: [Person] + } + + """An edge in a connection.""" + type VehiclePilotsEdge { + """The item at the end of the edge""" + node: Person + + """A cursor for use in pagination""" + cursor: String! + } + + """A connection to a list of items.""" + type VehiclesConnection { + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """A list of edges.""" + edges: [VehiclesEdge] + + """ + A count of the total number of objects in this connection, ignoring pagination. + This allows a client to fetch the first five objects by passing "5" as the + argument to "first", then fetch the total count so it could display "5 of 83", + for example. + """ + totalCount: Int + + """ + A list of all of the objects returned in the connection. This is a convenience + field provided for quickly exploring the API; rather than querying for + "{ edges { node } }" when no edge data is needed, this field can be be used + instead. Note that when clients like Relay need to fetch the "cursor" field on + the edge to enable efficient pagination, this shortcut cannot be used, and the + full "{ edges { node } }" version should be used instead. + """ + vehicles: [Vehicle] + } + + """An edge in a connection.""" + type VehiclesEdge { + """The item at the end of the edge""" + node: Vehicle + + """A cursor for use in pagination""" + cursor: String! + } diff --git a/packages/catalog-model/examples/components/artist-lookup-component.yaml b/packages/catalog-model/examples/components/artist-lookup-component.yaml index 3fc516ece9..fbf28d7fcb 100644 --- a/packages/catalog-model/examples/components/artist-lookup-component.yaml +++ b/packages/catalog-model/examples/components/artist-lookup-component.yaml @@ -6,6 +6,12 @@ metadata: tags: - java - data + links: + - url: https://example.com/apm/artists-lookup + title: APM + icon: dashboard + - url: https://example.com/logs/artists-lookup + title: Logs spec: type: service lifecycle: experimental diff --git a/packages/catalog-model/examples/domains/artists-domain.yaml b/packages/catalog-model/examples/domains/artists-domain.yaml index 7ac4ba66a1..2a74425791 100644 --- a/packages/catalog-model/examples/domains/artists-domain.yaml +++ b/packages/catalog-model/examples/domains/artists-domain.yaml @@ -4,7 +4,10 @@ metadata: name: artists description: Everything related to artists links: - - url: http://example.com/domain/readme - title: Artists Domain Readme + - url: http://example.com/domain/artists/ + title: Domain Readme + - url: http://example.com/domains/artists/dashboard + title: Domain Metrics Dashboard + icon: dashboard spec: owner: team-a From fb5d20e65470266ded213821b30917b48603391c Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Thu, 28 Jan 2021 05:48:20 -0500 Subject: [PATCH 099/297] export entity link type --- packages/catalog-model/src/entity/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/catalog-model/src/entity/index.ts b/packages/catalog-model/src/entity/index.ts index d450119f0a..4afb8f5c96 100644 --- a/packages/catalog-model/src/entity/index.ts +++ b/packages/catalog-model/src/entity/index.ts @@ -20,6 +20,7 @@ export { } from './constants'; export type { Entity, + EntityLink, EntityMeta, EntityRelation, EntityRelationSpec, From 91fc991a7930ae4b4fc458652b800a12324d6eab Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Thu, 28 Jan 2021 06:07:02 -0500 Subject: [PATCH 100/297] add entity link schema validation policy --- .../policies/SchemaValidEntityPolicy.test.ts | 22 +++++++++++++++++++ .../policies/SchemaValidEntityPolicy.ts | 3 ++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts index d63ba49dbe..23289d83aa 100644 --- a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts @@ -39,6 +39,10 @@ describe('SchemaValidEntityPolicy', () => { tags: - java - data + links: + - url: https://example.com + title: Website + icon: website spec: custom: stuff `); @@ -198,6 +202,24 @@ describe('SchemaValidEntityPolicy', () => { await expect(policy.enforce(data)).rejects.toThrow(/tags/); }); + it('accepts missing links', async () => { + delete data.metadata.links; + await expect(policy.enforce(data)).resolves.toBe(data); + }); + + it('accepts empty links array', async () => { + data.metadata.links = []; + await expect(policy.enforce(data)).resolves.toBe(data); + }); + + it.each([['invalid type'], [123], [{}], [{ url: 'https://foo' }]])( + 'rejects bad links type %s', + async (val: any) => { + data.metadata.links = val; + await expect(policy.enforce(data)).rejects.toThrow(/links/); + }, + ); + // // spec // diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts index 4ff73e9a49..ed49bea526 100644 --- a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts @@ -16,7 +16,7 @@ import * as yup from 'yup'; import { EntityPolicy } from '../../types'; -import { Entity } from '../Entity'; +import { Entity, EntityLink } from '../Entity'; const DEFAULT_ENTITY_SCHEMA = yup .object({ @@ -33,6 +33,7 @@ const DEFAULT_ENTITY_SCHEMA = yup labels: yup.object>().notRequired(), annotations: yup.object>().notRequired(), tags: yup.array().notRequired(), + links: yup.array().notRequired(), }) .required(), spec: yup.object({}).notRequired(), From a515fc1d1184f6178bb61ca75c9c0edd22a8917a Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Thu, 28 Jan 2021 07:16:22 -0500 Subject: [PATCH 101/297] add entity link field validation & tests --- .../policies/FieldFormatEntityPolicy.test.ts | 96 ++++++++++++++++++- .../policies/FieldFormatEntityPolicy.ts | 10 +- .../policies/SchemaValidEntityPolicy.test.ts | 2 +- .../CommonValidatorFunctions.test.ts | 40 ++++++++ .../validation/CommonValidatorFunctions.ts | 31 ++++++ .../src/validation/makeValidator.ts | 3 + .../catalog-model/src/validation/types.ts | 3 + 7 files changed, 180 insertions(+), 5 deletions(-) diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts index 52a8207cc8..e5a56764e3 100644 --- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts @@ -115,8 +115,98 @@ describe('FieldFormatEntityPolicy', () => { await expect(policy.enforce(data)).rejects.toThrow(/tags.*"Hello World"/i); }); - it('rejects bad link icon value', async () => { - data.metadata.links.push({ url: 'https://foo', icon: 'some icon' }); - await expect(policy.enforce(data)).rejects.toThrow(/links.*"some icon"/i); + it('accepts missing links', async () => { + delete data.metadata.links; + await expect(policy.enforce(data)).resolves.toBe(data); + }); + + it('accepts empty links array', async () => { + data.metadata.links = []; + await expect(policy.enforce(data)).resolves.toBe(data); + }); + + it('accepts multiple links', async () => { + data.metadata.links = [{ url: 'http://foo' }, { url: 'https://bar' }]; + await expect(policy.enforce(data)).resolves.toBe(data); + }); + + it('rejects missing link url value', async () => { + data.metadata.links = [{}]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + }); + + it('rejects a single bad missing url value', async () => { + data.metadata.links = [{ url: 'http://good' }, { url: '' }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.1/); + }); + + it('rejects empty link url value', async () => { + data.metadata.links = [{ url: '' }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + }); + + it('rejects bad link url value', async () => { + data.metadata.links = [{ url: 'invalid' }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + }); + + it('accepts missing link title', async () => { + data.metadata.links = [{ url: 'http://foo', icon: 'dashboard' }]; + await expect(policy.enforce(data)).resolves.toBe(data); + }); + + it('rejects empty link title', async () => { + data.metadata.links = [{ url: 'http://foo', title: '' }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + }); + + it.each([[123], [{}], [[]]])( + 'rejects bad link title %s', + async (title: unknown) => { + data.metadata.links = [{ url: 'http://foo', title }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + }, + ); + + it('rejects a single bad link title', async () => { + data.metadata.links = [ + { url: 'http://foo', title: 'good' }, + { url: 'http://foo', title: '' }, + ]; + await expect(policy.enforce(data)).rejects.toThrow(/links.1/); + }); + + it('accepts missing link icon', async () => { + data.metadata.links = [{ url: 'http://foo', title: 'foo' }]; + await expect(policy.enforce(data)).resolves.toBe(data); + }); + + it('rejects empty link icon', async () => { + data.metadata.links = [{ url: 'http://foo', icon: '' }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + }); + + it.each([['dashboard'], ['admin-dashboard'], ['foo_dashboard']])( + 'accepts valid link icon', + async icon => { + data.metadata.links = [{ url: 'http://foo', icon }]; + await expect(policy.enforce(data)).resolves.toBe(data); + }, + ); + + it.each([[123], [{}], [[]], ['abc xyz']])( + 'rejects bad link icon value %s', + async (icon: unknown) => { + data.metadata.links = [{ url: 'http://foo', icon }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + }, + ); + + it('rejects a single bad link icon value', async () => { + data.metadata.links = [ + { url: 'http://foo', icon: 'good' }, + { url: 'http://foo', icon: 'not good' }, + ]; + await expect(policy.enforce(data)).rejects.toThrow(/links.1/); }); }); diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts index 09ccca6506..d854a3d514 100644 --- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts @@ -83,6 +83,12 @@ export class FieldFormatEntityPolicy implements EntityPolicy { expectation = 'a string that is a sequence of [a-zA-Z][a-z0-9A-Z], at most 63 characters in total'; break; + case 'isValidUrl': + expectation = 'a string that is a valid url'; + break; + case 'isValidString': + expectation = 'a non empty string'; + break; default: expectation = undefined; break; @@ -137,7 +143,9 @@ export class FieldFormatEntityPolicy implements EntityPolicy { const links = entity.metadata.links ?? []; for (let i = 0; i < links.length; ++i) { - optional(`links.${i}`, links[i]?.icon, this.validators.isValidEntityName); + require(`links.${i}`, links[i]?.url, this.validators.isValidLinkUrl); + optional(`links.${i}`, links[i]?.title, this.validators.isValidLinkTitle); + optional(`links.${i}`, links[i]?.icon, this.validators.isValidLinkIcon); } return entity; diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts index 23289d83aa..c84bdbdd38 100644 --- a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.test.ts @@ -214,7 +214,7 @@ describe('SchemaValidEntityPolicy', () => { it.each([['invalid type'], [123], [{}], [{ url: 'https://foo' }]])( 'rejects bad links type %s', - async (val: any) => { + async (val: unknown) => { data.metadata.links = val; await expect(policy.enforce(data)).rejects.toThrow(/links/); }, diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts index 129fdd2b62..bef997d54a 100644 --- a/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts +++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.test.ts @@ -161,4 +161,44 @@ describe('CommonValidatorFunctions', () => { ])(`isValidDnsLabel %p ? %p`, (value, result) => { expect(CommonValidatorFunctions.isValidDnsLabel(value)).toBe(result); }); + + it.each([ + [null, false], + [7, false], + ['', false], + ['abc', false], + [' abc', false], + ['', false], + [{}, false], + ['http://foo', true], + ['https://www.foo.com/', true], + ['https://foo.com:8080', true], + ['https://foo:8080/page', true], + ['https://foo:8080/sub/page', true], + ['https://foo:8080/sub/page?query', true], + ['https://foo:8080/sub/page/?query=value', true], + ['https://foo:8080/sub/page/?query=value&', true], + ['https://foo:8080/sub/page/?query=value&another=val', true], + ['https://foo.com/page#fragment', true], + ['ftp://ftp.some.domain.com/path', true], + ['xyz://custom-protocol:4444/path', true], + ])(`isValidUrl %p ? %p`, (value, result) => { + expect(CommonValidatorFunctions.isValidUrl(value)).toBe(result); + }); + + it.each([ + [null, false], + [true, false], + [7, false], + [{}, false], + ['', false], + [' ', false], + [' ', false], + ['abc', true], + [' abc ', true], + ['abc xyz', true], + ['abc xyz abc.', true], + ])(`isValidString %p ? %p`, (value, result) => { + expect(CommonValidatorFunctions.isValidString(value)).toBe(result); + }); }); diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts index 90b4e1a611..a53a102c00 100644 --- a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts +++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts @@ -92,4 +92,35 @@ export class CommonValidatorFunctions { /^[a-z0-9]+(\-[a-z0-9]+)*$/.test(value) ); } + + /** + * Checks that the value is a valid URL. + * + * @param value The value to check + */ + static isValidUrl(value: unknown): boolean { + if (typeof value !== 'string') { + return false; + } + + try { + const url = new URL(value); + return !!url; + } catch (_) { + return false; + } + } + + /** + * Checks that the value is a non empty string value. + * + * @param value The value to check + */ + static isValidString(value: unknown): boolean { + return ( + typeof value === 'string' && + value.length >= 1 && + /^(?!\s*$).+/.test(value) + ); + } } diff --git a/packages/catalog-model/src/validation/makeValidator.ts b/packages/catalog-model/src/validation/makeValidator.ts index 63341f5c33..e910b25f2a 100644 --- a/packages/catalog-model/src/validation/makeValidator.ts +++ b/packages/catalog-model/src/validation/makeValidator.ts @@ -28,6 +28,9 @@ const defaultValidators: Validators = { isValidAnnotationKey: KubernetesValidatorFunctions.isValidAnnotationKey, isValidAnnotationValue: KubernetesValidatorFunctions.isValidAnnotationValue, isValidTag: CommonValidatorFunctions.isValidDnsLabel, + isValidLinkUrl: CommonValidatorFunctions.isValidUrl, + isValidLinkTitle: CommonValidatorFunctions.isValidString, + isValidLinkIcon: KubernetesValidatorFunctions.isValidObjectName, }; export function makeValidator(overrides: Partial = {}): Validators { diff --git a/packages/catalog-model/src/validation/types.ts b/packages/catalog-model/src/validation/types.ts index a4475c4809..9c7c173ea3 100644 --- a/packages/catalog-model/src/validation/types.ts +++ b/packages/catalog-model/src/validation/types.ts @@ -24,4 +24,7 @@ export type Validators = { isValidAnnotationKey(value: unknown): boolean; isValidAnnotationValue(value: unknown): boolean; isValidTag(value: unknown): boolean; + isValidLinkUrl(value: unknown): boolean; + isValidLinkTitle(value: unknown): boolean; + isValidLinkIcon(value: unknown): boolean; }; From 232f8a58ea81731d620cb723b7580817bc842b11 Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Thu, 28 Jan 2021 07:28:25 -0500 Subject: [PATCH 102/297] add entity links example yaml --- docs/features/software-catalog/descriptor-format.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 1134fbb61a..469085a4c5 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -86,6 +86,10 @@ metadata: circleci.com/project-slug: github/example-org/artist-website tags: - java + links: + url: https://admin.example-org.com + title: Admin Dashboard + icon: dashboard spec: type: website lifecycle: production From f69f62738f08f59d27a87169e6804c7791a5be33 Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Thu, 28 Jan 2021 07:28:52 -0500 Subject: [PATCH 103/297] update changeset for entity links --- .changeset/fuzzy-pumpkins-tie.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fuzzy-pumpkins-tie.md b/.changeset/fuzzy-pumpkins-tie.md index 9b4a928c7c..1e87972ce1 100644 --- a/.changeset/fuzzy-pumpkins-tie.md +++ b/.changeset/fuzzy-pumpkins-tie.md @@ -1,5 +1,5 @@ --- -'@backstage/catalog-model': minor +'@backstage/catalog-model': patch --- Adds a new optional `links` metadata field to the Entity class within the `catalog-model` package (as discussed in [[RFC] Entity Links](https://github.com/backstage/backstage/issues/3787)). This PR adds support for the entity links only. Follow up PR's will introduce the UI component to display them. From 87411c4036d599321d1a5afd15f46ecc6e13850f Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Thu, 28 Jan 2021 10:14:18 -0500 Subject: [PATCH 104/297] Update docs/features/software-catalog/descriptor-format.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- docs/features/software-catalog/descriptor-format.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 469085a4c5..70376ca2d7 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -326,7 +326,7 @@ in total. ### `links` [optional] A list of external hyperlinks related to the entity. Links can provide -additional contextual information that may be located outside of backstage +additional contextual information that may be located outside of Backstage itself. For example, an admin dashboard or external CMS page. Users may add links to descriptor YAML files to provide additional reference From 18c6261ddec75b9106e170e4d81104eb6787fb4c Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Thu, 28 Jan 2021 10:14:28 -0500 Subject: [PATCH 105/297] Update docs/features/software-catalog/descriptor-format.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- docs/features/software-catalog/descriptor-format.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 70376ca2d7..2357d62836 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -331,7 +331,7 @@ itself. For example, an admin dashboard or external CMS page. Users may add links to descriptor YAML files to provide additional reference information to external content & resources. Links are not intended to drive any -additional functionality within backstage, which is best left to `annotations` +additional functionality within Backstage, which is best left to `annotations` and `labels`. It is recommended to use links only when an equivalent well-known `annotation` does not cover a similar use case. From 34f76899bdf25db994146aaca3c9736efaa02316 Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Thu, 28 Jan 2021 10:14:37 -0500 Subject: [PATCH 106/297] Update docs/features/software-catalog/descriptor-format.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- docs/features/software-catalog/descriptor-format.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 2357d62836..a6bd4cd0a6 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -345,7 +345,7 @@ Fields of a link are: _NOTE_: The `icon` field value is meant to be a semantic key that will map to a specific icon that may be provided by an icon library (e.g. `material-ui` -icons). These keys should a sequence of `[a-z0-9A-Z]` and possibly separated by +icons). These keys should be a sequence of `[a-z0-9A-Z]`, possibly separated by one of `[-_.]`. Backstage may support some basic icons out of the box, but the backstage integrator will ultimately be left to provide the appropriate icon component mappings. A generic fallback icon would be provided if a mapping From 97e8e6936850223155172de5391ce63be9fea260 Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Thu, 28 Jan 2021 10:14:45 -0500 Subject: [PATCH 107/297] Update docs/features/software-catalog/descriptor-format.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- docs/features/software-catalog/descriptor-format.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index a6bd4cd0a6..6e3ac37657 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -347,7 +347,7 @@ _NOTE_: The `icon` field value is meant to be a semantic key that will map to a specific icon that may be provided by an icon library (e.g. `material-ui` icons). These keys should be a sequence of `[a-z0-9A-Z]`, possibly separated by one of `[-_.]`. Backstage may support some basic icons out of the box, but the -backstage integrator will ultimately be left to provide the appropriate icon +Backstage integrator will ultimately be left to provide the appropriate icon component mappings. A generic fallback icon would be provided if a mapping cannot be resolved. From 1362709953d11c064f38e7cc71df5599a99aa48a Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Thu, 28 Jan 2021 10:21:10 -0500 Subject: [PATCH 108/297] fix entity link examples --- docs/features/software-catalog/descriptor-format.md | 6 +++--- packages/catalog-model/examples/acme/org.yaml | 4 ++-- packages/catalog-model/examples/apis/petstore-api.yaml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 6e3ac37657..1aab1268cd 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -87,9 +87,9 @@ metadata: tags: - java links: - url: https://admin.example-org.com - title: Admin Dashboard - icon: dashboard + - url: https://admin.example-org.com + title: Admin Dashboard + icon: dashboard spec: type: website lifecycle: production diff --git a/packages/catalog-model/examples/acme/org.yaml b/packages/catalog-model/examples/acme/org.yaml index f0a5003f7c..9a0d690b57 100644 --- a/packages/catalog-model/examples/acme/org.yaml +++ b/packages/catalog-model/examples/acme/org.yaml @@ -5,9 +5,9 @@ metadata: description: The acme-corp organization links: - url: http://www.acme.com/ - name: Website + title: Website - url: https://meta.wikimedia.org/wiki/ - name: Intranet + title: Intranet spec: type: organization profile: diff --git a/packages/catalog-model/examples/apis/petstore-api.yaml b/packages/catalog-model/examples/apis/petstore-api.yaml index 4c18b16a16..5b6878f7d3 100644 --- a/packages/catalog-model/examples/apis/petstore-api.yaml +++ b/packages/catalog-model/examples/apis/petstore-api.yaml @@ -12,7 +12,7 @@ metadata: icon: github - url: https://github.com/OAI/OpenAPI-Specification/blob/master/examples/v3.0/petstore.yaml title: API Spec - code: code + icon: code spec: type: openapi lifecycle: experimental From 13c59b82b37fb52a76ecb1cd1983e67447a0d106 Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Thu, 28 Jan 2021 10:27:22 -0500 Subject: [PATCH 109/297] make isValidString use trim instead of regex --- .../src/validation/CommonValidatorFunctions.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts index a53a102c00..84e6222457 100644 --- a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts +++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts @@ -106,7 +106,7 @@ export class CommonValidatorFunctions { try { const url = new URL(value); return !!url; - } catch (_) { + } catch { return false; } } @@ -117,10 +117,6 @@ export class CommonValidatorFunctions { * @param value The value to check */ static isValidString(value: unknown): boolean { - return ( - typeof value === 'string' && - value.length >= 1 && - /^(?!\s*$).+/.test(value) - ); + return typeof value === 'string' && value?.trim()?.length >= 1; } } From 059720015cf0a0371c070a9714ba2401ddb0777e Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Thu, 28 Jan 2021 11:28:07 -0500 Subject: [PATCH 110/297] replace link validators with common functions --- .../policies/FieldFormatEntityPolicy.test.ts | 33 ++++++++++++------- .../policies/FieldFormatEntityPolicy.ts | 15 +++++++-- .../src/validation/makeValidator.ts | 3 -- .../catalog-model/src/validation/types.ts | 3 -- 4 files changed, 34 insertions(+), 20 deletions(-) diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts index e5a56764e3..33ee045c1e 100644 --- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts +++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.test.ts @@ -132,22 +132,26 @@ describe('FieldFormatEntityPolicy', () => { it('rejects missing link url value', async () => { data.metadata.links = [{}]; - await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + await expect(policy.enforce(data)).rejects.toThrow(/links.0.url/i); }); - it('rejects a single bad missing url value', async () => { + it('rejects a single bad missing link url value', async () => { data.metadata.links = [{ url: 'http://good' }, { url: '' }]; - await expect(policy.enforce(data)).rejects.toThrow(/links.1/); + await expect(policy.enforce(data)).rejects.toThrow( + /links.1.url.*valid url/i, + ); }); it('rejects empty link url value', async () => { data.metadata.links = [{ url: '' }]; - await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + await expect(policy.enforce(data)).rejects.toThrow(/links.0.url.*/i); }); it('rejects bad link url value', async () => { data.metadata.links = [{ url: 'invalid' }]; - await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + await expect(policy.enforce(data)).rejects.toThrow( + /links.0.url.*"invalid"/i, + ); }); it('accepts missing link title', async () => { @@ -157,14 +161,19 @@ describe('FieldFormatEntityPolicy', () => { it('rejects empty link title', async () => { data.metadata.links = [{ url: 'http://foo', title: '' }]; - await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + await expect(policy.enforce(data)).rejects.toThrow(/links.0.title.*""/i); + }); + + it('rejects bad link title', async () => { + data.metadata.links = [{ url: 'http://foo', title: 123 }]; + await expect(policy.enforce(data)).rejects.toThrow(/links.0.title.*"123"/i); }); it.each([[123], [{}], [[]]])( 'rejects bad link title %s', async (title: unknown) => { data.metadata.links = [{ url: 'http://foo', title }]; - await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + await expect(policy.enforce(data)).rejects.toThrow(/links.0.title.*/i); }, ); @@ -173,7 +182,7 @@ describe('FieldFormatEntityPolicy', () => { { url: 'http://foo', title: 'good' }, { url: 'http://foo', title: '' }, ]; - await expect(policy.enforce(data)).rejects.toThrow(/links.1/); + await expect(policy.enforce(data)).rejects.toThrow(/links.1.title.*""/i); }); it('accepts missing link icon', async () => { @@ -183,7 +192,7 @@ describe('FieldFormatEntityPolicy', () => { it('rejects empty link icon', async () => { data.metadata.links = [{ url: 'http://foo', icon: '' }]; - await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + await expect(policy.enforce(data)).rejects.toThrow(/links.0.icon.*""/i); }); it.each([['dashboard'], ['admin-dashboard'], ['foo_dashboard']])( @@ -198,7 +207,7 @@ describe('FieldFormatEntityPolicy', () => { 'rejects bad link icon value %s', async (icon: unknown) => { data.metadata.links = [{ url: 'http://foo', icon }]; - await expect(policy.enforce(data)).rejects.toThrow(/links.0/); + await expect(policy.enforce(data)).rejects.toThrow(/links.0.icon.*/i); }, ); @@ -207,6 +216,8 @@ describe('FieldFormatEntityPolicy', () => { { url: 'http://foo', icon: 'good' }, { url: 'http://foo', icon: 'not good' }, ]; - await expect(policy.enforce(data)).rejects.toThrow(/links.1/); + await expect(policy.enforce(data)).rejects.toThrow( + /links.1.icon.*"not good"/i, + ); }); }); diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts index d854a3d514..00add2f63c 100644 --- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts @@ -143,9 +143,18 @@ export class FieldFormatEntityPolicy implements EntityPolicy { const links = entity.metadata.links ?? []; for (let i = 0; i < links.length; ++i) { - require(`links.${i}`, links[i]?.url, this.validators.isValidLinkUrl); - optional(`links.${i}`, links[i]?.title, this.validators.isValidLinkTitle); - optional(`links.${i}`, links[i]?.icon, this.validators.isValidLinkIcon); + require(`links.${i}.url`, links[i] + ?.url, CommonValidatorFunctions.isValidUrl); + optional( + `links.${i}.title`, + links[i]?.title, + CommonValidatorFunctions.isValidString, + ); + optional( + `links.${i}.icon`, + links[i]?.icon, + KubernetesValidatorFunctions.isValidObjectName, + ); } return entity; diff --git a/packages/catalog-model/src/validation/makeValidator.ts b/packages/catalog-model/src/validation/makeValidator.ts index e910b25f2a..63341f5c33 100644 --- a/packages/catalog-model/src/validation/makeValidator.ts +++ b/packages/catalog-model/src/validation/makeValidator.ts @@ -28,9 +28,6 @@ const defaultValidators: Validators = { isValidAnnotationKey: KubernetesValidatorFunctions.isValidAnnotationKey, isValidAnnotationValue: KubernetesValidatorFunctions.isValidAnnotationValue, isValidTag: CommonValidatorFunctions.isValidDnsLabel, - isValidLinkUrl: CommonValidatorFunctions.isValidUrl, - isValidLinkTitle: CommonValidatorFunctions.isValidString, - isValidLinkIcon: KubernetesValidatorFunctions.isValidObjectName, }; export function makeValidator(overrides: Partial = {}): Validators { diff --git a/packages/catalog-model/src/validation/types.ts b/packages/catalog-model/src/validation/types.ts index 9c7c173ea3..a4475c4809 100644 --- a/packages/catalog-model/src/validation/types.ts +++ b/packages/catalog-model/src/validation/types.ts @@ -24,7 +24,4 @@ export type Validators = { isValidAnnotationKey(value: unknown): boolean; isValidAnnotationValue(value: unknown): boolean; isValidTag(value: unknown): boolean; - isValidLinkUrl(value: unknown): boolean; - isValidLinkTitle(value: unknown): boolean; - isValidLinkIcon(value: unknown): boolean; }; From f11c9060786001e4ee5f1ee6eac0cbd75f4dda9f Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Thu, 28 Jan 2021 12:21:54 -0500 Subject: [PATCH 111/297] update isvalidurl --- .../catalog-model/src/validation/CommonValidatorFunctions.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts index 84e6222457..87b6ad3838 100644 --- a/packages/catalog-model/src/validation/CommonValidatorFunctions.ts +++ b/packages/catalog-model/src/validation/CommonValidatorFunctions.ts @@ -104,8 +104,9 @@ export class CommonValidatorFunctions { } try { - const url = new URL(value); - return !!url; + // eslint-disable-next-line no-new + new URL(value); + return true; } catch { return false; } From e1e650e12a04345ff18d3e1072a3290595e3c98d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 Jan 2021 19:02:32 +0100 Subject: [PATCH 112/297] core-api: if loginPopup is rejected, throw PopupRejectedError --- packages/core-api/src/lib/loginPopup.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core-api/src/lib/loginPopup.ts b/packages/core-api/src/lib/loginPopup.ts index 2e14447882..425998f4bc 100644 --- a/packages/core-api/src/lib/loginPopup.ts +++ b/packages/core-api/src/lib/loginPopup.ts @@ -82,7 +82,9 @@ export function showLoginPopup(options: LoginPopupOptions): Promise { let targetOrigin = ''; if (!popup || typeof popup.closed === 'undefined' || popup.closed) { - reject(new Error('Failed to open auth popup.')); + const error = new Error('Failed to open auth popup.'); + error.name = 'PopupRejectedError'; + reject(error); return; } From a68827420942f5a13f478fc54c7401bcbf2dbfa0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 Jan 2021 19:03:26 +0100 Subject: [PATCH 113/297] core-api: fix check to determine whether popup was closed or the messaging was misconfigured --- packages/core-api/src/lib/loginPopup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-api/src/lib/loginPopup.ts b/packages/core-api/src/lib/loginPopup.ts index 425998f4bc..716c4f9651 100644 --- a/packages/core-api/src/lib/loginPopup.ts +++ b/packages/core-api/src/lib/loginPopup.ts @@ -122,7 +122,7 @@ export function showLoginPopup(options: LoginPopupOptions): Promise { const intervalId = setInterval(() => { if (popup.closed) { const errMessage = `Login failed, ${ - targetOrigin !== window.location.origin + targetOrigin && targetOrigin !== window.location.origin ? `Incorrect app origin, expected ${targetOrigin}` : 'popup was closed' }`; From 32c95605fbc3170a241582797a559797005e774b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 Jan 2021 19:06:05 +0100 Subject: [PATCH 114/297] add popup fix changeset --- .changeset/shiny-rabbits-unite.md | 5 +++++ .github/styles/vocab.txt | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/shiny-rabbits-unite.md diff --git a/.changeset/shiny-rabbits-unite.md b/.changeset/shiny-rabbits-unite.md new file mode 100644 index 0000000000..dca9ee0dd9 --- /dev/null +++ b/.changeset/shiny-rabbits-unite.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Fix check that determines whether popup was closed or the messaging was misconfigured. diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 425e6eea6f..d545b380f5 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -130,6 +130,7 @@ middleware minikube Minikube misconfiguration +misconfigured misgendering mkdocs Mkdocs From 01058d486c074c3874243562786370010b85a605 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 Jan 2021 19:13:01 +0100 Subject: [PATCH 115/297] core: add separate prop union for SignInPage for using a single provider --- .../core/src/layout/SignInPage/SignInPage.tsx | 105 ++++++++++++++++-- 1 file changed, 98 insertions(+), 7 deletions(-) diff --git a/packages/core/src/layout/SignInPage/SignInPage.tsx b/packages/core/src/layout/SignInPage/SignInPage.tsx index 83ef8fe8a8..c3e5b75bb7 100644 --- a/packages/core/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core/src/layout/SignInPage/SignInPage.tsx @@ -14,30 +14,38 @@ * limitations under the License. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Page } from '../Page'; import { Header } from '../Header'; import { Content } from '../Content/Content'; import { ContentHeader } from '../ContentHeader/ContentHeader'; -import { Grid } from '@material-ui/core'; +import { Grid, Button, Typography } from '@material-ui/core'; import { SignInPageProps, useApi, configApiRef } from '@backstage/core-api'; import { useSignInProviders, getSignInProviders } from './providers'; -import { IdentityProviders } from './types'; +import { IdentityProviders, SignInConfig } from './types'; import { Progress } from '../../components/Progress'; -import { useStyles } from './styles'; +import { GridItem, useStyles } from './styles'; +import { InfoCard } from '../InfoCard'; -export type Props = SignInPageProps & { +type MultiSignInPageProps = SignInPageProps & { providers: IdentityProviders; title?: string; align?: 'center' | 'left'; }; -export const SignInPage = ({ +type SingleSignInPageProps = SignInPageProps & { + provider: SignInConfig; + auto?: boolean; +}; + +export type Props = MultiSignInPageProps | SingleSignInPageProps; + +export const MultiSignInPage = ({ onResult, providers = [], title, align = 'left', -}: Props) => { +}: MultiSignInPageProps) => { const configApi = useApi(configApiRef); const classes = useStyles(); @@ -69,3 +77,86 @@ export const SignInPage = ({ ); }; + +export const SingleSignInPage = ({ + onResult, + provider, + auto, +}: SingleSignInPageProps) => { + const classes = useStyles(); + const authApi = useApi(provider.apiRef); + const configApi = useApi(configApiRef); + + const [retry, setRetry] = useState<{} | boolean | undefined>(auto); + const [error, setError] = useState(); + + useEffect(() => { + const login = async () => { + const identity = await authApi.getBackstageIdentity({ + instantPopup: true, + }); + + const profile = await authApi.getProfile(); + onResult({ + userId: identity!.id, + profile: profile!, + getIdToken: () => { + return authApi.getBackstageIdentity().then(i => i!.idToken); + }, + signOut: async () => { + await authApi.signOut(); + }, + }); + }; + + if (retry) { + login().catch(setError); + } + }, [onResult, authApi, retry]); + + return ( + +
+ + + + setRetry({})} + > + Sign In + + } + > + {provider.message} + {error && error.name !== 'PopupRejectedError' && ( + + {error.message} + + )} + + + + + + ); +}; + +export const SignInPage = (props: Props) => { + if ('provider' in props) { + return ; + } + + return ; +}; From 23381386d6ee7a659ca24fc166356f70ae8c6a00 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 Jan 2021 19:25:58 +0100 Subject: [PATCH 116/297] docs: update auth docs to mention and prefer single-provider `SignInPage` --- docs/auth/index.md | 3 ++- docs/tutorials/quickstart-app-auth.md | 28 ++++++++++++--------------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/docs/auth/index.md b/docs/auth/index.md index cf468c7bb7..a8ddf17c6b 100644 --- a/docs/auth/index.md +++ b/docs/auth/index.md @@ -86,7 +86,8 @@ to a `guest` identity for all users, without any ID token. To enable sign-in, a `SignInPage` needs to be configured, which in turn has to supply a user to the app. The `@backstage/core` package provides a basic sign-in page that allows both the user and the app developer to choose between a couple of different -sign-in methods. +sign-in methods, or to designate a single provider that may also be logged in to +automatically. ## Further Reading diff --git a/docs/tutorials/quickstart-app-auth.md b/docs/tutorials/quickstart-app-auth.md index 1c661d274c..d322e2ec3c 100644 --- a/docs/tutorials/quickstart-app-auth.md +++ b/docs/tutorials/quickstart-app-auth.md @@ -349,22 +349,18 @@ const app = createApp({ apis, plugins: Object.values(plugins), components: { - SignInPage: props => { - return ( - - ); - }, + SignInPage: props => ( + + ), }, }); ``` From da9f53c605d6850daa88d53b22256c4a2987e04e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 28 Jan 2021 19:31:31 +0100 Subject: [PATCH 117/297] add auto SignInPage changeset --- .changeset/ninety-keys-serve.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/ninety-keys-serve.md diff --git a/.changeset/ninety-keys-serve.md b/.changeset/ninety-keys-serve.md new file mode 100644 index 0000000000..d15884b1c4 --- /dev/null +++ b/.changeset/ninety-keys-serve.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Add a `prop` union for `SignInPage` that allows it to be used for just a single provider, with inline errors, and optionally with automatic sign-in. From c777df180afc3a6bede6d7d7febc0dea1902b396 Mon Sep 17 00:00:00 2001 From: vitorgrenzel Date: Wed, 13 Jan 2021 09:20:58 -0300 Subject: [PATCH 118/297] feat(techdocs-common): add Azure Storage --- .changeset/chilly-dodos-drop.md | 6 + app-config.yaml | 2 +- docs/features/techdocs/README.md | 12 +- docs/features/techdocs/configuration.md | 13 + docs/features/techdocs/using-cloud-storage.md | 61 +++++ .../__mocks__/@azure/storage-blob.ts | 86 +++++++ packages/techdocs-common/package.json | 1 + .../src/stages/publish/azureStorage.test.ts | 149 ++++++++++++ .../src/stages/publish/azureStorage.ts | 223 ++++++++++++++++++ .../src/stages/publish/publish.test.ts | 25 ++ .../src/stages/publish/publish.ts | 4 + .../src/stages/publish/types.ts | 2 +- .../techdocs-backend/src/service/router.ts | 1 + plugins/techdocs/config.d.ts | 39 +++ 14 files changed, 616 insertions(+), 8 deletions(-) create mode 100644 .changeset/chilly-dodos-drop.md create mode 100644 packages/techdocs-common/__mocks__/@azure/storage-blob.ts create mode 100644 packages/techdocs-common/src/stages/publish/azureStorage.test.ts create mode 100644 packages/techdocs-common/src/stages/publish/azureStorage.ts diff --git a/.changeset/chilly-dodos-drop.md b/.changeset/chilly-dodos-drop.md new file mode 100644 index 0000000000..b262c14e4d --- /dev/null +++ b/.changeset/chilly-dodos-drop.md @@ -0,0 +1,6 @@ +--- +'@backstage/techdocs-common': patch +'@backstage/plugin-techdocs-backend': patch +--- + +1. Added option to use Azure Storage as a choice to store the static generated files for TechDocs. diff --git a/app-config.yaml b/app-config.yaml index c150ef5902..09d2e75253 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -80,7 +80,7 @@ techdocs: generators: techdocs: 'docker' # Alternatives - 'local' publisher: - type: 'local' # Alternatives - 'googleGcs' or 'awsS3'. Read documentation for using alternatives. + type: 'local' # Alternatives - 'googleGcs' or 'awsS3' or 'azureStorage'. Read documentation for using alternatives. sentry: organization: my-company diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md index c43d6d978d..24b4410092 100644 --- a/docs/features/techdocs/README.md +++ b/docs/features/techdocs/README.md @@ -108,12 +108,12 @@ providers are used. | GitLab | Yes ✅ | | GitLab Enterprise | Yes ✅ | -| File Storage Provider | Support Status | -| --------------------------------- | ----------------------------------------------------------------- | -| Local Filesystem of Backstage app | Yes ✅ | -| Google Cloud Storage (GCS) | Yes ✅ | -| Amazon Web Services (AWS) S3 | Yes ✅ | -| Azure Storage | No ❌ [#3938](https://github.com/backstage/backstage/issues/3938) | +| File Storage Provider | Support Status | +| --------------------------------- | -------------- | +| Local Filesystem of Backstage app | Yes ✅ | +| Google Cloud Storage (GCS) | Yes ✅ | +| Amazon Web Services (AWS) S3 | Yes ✅ | +| Azure Storage | Yes ✅ | [Reach out to us](#feedback) if you want to request more platforms. diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index 1580abe69e..8d5906b18f 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -84,4 +84,17 @@ techdocs: # https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html region: $env: AWS_REGION + + # Required when techdocs.publisher.type is set to 'azureStorage'. Skip otherwise. + + azureStorage: + # An API key is required to write to a storage container. + credentials: + account: + $env: TECHDOCS_AZURE_STORAGE_ACCOUNT + accountKey: + $env: TECHDOCS_AZURE_STORAGE_ACCOUNT_KEY + + # Azure Storage Container Name + containerName: 'techdocs-storage' ``` diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index 21206dede5..2d906ca1b9 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -195,3 +195,64 @@ Your Backstage app is now ready to use AWS S3 for TechDocs, to store and read the static generated documentation files. When you start the backend of the app, you should be able to see `techdocs info Successfully connected to the AWS S3 bucket` in the logs. + +## Configuring Azure Storage Container with TechDocs + +Follow the +[official Azure Storage documentation](https://docs.microsoft.com/pt-br/javascript/api/@azure/storage-blob/?view=azure-node-latest) +for the latest instructions on the following steps involving Azure Storage. + +**1. Set `techdocs.publisher.type` config in your `app-config.yaml`** + +Set `techdocs.publisher.type` to `'azureStorage'`. + +```yaml +techdocs: + publisher: + type: 'azureStorage' +``` + +**2. Service account credentials** + +To get credentials, access the Azure Portal and go to "Settings > Access Keys", +and get your Storage account name and Primary Key. + +```yaml +techdocs: + publisher: + type: 'azureStorage' + azureStorage: + credentials: + account: 'account' + accountKey: 'accountKey' +``` + +**3. Azure Storage Container** + +Create a dedicated container for TechDocs sites. techdocs-backend will publish +documentation to this container. TechDocs will fetch files from here to serve +documentation in Backstage. + +To create a new container, access "Blob Service > Containers > New Container". + +Set the name of the container to +`techdocs.publisher.azureStorage.containerName`. + +```yaml +techdocs: + publisher: + type: 'azureStorage' + azureStorage: + credentials: + account: 'account' + accountKey: 'accountKey' + containerName: 'name-of-techdocs-storage-container' +``` + +**4. That's it!** + +Your Backstage app is now ready to use Azure Storage for TechDocs, to store and +read the static generated documentation files. When you start the backend of the +app, you should be able to see +`techdocs info Successfully connected to the Azure Storage container` in the +logs. diff --git a/packages/techdocs-common/__mocks__/@azure/storage-blob.ts b/packages/techdocs-common/__mocks__/@azure/storage-blob.ts new file mode 100644 index 0000000000..60a705f6a7 --- /dev/null +++ b/packages/techdocs-common/__mocks__/@azure/storage-blob.ts @@ -0,0 +1,86 @@ +/* + * 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 fs from 'fs'; + +export class BlockBlobClient { + private readonly blobName; + + constructor(blobName: string) { + this.blobName = blobName; + } + + uploadFile(source: string) { + return new Promise((resolve, reject) => { + if (!fs.existsSync(source)) { + reject(''); + } else { + resolve(''); + } + }); + } + + exists() { + return new Promise((resolve, reject) => { + if (fs.existsSync(this.blobName)) { + resolve(true); + } else { + reject({ message: 'The object doest not exist !' }); + } + }); + } +} + +export class ContainerClient { + private readonly containerName; + + constructor(containerName: string) { + this.containerName = containerName; + } + + getProperties() { + return new Promise(resolve => { + resolve(''); + }); + } + + getBlockBlobClient(blobName: string) { + return new BlockBlobClient(blobName); + } +} + +export class BlobServiceClient { + private readonly url; + private readonly credential; + + constructor(url: string, credential?: StorageSharedKeyCredential) { + this.url = url; + this.credential = credential; + } + + getContainerClient(containerName: string) { + return new ContainerClient(containerName); + } +} + +export class StorageSharedKeyCredential { + private readonly accountName; + private readonly accountKey; + + constructor(accountName: string, accountKey: string) { + this.accountName = accountName; + this.accountKey = accountKey; + } +} diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index 64c5c19425..09893bd38e 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -36,6 +36,7 @@ "url": "https://github.com/backstage/backstage/issues" }, "dependencies": { + "@azure/storage-blob": "^12.3.0", "@aws-sdk/client-s3": "^3.1.0", "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", diff --git a/packages/techdocs-common/src/stages/publish/azureStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureStorage.test.ts new file mode 100644 index 0000000000..05b5441bff --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/azureStorage.test.ts @@ -0,0 +1,149 @@ +/* + * 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 mockFs from 'mock-fs'; +import { ConfigReader } from '@backstage/config'; +import { getVoidLogger } from '@backstage/backend-common'; +import { AzureStoragePublish } from './azureStorage'; +import { PublisherBase } from './types'; +import type { Entity } from '@backstage/catalog-model'; + +const createMockEntity = (annotations = {}) => { + return { + apiVersion: 'version', + kind: 'TestKind', + metadata: { + name: 'test-component-name', + namespace: 'test-namespace', + annotations: { + ...annotations, + }, + }, + }; +}; + +const getEntityRootDir = (entity: Entity) => { + const { + kind, + metadata: { namespace, name }, + } = entity; + const entityRootDir = `${namespace}/${kind}/${name}`; + return entityRootDir; +}; + +const logger = getVoidLogger(); +jest.spyOn(logger, 'info').mockReturnValue(logger); +jest.spyOn(logger, 'error').mockReturnValue(logger); + +let publisher: PublisherBase; + +beforeEach(async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureStorage', + azureStorage: { + credentials: { + account: 'account', + accountKey: 'accountKey', + }, + containerName: 'containerName', + }, + }, + }, + }); + + publisher = await AzureStoragePublish.fromConfig(mockConfig, logger); +}); + +describe('AzureStoragePublish', () => { + describe('publish', () => { + it('should publish a directory', async () => { + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': '', + '404.html': '', + assets: { + 'main.css': '', + }, + }, + }); + + expect( + await publisher.publish({ + entity, + directory: entityRootDir, + }), + ).toBeUndefined(); + mockFs.restore(); + }); + + it('should fail to publish a directory', async () => { + const wrongPathToGeneratedDirectory = 'wrong/path/to/generatedDirectory'; + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': '', + '404.html': '', + assets: { + 'main.css': '', + }, + }, + }); + + await publisher + .publish({ + entity, + directory: wrongPathToGeneratedDirectory, + }) + .catch(error => + expect(error).toEqual( + new Error( + `Unable to upload file(s) to Azure Storage. Error Failed to read template directory: ENOENT, no such file or directory '${wrongPathToGeneratedDirectory}'`, + ), + ), + ); + mockFs.restore(); + }); + }); + + describe('hasDocsBeenGenerated', () => { + it('should return true if docs has been generated', async () => { + const entity = createMockEntity(); + const entityRootDir = getEntityRootDir(entity); + + mockFs({ + [entityRootDir]: { + 'index.html': 'file-content', + }, + }); + + expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true); + mockFs.restore(); + }); + + it('should return false if docs has not been generated', async () => { + const entity = createMockEntity(); + + expect(await publisher.hasDocsBeenGenerated(entity)).toBe(false); + }); + }); +}); diff --git a/packages/techdocs-common/src/stages/publish/azureStorage.ts b/packages/techdocs-common/src/stages/publish/azureStorage.ts new file mode 100644 index 0000000000..bc9a6825d9 --- /dev/null +++ b/packages/techdocs-common/src/stages/publish/azureStorage.ts @@ -0,0 +1,223 @@ +/* + * 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 path from 'path'; +import express from 'express'; +import { + BlobServiceClient, + BlobUploadCommonResponse, + StorageSharedKeyCredential, +} from '@azure/storage-blob'; +import { Logger } from 'winston'; +import { Entity, EntityName } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { getHeadersForFileExtension, getFileTreeRecursively } from './helpers'; +import { PublisherBase, PublishRequest } from './types'; + +export class AzureStoragePublish implements PublisherBase { + static async fromConfig( + config: Config, + logger: Logger, + ): Promise { + let account = ''; + let accountKey = ''; + let containerName = ''; + try { + account = config.getString( + 'techdocs.publisher.azureStorage.credentials.account', + ); + accountKey = config.getString( + 'techdocs.publisher.azureStorage.credentials.accountKey', + ); + containerName = config.getString( + 'techdocs.publisher.azureStorage.containerName', + ); + } catch (error) { + throw new Error( + "Since techdocs.publisher.type is set to 'azureStorage' in your app config, " + + 'credentials and containerName are required in techdocs.publisher.azureStorage ' + + 'required to authenticate with Azure Storage.', + ); + } + + const credential = new StorageSharedKeyCredential(account, accountKey); + const storageClient = new BlobServiceClient( + `https://${account}.blob.core.windows.net`, + credential, + ); + + await storageClient + .getContainerClient(containerName) + .getProperties() + .then(() => { + logger.info( + `Successfully connected to the Azure Storage container ${containerName}.`, + ); + }) + .catch(reason => { + logger.error( + `Could not retrieve metadata about the Azure Storage container ${containerName}. ` + + 'Make sure the Azure project and the container exists and the access key located at the path ' + + "techdocs.publisher.azureStorage.credentials defined in app config has the role 'Storage Object Creator'. " + + 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', + ); + throw new Error(`from Azure Storage client library: ${reason.message}`); + }); + + return new AzureStoragePublish(storageClient, containerName, logger); + } + + constructor( + private readonly storageClient: BlobServiceClient, + private readonly containerName: string, + private readonly logger: Logger, + ) { + this.storageClient = storageClient; + this.containerName = containerName; + this.logger = logger; + } + + /** + * Upload all the files from the generated `directory` to the Azure Storage container. + * Directory structure used in the container is - entityNamespace/entityKind/entityName/index.html + */ + async publish({ entity, directory }: PublishRequest): Promise { + try { + // Note: Azure Storage manages creation of parent directories if they do not exist. + // So collecting path of only the files is good enough. + const allFilesToUpload = await getFileTreeRecursively(directory); + + const uploadPromises: Array> = []; + allFilesToUpload.forEach(filePath => { + // Remove the absolute path prefix of the source directory + // Path of all files to upload, relative to the root of the source directory + // e.g. ['index.html', 'sub-page/index.html', 'assets/images/favicon.png'] + const relativeFilePath = filePath.replace(`${directory}/`, ''); + const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + const destination = path.normalize( + `${entityRootDir}/${relativeFilePath}`, + ); // Azure Storage Container file relative path + // TODO: Upload in chunks of ~10 files instead of all files at once. + uploadPromises.push( + this.storageClient + .getContainerClient(this.containerName) + .getBlockBlobClient(destination) + .uploadFile(filePath), + ); + }); + + await Promise.all(uploadPromises).then(() => { + this.logger.info( + `Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`, + ); + }); + return; + } catch (e) { + const errorMessage = `Unable to upload file(s) to Azure Storage. Error ${e.message}`; + this.logger.error(errorMessage); + throw new Error(errorMessage); + } + } + + download(containerName: string, path: string): Promise { + return new Promise((resolve, reject) => { + const fileStreamChunks: Array = []; + this.storageClient + .getContainerClient(containerName) + .getBlockBlobClient(path) + .download() + .then(res => { + const body = res.readableStreamBody; + if (!body) { + reject(new Error(`Unable to parse the response data`)); + return; + } + body + .on('error', e => { + this.logger.error(e.message); + reject(e.message); + }) + .on('data', chunk => { + fileStreamChunks.push(chunk); + }) + .on('end', () => { + resolve(Buffer.concat(fileStreamChunks).toString()); + }); + }); + }); + } + + async fetchTechDocsMetadata(entityName: EntityName): Promise { + const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; + try { + return this.download( + this.containerName, + `${entityRootDir}/techdocs_metadata.json`, + ); + } catch (e) { + this.logger.error(e.message); + throw e; + } + } + + /** + * Express route middleware to serve static files on a route in techdocs-backend. + */ + docsRouter(): express.Handler { + return (req, res) => { + // Trim the leading forward slash + // filePath example - /default/Component/documented-component/index.html + const filePath = req.path.replace(/^\//, ''); + // Files with different extensions (CSS, HTML) need to be served with different headers + const fileExtension = path.extname(filePath); + const responseHeaders = getHeadersForFileExtension(fileExtension); + + try { + this.download(this.containerName, filePath).then(fileContent => { + // Inject response headers + for (const [headerKey, headerValue] of Object.entries( + responseHeaders, + )) { + res.setHeader(headerKey, headerValue); + } + res.send(fileContent); + }); + } catch (e) { + this.logger.error(e.message); + res.status(404).send(e.message); + } + }; + } + + /** + * A helper function which checks if index.html of an Entity's docs site is available. This + * can be used to verify if there are any pre-generated docs available to serve. + */ + async hasDocsBeenGenerated(entity: Entity): Promise { + return new Promise(resolve => { + const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; + this.storageClient + .getContainerClient(this.containerName) + .getBlockBlobClient(`${entityRootDir}/index.html`) + .exists() + .then((response: boolean) => { + resolve(response); + }) + .catch(() => { + resolve(false); + }); + }); + } +} diff --git a/packages/techdocs-common/src/stages/publish/publish.test.ts b/packages/techdocs-common/src/stages/publish/publish.test.ts index 89f2c0ffdd..a97eadb404 100644 --- a/packages/techdocs-common/src/stages/publish/publish.test.ts +++ b/packages/techdocs-common/src/stages/publish/publish.test.ts @@ -22,6 +22,7 @@ import { Publisher } from './publish'; import { LocalPublish } from './local'; import { GoogleGCSPublish } from './googleStorage'; import { AwsS3Publish } from './awsS3'; +import { AzureStoragePublish } from './azureStorage'; const logger = getVoidLogger(); const discovery: jest.Mocked = { @@ -105,4 +106,28 @@ describe('Publisher', () => { }); expect(publisher).toBeInstanceOf(AwsS3Publish); }); + + it('should create Azure Storage publisher from config', async () => { + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureStorage', + azureStorage: { + credentials: { + account: 'account', + accountKey: 'accountKey', + }, + containerName: 'containerName', + }, + }, + }, + }); + + const publisher = await Publisher.fromConfig(mockConfig, { + logger, + discovery, + }); + expect(publisher).toBeInstanceOf(AzureStoragePublish); + }); }); diff --git a/packages/techdocs-common/src/stages/publish/publish.ts b/packages/techdocs-common/src/stages/publish/publish.ts index 82232c2fd1..fc72d2812e 100644 --- a/packages/techdocs-common/src/stages/publish/publish.ts +++ b/packages/techdocs-common/src/stages/publish/publish.ts @@ -21,6 +21,7 @@ import { PublisherType, PublisherBase } from './types'; import { LocalPublish } from './local'; import { GoogleGCSPublish } from './googleStorage'; import { AwsS3Publish } from './awsS3'; +import { AzureStoragePublish } from './azureStorage'; type factoryOptions = { logger: Logger; @@ -47,6 +48,9 @@ export class Publisher { case 'awsS3': logger.info('Creating AWS S3 Bucket publisher for TechDocs'); return AwsS3Publish.fromConfig(config, logger); + case 'azureStorage': + logger.info('Creating Azure Storage Container publisher for TechDocs'); + return AzureStoragePublish.fromConfig(config, logger); case 'local': logger.info('Creating Local publisher for TechDocs'); return new LocalPublish(config, logger, discovery); diff --git a/packages/techdocs-common/src/stages/publish/types.ts b/packages/techdocs-common/src/stages/publish/types.ts index db6a075d43..f67f65ee23 100644 --- a/packages/techdocs-common/src/stages/publish/types.ts +++ b/packages/techdocs-common/src/stages/publish/types.ts @@ -19,7 +19,7 @@ import express from 'express'; /** * Key for all the different types of TechDocs publishers that are supported. */ -export type PublisherType = 'local' | 'googleGcs' | 'awsS3'; +export type PublisherType = 'local' | 'googleGcs' | 'awsS3' | 'azureStorage'; export type PublishRequest = { entity: Entity; diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 5ad23cde84..cbb6efa3dd 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -148,6 +148,7 @@ export async function createRouter({ } break; case 'awsS3': + case 'azureStorage': case 'googleGcs': // This block should be valid for all external storage implementations. So no need to duplicate in future, // add the publisher type in the list here. diff --git a/plugins/techdocs/config.d.ts b/plugins/techdocs/config.d.ts index f9831f27cb..1dd7319f61 100644 --- a/plugins/techdocs/config.d.ts +++ b/plugins/techdocs/config.d.ts @@ -114,6 +114,45 @@ export interface Config { region?: string; }; } + | { + /** + * attr: 'type' - accepts a string value + * e.g. type: 'azureStorage' + * alternatives: 'azureStorage' etc. + * @see http://backstage.io/docs/features/techdocs/configuration + */ + type: 'azureStorage'; + + /** + * azureStorage required when 'type' is set to azureStorage + */ + azureStorage?: { + /** + * Credentials used to access a storage container + * @visibility secret + */ + credentials: { + /** + * Account access name + * attr: 'account' - accepts a string value + * @visibility secret + */ + account: string; + /** + * Account secret primary key + * attr: 'accountKey' - accepts a string value + * @visibility secret + */ + accountKey: string; + }; + /** + * Cloud Storage Container Name + * attr: 'containerName' - accepts a string value + * @visibility backend + */ + containerName: string; + }; + } | { /** * attr: 'type' - accepts a string value From 42494c7e91650459cd1fe5158b5ee9ec38bcd0ba Mon Sep 17 00:00:00 2001 From: vitorgrenzel Date: Wed, 20 Jan 2021 17:21:07 -0300 Subject: [PATCH 119/297] feat(docs): add Azure Blob Storage --- .changeset/chilly-dodos-drop.md | 2 +- docs/features/techdocs/README.md | 2 +- docs/features/techdocs/configuration.md | 20 +++++----- docs/features/techdocs/using-cloud-storage.md | 40 ++++++++++--------- 4 files changed, 34 insertions(+), 30 deletions(-) diff --git a/.changeset/chilly-dodos-drop.md b/.changeset/chilly-dodos-drop.md index b262c14e4d..2af5255e70 100644 --- a/.changeset/chilly-dodos-drop.md +++ b/.changeset/chilly-dodos-drop.md @@ -3,4 +3,4 @@ '@backstage/plugin-techdocs-backend': patch --- -1. Added option to use Azure Storage as a choice to store the static generated files for TechDocs. +1. Added option to use Azure Blob Storage as a choice to store the static generated files for TechDocs. diff --git a/docs/features/techdocs/README.md b/docs/features/techdocs/README.md index 24b4410092..f81f41668d 100644 --- a/docs/features/techdocs/README.md +++ b/docs/features/techdocs/README.md @@ -113,7 +113,7 @@ providers are used. | Local Filesystem of Backstage app | Yes ✅ | | Google Cloud Storage (GCS) | Yes ✅ | | Amazon Web Services (AWS) S3 | Yes ✅ | -| Azure Storage | Yes ✅ | +| Azure Blob Storage | Yes ✅ | [Reach out to us](#feedback) if you want to request more platforms. diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index 8d5906b18f..efed37fc69 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -44,7 +44,7 @@ techdocs: # or you want to use External storage providers like Google Cloud Storage, AWS S3, etc. publisher: - # techdocs.publisher.type can be - 'local' or 'googleGcs' or 'awsS3' (azureStorage to be available in future). + # techdocs.publisher.type can be - 'local' or 'googleGcs' or 'awsS3' or 'azureBlobStorage'. # When set to 'local', techdocs-backend will create a 'static' directory at its root to store generated documentation files. # When set to 'googleGcs', techdocs-backend will use a Google Cloud Storage Bucket to store generated documentation files. # When set to 'awsS3', techdocs-backend will use an Amazon Web Service (AWS) S3 bucket to store generated documentation files. @@ -85,16 +85,18 @@ techdocs: region: $env: AWS_REGION - # Required when techdocs.publisher.type is set to 'azureStorage'. Skip otherwise. + # Required when techdocs.publisher.type is set to 'azureBlobStorage'. Skip otherwise. - azureStorage: - # An API key is required to write to a storage container. + azureBlobStorage: + # (Required) Azure Blob Storage Container Name + containerName: 'techdocs-storage' + + # (Optional) An API key is required to write to a storage container. + # If not set, environment variables will be used to authenticate. + #https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json credentials: account: - $env: TECHDOCS_AZURE_STORAGE_ACCOUNT + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT accountKey: - $env: TECHDOCS_AZURE_STORAGE_ACCOUNT_KEY - - # Azure Storage Container Name - containerName: 'techdocs-storage' + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY ``` diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index 2d906ca1b9..9ae14abfe3 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -196,20 +196,20 @@ the static generated documentation files. When you start the backend of the app, you should be able to see `techdocs info Successfully connected to the AWS S3 bucket` in the logs. -## Configuring Azure Storage Container with TechDocs +## Configuring Azure Blob Storage Container with TechDocs Follow the -[official Azure Storage documentation](https://docs.microsoft.com/pt-br/javascript/api/@azure/storage-blob/?view=azure-node-latest) -for the latest instructions on the following steps involving Azure Storage. +[official Azure Blob Storage documentation](https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json) +for the latest instructions on the following steps involving Azure Blob Storage. **1. Set `techdocs.publisher.type` config in your `app-config.yaml`** -Set `techdocs.publisher.type` to `'azureStorage'`. +Set `techdocs.publisher.type` to `'azureBlobStorage'`. ```yaml techdocs: publisher: - type: 'azureStorage' + type: 'azureBlobStorage' ``` **2. Service account credentials** @@ -220,14 +220,14 @@ and get your Storage account name and Primary Key. ```yaml techdocs: publisher: - type: 'azureStorage' - azureStorage: + type: 'azureBlobStorage' + azureBlobStorage: credentials: account: 'account' accountKey: 'accountKey' ``` -**3. Azure Storage Container** +**3. Azure Blob Storage Container** Create a dedicated container for TechDocs sites. techdocs-backend will publish documentation to this container. TechDocs will fetch files from here to serve @@ -236,23 +236,25 @@ documentation in Backstage. To create a new container, access "Blob Service > Containers > New Container". Set the name of the container to -`techdocs.publisher.azureStorage.containerName`. +`techdocs.publisher.azureBlobStorage.containerName`. ```yaml techdocs: publisher: - type: 'azureStorage' - azureStorage: - credentials: - account: 'account' - accountKey: 'accountKey' + type: 'azureBlobStorage' + azureBlobStorage: containerName: 'name-of-techdocs-storage-container' + credentials: + account: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT + accountKey: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY ``` **4. That's it!** -Your Backstage app is now ready to use Azure Storage for TechDocs, to store and -read the static generated documentation files. When you start the backend of the -app, you should be able to see -`techdocs info Successfully connected to the Azure Storage container` in the -logs. +Your Backstage app is now ready to use Azure Blob Storage for TechDocs, to store +and read the static generated documentation files. When you start the backend of +the app, you should be able to see +`techdocs info Successfully connected to the Azure Blob Storage container` in +the logs. From 59b8d5a0d94110b5d4c7ed9cc89076170e72a817 Mon Sep 17 00:00:00 2001 From: vitorgrenzel Date: Wed, 20 Jan 2021 17:21:40 -0300 Subject: [PATCH 120/297] feat(techdocs-common): add Azure Blob Storage --- app-config.yaml | 2 +- ...orage.test.ts => azureBlobStorage.test.ts} | 12 +- .../{azureStorage.ts => azureBlobStorage.ts} | 103 +++++++++++------- .../src/stages/publish/publish.test.ts | 10 +- .../src/stages/publish/publish.ts | 10 +- .../src/stages/publish/types.ts | 6 +- .../techdocs-backend/src/service/router.ts | 2 +- plugins/techdocs/config.d.ts | 18 +-- 8 files changed, 99 insertions(+), 64 deletions(-) rename packages/techdocs-common/src/stages/publish/{azureStorage.test.ts => azureBlobStorage.test.ts} (89%) rename packages/techdocs-common/src/stages/publish/{azureStorage.ts => azureBlobStorage.ts} (67%) diff --git a/app-config.yaml b/app-config.yaml index 09d2e75253..90cf73f58f 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -80,7 +80,7 @@ techdocs: generators: techdocs: 'docker' # Alternatives - 'local' publisher: - type: 'local' # Alternatives - 'googleGcs' or 'awsS3' or 'azureStorage'. Read documentation for using alternatives. + type: 'local' # Alternatives - 'googleGcs' or 'awsS3' or 'azureBlobStorage'. Read documentation for using alternatives. sentry: organization: my-company diff --git a/packages/techdocs-common/src/stages/publish/azureStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts similarity index 89% rename from packages/techdocs-common/src/stages/publish/azureStorage.test.ts rename to packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts index 05b5441bff..95e4a52f62 100644 --- a/packages/techdocs-common/src/stages/publish/azureStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts @@ -16,7 +16,7 @@ import mockFs from 'mock-fs'; import { ConfigReader } from '@backstage/config'; import { getVoidLogger } from '@backstage/backend-common'; -import { AzureStoragePublish } from './azureStorage'; +import { AzureBlobStoragePublish } from './azureBlobStorage'; import { PublisherBase } from './types'; import type { Entity } from '@backstage/catalog-model'; @@ -54,8 +54,8 @@ beforeEach(async () => { techdocs: { requestUrl: 'http://localhost:7000', publisher: { - type: 'azureStorage', - azureStorage: { + type: 'azureBlobStorage', + azureBlobStorage: { credentials: { account: 'account', accountKey: 'accountKey', @@ -66,10 +66,10 @@ beforeEach(async () => { }, }); - publisher = await AzureStoragePublish.fromConfig(mockConfig, logger); + publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger); }); -describe('AzureStoragePublish', () => { +describe('AzureBlobStoragePublish', () => { describe('publish', () => { it('should publish a directory', async () => { const entity = createMockEntity(); @@ -117,7 +117,7 @@ describe('AzureStoragePublish', () => { .catch(error => expect(error).toEqual( new Error( - `Unable to upload file(s) to Azure Storage. Error Failed to read template directory: ENOENT, no such file or directory '${wrongPathToGeneratedDirectory}'`, + `Unable to upload file(s) to Azure Blob Storage. Error Failed to read template directory: ENOENT, no such file or directory '${wrongPathToGeneratedDirectory}'`, ), ), ); diff --git a/packages/techdocs-common/src/stages/publish/azureStorage.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts similarity index 67% rename from packages/techdocs-common/src/stages/publish/azureStorage.ts rename to packages/techdocs-common/src/stages/publish/azureBlobStorage.ts index bc9a6825d9..7ba4d35892 100644 --- a/packages/techdocs-common/src/stages/publish/azureStorage.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts @@ -24,34 +24,45 @@ import { Logger } from 'winston'; import { Entity, EntityName } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { getHeadersForFileExtension, getFileTreeRecursively } from './helpers'; -import { PublisherBase, PublishRequest } from './types'; +import { PublisherBase, PublishRequest, TechDocsMetadata } from './types'; +import limiterFactory from 'p-limit'; +import JSON5 from 'json5'; -export class AzureStoragePublish implements PublisherBase { +// The number of batches that may be ongoing at the same time. +const BATCH_CONCURRENCY = 3; + +export class AzureBlobStoragePublish implements PublisherBase { static async fromConfig( config: Config, logger: Logger, ): Promise { - let account = ''; - let accountKey = ''; let containerName = ''; try { - account = config.getString( - 'techdocs.publisher.azureStorage.credentials.account', - ); - accountKey = config.getString( - 'techdocs.publisher.azureStorage.credentials.accountKey', - ); containerName = config.getString( - 'techdocs.publisher.azureStorage.containerName', + 'techdocs.publisher.azureBlobStorage.containerName', ); } catch (error) { throw new Error( - "Since techdocs.publisher.type is set to 'azureStorage' in your app config, " + - 'credentials and containerName are required in techdocs.publisher.azureStorage ' + - 'required to authenticate with Azure Storage.', + "Since techdocs.publisher.type is set to 'awsS3' in your app config, " + + 'techdocs.publisher.awsS3.bucketName is required.', ); } + // Credentials is an optional config. If missing, default AWS environment variables + // or AWS shared credentials file at ~/.aws/credentials will be used to authenticate + // https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html + // https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-shared.html + let account = ''; + let accountKey = ''; + account = + config.getOptionalString( + 'techdocs.publisher.azureBlobStorage.credentials.account', + ) || ''; + accountKey = + config.getOptionalString( + 'techdocs.publisher.azureBlobStorage.credentials.accountKey', + ) || ''; + const credential = new StorageSharedKeyCredential(account, accountKey); const storageClient = new BlobServiceClient( `https://${account}.blob.core.windows.net`, @@ -63,20 +74,22 @@ export class AzureStoragePublish implements PublisherBase { .getProperties() .then(() => { logger.info( - `Successfully connected to the Azure Storage container ${containerName}.`, + `Successfully connected to the Azure Blob Storage container ${containerName}.`, ); }) .catch(reason => { logger.error( - `Could not retrieve metadata about the Azure Storage container ${containerName}. ` + + `Could not retrieve metadata about the Azure Blob Storage container ${containerName}. ` + 'Make sure the Azure project and the container exists and the access key located at the path ' + - "techdocs.publisher.azureStorage.credentials defined in app config has the role 'Storage Object Creator'. " + + "techdocs.publisher.azureBlobStorage.credentials defined in app config has the role 'Storage Object Creator'. " + 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', ); - throw new Error(`from Azure Storage client library: ${reason.message}`); + throw new Error( + `from Azure Blob Storage client library: ${reason.message}`, + ); }); - return new AzureStoragePublish(storageClient, containerName, logger); + return new AzureBlobStoragePublish(storageClient, containerName, logger); } constructor( @@ -90,17 +103,23 @@ export class AzureStoragePublish implements PublisherBase { } /** - * Upload all the files from the generated `directory` to the Azure Storage container. + * Upload all the files from the generated `directory` to the Azure Blob Storage container. * Directory structure used in the container is - entityNamespace/entityKind/entityName/index.html */ async publish({ entity, directory }: PublishRequest): Promise { try { - // Note: Azure Storage manages creation of parent directories if they do not exist. + // Note: Azure Blob Storage manages creation of parent directories if they do not exist. // So collecting path of only the files is good enough. const allFilesToUpload = await getFileTreeRecursively(directory); const uploadPromises: Array> = []; - allFilesToUpload.forEach(filePath => { + + // Bound the number of concurrent batches. We want a bit of concurrency for + // performance reasons, but not so much that we starve the connection pool + // or start thrashing. + const limiter = limiterFactory(BATCH_CONCURRENCY); + + const promises = allFilesToUpload.map(filePath => { // Remove the absolute path prefix of the source directory // Path of all files to upload, relative to the root of the source directory // e.g. ['index.html', 'sub-page/index.html', 'assets/images/favicon.png'] @@ -108,30 +127,33 @@ export class AzureStoragePublish implements PublisherBase { const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`; const destination = path.normalize( `${entityRootDir}/${relativeFilePath}`, - ); // Azure Storage Container file relative path + ); // Azure Blob Storage Container file relative path + // TODO: Upload in chunks of ~10 files instead of all files at once. - uploadPromises.push( - this.storageClient - .getContainerClient(this.containerName) - .getBlockBlobClient(destination) - .uploadFile(filePath), - ); + return limiter(async () => { + await uploadPromises.push( + this.storageClient + .getContainerClient(this.containerName) + .getBlockBlobClient(destination) + .uploadFile(filePath), + ); + }); }); - await Promise.all(uploadPromises).then(() => { + await Promise.all(promises).then(() => { this.logger.info( `Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`, ); }); return; } catch (e) { - const errorMessage = `Unable to upload file(s) to Azure Storage. Error ${e.message}`; + const errorMessage = `Unable to upload file(s) to Azure Blob Storage. Error ${e.message}`; this.logger.error(errorMessage); throw new Error(errorMessage); } } - download(containerName: string, path: string): Promise { + private download(containerName: string, path: string): Promise { return new Promise((resolve, reject) => { const fileStreamChunks: Array = []; this.storageClient @@ -153,19 +175,24 @@ export class AzureStoragePublish implements PublisherBase { fileStreamChunks.push(chunk); }) .on('end', () => { - resolve(Buffer.concat(fileStreamChunks).toString()); + resolve(Buffer.concat(fileStreamChunks)); }); }); }); } - async fetchTechDocsMetadata(entityName: EntityName): Promise { + async fetchTechDocsMetadata( + entityName: EntityName, + ): Promise { const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; try { - return this.download( - this.containerName, - `${entityRootDir}/techdocs_metadata.json`, - ); + return await new Promise(resolve => { + const download = this.download( + this.containerName, + `${entityRootDir}/techdocs_metadata.json`, + ); + resolve(JSON5.parse(download.toString())); + }); } catch (e) { this.logger.error(e.message); throw e; diff --git a/packages/techdocs-common/src/stages/publish/publish.test.ts b/packages/techdocs-common/src/stages/publish/publish.test.ts index a97eadb404..a563e53a9b 100644 --- a/packages/techdocs-common/src/stages/publish/publish.test.ts +++ b/packages/techdocs-common/src/stages/publish/publish.test.ts @@ -22,7 +22,7 @@ import { Publisher } from './publish'; import { LocalPublish } from './local'; import { GoogleGCSPublish } from './googleStorage'; import { AwsS3Publish } from './awsS3'; -import { AzureStoragePublish } from './azureStorage'; +import { AzureBlobStoragePublish } from './azureBlobStorage'; const logger = getVoidLogger(); const discovery: jest.Mocked = { @@ -107,13 +107,13 @@ describe('Publisher', () => { expect(publisher).toBeInstanceOf(AwsS3Publish); }); - it('should create Azure Storage publisher from config', async () => { + it('should create Azure Blob Storage publisher from config', async () => { const mockConfig = new ConfigReader({ techdocs: { requestUrl: 'http://localhost:7000', publisher: { - type: 'azureStorage', - azureStorage: { + type: 'azureBlobStorage', + azureBlobStorage: { credentials: { account: 'account', accountKey: 'accountKey', @@ -128,6 +128,6 @@ describe('Publisher', () => { logger, discovery, }); - expect(publisher).toBeInstanceOf(AzureStoragePublish); + expect(publisher).toBeInstanceOf(AzureBlobStoragePublish); }); }); diff --git a/packages/techdocs-common/src/stages/publish/publish.ts b/packages/techdocs-common/src/stages/publish/publish.ts index fc72d2812e..a4e33a2d9c 100644 --- a/packages/techdocs-common/src/stages/publish/publish.ts +++ b/packages/techdocs-common/src/stages/publish/publish.ts @@ -21,7 +21,7 @@ import { PublisherType, PublisherBase } from './types'; import { LocalPublish } from './local'; import { GoogleGCSPublish } from './googleStorage'; import { AwsS3Publish } from './awsS3'; -import { AzureStoragePublish } from './azureStorage'; +import { AzureBlobStoragePublish } from './azureBlobStorage'; type factoryOptions = { logger: Logger; @@ -48,9 +48,11 @@ export class Publisher { case 'awsS3': logger.info('Creating AWS S3 Bucket publisher for TechDocs'); return AwsS3Publish.fromConfig(config, logger); - case 'azureStorage': - logger.info('Creating Azure Storage Container publisher for TechDocs'); - return AzureStoragePublish.fromConfig(config, logger); + case 'azureBlobStorage': + logger.info( + 'Creating Azure Blob Storage Container publisher for TechDocs', + ); + return AzureBlobStoragePublish.fromConfig(config, logger); case 'local': logger.info('Creating Local publisher for TechDocs'); return new LocalPublish(config, logger, discovery); diff --git a/packages/techdocs-common/src/stages/publish/types.ts b/packages/techdocs-common/src/stages/publish/types.ts index f67f65ee23..5e953deb81 100644 --- a/packages/techdocs-common/src/stages/publish/types.ts +++ b/packages/techdocs-common/src/stages/publish/types.ts @@ -19,7 +19,11 @@ import express from 'express'; /** * Key for all the different types of TechDocs publishers that are supported. */ -export type PublisherType = 'local' | 'googleGcs' | 'awsS3' | 'azureStorage'; +export type PublisherType = + | 'local' + | 'googleGcs' + | 'awsS3' + | 'azureBlobStorage'; export type PublishRequest = { entity: Entity; diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index cbb6efa3dd..dc5e6a4911 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -148,7 +148,7 @@ export async function createRouter({ } break; case 'awsS3': - case 'azureStorage': + case 'azureBlobStorage': case 'googleGcs': // This block should be valid for all external storage implementations. So no need to duplicate in future, // add the publisher type in the list here. diff --git a/plugins/techdocs/config.d.ts b/plugins/techdocs/config.d.ts index 1dd7319f61..f7903ea3fb 100644 --- a/plugins/techdocs/config.d.ts +++ b/plugins/techdocs/config.d.ts @@ -117,21 +117,23 @@ export interface Config { | { /** * attr: 'type' - accepts a string value - * e.g. type: 'azureStorage' - * alternatives: 'azureStorage' etc. + * e.g. type: 'azureBlobStorage' + * alternatives: 'azureBlobStorage' etc. * @see http://backstage.io/docs/features/techdocs/configuration */ - type: 'azureStorage'; + type: 'azureBlobStorage'; /** - * azureStorage required when 'type' is set to azureStorage + * azureBlobStorage required when 'type' is set to azureBlobStorage */ - azureStorage?: { + azureBlobStorage?: { /** - * Credentials used to access a storage container + * (Optional) Credentials used to access a storage container. + * If not set, environment variables will be used to authenticate. + * https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json * @visibility secret */ - credentials: { + credentials?: { /** * Account access name * attr: 'account' - accepts a string value @@ -146,7 +148,7 @@ export interface Config { accountKey: string; }; /** - * Cloud Storage Container Name + * (Required) Cloud Storage Container Name * attr: 'containerName' - accepts a string value * @visibility backend */ From 74d34c8ff9ab85dc8748c333c0348d113d2cab30 Mon Sep 17 00:00:00 2001 From: vitorgrenzel Date: Wed, 20 Jan 2021 18:00:12 -0300 Subject: [PATCH 121/297] feat(docs): update Azure Blob Storage --- docs/features/techdocs/using-cloud-storage.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index 9ae14abfe3..402ae657ea 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -223,8 +223,10 @@ techdocs: type: 'azureBlobStorage' azureBlobStorage: credentials: - account: 'account' - accountKey: 'accountKey' + account: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT + accountKey: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY ``` **3. Azure Blob Storage Container** From 2d8d7697d60d3bc685787f80eaa04d6dc6759219 Mon Sep 17 00:00:00 2001 From: vitorgrenzel Date: Wed, 20 Jan 2021 18:20:17 -0300 Subject: [PATCH 122/297] feat(techdocs-common): update Azure Blob Storage --- packages/techdocs-common/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index 09893bd38e..1834248c3a 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -36,8 +36,8 @@ "url": "https://github.com/backstage/backstage/issues" }, "dependencies": { - "@azure/storage-blob": "^12.3.0", "@aws-sdk/client-s3": "^3.1.0", + "@azure/storage-blob": "^12.4.0", "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", From 0be9694aa6997d78b091ddcbb3fe3a5763ff283c Mon Sep 17 00:00:00 2001 From: vitorgrenzel Date: Fri, 22 Jan 2021 16:09:06 -0300 Subject: [PATCH 123/297] feat(techdocs-common): update Azure Blob Storage --- docs/features/techdocs/configuration.md | 12 +-- docs/features/techdocs/using-cloud-storage.md | 76 ++++++++++++------- packages/techdocs-common/package.json | 1 + .../stages/publish/azureBlobStorage.test.ts | 2 +- .../src/stages/publish/azureBlobStorage.ts | 48 +++++++----- .../src/stages/publish/publish.test.ts | 2 +- plugins/techdocs/config.d.ts | 14 ++-- 7 files changed, 96 insertions(+), 59 deletions(-) diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index efed37fc69..f588be7c8c 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -91,12 +91,14 @@ techdocs: # (Required) Azure Blob Storage Container Name containerName: 'techdocs-storage' - # (Optional) An API key is required to write to a storage container. - # If not set, environment variables will be used to authenticate. - #https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json + # (Required) An account name is required to write to a storage blob container. + # https://docs.microsoft.com/pt-br/rest/api/storageservices/authorize-with-shared-key credentials: - account: - $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT + accountName: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME + # (Optional) An account key is required to write to a storage container. + # If missing,AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET environment variable will be used. + # https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json accountKey: $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY ``` diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index 402ae657ea..f384270379 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -212,33 +212,17 @@ techdocs: type: 'azureBlobStorage' ``` -**2. Service account credentials** +**2. Create an Azure Blob Storage Container** -To get credentials, access the Azure Portal and go to "Settings > Access Keys", -and get your Storage account name and Primary Key. +Create a dedicated container for TechDocs sites. +[Refer to the official documentation](https://docs.microsoft.com/pt-br/azure/storage/blobs/storage-quickstart-blobs-portal). -```yaml -techdocs: - publisher: - type: 'azureBlobStorage' - azureBlobStorage: - credentials: - account: - $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT - accountKey: - $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY -``` +TechDocs will publish documentation to this container and will fetch files from +here to serve documentation in Backstage. Note that the container names are +globally unique. -**3. Azure Blob Storage Container** - -Create a dedicated container for TechDocs sites. techdocs-backend will publish -documentation to this container. TechDocs will fetch files from here to serve -documentation in Backstage. - -To create a new container, access "Blob Service > Containers > New Container". - -Set the name of the container to -`techdocs.publisher.azureBlobStorage.containerName`. +Set the config `techdocs.publisher.azureBlobStorage.containerName` in your +`app-config.yaml` to the name of the container you just created. ```yaml techdocs: @@ -246,9 +230,49 @@ techdocs: type: 'azureBlobStorage' azureBlobStorage: containerName: 'name-of-techdocs-storage-container' +``` + +**3a. (Recommended) Authentication using environment variable** + +Set the config `techdocs.publisher.azureBlobStorage.credentials.accountName` in +your `app-config.yaml` to the your account name. + +The storage blob client will automatically use the environment variable +`AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` to authenticate with +Azure Blob Storage. +https://docs.microsoft.com/pt-br/azure/storage/common/storage-auth-aad for more +details. + +```yaml +techdocs: + publisher: + type: 'azureBlobStorage' + azureBlobStorage: + containerName: 'name-of-techdocs-storage-bucket' credentials: - account: - $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT + accountName: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME +``` + +**3b. Authentication using app-config.yaml** + +If you do not prefer (3a) and optionally like to use a service account, you can +follow these steps. + +To get credentials, access the Azure Portal and go to "Settings > Access Keys", +and get your Storage account name and Primary Key. +https://docs.microsoft.com/pt-br/rest/api/storageservices/authorize-with-shared-key +for more details. + +```yaml +techdocs: + publisher: + type: 'azureBlobStorage' + azureBlobStorage: + containerName: 'name-of-techdocs-storage-bucket' + credentials: + accountName: + $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME accountKey: $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY ``` diff --git a/packages/techdocs-common/package.json b/packages/techdocs-common/package.json index 1834248c3a..2a065600a1 100644 --- a/packages/techdocs-common/package.json +++ b/packages/techdocs-common/package.json @@ -37,6 +37,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1.0", + "@azure/identity": "^1.2.2", "@azure/storage-blob": "^12.4.0", "@backstage/backend-common": "^0.5.1", "@backstage/catalog-model": "^0.7.0", diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts index 95e4a52f62..9d2ee1ba9b 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts @@ -57,7 +57,7 @@ beforeEach(async () => { type: 'azureBlobStorage', azureBlobStorage: { credentials: { - account: 'account', + accountName: 'accountName', accountKey: 'accountKey', }, containerName: 'containerName', diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts index 7ba4d35892..abfa9d71bf 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts @@ -20,6 +20,7 @@ import { BlobUploadCommonResponse, StorageSharedKeyCredential, } from '@azure/storage-blob'; +import { DefaultAzureCredential } from '@azure/identity'; import { Logger } from 'winston'; import { Entity, EntityName } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; @@ -43,29 +44,39 @@ export class AzureBlobStoragePublish implements PublisherBase { ); } catch (error) { throw new Error( - "Since techdocs.publisher.type is set to 'awsS3' in your app config, " + - 'techdocs.publisher.awsS3.bucketName is required.', + "Since techdocs.publisher.type is set to 'azureBlobStorage' in your app config, " + + 'techdocs.publisher.azureBlobStorage.containerName is required.', ); } - // Credentials is an optional config. If missing, default AWS environment variables - // or AWS shared credentials file at ~/.aws/credentials will be used to authenticate - // https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html - // https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-shared.html - let account = ''; - let accountKey = ''; - account = - config.getOptionalString( - 'techdocs.publisher.azureBlobStorage.credentials.account', - ) || ''; - accountKey = - config.getOptionalString( - 'techdocs.publisher.azureBlobStorage.credentials.accountKey', - ) || ''; + let accountName = ''; + try { + accountName = config.getString( + 'techdocs.publisher.azureBlobStorage.credentials.accountName', + ); + } catch (error) { + throw new Error( + "Since techdocs.publisher.type is set to 'azureBlobStorage' in your app config, " + + 'techdocs.publisher.azureBlobStorage.credentials.accountName is required.', + ); + } + + // Credentials is an optional config. If missing, default Azure Blob Storage environment variables + // https://docs.microsoft.com/pt-br/azure/storage/common/storage-auth-aad-app + const accountKey = config.getOptionalString( + 'techdocs.publisher.azureBlobStorage.credentials.accountKey', + ); + + let credential; + if (accountKey) { + console.log('accountKey =>', accountKey); + credential = new StorageSharedKeyCredential(accountName, accountKey); + } else { + credential = new DefaultAzureCredential(); + } - const credential = new StorageSharedKeyCredential(account, accountKey); const storageClient = new BlobServiceClient( - `https://${account}.blob.core.windows.net`, + `https://${accountName}.blob.core.windows.net`, credential, ); @@ -129,7 +140,6 @@ export class AzureBlobStoragePublish implements PublisherBase { `${entityRootDir}/${relativeFilePath}`, ); // Azure Blob Storage Container file relative path - // TODO: Upload in chunks of ~10 files instead of all files at once. return limiter(async () => { await uploadPromises.push( this.storageClient diff --git a/packages/techdocs-common/src/stages/publish/publish.test.ts b/packages/techdocs-common/src/stages/publish/publish.test.ts index a563e53a9b..4ae59a597e 100644 --- a/packages/techdocs-common/src/stages/publish/publish.test.ts +++ b/packages/techdocs-common/src/stages/publish/publish.test.ts @@ -115,7 +115,7 @@ describe('Publisher', () => { type: 'azureBlobStorage', azureBlobStorage: { credentials: { - account: 'account', + accountName: 'accountName', accountKey: 'accountKey', }, containerName: 'containerName', diff --git a/plugins/techdocs/config.d.ts b/plugins/techdocs/config.d.ts index f7903ea3fb..0af3ab4290 100644 --- a/plugins/techdocs/config.d.ts +++ b/plugins/techdocs/config.d.ts @@ -128,24 +128,24 @@ export interface Config { */ azureBlobStorage?: { /** - * (Optional) Credentials used to access a storage container. - * If not set, environment variables will be used to authenticate. - * https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json + * (Required) Credentials used to access a storage container. * @visibility secret */ - credentials?: { + credentials: { /** * Account access name * attr: 'account' - accepts a string value * @visibility secret */ - account: string; + accountName: string; /** - * Account secret primary key + * (Optional) Account secret primary key + * If not set, environment variables will be used to authenticate. + * https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json * attr: 'accountKey' - accepts a string value * @visibility secret */ - accountKey: string; + accountKey?: string; }; /** * (Required) Cloud Storage Container Name From 0024d6d8c0c44ce074cc047729c77fea3448fa47 Mon Sep 17 00:00:00 2001 From: vitorgrenzel Date: Mon, 25 Jan 2021 10:01:30 -0300 Subject: [PATCH 124/297] feat(techdocs-common): update Azure Blob Storage --- docs/features/techdocs/using-cloud-storage.md | 6 ++-- .../src/stages/publish/azureBlobStorage.ts | 9 +++--- .../src/stages/publish/publish.test.ts | 31 +++++++++++++++++++ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index f384270379..393e478302 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -215,7 +215,7 @@ techdocs: **2. Create an Azure Blob Storage Container** Create a dedicated container for TechDocs sites. -[Refer to the official documentation](https://docs.microsoft.com/pt-br/azure/storage/blobs/storage-quickstart-blobs-portal). +[Refer to the official documentation](https://docs.microsoft.com/en-us/azure/storage/blobs/storage-quickstart-blobs-portal). TechDocs will publish documentation to this container and will fetch files from here to serve documentation in Backstage. Note that the container names are @@ -240,7 +240,9 @@ your `app-config.yaml` to the your account name. The storage blob client will automatically use the environment variable `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` to authenticate with Azure Blob Storage. -https://docs.microsoft.com/pt-br/azure/storage/common/storage-auth-aad for more +[Steps to create the service where the variables can be retrieved from](https://docs.microsoft.com/en-us/azure/active-directory/develop/howto-create-service-principal-portal). + +https://docs.microsoft.com/en-us/azure/storage/common/storage-auth-aad for more details. ```yaml diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts index abfa9d71bf..1b4e59a714 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.ts @@ -61,15 +61,14 @@ export class AzureBlobStoragePublish implements PublisherBase { ); } - // Credentials is an optional config. If missing, default Azure Blob Storage environment variables - // https://docs.microsoft.com/pt-br/azure/storage/common/storage-auth-aad-app + // Credentials is an optional config. If missing, default Azure Blob Storage environment variables will be used. + // https://docs.microsoft.com/en-us/azure/storage/common/storage-auth-aad-app const accountKey = config.getOptionalString( 'techdocs.publisher.azureBlobStorage.credentials.accountKey', ); let credential; if (accountKey) { - console.log('accountKey =>', accountKey); credential = new StorageSharedKeyCredential(accountName, accountKey); } else { credential = new DefaultAzureCredential(); @@ -91,8 +90,8 @@ export class AzureBlobStoragePublish implements PublisherBase { .catch(reason => { logger.error( `Could not retrieve metadata about the Azure Blob Storage container ${containerName}. ` + - 'Make sure the Azure project and the container exists and the access key located at the path ' + - "techdocs.publisher.azureBlobStorage.credentials defined in app config has the role 'Storage Object Creator'. " + + 'Make sure that the Azure project and container exist and the access key is setup correctly ' + + 'techdocs.publisher.azureBlobStorage.credentials defined in app config has correct permissions. ' + 'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage', ); throw new Error( diff --git a/packages/techdocs-common/src/stages/publish/publish.test.ts b/packages/techdocs-common/src/stages/publish/publish.test.ts index 4ae59a597e..d1faf82b78 100644 --- a/packages/techdocs-common/src/stages/publish/publish.test.ts +++ b/packages/techdocs-common/src/stages/publish/publish.test.ts @@ -31,6 +31,10 @@ const discovery: jest.Mocked = { }; describe('Publisher', () => { + beforeEach(() => { + jest.resetModules(); // clear the cache + }); + it('should create local publisher by default', async () => { const mockConfig = new ConfigReader({ techdocs: { @@ -130,4 +134,31 @@ describe('Publisher', () => { }); expect(publisher).toBeInstanceOf(AzureBlobStoragePublish); }); + + it('should create Azure Blob Storage publisher from environment variables', async () => { + process.env.AZURE_TENANT_ID = 'AZURE_TENANT_ID'; + process.env.AZURE_CLIENT_ID = 'AZURE_CLIENT_ID'; + process.env.AZURE_CLIENT_SECRET = 'AZURE_CLIENT_SECRET'; + + const mockConfig = new ConfigReader({ + techdocs: { + requestUrl: 'http://localhost:7000', + publisher: { + type: 'azureBlobStorage', + azureBlobStorage: { + credentials: { + accountName: 'accountName', + }, + containerName: 'containerName', + }, + }, + }, + }); + + const publisher = await Publisher.fromConfig(mockConfig, { + logger, + discovery, + }); + expect(publisher).toBeInstanceOf(AzureBlobStoragePublish); + }); }); From 9197ac3a0baf3281d016e95c7fdb1c0f2ce8b32f Mon Sep 17 00:00:00 2001 From: vitorgrenzel Date: Mon, 25 Jan 2021 10:20:56 -0300 Subject: [PATCH 125/297] feat(techdocs-common): update tests Azure Blob Storage --- packages/techdocs-common/__mocks__/@azure/identity.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/techdocs-common/__mocks__/@azure/identity.ts diff --git a/packages/techdocs-common/__mocks__/@azure/identity.ts b/packages/techdocs-common/__mocks__/@azure/identity.ts new file mode 100644 index 0000000000..0fb8a75ebd --- /dev/null +++ b/packages/techdocs-common/__mocks__/@azure/identity.ts @@ -0,0 +1 @@ +export class DefaultAzureCredential {} From a2feeb288d08ff7e1f8daf9e323d584e7ee57e9b Mon Sep 17 00:00:00 2001 From: vitorgrenzel Date: Mon, 25 Jan 2021 11:24:12 -0300 Subject: [PATCH 126/297] feat(techdocs-common): update tests Azure Blob Storage --- .../__mocks__/@azure/identity.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/techdocs-common/__mocks__/@azure/identity.ts b/packages/techdocs-common/__mocks__/@azure/identity.ts index 0fb8a75ebd..cc89a4d514 100644 --- a/packages/techdocs-common/__mocks__/@azure/identity.ts +++ b/packages/techdocs-common/__mocks__/@azure/identity.ts @@ -1 +1,20 @@ -export class DefaultAzureCredential {} +/* + * 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 class DefaultAzureCredential { + /** + * Creates an instance of the DefaultAzureCredential class. + */ +} From ee271bf86ed398d9cb27f26a588adbf5a8f87732 Mon Sep 17 00:00:00 2001 From: vitorgrenzel Date: Mon, 25 Jan 2021 11:46:25 -0300 Subject: [PATCH 127/297] feat(docs): update Azure Blob Storage --- docs/features/techdocs/configuration.md | 2 +- docs/features/techdocs/using-cloud-storage.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index f588be7c8c..bb44050dcf 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -92,7 +92,7 @@ techdocs: containerName: 'techdocs-storage' # (Required) An account name is required to write to a storage blob container. - # https://docs.microsoft.com/pt-br/rest/api/storageservices/authorize-with-shared-key + # https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key credentials: accountName: $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_NAME diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index 393e478302..de2d505c87 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -263,7 +263,7 @@ follow these steps. To get credentials, access the Azure Portal and go to "Settings > Access Keys", and get your Storage account name and Primary Key. -https://docs.microsoft.com/pt-br/rest/api/storageservices/authorize-with-shared-key +https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-shared-key for more details. ```yaml From 4f32647f00e79d6a3673e80208d47e063689bcc3 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 28 Jan 2021 16:01:10 +0100 Subject: [PATCH 128/297] TechDocs: docs - Add reference link to techdocs-ref annotation --- docs/features/techdocs/creating-and-publishing.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/features/techdocs/creating-and-publishing.md b/docs/features/techdocs/creating-and-publishing.md index b32bda1e15..c15dc1fe69 100644 --- a/docs/features/techdocs/creating-and-publishing.md +++ b/docs/features/techdocs/creating-and-publishing.md @@ -71,6 +71,11 @@ metadata: # backstage.io/techdocs-ref: url:https://github.com/org/repo/tree/branchName/subFolder ``` +The +[`backstage.io/techdocs-ref` annotation](../software-catalog/well-known-annotations.md#backstageiotechdocs-ref) +is used by TechDocs to download the documentation source files for generating an +Entity's TechDocs site. + Create a `/docs` folder in the root of the project with at least an `index.md` file. _(If you add more markdown files, make sure to update the nav in the mkdocs.yml file to get a proper navigation for your documentation.)_ From 78bb57db06373eeb040de6754ed047fc537c66ba Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Thu, 28 Jan 2021 14:40:44 -0500 Subject: [PATCH 129/297] Update to accordion UX --- .../WarningPanel/WarningPanel.test.tsx | 50 +++++++- .../components/WarningPanel/WarningPanel.tsx | 109 +++++++++++++----- 2 files changed, 126 insertions(+), 33 deletions(-) diff --git a/packages/core/src/components/WarningPanel/WarningPanel.test.tsx b/packages/core/src/components/WarningPanel/WarningPanel.test.tsx index 07a25d34c8..fd7a5f4349 100644 --- a/packages/core/src/components/WarningPanel/WarningPanel.test.tsx +++ b/packages/core/src/components/WarningPanel/WarningPanel.test.tsx @@ -15,23 +15,61 @@ */ import React from 'react'; +import { fireEvent } from '@testing-library/react'; import { renderInTestApp } from '@backstage/test-utils'; +import { Typography } from '@material-ui/core'; import { WarningPanel } from './WarningPanel'; -const minProps = { title: 'Mock title', message: 'Some more info' }; +const propsTitle = { title: 'Mock title' }; +const propsTitleMessage = { title: 'Mock title', message: 'Some more info' }; +const propsMessage = { message: 'Some more info' }; describe('', () => { it('renders without exploding', async () => { - const { getByText } = await renderInTestApp(); - expect(getByText('Mock title')).toBeInTheDocument(); + const { getByText } = await renderInTestApp( + , + ); + expect(getByText('Warning: Mock title')).toBeInTheDocument(); }); - it('renders message and children', async () => { + it('renders title', async () => { const { getByText } = await renderInTestApp( - children, + , ); + const expandIcon = await getByText('Warning: Mock title'); + fireEvent.click(expandIcon); + expect(getByText('Warning: Mock title')).toBeInTheDocument(); expect(getByText('Some more info')).toBeInTheDocument(); - expect(getByText('children')).toBeInTheDocument(); + }); + + it('renders title and children', async () => { + const { getByText } = await renderInTestApp( + + Java stacktrace + , + ); + expect(getByText('Java stacktrace')).toBeInTheDocument(); + }); + + it('renders message', async () => { + const { getByText } = await renderInTestApp( + , + ); + expect(getByText('Warning')).toBeInTheDocument(); + expect(getByText('Some more info')).toBeInTheDocument(); + }); + + it('renders title, message, and children', async () => { + const { getByText } = await renderInTestApp( + + Java stacktrace + , + ); + expect(getByText('Warning: Mock title')).toBeInTheDocument(); + expect(getByText('Some more info')).toBeInTheDocument(); + expect(getByText('Java stacktrace')).toBeInTheDocument(); + // expect(getByText(/Some more info/)).toBeTruthy(); + // expect(getByText(/Java stacktrace/)).toBeTruthy(); }); }); diff --git a/packages/core/src/components/WarningPanel/WarningPanel.tsx b/packages/core/src/components/WarningPanel/WarningPanel.tsx index ae4d2bff02..e55a3c35f4 100644 --- a/packages/core/src/components/WarningPanel/WarningPanel.tsx +++ b/packages/core/src/components/WarningPanel/WarningPanel.tsx @@ -15,8 +15,16 @@ */ import { BackstageTheme } from '@backstage/theme'; -import { makeStyles, Typography } from '@material-ui/core'; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Grid, + makeStyles, + Typography, +} from '@material-ui/core'; import ErrorOutline from '@material-ui/icons/ErrorOutline'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; import React from 'react'; const useErrorOutlineStyles = makeStyles(theme => ({ @@ -29,57 +37,104 @@ const ErrorOutlineStyled = () => { const classes = useErrorOutlineStyles(); return ; }; +const ExpandMoreIconStyled = () => { + const classes = useErrorOutlineStyles(); + return ; +}; const useStyles = makeStyles(theme => ({ - message: { - display: 'flex', - flexDirection: 'column', - padding: theme.spacing(1.5), + panel: { + // display: 'flex', + // flexDirection: 'column', + // padding: theme.spacing(1.5), backgroundColor: theme.palette.warningBackground, color: theme.palette.warningText, verticalAlign: 'middle', }, - header: { + summary: { display: 'flex', flexDirection: 'row', - marginBottom: theme.spacing(1), }, - headerText: { + summaryText: { color: theme.palette.warningText, + fontWeight: 'bold', }, - messageText: { + message: { + width: '100%', + display: 'block', color: theme.palette.warningText, + backgroundColor: theme.palette.warningBackground, + }, + details: { + width: '100%', + display: 'block', + color: theme.palette.textContrast, + backgroundColor: theme.palette.background.default, + border: `1px solid ${theme.palette.border}`, + padding: theme.spacing(2.0), + fontFamily: 'sans-serif', }, })); -/** - * WarningPanel. Show a user friendly error message to a user similar to ErrorPanel except that the warning panel - * only shows the warning message to the user - */ - type Props = { - message?: React.ReactNode; title?: string; + severity?: 'warning' | 'error' | 'info'; + message?: React.ReactNode; children?: React.ReactNode; }; +const capitalize = s => { + if (typeof s !== 'string') return ''; + return s.charAt(0).toUpperCase() + s.slice(1); +}; + +/** + * WarningPanel. Show a user friendly error message to a user similar to ErrorPanel except that the warning panel + * only shows the warning message to the user. + * + * @param {string} [severity=warning] Ability to change the severity of the alert. Not fully implemented. (error, warning, info) + * @param {string} [title] A title for the warning. If not supplied, "Warning" will be used. + * @param {Object} [message] Optional more detailed user-friendly message elaborating on the cause of the error. + * @param {Object} [children] Objects to provide context, such as a stack trace or detailed error reporting. + * Will be available inside an unfolded accordion. + */ export const WarningPanel = (props: Props) => { const classes = useStyles(props); - const { title, message, children } = props; + const { severity, title, message, children } = props; + + // If no severity or title provided, the heading will read simply "Warning" + const subTitle = + (severity ? capitalize(severity) : 'Warning') + (title ? `: ${title}` : ''); + return ( -
-
+ + } + className={classes.summary} + > - - {title} - -
- {message && ( - - {message} + + {subTitle} + + {(message || children) && ( + + + {message && ( + + + {message} + + + )} + {children && ( + + {children} + + )} + + )} - {children} -
+ ); }; From 75731af05d65152de6686376a42a8120202cf3cb Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Thu, 28 Jan 2021 14:41:14 -0500 Subject: [PATCH 130/297] Update WarningPanel examples for accordion --- .../WarningPanel/WarningPanel.stories.tsx | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx b/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx index 5098f0aa31..ef99a0fce4 100644 --- a/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx +++ b/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { WarningPanel } from './WarningPanel'; -import { Link, Button } from '@material-ui/core'; +import { Button, Link, Typography } from '@material-ui/core'; export default { title: 'Feedback/Warning Panel', @@ -25,11 +25,11 @@ export default { export const Default = () => ( - This example entity is missing something. If this is unexpected, please - make sure you have set up everything correctly by following{' '} + This example entity is missing an annotation. If this is unexpected, + please make sure you have set up everything correctly by following{' '} this guide. } @@ -37,9 +37,36 @@ export const Default = () => ( ); export const Children = () => ( - - + + + Supports custom children - for example these text elements. This can be + used to hide/expose stack traces for warnings, like this example: +
+ SyntaxError: Error transforming + /home/user/github/backstage/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx: + Unexpected token (42:16) at unexpected + (/home/user/github/backstage/node_modules/sucrase/dist/parser/traverser/util.js:83:15) + at tsParseMaybeAssignWithJSX + (/home/user/github/backstage/node_modules/sucrase/dist/parser/plugins/typescript.js:1399:22) + at tsParseMaybeAssign + (/home/user/github/backstage/node_modules/sucrase/dist/parser/plugins/typescript.js:1373:12) + at parseMaybeAssign + (/home/user/github/backstage/node_modules/sucrase/dist/parser/traverser/expression.js:118:43) + at parseExprListItem + (/home/user/github/backstage/node_modules/sucrase/dist/parser/traverser/expression.js:969:5) +
+
); + +export const FullExample = () => ( + + HTTP 500 Bad Gateway response from + https://usefulservice.mycompany.com/api/entity?44433 + +); + +export const TitleOnly = () => ; From d822468671cc08259a6de908193da9b5fe8f1b9a Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Thu, 28 Jan 2021 14:42:13 -0500 Subject: [PATCH 131/297] Add changeset --- .changeset/eight-carrots-talk.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/eight-carrots-talk.md diff --git a/.changeset/eight-carrots-talk.md b/.changeset/eight-carrots-talk.md new file mode 100644 index 0000000000..01e38b845d --- /dev/null +++ b/.changeset/eight-carrots-talk.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Update `WarningPanel` component to use accordion-style expansion From 062df71db1c5878866c2ee3a193f6bca516e9ce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 27 Jan 2021 20:57:35 +0100 Subject: [PATCH 132/297] config-loader: Bump to ajv 7, to enable v7 feature use elsewhere --- .changeset/metal-pans-leave.md | 5 +++ packages/cli/package.json | 4 ++- packages/config-loader/package.json | 2 +- .../src/lib/schema/compile.test.ts | 12 +++---- .../config-loader/src/lib/schema/compile.ts | 12 ++++--- .../src/lib/schema/filtering.test.ts | 36 +++++++++---------- .../config-loader/src/lib/schema/filtering.ts | 4 +-- .../config-loader/src/lib/schema/load.test.ts | 2 +- 8 files changed, 43 insertions(+), 34 deletions(-) create mode 100644 .changeset/metal-pans-leave.md diff --git a/.changeset/metal-pans-leave.md b/.changeset/metal-pans-leave.md new file mode 100644 index 0000000000..249ffe7aec --- /dev/null +++ b/.changeset/metal-pans-leave.md @@ -0,0 +1,5 @@ +--- +'@backstage/config-loader': patch +--- + +Bump config-loader to ajv 7, to enable v7 feature use elsewhere diff --git a/packages/cli/package.json b/packages/cli/package.json index ad45a33d93..35ad049741 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -173,7 +173,9 @@ "type": "string", "visibility": "frontend", "description": "Tracking ID for Google Analytics", - "example": "UA-000000-0" + "examples": [ + "UA-000000-0" + ] }, "listen": { "type": "object", diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json index 45ceb36e19..3387477231 100644 --- a/packages/config-loader/package.json +++ b/packages/config-loader/package.json @@ -32,7 +32,7 @@ "dependencies": { "@backstage/cli-common": "^0.1.1", "@backstage/config": "^0.1.1", - "ajv": "^6.12.5", + "ajv": "^7.0.3", "fs-extra": "^9.0.0", "json-schema": "^0.2.5", "json-schema-merge-allof": "^0.7.0", diff --git a/packages/config-loader/src/lib/schema/compile.test.ts b/packages/config-loader/src/lib/schema/compile.test.ts index f03d7e6d10..e1d8ee5999 100644 --- a/packages/config-loader/src/lib/schema/compile.test.ts +++ b/packages/config-loader/src/lib/schema/compile.test.ts @@ -29,11 +29,11 @@ describe('compileConfigSchemas', () => { }, ]); expect(validate([{ data: { a: 1 }, context: 'test' }])).toEqual({ - errors: ['Config should be string { type=string } at .a'], + errors: ['Config should be string { type=string } at /a'], visibilityByPath: new Map(), }); expect(validate([{ data: { b: 'b' }, context: 'test' }])).toEqual({ - errors: ['Config should be number { type=number } at .b'], + errors: ['Config should be number { type=number } at /b'], visibilityByPath: new Map(), }); }); @@ -80,10 +80,10 @@ describe('compileConfigSchemas', () => { ).toEqual({ visibilityByPath: new Map( Object.entries({ - '.a': 'frontend', - '.b': 'secret', - '.d': 'secret', - '.d.0': 'frontend', + '/a': 'frontend', + '/b': 'secret', + '/d': 'secret', + '/d/0': 'frontend', }), ), }); diff --git a/packages/config-loader/src/lib/schema/compile.ts b/packages/config-loader/src/lib/schema/compile.ts index f01a4f640a..2607d51f80 100644 --- a/packages/config-loader/src/lib/schema/compile.ts +++ b/packages/config-loader/src/lib/schema/compile.ts @@ -42,23 +42,25 @@ export function compileConfigSchemas( const ajv = new Ajv({ allErrors: true, + allowUnionTypes: true, schemas: { 'https://backstage.io/schema/config-v1': true, }, - }).addKeyword('visibility', { + }).addKeyword({ + keyword: 'visibility', metaSchema: { type: 'string', enum: CONFIG_VISIBILITIES, }, compile(visibility: ConfigVisibility) { - return (_data, dataPath) => { - if (!dataPath) { + return (_data, context) => { + if (context?.dataPath === undefined) { return false; } if (visibility && visibility !== 'backend') { - const normalizedPath = dataPath.replace( + const normalizedPath = context.dataPath.replace( /\['?(.*?)'?\]/g, - (_, segment) => `.${segment}`, + (_, segment) => `/${segment}`, ); visibilityByPath.set(normalizedPath, visibility); } diff --git a/packages/config-loader/src/lib/schema/filtering.test.ts b/packages/config-loader/src/lib/schema/filtering.test.ts index f6926e3560..feb31ebab0 100644 --- a/packages/config-loader/src/lib/schema/filtering.test.ts +++ b/packages/config-loader/src/lib/schema/filtering.test.ts @@ -40,24 +40,24 @@ const data = { const visibility = new Map( Object.entries({ - '.arr.0': 'frontend', - '.arr.1': 'backend', - '.arr.2': 'secret', - '.obj.f': 'frontend', - '.obj.b': 'backend', - '.obj.b.s': 'secret', - '.objArr.0.f': 'frontend', - '.objArr.0.b': 'backend', - '.objArr.0.s': 'secret', - '.objArr.1.f': 'frontend', - '.objArr.1.b': 'backend', - '.objArr.1.s': 'secret', - '.arrF': 'frontend', - '.arrB': 'backend', - '.arrS': 'secret', - '.objF': 'frontend', - '.objB': 'backend', - '.objS': 'secret', + '/arr/0': 'frontend', + '/arr/1': 'backend', + '/arr/2': 'secret', + '/obj/f': 'frontend', + '/obj/b': 'backend', + '/obj/b/s': 'secret', + '/objArr/0/f': 'frontend', + '/objArr/0/b': 'backend', + '/objArr/0/s': 'secret', + '/objArr/1/f': 'frontend', + '/objArr/1/b': 'backend', + '/objArr/1/s': 'secret', + '/arrF': 'frontend', + '/arrB': 'backend', + '/arrS': 'secret', + '/objF': 'frontend', + '/objB': 'backend', + '/objS': 'secret', }), ); diff --git a/packages/config-loader/src/lib/schema/filtering.ts b/packages/config-loader/src/lib/schema/filtering.ts index 10a97f9a7f..93e899c9d8 100644 --- a/packages/config-loader/src/lib/schema/filtering.ts +++ b/packages/config-loader/src/lib/schema/filtering.ts @@ -49,7 +49,7 @@ export function filterByVisibility( const arr = new Array(); for (const [index, value] of jsonVal.entries()) { - const out = transform(value, `${path}.${index}`); + const out = transform(value, `${path}/${index}`); if (out !== undefined) { arr.push(out); } @@ -68,7 +68,7 @@ export function filterByVisibility( if (value === undefined) { continue; } - const out = transform(value, `${path}.${key}`); + const out = transform(value, `${path}/${key}`); if (out !== undefined) { outObj[key] = out; hasOutput = true; diff --git a/packages/config-loader/src/lib/schema/load.test.ts b/packages/config-loader/src/lib/schema/load.test.ts index baf63525bb..7d9f7cc803 100644 --- a/packages/config-loader/src/lib/schema/load.test.ts +++ b/packages/config-loader/src/lib/schema/load.test.ts @@ -85,7 +85,7 @@ describe('loadConfigSchema', () => { expect(() => schema2.process([...configs, { data: { key1: 3 }, context: 'test2' }]), ).toThrow( - 'Config validation failed, Config should be string { type=string } at .key1', + 'Config validation failed, Config should be string { type=string } at /key1', ); await expect( From 7881f21174cafb91e69bf17d71066f34bc295c76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 27 Jan 2021 21:04:28 +0100 Subject: [PATCH 133/297] catalog-model: introduce jsonschema variants of the yup validation schemas --- .changeset/six-ravens-heal.md | 5 + packages/catalog-model/package.json | 3 +- .../catalog-model/schema/Entity.schema.json | 67 +++++++++++++ .../schema/EntityMeta.schema.json | 89 +++++++++++++++++ .../schema/kinds/API.v1alpha1.schema.json | 79 +++++++++++++++ .../kinds/Component.v1alpha1.schema copy.json | 93 ++++++++++++++++++ .../schema/kinds/Domain.v1alpha1.schema.json | 47 +++++++++ .../schema/kinds/Group.v1alpha1.schema.json | 95 +++++++++++++++++++ .../kinds/Location.v1alpha1.schema.json | 68 +++++++++++++ .../kinds/Resource.v1alpha1.schema.json | 60 ++++++++++++ .../schema/kinds/System.v1alpha1.schema.json | 54 +++++++++++ .../kinds/Template.v1alpha1.schema.json | 94 ++++++++++++++++++ .../schema/kinds/User.v1alpha1.schema.json | 80 ++++++++++++++++ .../schema/shared/common.schema.json | 50 ++++++++++ 14 files changed, 883 insertions(+), 1 deletion(-) create mode 100644 .changeset/six-ravens-heal.md create mode 100644 packages/catalog-model/schema/Entity.schema.json create mode 100644 packages/catalog-model/schema/EntityMeta.schema.json create mode 100644 packages/catalog-model/schema/kinds/API.v1alpha1.schema.json create mode 100644 packages/catalog-model/schema/kinds/Component.v1alpha1.schema copy.json create mode 100644 packages/catalog-model/schema/kinds/Domain.v1alpha1.schema.json create mode 100644 packages/catalog-model/schema/kinds/Group.v1alpha1.schema.json create mode 100644 packages/catalog-model/schema/kinds/Location.v1alpha1.schema.json create mode 100644 packages/catalog-model/schema/kinds/Resource.v1alpha1.schema.json create mode 100644 packages/catalog-model/schema/kinds/System.v1alpha1.schema.json create mode 100644 packages/catalog-model/schema/kinds/Template.v1alpha1.schema.json create mode 100644 packages/catalog-model/schema/kinds/User.v1alpha1.schema.json create mode 100644 packages/catalog-model/schema/shared/common.schema.json diff --git a/.changeset/six-ravens-heal.md b/.changeset/six-ravens-heal.md new file mode 100644 index 0000000000..91ed92c89c --- /dev/null +++ b/.changeset/six-ravens-heal.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': patch +--- + +Introduce jsonschema variants of the yup vlidation schemas diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 0b05f489a1..56d167d390 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -45,6 +45,7 @@ "yaml": "^1.9.2" }, "files": [ - "dist" + "dist", + "schema" ] } diff --git a/packages/catalog-model/schema/Entity.schema.json b/packages/catalog-model/schema/Entity.schema.json new file mode 100644 index 0000000000..803e025796 --- /dev/null +++ b/packages/catalog-model/schema/Entity.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "Entity", + "description": "The format envelope that's common to all versions/kinds of entity.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Component", + "metadata": { + "name": "LoremService", + "description": "Creates Lorems like a pro.", + "labels": { + "product_name": "Random value Generator" + }, + "annotations": { + "docs": "https://github.com/..../tree/develop/doc" + } + }, + "spec": { + "type": "service", + "lifecycle": "production", + "owner": "tools" + } + } + ], + "type": "object", + "required": ["apiVersion", "kind", "metadata"], + "additionalProperties": false, + "properties": { + "apiVersion": { + "type": "string", + "description": "The version of specification format for this particular entity that this is written against.", + "minLength": 1, + "examples": ["backstage.io/v1alpha1", "my-company.net/v1", "1.0"] + }, + "kind": { + "type": "string", + "description": "The high level entity type being described.", + "minLength": 1, + "examples": [ + "API", + "Component", + "Domain", + "Group", + "Location", + "Resource", + "System", + "Template", + "User" + ] + }, + "metadata": { + "$ref": "EntityMeta" + }, + "spec": { + "type": "object", + "description": "The specification data describing the entity itself." + }, + "relations": { + "type": "array", + "description": "The relations that this entity has with other entities.", + "items": { + "$ref": "common#relation" + } + } + } +} diff --git a/packages/catalog-model/schema/EntityMeta.schema.json b/packages/catalog-model/schema/EntityMeta.schema.json new file mode 100644 index 0000000000..d44ca84bf4 --- /dev/null +++ b/packages/catalog-model/schema/EntityMeta.schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "EntityMeta", + "description": "Metadata fields common to all versions/kinds of entity.", + "examples": [ + { + "uid": "e01199ab-08cc-44c2-8e19-5c29ded82521", + "etag": "lsndfkjsndfkjnsdfkjnsd==", + "generation": 13, + "name": "my-component-yay", + "namespace": "the-namespace", + "labels": { + "backstage.io/custom": "ValueStuff" + }, + "annotations": { + "example.com/bindings": "are-secret" + }, + "tags": ["java", "data"] + } + ], + "type": "object", + "required": ["name"], + "additionalProperties": true, + "properties": { + "uid": { + "type": "string", + "description": "A globally unique ID for the entity. This field can not be set by the user at creation time, and the server will reject an attempt to do so. The field will be populated in read operations. The field can (optionally) be specified when performing update or delete operations, but the server is free to reject requests that do so in such a way that it breaks semantics.", + "examples": ["e01199ab-08cc-44c2-8e19-5c29ded82521"], + "minLength": 1 + }, + "etag": { + "type": "string", + "description": "An opaque string that changes for each update operation to any part of the entity, including metadata. This field can not be set by the user at creation time, and the server will reject an attempt to do so. The field will be populated in read operations. The field can (optionally) be specified when performing update or delete operations, and the server will then reject the operation if it does not match the current stored value.", + "examples": ["lsndfkjsndfkjnsdfkjnsd=="], + "minLength": 1 + }, + "generation": { + "type": "integer", + "description": "A positive nonzero number that indicates the current generation of data for this entity; the value is incremented each time the spec changes. This field can not be set by the user at creation time, and the server will reject an attempt to do so. The field will be populated in read operations.", + "examples": [1], + "minimum": 1 + }, + "name": { + "type": "string", + "description": "The name of the entity. Must be unique within the catalog at any given point in time, for any given namespace + kind pair.", + "examples": ["metadata-proxy"], + "minLength": 1 + }, + "namespace": { + "type": "string", + "description": "The namespace that the entity belongs to.", + "default": "default", + "examples": ["default", "admin"], + "minLength": 1 + }, + "description": { + "type": "string", + "description": "A short (typically relatively few words, on one line) description of the entity." + }, + "labels": { + "type": "object", + "description": "Key/value pairs of identifying information attached to the entity.", + "additionalProperties": true, + "patternProperties": { + "^.+$": { + "type": "string" + } + } + }, + "annotations": { + "type": "object", + "description": "Key/value pairs of non-identifying auxiliary information attached to the entity.", + "additionalProperties": true, + "patternProperties": { + "^.+$": { + "type": "string" + } + } + }, + "tags": { + "type": "array", + "description": "A list of single-valued strings, to for example classify catalog entities in various ways.", + "items": { + "type": "string", + "minLength": 1 + } + } + } +} diff --git a/packages/catalog-model/schema/kinds/API.v1alpha1.schema.json b/packages/catalog-model/schema/kinds/API.v1alpha1.schema.json new file mode 100644 index 0000000000..fae1225764 --- /dev/null +++ b/packages/catalog-model/schema/kinds/API.v1alpha1.schema.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "ApiV1alpha1", + "description": "An API describes an interface that can be exposed by a component. The API can be defined in different formats, like OpenAPI, AsyncAPI, GraphQL, gRPC, or other formats.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "API", + "metadata": { + "name": "artist-api", + "description": "Retrieve artist details", + "labels": { + "product_name": "Random value Generator" + }, + "annotations": { + "docs": "https://github.com/..../tree/develop/doc" + } + }, + "spec": { + "type": "openapi", + "lifecycle": "production", + "owner": "artist-relations-team", + "system": "artist-engagement-portal", + "definition": "openapi: \"3.0.0\"\ninfo:..." + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "enum": ["API"] + }, + "spec": { + "type": "object", + "required": ["type", "lifecycle", "owner", "definition"], + "properties": { + "type": { + "type": "string", + "description": "The type of the API definition.", + "examples": ["openapi", "asyncapi", "graphql", "grpc"], + "minLength": 1 + }, + "lifecycle": { + "type": "string", + "description": "The lifecycle state of the API.", + "examples": ["experimental", "production", "deprecated"], + "minLength": 1 + }, + "owner": { + "type": "string", + "description": "An entity reference to the owner of the API.", + "examples": ["artist-relations-team", "user:john.johnson"], + "minLength": 1 + }, + "system": { + "type": "string", + "description": "An entity reference to the system that the API belongs to.", + "minLength": 1 + }, + "definition": { + "type": "string", + "description": "The definition of the API, based on the format defined by the type.", + "minLength": 1 + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/schema/kinds/Component.v1alpha1.schema copy.json b/packages/catalog-model/schema/kinds/Component.v1alpha1.schema copy.json new file mode 100644 index 0000000000..822059a0ea --- /dev/null +++ b/packages/catalog-model/schema/kinds/Component.v1alpha1.schema copy.json @@ -0,0 +1,93 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "ComponentV1alpha1", + "description": "A Component describes a software component. It is typically intimately linked to the source code that constitutes the component, and should be what a developer may regard a \"unit of software\", usually with a distinct deployable or linkable artifact.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Component", + "metadata": { + "name": "LoremService", + "description": "Creates Lorems like a pro.", + "labels": { + "product_name": "Random value Generator" + }, + "annotations": { + "docs": "https://github.com/..../tree/develop/doc" + } + }, + "spec": { + "type": "service", + "lifecycle": "production", + "owner": "tools" + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "enum": ["Component"] + }, + "spec": { + "type": "object", + "required": ["type", "lifecycle", "owner"], + "properties": { + "type": { + "type": "string", + "description": "The type of component.", + "examples": ["service", "website", "library"], + "minLength": 1 + }, + "lifecycle": { + "type": "string", + "description": "The lifecycle state of the component.", + "examples": ["experimental", "production", "deprecated"], + "minLength": 1 + }, + "owner": { + "type": "string", + "description": "An entity reference to the owner of the component.", + "examples": ["artist-relations-team", "user:john.johnson"], + "minLength": 1 + }, + "system": { + "type": "string", + "description": "An entity reference to the system that the component belongs to.", + "minLength": 1 + }, + "subcomponentOf": { + "type": "string", + "description": "An entity reference to another component of which the component is a part.", + "minLength": 1 + }, + "providesApis": { + "type": "array", + "description": "An array of entity references to the APIs that are provided by the component.", + "items": { + "type": "string", + "minLength": 1 + } + }, + "consumesApis": { + "type": "array", + "description": "An array of entity references to the APIs that are consumed by the component.", + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/schema/kinds/Domain.v1alpha1.schema.json b/packages/catalog-model/schema/kinds/Domain.v1alpha1.schema.json new file mode 100644 index 0000000000..a1a09b1213 --- /dev/null +++ b/packages/catalog-model/schema/kinds/Domain.v1alpha1.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "DomainV1alpha1", + "description": "A Domain groups a collection of systems that share terminology, domain models, business purpose, or documentation, i.e. form a bounded context.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Domain", + "metadata": { + "name": "artists", + "description": "Everything about artists" + }, + "spec": { + "owner": "artist-relations-team" + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "enum": ["Domain"] + }, + "spec": { + "type": "object", + "required": ["owner"], + "properties": { + "owner": { + "type": "string", + "description": "An entity reference to the owner of the component.", + "examples": ["artist-relations-team", "user:john.johnson"], + "minLength": 1 + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/schema/kinds/Group.v1alpha1.schema.json b/packages/catalog-model/schema/kinds/Group.v1alpha1.schema.json new file mode 100644 index 0000000000..fc19d98301 --- /dev/null +++ b/packages/catalog-model/schema/kinds/Group.v1alpha1.schema.json @@ -0,0 +1,95 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "GroupV1alpha1", + "description": "A group describes an organizational entity, such as for example a team, a business unit, or a loose collection of people in an interest group. Members of these groups are modeled in the catalog as kind User.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Group", + "metadata": { + "name": "infrastructure", + "description": "The infra business unit" + }, + "spec": { + "type": "business-unit", + "profile": { + "displayName": "Infrastructure", + "email": "infrastructure@example.com", + "picture": "https://example.com/groups/bu-infrastructure.jpeg" + }, + "parent": "ops", + "children": ["backstage", "other"] + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "enum": ["Group"] + }, + "spec": { + "type": "object", + "required": ["type", "children"], + "properties": { + "type": { + "type": "string", + "description": "The type of group. There is currently no enforced set of values for this field, so it is left up to the adopting organization to choose a nomenclature that matches their org hierarchy.", + "examples": ["team", "business-unit", "product-area", "root"], + "minLength": 1 + }, + "profile": { + "type": "object", + "description": "Optional profile information about the group, mainly for display purposes. All fields of this structure are also optional. The email would be a group email of some form, that the group may wish to be used for contacting them. The picture is expected to be a URL pointing to an image that's representative of the group, and that a browser could fetch and render on a group page or similar.", + "properties": { + "displayName": { + "type": "string", + "description": "A simple display name to present to users.", + "examples": ["Infrastructure"], + "minLength": 1 + }, + "email": { + "type": "string", + "description": "An email where this entity can be reached.", + "examples": ["infrastructure@example.com"], + "minLength": 1 + }, + "picture": { + "type": "string", + "description": "The URL of an image that represents this entity.", + "examples": [ + "https://example.com/groups/bu-infrastructure.jpeg" + ], + "minLength": 1 + } + } + }, + "parent": { + "type": "string", + "description": "The immediate parent group in the hierarchy, if any. Not all groups must have a parent; the catalog supports multi-root hierarchies. Groups may however not have more than one parent. This field is an entity reference.", + "examples": ["ops"], + "minLength": 1 + }, + "children": { + "type": "array", + "description": "The immediate child groups of this group in the hierarchy (whose parent field points to this group). The list must be present, but may be empty if there are no child groups. The items are not guaranteed to be ordered in any particular way. The entries of this array are entity references.", + "items": { + "type": "string", + "examples": ["backstage", "other"], + "minLength": 1 + } + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/schema/kinds/Location.v1alpha1.schema.json b/packages/catalog-model/schema/kinds/Location.v1alpha1.schema.json new file mode 100644 index 0000000000..d633d30229 --- /dev/null +++ b/packages/catalog-model/schema/kinds/Location.v1alpha1.schema.json @@ -0,0 +1,68 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "LocationV1alpha1", + "description": "A location is a marker that references other places to look for catalog data.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Location", + "metadata": { + "name": "org-data" + }, + "spec": { + "type": "url", + "targets": [ + "http://github.com/myorg/myproject/org-data-dump/catalog-info-staff.yaml", + "http://github.com/myorg/myproject/org-data-dump/catalog-info-consultants.yaml" + ] + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "enum": ["Location"] + }, + "spec": { + "type": "object", + "required": [], + "properties": { + "type": { + "type": "string", + "description": "The single location type, that's common to the targets specified in the spec. If it is left out, it is inherited from the location type that originally read the entity data.", + "examples": ["url"], + "minLength": 1 + }, + "target": { + "type": "string", + "description": "A single target as a string. Can be either an absolute path/URL (depending on the type), or a relative path such as ./details/catalog-info.yaml which is resolved relative to the location of this Location entity itself.", + "examples": ["./details/catalog-info.yaml"], + "minLength": 1 + }, + "targets": { + "type": "array", + "description": "A list of targets as strings. They can all be either absolute paths/URLs (depending on the type), or relative paths such as ./details/catalog-info.yaml which are resolved relative to the location of this Location entity itself.", + "items": { + "type": "string", + "examples": [ + "./details/catalog-info.yaml", + "http://github.com/myorg/myproject/org-data-dump/catalog-info-staff.yaml" + ], + "minLength": 1 + } + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/schema/kinds/Resource.v1alpha1.schema.json b/packages/catalog-model/schema/kinds/Resource.v1alpha1.schema.json new file mode 100644 index 0000000000..b426dd7f94 --- /dev/null +++ b/packages/catalog-model/schema/kinds/Resource.v1alpha1.schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "ResourceV1alpha1", + "description": "A resource describes the infrastructure a system needs to operate, like BigTable databases, Pub/Sub topics, S3 buckets or CDNs. Modelling them together with components and systems allows to visualize resource footprint, and create tooling around them.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Resource", + "metadata": { + "name": "artists-db", + "description": "Stores artist details" + }, + "spec": { + "type": "database", + "owner": "artist-relations-team", + "system": "artist-engagement-portal" + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "enum": ["Resource"] + }, + "spec": { + "type": "object", + "required": ["type", "owner"], + "properties": { + "type": { + "type": "string", + "description": "The type of resource.", + "examples": ["database", "s3-bucket", "cluster"], + "minLength": 1 + }, + "owner": { + "type": "string", + "description": "An entity reference to the owner of the resource.", + "examples": ["artist-relations-team", "user:john.johnson"], + "minLength": 1 + }, + "system": { + "type": "string", + "description": "An entity reference to the system that the resource belongs to.", + "minLength": 1 + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/schema/kinds/System.v1alpha1.schema.json b/packages/catalog-model/schema/kinds/System.v1alpha1.schema.json new file mode 100644 index 0000000000..2cdbc37076 --- /dev/null +++ b/packages/catalog-model/schema/kinds/System.v1alpha1.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "SystemV1alpha1", + "description": "A system is a collection of resources and components. The system may expose or consume one or several APIs. It is viewed as abstraction level that provides potential consumers insights into exposed features without needing a too detailed view into the details of all components. This also gives the owning team the possibility to decide about published artifacts and APIs.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "System", + "metadata": { + "name": "artist-engagement-portal", + "description": "Handy tools to keep artists in the loop" + }, + "spec": { + "owner": "artist-relations-team", + "domain": "artists" + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "enum": ["System"] + }, + "spec": { + "type": "object", + "required": ["owner"], + "properties": { + "owner": { + "type": "string", + "description": "An entity reference to the owner of the component.", + "examples": ["artist-relations-team", "user:john.johnson"], + "minLength": 1 + }, + "domain": { + "type": "string", + "description": "An entity reference to the domain that the system belongs to.", + "examples": ["artists"], + "minLength": 1 + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/schema/kinds/Template.v1alpha1.schema.json b/packages/catalog-model/schema/kinds/Template.v1alpha1.schema.json new file mode 100644 index 0000000000..5bb83d116b --- /dev/null +++ b/packages/catalog-model/schema/kinds/Template.v1alpha1.schema.json @@ -0,0 +1,94 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "TemplateV1alpha1", + "description": "A Template describes a skeleton for use with the Scaffolder. It is used for describing what templating library is supported, and also for documenting the variables that the template requires using JSON Forms Schema.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Template", + "metadata": { + "name": "react-ssr-template", + "title": "React SSR Template", + "description": "Next.js application skeleton for creating isomorphic web applications.", + "tags": ["recommended", "react"] + }, + "spec": { + "owner": "artist-relations-team", + "templater": "cookiecutter", + "type": "website", + "path": ".", + "schema": { + "required": ["component-id", "description"], + "properties": { + "component_id": { + "title": "Name", + "type": "string", + "description": "Unique name of the component" + }, + "description": { + "title": "Description", + "type": "string", + "description": "Description of the component" + } + } + } + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "enum": ["Template"] + }, + "metadata": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "The nice display name for the template. This field is required as is used to reference the template to the user instead of the metadata.name field.", + "examples": ["React SSR Template"], + "minLength": 1 + } + } + }, + "spec": { + "type": "object", + "required": ["type", "templater", "schema"], + "properties": { + "type": { + "type": "string", + "description": "The type of component. This field is optional but recommended. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.", + "examples": ["service", "website", "library"], + "minLength": 1 + }, + "templater": { + "type": "string", + "description": "The templating library that is supported by the template skeleton.", + "examples": ["cookiecutter"], + "minLength": 1 + }, + "path": { + "type": "string", + "description": "The string location where the templater should be run if it is not on the same level as the template.yaml definition.", + "examples": ["./cookiecutter/skeleton"], + "minLength": 1 + }, + "schema": { + "type": "object", + "description": "The JSONSchema describing the inputs for the template." + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/schema/kinds/User.v1alpha1.schema.json b/packages/catalog-model/schema/kinds/User.v1alpha1.schema.json new file mode 100644 index 0000000000..a71409d5ba --- /dev/null +++ b/packages/catalog-model/schema/kinds/User.v1alpha1.schema.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "UserV1alpha1", + "description": "A user describes a person, such as an employee, a contractor, or similar. Users belong to Group entities in the catalog. These catalog user entries are connected to the way that authentication within the Backstage ecosystem works. See the auth section of the docs for a discussion of these concepts.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "User", + "metadata": { + "name": "jdoe" + }, + "spec": { + "profile": { + "displayName": "Jenny Doe", + "email": "jenny-doe@example.com", + "picture": "https://example.com/staff/jenny-with-party-hat.jpeg" + }, + "memberOf": ["team-b", "employees"] + } + } + ], + "allOf": [ + { + "$ref": "Entity" + }, + { + "type": "object", + "required": ["spec"], + "properties": { + "apiVersion": { + "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"] + }, + "kind": { + "enum": ["User"] + }, + "spec": { + "type": "object", + "required": ["memberOf"], + "properties": { + "profile": { + "type": "object", + "description": "Optional profile information about the user, mainly for display purposes. All fields of this structure are also optional. The email would be a primary email of some form, that the user may wish to be used for contacting them. The picture is expected to be a URL pointing to an image that's representative of the user, and that a browser could fetch and render on a profile page or similar.", + "properties": { + "displayName": { + "type": "string", + "description": "A simple display name to present to users.", + "examples": ["Jenny Doe"], + "minLength": 1 + }, + "email": { + "type": "string", + "description": "An email where this user can be reached.", + "examples": ["jenny-doe@example.com"], + "minLength": 1 + }, + "picture": { + "type": "string", + "description": "The URL of an image that represents this user.", + "examples": [ + "https://example.com/staff/jenny-with-party-hat.jpeg" + ], + "minLength": 1 + } + } + }, + "memberOf": { + "type": "array", + "description": "The list of groups that the user is a direct member of (i.e., no transitive memberships are listed here). The list must be present, but may be empty if the user is not member of any groups. The items are not guaranteed to be ordered in any particular way. The entries of this array are entity references.", + "items": { + "type": "string", + "examples": ["team-b", "employees"], + "minLength": 1 + } + } + } + } + } + } + ] +} diff --git a/packages/catalog-model/schema/shared/common.schema.json b/packages/catalog-model/schema/shared/common.schema.json new file mode 100644 index 0000000000..84ae8a4147 --- /dev/null +++ b/packages/catalog-model/schema/shared/common.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "common", + "type": "object", + "description": "Common definitions to import from other schemas", + "definitions": { + "reference": { + "$id": "#reference", + "type": "object", + "description": "A reference by name to another entity.", + "required": ["kind", "namespace", "name"], + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "description": "The kind field of the entity." + }, + "namespace": { + "type": "string", + "description": "The metadata.namespace field of the entity." + }, + "name": { + "type": "string", + "description": "The metadata.name field of the entity." + } + } + }, + "relation": { + "$id": "#relation", + "type": "object", + "description": "A directed relation from one entity to another.", + "required": ["type", "source", "target"], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "minLength": 1, + "pattern": "^\\w+$", + "description": "The type of relation." + }, + "source": { + "$ref": "#reference" + }, + "target": { + "$ref": "#reference" + } + } + } + } +} From d822077dd69271c450c01b399af0f42413c69733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 27 Jan 2021 21:13:02 +0100 Subject: [PATCH 134/297] catalog-model: unbreak EntityPolicy import cycle --- .../catalog-model/src/EntityPolicies.test.ts | 3 +- packages/catalog-model/src/EntityPolicies.ts | 3 +- .../policies/DefaultNamespaceEntityPolicy.ts | 2 +- .../policies/FieldFormatEntityPolicy.ts | 2 +- .../NoForeignRootFieldsEntityPolicy.ts | 2 +- .../policies/SchemaValidEntityPolicy.ts | 2 +- .../src/entity/policies/index.ts | 1 + .../src/entity/policies/types.ts | 33 +++++++++++++++++++ packages/catalog-model/src/index.ts | 2 +- packages/catalog-model/src/types.ts | 17 ---------- 10 files changed, 41 insertions(+), 26 deletions(-) create mode 100644 packages/catalog-model/src/entity/policies/types.ts diff --git a/packages/catalog-model/src/EntityPolicies.test.ts b/packages/catalog-model/src/EntityPolicies.test.ts index cd869f798c..b67180241e 100644 --- a/packages/catalog-model/src/EntityPolicies.test.ts +++ b/packages/catalog-model/src/EntityPolicies.test.ts @@ -1,4 +1,3 @@ -import { Entity } from './entity'; /* * Copyright 2020 Spotify AB * @@ -15,8 +14,8 @@ import { Entity } from './entity'; * limitations under the License. */ +import { Entity, EntityPolicy } from './entity'; import { EntityPolicies } from './EntityPolicies'; -import { EntityPolicy } from './types'; describe('EntityPolicies', () => { const p1: jest.Mocked = { enforce: jest.fn() }; diff --git a/packages/catalog-model/src/EntityPolicies.ts b/packages/catalog-model/src/EntityPolicies.ts index 1f304f8f27..2d576b1670 100644 --- a/packages/catalog-model/src/EntityPolicies.ts +++ b/packages/catalog-model/src/EntityPolicies.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { Entity } from './entity'; -import { EntityPolicy } from './types'; +import { Entity, EntityPolicy } from './entity'; // Helper that requires that all of a set of policies can be successfully // applied diff --git a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts index 3119164454..4f5bbe04f4 100644 --- a/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/DefaultNamespaceEntityPolicy.ts @@ -15,7 +15,7 @@ */ import lodash from 'lodash'; -import { EntityPolicy } from '../../types'; +import { EntityPolicy } from './types'; import { ENTITY_DEFAULT_NAMESPACE } from '../constants'; import { Entity } from '../Entity'; diff --git a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts index 00add2f63c..1ed13c972c 100644 --- a/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/FieldFormatEntityPolicy.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { EntityPolicy } from '../../types'; +import { EntityPolicy } from './types'; import { CommonValidatorFunctions, KubernetesValidatorFunctions, diff --git a/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.ts b/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.ts index 9d1851bc02..7d401542ba 100644 --- a/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/NoForeignRootFieldsEntityPolicy.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { EntityPolicy } from '../../types'; +import { EntityPolicy } from './types'; import { Entity } from '../Entity'; const defaultKnownFields = ['apiVersion', 'kind', 'metadata', 'spec']; diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts index ed49bea526..2796826b20 100644 --- a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts @@ -15,8 +15,8 @@ */ import * as yup from 'yup'; -import { EntityPolicy } from '../../types'; import { Entity, EntityLink } from '../Entity'; +import { EntityPolicy } from './types'; const DEFAULT_ENTITY_SCHEMA = yup .object({ diff --git a/packages/catalog-model/src/entity/policies/index.ts b/packages/catalog-model/src/entity/policies/index.ts index c381d29a23..5d75ef4d84 100644 --- a/packages/catalog-model/src/entity/policies/index.ts +++ b/packages/catalog-model/src/entity/policies/index.ts @@ -18,3 +18,4 @@ export { DefaultNamespaceEntityPolicy } from './DefaultNamespaceEntityPolicy'; export { FieldFormatEntityPolicy } from './FieldFormatEntityPolicy'; export { NoForeignRootFieldsEntityPolicy } from './NoForeignRootFieldsEntityPolicy'; export { SchemaValidEntityPolicy } from './SchemaValidEntityPolicy'; +export type { EntityPolicy } from './types'; diff --git a/packages/catalog-model/src/entity/policies/types.ts b/packages/catalog-model/src/entity/policies/types.ts new file mode 100644 index 0000000000..415c98bbd2 --- /dev/null +++ b/packages/catalog-model/src/entity/policies/types.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2021 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 { Entity } from '../Entity'; + +/** + * A policy for validation or mutation to be applied to entities as they are + * entering the system. + */ +export type EntityPolicy = { + /** + * Applies validation or mutation on an entity. + * + * @param entity The entity, as validated/mutated so far in the policy tree + * @returns The incoming entity, or a mutated version of the same, or + * undefined if this processor could not handle the entity + * @throws An error if the entity should be rejected + */ + enforce(entity: Entity): Promise; +}; diff --git a/packages/catalog-model/src/index.ts b/packages/catalog-model/src/index.ts index f93a4001a5..976b5f6148 100644 --- a/packages/catalog-model/src/index.ts +++ b/packages/catalog-model/src/index.ts @@ -18,5 +18,5 @@ export * from './entity'; export { EntityPolicies } from './EntityPolicies'; export * from './kinds'; export * from './location'; -export type { EntityName, EntityPolicy, EntityRef, JSONSchema } from './types'; +export type { EntityName, EntityRef, JSONSchema } from './types'; export * from './validation'; diff --git a/packages/catalog-model/src/types.ts b/packages/catalog-model/src/types.ts index aa96e46102..edac03466d 100644 --- a/packages/catalog-model/src/types.ts +++ b/packages/catalog-model/src/types.ts @@ -16,23 +16,6 @@ import { JsonValue } from '@backstage/config'; import { JSONSchema7 } from 'json-schema'; -import type { Entity } from './entity/Entity'; - -/** - * A policy for validation or mutation to be applied to entities as they are - * entering the system. - */ -export type EntityPolicy = { - /** - * Applies validation or mutation on an entity. - * - * @param entity The entity, as validated/mutated so far in the policy tree - * @returns The incoming entity, or a mutated version of the same, or - * undefined if this processor could not handle the entity - * @throws An error if the entity should be rejected - */ - enforce(entity: Entity): Promise; -}; export type JSONSchema = JSONSchema7 & { [key in string]?: JsonValue }; From 919820ea8a0af024459e6b3fd2712ac33c48b6a5 Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Thu, 28 Jan 2021 14:53:42 -0500 Subject: [PATCH 135/297] Update warning text color for accordion --- packages/theme/src/themes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/theme/src/themes.ts b/packages/theme/src/themes.ts index 5b233ac303..fd13343ca8 100644 --- a/packages/theme/src/themes.ts +++ b/packages/theme/src/themes.ts @@ -58,7 +58,7 @@ export const lightTheme = createTheme({ infoBackground: '#ebf5ff', errorText: '#CA001B', infoText: '#004e8a', - warningText: '#FEFEFE', + warningText: '#000000', linkHover: '#2196F3', link: '#0A6EBE', gold: yellow.A700, @@ -120,7 +120,7 @@ export const darkTheme = createTheme({ infoBackground: '#ebf5ff', errorText: '#CA001B', infoText: '#004e8a', - warningText: '#FEFEFE', + warningText: '#000000', linkHover: '#2196F3', link: '#0A6EBE', gold: yellow.A700, From c810082ae6e650cbcfeaaf01272a0f4f4f2f31f3 Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Thu, 28 Jan 2021 14:54:46 -0500 Subject: [PATCH 136/297] Add theme changeset --- .changeset/fair-kids-laugh.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fair-kids-laugh.md diff --git a/.changeset/fair-kids-laugh.md b/.changeset/fair-kids-laugh.md new file mode 100644 index 0000000000..d478508ed9 --- /dev/null +++ b/.changeset/fair-kids-laugh.md @@ -0,0 +1,5 @@ +--- +'@backstage/theme': patch +--- + +Updates warning text color to align to updated `WarningPanel` styling From ff58e9765c214487d3751c1a9138d398d9d6bf1a Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Thu, 28 Jan 2021 15:12:03 -0500 Subject: [PATCH 137/297] Fix TypeScript compile error --- packages/core/src/components/WarningPanel/WarningPanel.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/components/WarningPanel/WarningPanel.tsx b/packages/core/src/components/WarningPanel/WarningPanel.tsx index e55a3c35f4..3da110c411 100644 --- a/packages/core/src/components/WarningPanel/WarningPanel.tsx +++ b/packages/core/src/components/WarningPanel/WarningPanel.tsx @@ -83,8 +83,7 @@ type Props = { children?: React.ReactNode; }; -const capitalize = s => { - if (typeof s !== 'string') return ''; +const capitalize = (s: string) => { return s.charAt(0).toUpperCase() + s.slice(1); }; From 12ece98cdb7ae98e354b59eb9c2847dc85d7a6c3 Mon Sep 17 00:00:00 2001 From: vitorgrenzel Date: Thu, 28 Jan 2021 17:39:44 -0300 Subject: [PATCH 138/297] Add className to the SidebarItem --- .changeset/dirty-carrots-invent.md | 5 +++++ packages/core/package.json | 5 +++-- packages/core/src/layout/Sidebar/Items.test.tsx | 17 +++++++++++++++++ packages/core/src/layout/Sidebar/Items.tsx | 3 +++ 4 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 .changeset/dirty-carrots-invent.md diff --git a/.changeset/dirty-carrots-invent.md b/.changeset/dirty-carrots-invent.md new file mode 100644 index 0000000000..ceb4e4d2bb --- /dev/null +++ b/.changeset/dirty-carrots-invent.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Add className to the SidebarItem diff --git a/packages/core/package.json b/packages/core/package.json index 325689e179..d4592d48ac 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -35,21 +35,22 @@ "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", + "@testing-library/react-hooks": "^3.4.2", "@types/dagre": "^0.7.44", + "@types/prop-types": "^15.7.3", "@types/react": "^16.9", "@types/react-sparklines": "^1.7.0", - "@types/prop-types": "^15.7.3", "classnames": "^2.2.6", "clsx": "^1.1.0", "d3-selection": "^2.0.0", "d3-shape": "^2.0.0", "d3-zoom": "^2.0.0", "dagre": "^0.8.5", - "qs": "^6.9.4", "immer": "^8.0.1", "lodash": "^4.17.15", "material-table": "^1.69.1", "prop-types": "^15.7.2", + "qs": "^6.9.4", "rc-progress": "^3.0.0", "react": "^16.12.0", "react-dom": "^16.12.0", diff --git a/packages/core/src/layout/Sidebar/Items.test.tsx b/packages/core/src/layout/Sidebar/Items.test.tsx index 099a3477d3..fb0ee174f8 100644 --- a/packages/core/src/layout/Sidebar/Items.test.tsx +++ b/packages/core/src/layout/Sidebar/Items.test.tsx @@ -22,8 +22,18 @@ import HomeIcon from '@material-ui/icons/Home'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; import { Sidebar } from './Bar'; import { SidebarItem } from './Items'; +import { renderHook } from '@testing-library/react-hooks'; +import { hexToRgb, makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles({ + spotlight: { + backgroundColor: '#2b2a2a', + }, +}); async function renderSidebar() { + const { result } = renderHook(() => useStyles()); + await renderInTestApp( @@ -31,6 +41,7 @@ async function renderSidebar() { icon={CreateComponentIcon} onClick={() => {}} text="Create..." + className={result.current.spotlight} /> , ); @@ -54,5 +65,11 @@ describe('Items', () => { await screen.findByRole('button', { name: /create/i }), ).toBeInTheDocument(); }); + + it('should render a button with custom style', async () => { + expect( + await screen.findByRole('button', { name: /create/i }), + ).toHaveStyle(`background-color: ${hexToRgb('2b2a2a')}`); + }); }); }); diff --git a/packages/core/src/layout/Sidebar/Items.tsx b/packages/core/src/layout/Sidebar/Items.tsx index e954dcb4f8..57cf435651 100644 --- a/packages/core/src/layout/Sidebar/Items.tsx +++ b/packages/core/src/layout/Sidebar/Items.tsx @@ -128,6 +128,7 @@ type SidebarItemBaseProps = { text?: string; hasNotifications?: boolean; children?: ReactNode; + className?: string; }; type SidebarItemButtonProps = SidebarItemBaseProps & { @@ -154,6 +155,7 @@ export const SidebarItem = forwardRef((props, ref) => { hasNotifications = false, onClick, children, + className, } = props; const classes = useStyles(); // XXX (@koroeskohr): unsure this is optimal. But I just really didn't want to have the item component @@ -193,6 +195,7 @@ export const SidebarItem = forwardRef((props, ref) => { const childProps = { onClick, className: clsx( + className, classes.root, isOpen ? classes.open : classes.closed, isButtonItem(props) && classes.buttonItem, From 025e122c3e5147c91eb9e5b963170c797c0d5383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 27 Jan 2021 21:32:39 +0100 Subject: [PATCH 139/297] catalog-model: replace yup with ajv, for validation of catalog entities --- .changeset/little-pets-cross.md | 5 ++ .changeset/metal-pans-leave.md | 2 +- .changeset/six-ravens-heal.md | 2 +- packages/catalog-model/package.json | 4 +- .../policies/SchemaValidEntityPolicy.ts | 58 ++++++++----------- .../src/kinds/ApiEntityV1alpha1.ts | 24 +++----- .../src/kinds/ComponentEntityV1alpha1.ts | 26 +++------ .../src/kinds/DomainEntityV1alpha1.ts | 20 +++---- .../src/kinds/GroupEntityV1alpha1.ts | 37 +++--------- .../src/kinds/LocationEntityV1alpha1.ts | 22 +++---- .../src/kinds/ResourceEntityV1alpha1.ts | 22 +++---- .../src/kinds/SystemEntityV1alpha1.ts | 21 +++---- .../src/kinds/TemplateEntityV1alpha1.ts | 23 +++----- .../src/kinds/UserEntityV1alpha1.ts | 35 +++-------- packages/catalog-model/src/kinds/util.ts | 44 ++++++++++++-- .../{ => src}/schema/Entity.schema.json | 0 .../{ => src}/schema/EntityMeta.schema.json | 28 +++++++++ .../schema/kinds/API.v1alpha1.schema.json | 0 .../kinds/Component.v1alpha1.schema.json} | 0 .../schema/kinds/Domain.v1alpha1.schema.json | 0 .../schema/kinds/Group.v1alpha1.schema.json | 0 .../kinds/Location.v1alpha1.schema.json | 0 .../kinds/Resource.v1alpha1.schema.json | 0 .../schema/kinds/System.v1alpha1.schema.json | 0 .../kinds/Template.v1alpha1.schema.json | 0 .../schema/kinds/User.v1alpha1.schema.json | 0 .../schema/shared/common.schema.json | 0 yarn.lock | 22 +++++++ 28 files changed, 187 insertions(+), 208 deletions(-) create mode 100644 .changeset/little-pets-cross.md rename packages/catalog-model/{ => src}/schema/Entity.schema.json (100%) rename packages/catalog-model/{ => src}/schema/EntityMeta.schema.json (77%) rename packages/catalog-model/{ => src}/schema/kinds/API.v1alpha1.schema.json (100%) rename packages/catalog-model/{schema/kinds/Component.v1alpha1.schema copy.json => src/schema/kinds/Component.v1alpha1.schema.json} (100%) rename packages/catalog-model/{ => src}/schema/kinds/Domain.v1alpha1.schema.json (100%) rename packages/catalog-model/{ => src}/schema/kinds/Group.v1alpha1.schema.json (100%) rename packages/catalog-model/{ => src}/schema/kinds/Location.v1alpha1.schema.json (100%) rename packages/catalog-model/{ => src}/schema/kinds/Resource.v1alpha1.schema.json (100%) rename packages/catalog-model/{ => src}/schema/kinds/System.v1alpha1.schema.json (100%) rename packages/catalog-model/{ => src}/schema/kinds/Template.v1alpha1.schema.json (100%) rename packages/catalog-model/{ => src}/schema/kinds/User.v1alpha1.schema.json (100%) rename packages/catalog-model/{ => src}/schema/shared/common.schema.json (100%) diff --git a/.changeset/little-pets-cross.md b/.changeset/little-pets-cross.md new file mode 100644 index 0000000000..0f4e2605cd --- /dev/null +++ b/.changeset/little-pets-cross.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': patch +--- + +Replace `yup` with `ajv`, for validation of catalog entities. diff --git a/.changeset/metal-pans-leave.md b/.changeset/metal-pans-leave.md index 249ffe7aec..16d5478e43 100644 --- a/.changeset/metal-pans-leave.md +++ b/.changeset/metal-pans-leave.md @@ -2,4 +2,4 @@ '@backstage/config-loader': patch --- -Bump config-loader to ajv 7, to enable v7 feature use elsewhere +Bump `config-loader` to `ajv` 7, to enable v7 feature use elsewhere diff --git a/.changeset/six-ravens-heal.md b/.changeset/six-ravens-heal.md index 91ed92c89c..960c482be9 100644 --- a/.changeset/six-ravens-heal.md +++ b/.changeset/six-ravens-heal.md @@ -2,4 +2,4 @@ '@backstage/catalog-model': patch --- -Introduce jsonschema variants of the yup vlidation schemas +Introduce json schema variants of the `yup` validation schemas diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 56d167d390..e81bc3a9b6 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -32,6 +32,7 @@ "@backstage/config": "^0.1.2", "@types/json-schema": "^7.0.5", "@types/yup": "^0.29.8", + "ajv": "^7.0.3", "json-schema": "^0.2.5", "lodash": "^4.17.15", "uuid": "^8.0.0", @@ -45,7 +46,6 @@ "yaml": "^1.9.2" }, "files": [ - "dist", - "schema" + "dist" ] } diff --git a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts index 2796826b20..7e0a8df268 100644 --- a/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts +++ b/packages/catalog-model/src/entity/policies/SchemaValidEntityPolicy.ts @@ -14,32 +14,13 @@ * limitations under the License. */ -import * as yup from 'yup'; -import { Entity, EntityLink } from '../Entity'; +import Ajv, { ValidateFunction } from 'ajv'; +import entitySchema from '../../schema/Entity.schema.json'; +import entityMetaSchema from '../../schema/EntityMeta.schema.json'; +import commonSchema from '../../schema/shared/common.schema.json'; +import { Entity } from '../Entity'; import { EntityPolicy } from './types'; -const DEFAULT_ENTITY_SCHEMA = yup - .object({ - apiVersion: yup.string().required(), - kind: yup.string().required(), - metadata: yup - .object({ - uid: yup.string().notRequired().min(1), - etag: yup.string().notRequired().min(1), - generation: yup.number().notRequired().integer().min(1), - name: yup.string().required(), - namespace: yup.string().notRequired(), - description: yup.string().notRequired(), - labels: yup.object>().notRequired(), - annotations: yup.object>().notRequired(), - tags: yup.array().notRequired(), - links: yup.array().notRequired(), - }) - .required(), - spec: yup.object({}).notRequired(), - }) - .required(); - /** * Ensures that the entity spec is valid according to a schema. * @@ -48,17 +29,28 @@ const DEFAULT_ENTITY_SCHEMA = yup * typescript type. */ export class SchemaValidEntityPolicy implements EntityPolicy { - private readonly schema: yup.Schema; - - constructor(schema: yup.Schema = DEFAULT_ENTITY_SCHEMA) { - this.schema = schema; - } + private validate: ValidateFunction | undefined; async enforce(entity: Entity): Promise { - try { - return await this.schema.validate(entity, { strict: true }); - } catch (e) { - throw new Error(`Malformed envelope, ${e}`); + if (!this.validate) { + const ajv = new Ajv({ allowUnionTypes: true }); + this.validate = ajv + .addSchema([commonSchema, entityMetaSchema], undefined, undefined, true) + .compile(entitySchema); } + + const result = this.validate(entity); + if (result === true) { + return entity; + } + + const [error] = this.validate.errors || []; + if (!error) { + throw new Error(`Malformed envelope, Unknown error`); + } + + throw new Error( + `Malformed envelope, ${error.dataPath || ''} ${error.message}`, + ); } } diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts index 2c634ff091..5432cafdeb 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts @@ -14,27 +14,16 @@ * limitations under the License. */ -import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaValidator } from './util'; +import schema from '../schema/kinds/API.v1alpha1.schema.json'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; +import { ajvCompiledJsonSchemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'API' as const; -const schema = yup.object>({ - apiVersion: yup.string().required().oneOf(API_VERSION), - kind: yup.string().required().equals([KIND]), - spec: yup - .object({ - type: yup.string().required().min(1), - lifecycle: yup.string().required().min(1), - owner: yup.string().required().min(1), - definition: yup.string().required().min(1), - system: yup.string().notRequired().min(1), - }) - .required(), -}); - export interface ApiEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; @@ -47,8 +36,9 @@ export interface ApiEntityV1alpha1 extends Entity { }; } -export const apiEntityV1alpha1Validator = schemaValidator( +export const apiEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( KIND, API_VERSION, schema, + [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts index c55c48055a..3006a4288c 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts @@ -14,29 +14,16 @@ * limitations under the License. */ -import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaValidator } from './util'; +import schema from '../schema/kinds/Component.v1alpha1.schema.json'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; +import { ajvCompiledJsonSchemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'Component' as const; -const schema = yup.object>({ - apiVersion: yup.string().required().oneOf(API_VERSION), - kind: yup.string().required().equals([KIND]), - spec: yup - .object({ - type: yup.string().required().min(1), - lifecycle: yup.string().required().min(1), - owner: yup.string().required().min(1), - subcomponentOf: yup.string().notRequired().min(1), - providesApis: yup.array(yup.string().required()).notRequired(), - consumesApis: yup.array(yup.string().required()).notRequired(), - system: yup.string().notRequired().min(1), - }) - .required(), -}); - export interface ComponentEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; @@ -51,8 +38,9 @@ export interface ComponentEntityV1alpha1 extends Entity { }; } -export const componentEntityV1alpha1Validator = schemaValidator( +export const componentEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( KIND, API_VERSION, schema, + [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts index 60b11aa124..7aab35e367 100644 --- a/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts @@ -14,23 +14,16 @@ * limitations under the License. */ -import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaValidator } from './util'; +import schema from '../schema/kinds/Domain.v1alpha1.schema.json'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; +import { ajvCompiledJsonSchemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'Domain' as const; -const schema = yup.object>({ - apiVersion: yup.string().required().oneOf(API_VERSION), - kind: yup.string().required().equals([KIND]), - spec: yup - .object({ - owner: yup.string().required().min(1), - }) - .required(), -}); - export interface DomainEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; @@ -39,8 +32,9 @@ export interface DomainEntityV1alpha1 extends Entity { }; } -export const domainEntityV1alpha1Validator = schemaValidator( +export const domainEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( KIND, API_VERSION, schema, + [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts index 6f2664af77..d039fdaba8 100644 --- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts @@ -14,40 +14,16 @@ * limitations under the License. */ -import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaValidator } from './util'; +import schema from '../schema/kinds/Group.v1alpha1.schema.json'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; +import { ajvCompiledJsonSchemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'Group' as const; -const schema = yup.object>({ - apiVersion: yup.string().required().oneOf(API_VERSION), - kind: yup.string().required().equals([KIND]), - spec: yup - .object({ - type: yup.string().required().min(1), - profile: yup - .object({ - displayName: yup.string().min(1).notRequired(), - email: yup.string().min(1).notRequired(), - picture: yup.string().min(1).notRequired(), - }) - .notRequired(), - parent: yup.string().notRequired().min(1), - // Use these manual tests because yup .required() requires at least - // one element and there is no simple workaround -_- - // the cast is there to convince typescript that the array itself is - // required without using .required() - children: yup.array(yup.string().required()).test({ - name: 'isDefined', - message: 'children must be defined', - test: v => Boolean(v), - }) as yup.ArraySchema, - }) - .required(), -}); - export interface GroupEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; @@ -63,8 +39,9 @@ export interface GroupEntityV1alpha1 extends Entity { }; } -export const groupEntityV1alpha1Validator = schemaValidator( +export const groupEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( KIND, API_VERSION, schema, + [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts index 9cd767de94..fb452b6ac7 100644 --- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts @@ -14,25 +14,16 @@ * limitations under the License. */ -import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaValidator } from './util'; +import schema from '../schema/kinds/Location.v1alpha1.schema.json'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; +import { ajvCompiledJsonSchemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'Location' as const; -const schema = yup.object>({ - apiVersion: yup.string().required().oneOf(API_VERSION), - kind: yup.string().required().equals([KIND]), - spec: yup - .object({ - type: yup.string().notRequired().min(1), - target: yup.string().notRequired().min(1), - targets: yup.array(yup.string().required()).notRequired(), - }) - .required(), -}); - export interface LocationEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; @@ -43,8 +34,9 @@ export interface LocationEntityV1alpha1 extends Entity { }; } -export const locationEntityV1alpha1Validator = schemaValidator( +export const locationEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( KIND, API_VERSION, schema, + [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts index 12df7f6664..520a39f02c 100644 --- a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts @@ -14,25 +14,16 @@ * limitations under the License. */ -import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaValidator } from './util'; +import schema from '../schema/kinds/Resource.v1alpha1.schema.json'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; +import { ajvCompiledJsonSchemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'Resource' as const; -const schema = yup.object>({ - apiVersion: yup.string().required().oneOf(API_VERSION), - kind: yup.string().required().equals([KIND]), - spec: yup - .object({ - type: yup.string().required().min(1), - owner: yup.string().required().min(1), - system: yup.string().notRequired().min(1), - }) - .required(), -}); - export interface ResourceEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; @@ -43,8 +34,9 @@ export interface ResourceEntityV1alpha1 extends Entity { }; } -export const resourceEntityV1alpha1Validator = schemaValidator( +export const resourceEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( KIND, API_VERSION, schema, + [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts index 764514efdd..1ee19466f0 100644 --- a/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts @@ -14,24 +14,16 @@ * limitations under the License. */ -import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaValidator } from './util'; +import schema from '../schema/kinds/System.v1alpha1.schema.json'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; +import { ajvCompiledJsonSchemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'System' as const; -const schema = yup.object>({ - apiVersion: yup.string().required().oneOf(API_VERSION), - kind: yup.string().required().equals([KIND]), - spec: yup - .object({ - owner: yup.string().required().min(1), - domain: yup.string().notRequired().min(1), - }) - .required(), -}); - export interface SystemEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; @@ -41,8 +33,9 @@ export interface SystemEntityV1alpha1 extends Entity { }; } -export const systemEntityV1alpha1Validator = schemaValidator( +export const systemEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( KIND, API_VERSION, schema, + [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts index b16fcdc7e4..72479c7efa 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts @@ -14,27 +14,17 @@ * limitations under the License. */ -import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; +import schema from '../schema/kinds/Template.v1alpha1.schema.json'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; import type { JSONSchema } from '../types'; -import { schemaValidator } from './util'; +import { ajvCompiledJsonSchemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'Template' as const; -const schema = yup.object>({ - apiVersion: yup.string().required().oneOf(API_VERSION), - kind: yup.string().required().equals([KIND]), - spec: yup - .object({ - type: yup.string().required().min(1), - path: yup.string(), - schema: yup.object().required(), - templater: yup.string().required(), - }) - .required(), -}); - export interface TemplateEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; @@ -46,8 +36,9 @@ export interface TemplateEntityV1alpha1 extends Entity { }; } -export const templateEntityV1alpha1Validator = schemaValidator( +export const templateEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( KIND, API_VERSION, schema, + [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts index 16a5a86e05..a8700a496e 100644 --- a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts @@ -14,38 +14,16 @@ * limitations under the License. */ -import * as yup from 'yup'; import type { Entity } from '../entity/Entity'; -import { schemaValidator } from './util'; +import schema from '../schema/kinds/User.v1alpha1.schema.json'; +import entitySchema from '../schema/Entity.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; +import { ajvCompiledJsonSchemaValidator } from './util'; const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; const KIND = 'User' as const; -const schema = yup.object>({ - apiVersion: yup.string().required().oneOf(API_VERSION), - kind: yup.string().required().equals([KIND]), - spec: yup - .object({ - profile: yup - .object({ - displayName: yup.string().min(1).notRequired(), - email: yup.string().min(1).notRequired(), - picture: yup.string().min(1).notRequired(), - }) - .notRequired(), - // Use this manual test because yup .required() requires at least one - // element and there is no simple workaround -_- - // the cast is there to convince typescript that the array itself is - // required without using .required() - memberOf: yup.array(yup.string().required()).test({ - name: 'isDefined', - message: 'memberOf must be defined', - test: v => Boolean(v), - }) as yup.ArraySchema, - }) - .required(), -}); - export interface UserEntityV1alpha1 extends Entity { apiVersion: typeof API_VERSION[number]; kind: typeof KIND; @@ -59,8 +37,9 @@ export interface UserEntityV1alpha1 extends Entity { }; } -export const userEntityV1alpha1Validator = schemaValidator( +export const userEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( KIND, API_VERSION, schema, + [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/util.ts b/packages/catalog-model/src/kinds/util.ts index f1b02cfba1..4002f2806c 100644 --- a/packages/catalog-model/src/kinds/util.ts +++ b/packages/catalog-model/src/kinds/util.ts @@ -14,9 +14,13 @@ * limitations under the License. */ +import Ajv, { AnySchema } from 'ajv'; import * as yup from 'yup'; import { KindValidator } from './types'; +/** + * @deprecated We no longer use yup for the catalog model. This utility method will be removed. + */ export function schemaValidator( kind: string, apiVersion: readonly string[], @@ -24,10 +28,7 @@ export function schemaValidator( ): KindValidator { return { async check(envelope) { - if ( - kind !== envelope.kind || - !apiVersion.includes(envelope.apiVersion as any) - ) { + if (kind !== envelope.kind || !apiVersion.includes(envelope.apiVersion)) { return false; } await schema.validate(envelope, { strict: true }); @@ -35,3 +36,38 @@ export function schemaValidator( }, }; } + +export function ajvCompiledJsonSchemaValidator( + kind: string, + apiVersion: readonly string[], + schema: AnySchema, + extraSchemas?: AnySchema[], +): KindValidator { + const ajv = new Ajv({ allowUnionTypes: true }); + if (extraSchemas) { + ajv.addSchema(extraSchemas, undefined, undefined, true); + } + const validate = ajv.compile(schema); + + return { + async check(envelope) { + if (kind !== envelope.kind || !apiVersion.includes(envelope.apiVersion)) { + return false; + } + + const result = validate(envelope); + if (result === true) { + return true; + } + + const [error] = validate.errors || []; + if (!error) { + throw new TypeError(`Malformed ${kind}, Unknown error`); + } + + throw new TypeError( + `Malformed ${kind}, ${error.dataPath || ''} ${error.message}`, + ); + }, + }; +} diff --git a/packages/catalog-model/schema/Entity.schema.json b/packages/catalog-model/src/schema/Entity.schema.json similarity index 100% rename from packages/catalog-model/schema/Entity.schema.json rename to packages/catalog-model/src/schema/Entity.schema.json diff --git a/packages/catalog-model/schema/EntityMeta.schema.json b/packages/catalog-model/src/schema/EntityMeta.schema.json similarity index 77% rename from packages/catalog-model/schema/EntityMeta.schema.json rename to packages/catalog-model/src/schema/EntityMeta.schema.json index d44ca84bf4..ff0f9c84a8 100644 --- a/packages/catalog-model/schema/EntityMeta.schema.json +++ b/packages/catalog-model/src/schema/EntityMeta.schema.json @@ -84,6 +84,34 @@ "type": "string", "minLength": 1 } + }, + "links": { + "type": "array", + "description": "A list of external hyperlinks related to the entity. Links can provide additional contextual information that may be located outside of Backstage itself. For example, an admin dashboard or external CMS page.", + "items": { + "type": "object", + "required": ["url"], + "properties": { + "url": { + "type": "string", + "description": "A url in a standard uri format.", + "examples": ["https://admin.example-org.com"], + "minLength": 1 + }, + "title": { + "type": "string", + "description": "A user friendly display name for the link.", + "examples": ["Admin Dashboard"], + "minLength": 1 + }, + "icon": { + "type": "string", + "description": "A key representing a visual icon to be displayed in the UI.", + "examples": ["dashboard"], + "minLength": 1 + } + } + } } } } diff --git a/packages/catalog-model/schema/kinds/API.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/API.v1alpha1.schema.json similarity index 100% rename from packages/catalog-model/schema/kinds/API.v1alpha1.schema.json rename to packages/catalog-model/src/schema/kinds/API.v1alpha1.schema.json diff --git a/packages/catalog-model/schema/kinds/Component.v1alpha1.schema copy.json b/packages/catalog-model/src/schema/kinds/Component.v1alpha1.schema.json similarity index 100% rename from packages/catalog-model/schema/kinds/Component.v1alpha1.schema copy.json rename to packages/catalog-model/src/schema/kinds/Component.v1alpha1.schema.json diff --git a/packages/catalog-model/schema/kinds/Domain.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Domain.v1alpha1.schema.json similarity index 100% rename from packages/catalog-model/schema/kinds/Domain.v1alpha1.schema.json rename to packages/catalog-model/src/schema/kinds/Domain.v1alpha1.schema.json diff --git a/packages/catalog-model/schema/kinds/Group.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Group.v1alpha1.schema.json similarity index 100% rename from packages/catalog-model/schema/kinds/Group.v1alpha1.schema.json rename to packages/catalog-model/src/schema/kinds/Group.v1alpha1.schema.json diff --git a/packages/catalog-model/schema/kinds/Location.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Location.v1alpha1.schema.json similarity index 100% rename from packages/catalog-model/schema/kinds/Location.v1alpha1.schema.json rename to packages/catalog-model/src/schema/kinds/Location.v1alpha1.schema.json diff --git a/packages/catalog-model/schema/kinds/Resource.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Resource.v1alpha1.schema.json similarity index 100% rename from packages/catalog-model/schema/kinds/Resource.v1alpha1.schema.json rename to packages/catalog-model/src/schema/kinds/Resource.v1alpha1.schema.json diff --git a/packages/catalog-model/schema/kinds/System.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/System.v1alpha1.schema.json similarity index 100% rename from packages/catalog-model/schema/kinds/System.v1alpha1.schema.json rename to packages/catalog-model/src/schema/kinds/System.v1alpha1.schema.json diff --git a/packages/catalog-model/schema/kinds/Template.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json similarity index 100% rename from packages/catalog-model/schema/kinds/Template.v1alpha1.schema.json rename to packages/catalog-model/src/schema/kinds/Template.v1alpha1.schema.json diff --git a/packages/catalog-model/schema/kinds/User.v1alpha1.schema.json b/packages/catalog-model/src/schema/kinds/User.v1alpha1.schema.json similarity index 100% rename from packages/catalog-model/schema/kinds/User.v1alpha1.schema.json rename to packages/catalog-model/src/schema/kinds/User.v1alpha1.schema.json diff --git a/packages/catalog-model/schema/shared/common.schema.json b/packages/catalog-model/src/schema/shared/common.schema.json similarity index 100% rename from packages/catalog-model/schema/shared/common.schema.json rename to packages/catalog-model/src/schema/shared/common.schema.json diff --git a/yarn.lock b/yarn.lock index 43bcf73364..f553207d23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2478,6 +2478,7 @@ "@backstage/config" "^0.1.2" "@types/json-schema" "^7.0.5" "@types/yup" "^0.29.8" + ajv "^7.0.3" json-schema "^0.2.5" lodash "^4.17.15" uuid "^8.0.0" @@ -2489,6 +2490,7 @@ "@backstage/config" "^0.1.2" "@types/json-schema" "^7.0.5" "@types/yup" "^0.29.8" + ajv "^7.0.3" json-schema "^0.2.5" lodash "^4.17.15" uuid "^8.0.0" @@ -7970,6 +7972,16 @@ ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.1, ajv@^6.10.2, ajv@^6.12.4, ajv@^6.12.5, ajv json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^7.0.3: + version "7.0.3" + resolved "https://registry.npmjs.org/ajv/-/ajv-7.0.3.tgz#13ae747eff125cafb230ac504b2406cf371eece2" + integrity sha512-R50QRlXSxqXcQP5SvKUrw8VZeypvo12i2IX0EeR5PiZ7bEKeHWgzgo264LDadUsCU42lTJVhFikTqJwNeH34gQ== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + alphanum-sort@^1.0.0: version "1.0.2" resolved "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" @@ -16954,6 +16966,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + json-schema@0.2.3: version "0.2.3" resolved "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" @@ -22693,6 +22710,11 @@ require-directory@^2.1.1: resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + require-main-filename@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" From c7c69571073ad0c4d4d0c1312691351ff3a4aa87 Mon Sep 17 00:00:00 2001 From: vitorgrenzel Date: Thu, 28 Jan 2021 17:46:39 -0300 Subject: [PATCH 140/297] fix(core): update packages --- packages/core/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index d4592d48ac..49090e1362 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -37,20 +37,20 @@ "@material-ui/lab": "4.0.0-alpha.45", "@testing-library/react-hooks": "^3.4.2", "@types/dagre": "^0.7.44", - "@types/prop-types": "^15.7.3", "@types/react": "^16.9", "@types/react-sparklines": "^1.7.0", + "@types/prop-types": "^15.7.3", "classnames": "^2.2.6", "clsx": "^1.1.0", "d3-selection": "^2.0.0", "d3-shape": "^2.0.0", "d3-zoom": "^2.0.0", "dagre": "^0.8.5", + "qs": "^6.9.4", "immer": "^8.0.1", "lodash": "^4.17.15", "material-table": "^1.69.1", "prop-types": "^15.7.2", - "qs": "^6.9.4", "rc-progress": "^3.0.0", "react": "^16.12.0", "react-dom": "^16.12.0", From a08c4b0b057bdd452ae1dac9fb6df55835d3f697 Mon Sep 17 00:00:00 2001 From: Kevin Lee Date: Thu, 28 Jan 2021 14:35:08 -0800 Subject: [PATCH 141/297] Add check for outdated/duplicate packages to yarn start --- .changeset/orange-pets-whisper.md | 5 +++++ packages/cli/src/commands/app/serve.ts | 28 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 .changeset/orange-pets-whisper.md diff --git a/.changeset/orange-pets-whisper.md b/.changeset/orange-pets-whisper.md new file mode 100644 index 0000000000..e96c210e43 --- /dev/null +++ b/.changeset/orange-pets-whisper.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Add check for outdated/duplicate packages to yarn start diff --git a/packages/cli/src/commands/app/serve.ts b/packages/cli/src/commands/app/serve.ts index dc165ecb90..e0f2881332 100644 --- a/packages/cli/src/commands/app/serve.ts +++ b/packages/cli/src/commands/app/serve.ts @@ -15,12 +15,40 @@ */ import fs from 'fs-extra'; +import chalk from 'chalk'; import { Command } from 'commander'; import { serveBundle } from '../../lib/bundler'; import { loadCliConfig } from '../../lib/config'; import { paths } from '../../lib/paths'; +import { Lockfile } from '../../lib/versioning'; +import { includedFilter } from '../versions/lint'; export default async (cmd: Command) => { + const lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock')); + const result = lockfile.analyze({ + filter: includedFilter, + }); + const problemPackages = result.newVersions.map(({ name }) => name); + + if (problemPackages.length > 1) { + console.log( + chalk.yellow( + `The following packages may be outdated or have duplicate installations: + + ${problemPackages.join(', ')} + `, + ), + ); + console.log( + chalk.yellow( + `This can be resolved using the following command: + + yarn backstage-cli versions:check --fix + `, + ), + ); + } + const { name } = await fs.readJson(paths.resolveTarget('package.json')); const waitForExit = await serveBundle({ entry: 'src/index', From 51d5f79dce4b7104a7477d93039745968f74e1ce Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Jan 2021 00:04:28 +0100 Subject: [PATCH 142/297] backend-common: new azure PAT for e2e test ._. --- packages/backend-common/src/reading/integration.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend-common/src/reading/integration.test.ts b/packages/backend-common/src/reading/integration.test.ts index e8ebd1dd8d..da2e916210 100644 --- a/packages/backend-common/src/reading/integration.test.ts +++ b/packages/backend-common/src/reading/integration.test.ts @@ -45,8 +45,8 @@ const reader = UrlReaders.default({ azure: [ { host: 'dev.azure.com', - // lasts until 2022-01-27 - token: 'bhs5cbukiuxrkc3ftuyt5h3eqewtkj37lmf3jx5aoajivq3f5jmq', + // lasts until 2022-01-28 + token: `myvyavvfojh6wvw4ose4bfywqttqx${5}z${5}zs${5}bdxauqaek3yinkazq`, }, ], }, From 8de07b3e658bfd1900db0d6362f2d2020a1f4253 Mon Sep 17 00:00:00 2001 From: Kevin Lee Date: Thu, 28 Jan 2021 19:55:17 -0800 Subject: [PATCH 143/297] Prepend caution icon to yarn start warning and check for package newRanges --- packages/cli/src/commands/app/serve.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/app/serve.ts b/packages/cli/src/commands/app/serve.ts index e0f2881332..b542030109 100644 --- a/packages/cli/src/commands/app/serve.ts +++ b/packages/cli/src/commands/app/serve.ts @@ -16,6 +16,7 @@ import fs from 'fs-extra'; import chalk from 'chalk'; +import uniq from 'lodash/uniq'; import { Command } from 'commander'; import { serveBundle } from '../../lib/bundler'; import { loadCliConfig } from '../../lib/config'; @@ -28,20 +29,22 @@ export default async (cmd: Command) => { const result = lockfile.analyze({ filter: includedFilter, }); - const problemPackages = result.newVersions.map(({ name }) => name); + const problemPackages = [...result.newVersions, ...result.newRanges].map( + ({ name }) => name, + ); if (problemPackages.length > 1) { console.log( chalk.yellow( - `The following packages may be outdated or have duplicate installations: + `⚠️ Some of the following packages may be outdated or have duplicate installations: - ${problemPackages.join(', ')} + ${uniq(problemPackages).join(', ')} `, ), ); console.log( chalk.yellow( - `This can be resolved using the following command: + `⚠️ This can be resolved using the following command: yarn backstage-cli versions:check --fix `, From b379276f1038138a149ccab89ab98f7c23b833f9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 29 Jan 2021 04:57:47 +0000 Subject: [PATCH 144/297] chore(deps-dev): bump nodemon from 2.0.6 to 2.0.7 Bumps [nodemon](https://github.com/remy/nodemon) from 2.0.6 to 2.0.7. - [Release notes](https://github.com/remy/nodemon/releases) - [Commits](https://github.com/remy/nodemon/compare/v2.0.6...v2.0.7) Signed-off-by: dependabot[bot] --- yarn.lock | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/yarn.lock b/yarn.lock index f553207d23..c40b1ab675 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2505,6 +2505,7 @@ "@material-ui/core" "^4.11.0" "@material-ui/icons" "^4.9.1" "@material-ui/lab" "4.0.0-alpha.45" + "@testing-library/react-hooks" "^3.4.2" "@types/dagre" "^0.7.44" "@types/prop-types" "^15.7.3" "@types/react" "^16.9" @@ -14199,13 +14200,6 @@ git-url-parse@^11.4.4: dependencies: git-up "^4.0.0" -git-url-parse@^11.4.4: - version "11.4.4" - resolved "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.4.4.tgz#5d747debc2469c17bc385719f7d0427802d83d77" - integrity sha512-Y4o9o7vQngQDIU9IjyCmRJBin5iYjI5u9ZITnddRZpD7dcCFQj2sL2XuMNbLRE4b4B/4ENPsp2Q8P44fjAZ0Pw== - dependencies: - git-up "^4.0.0" - gitconfiglocal@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/gitconfiglocal/-/gitconfiglocal-1.0.0.tgz#41d045f3851a5ea88f03f24ca1c6178114464b9b" @@ -19226,9 +19220,9 @@ node-request-interceptor@^0.5.1: headers-utils "^1.2.0" nodemon@^2.0.2: - version "2.0.6" - resolved "https://registry.npmjs.org/nodemon/-/nodemon-2.0.6.tgz#1abe1937b463aaf62f0d52e2b7eaadf28cc2240d" - integrity sha512-4I3YDSKXg6ltYpcnZeHompqac4E6JeAMpGm8tJnB9Y3T0ehasLa4139dJOcCrB93HHrUMsCrKtoAlXTqT5n4AQ== + version "2.0.7" + resolved "https://registry.npmjs.org/nodemon/-/nodemon-2.0.7.tgz#6f030a0a0ebe3ea1ba2a38f71bf9bab4841ced32" + integrity sha512-XHzK69Awgnec9UzHr1kc8EomQh4sjTQ8oRf8TsGrSmHDx9/UmiGG9E/mM3BuTfNeFwdNBvrqQq/RHL0xIeyFOA== dependencies: chokidar "^3.2.2" debug "^3.2.6" From 6106345e6d139a64a7713e066a670a9bdff002db Mon Sep 17 00:00:00 2001 From: Debajyoti Halder Date: Fri, 29 Jan 2021 11:55:12 +0530 Subject: [PATCH 145/297] Update project with latest eslint rules --- .../src/reading/tree/TarArchiveResponse.ts | 4 +-- .../src/reading/tree/ZipArchiveResponse.ts | 10 +++---- packages/cli/config/eslint.backend.js | 2 ++ packages/cli/config/eslint.js | 2 ++ packages/cli/src/commands/config/print.ts | 4 +-- .../cli/src/commands/versions/bump.test.ts | 4 +-- .../lib/bundler/LinkedPackageResolvePlugin.ts | 2 +- packages/cli/src/lib/bundler/paths.ts | 6 ++--- .../config-loader/src/lib/schema/collect.ts | 4 ++- .../lib/AuthConnector/DirectAuthConnector.ts | 10 +++---- packages/core-api/src/routing/FlatRoutes.tsx | 4 +-- .../core/src/layout/HeaderTabs/HeaderTabs.tsx | 4 +-- packages/e2e-test/src/commands/run.ts | 6 ++--- .../integration/src/ScmIntegrations.test.ts | 8 +++--- .../github/GithubCredentialsProvider.test.ts | 12 ++++----- .../src/github/GithubCredentialsProvider.ts | 2 +- .../src/stages/publish/local.test.ts | 4 +-- .../ApiDefinitionCard.test.tsx | 4 +-- .../src/catalog/DatabaseEntitiesCatalog.ts | 2 +- ...sOrganizationCloudAccountProcessor.test.ts | 9 ++++--- .../processors/BuiltinKindsEntityProcessor.ts | 4 +-- .../src/components/ImportComponentForm.tsx | 14 +++++----- .../src/components/ImportComponentPage.tsx | 12 ++++----- .../components/EntityLayout/EntityLayout.tsx | 6 +++-- .../components/EntityLayout/TabbedLayout.tsx | 4 +-- .../components/EntitySwitch/EntitySwitch.tsx | 4 +-- plugins/catalog/src/components/isOwnerOf.ts | 8 +++--- .../AlertActionCardList.tsx | 4 +-- .../src/components/BarChart/BarChartLabel.tsx | 4 +-- .../src/components/BarChart/BarChartSteps.tsx | 4 +-- .../ProductInsights/ProductInsights.tsx | 27 ++++++++++--------- plugins/cost-insights/src/utils/change.ts | 4 +-- plugins/jenkins/src/components/useBuilds.ts | 10 +++---- .../getKubernetesObjectsForServiceHandler.ts | 6 ++--- .../DeploymentDrawer.tsx | 10 +++---- .../HorizontalPodAutoscalerDrawer.tsx | 14 +++++----- .../IngressesAccordions/IngressDrawer.tsx | 4 +-- .../src/components/Pods/PodDrawer.tsx | 16 +++++------ .../ServicesAccordions/ServiceDrawer.tsx | 4 +-- plugins/kubernetes/src/utils/pod.tsx | 8 +++--- .../Group/GroupProfile/GroupProfileCard.tsx | 4 +-- plugins/org/src/components/isOwnerOf.ts | 8 +++--- .../Escalation/EscalationPolicy.tsx | 4 +-- .../TriggerDialog/TriggerDialog.tsx | 4 +-- .../stages/prepare/bitbucket.test.ts | 8 +++--- .../scaffolder/stages/prepare/github.test.ts | 8 +++--- .../MultistepJsonForm/MultistepJsonForm.tsx | 4 +-- .../components/TemplatePage/TemplatePage.tsx | 12 +++++---- .../components/SearchResult/SearchResult.tsx | 8 +++--- 49 files changed, 173 insertions(+), 157 deletions(-) diff --git a/packages/backend-common/src/reading/tree/TarArchiveResponse.ts b/packages/backend-common/src/reading/tree/TarArchiveResponse.ts index 5927eb75a1..05dd140b16 100644 --- a/packages/backend-common/src/reading/tree/TarArchiveResponse.ts +++ b/packages/backend-common/src/reading/tree/TarArchiveResponse.ts @@ -15,7 +15,7 @@ */ import tar, { Parse, ParseStream, ReadEntry } from 'tar'; -import path from 'path'; +import platformPath from 'path'; import fs from 'fs-extra'; import { Readable, pipeline as pipelineCb } from 'stream'; import { promisify } from 'util'; @@ -136,7 +136,7 @@ export class TarArchiveResponse implements ReadTreeResponse { const dir = options?.targetDir ?? - (await fs.mkdtemp(path.join(this.workDir, 'backstage-'))); + (await fs.mkdtemp(platformPath.join(this.workDir, 'backstage-'))); const strip = this.subPath ? this.subPath.split('/').length - 1 : 0; diff --git a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts index 07d34faaa3..15bc558132 100644 --- a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts +++ b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import path from 'path'; +import platformPath from 'path'; import fs from 'fs-extra'; import unzipper, { Entry } from 'unzipper'; import archiver from 'archiver'; @@ -131,7 +131,7 @@ export class ZipArchiveResponse implements ReadTreeResponse { const dir = options?.targetDir ?? - (await fs.mkdtemp(path.join(this.workDir, 'backstage-'))); + (await fs.mkdtemp(platformPath.join(this.workDir, 'backstage-'))); await this.stream .pipe(unzipper.Parse()) @@ -140,11 +140,11 @@ export class ZipArchiveResponse implements ReadTreeResponse { // as a zip can have files with directories without directory entries if (entry.type === 'File' && this.shouldBeIncluded(entry)) { const entryPath = this.getPath(entry); - const dirname = path.dirname(entryPath); + const dirname = platformPath.dirname(entryPath); if (dirname) { - await fs.mkdirp(path.join(dir, dirname)); + await fs.mkdirp(platformPath.join(dir, dirname)); } - entry.pipe(fs.createWriteStream(path.join(dir, entryPath))); + entry.pipe(fs.createWriteStream(platformPath.join(dir, entryPath))); } else { entry.autodrain(); } diff --git a/packages/cli/config/eslint.backend.js b/packages/cli/config/eslint.backend.js index 6739d1c2ce..e7fd4cb688 100644 --- a/packages/cli/config/eslint.backend.js +++ b/packages/cli/config/eslint.backend.js @@ -38,6 +38,8 @@ module.exports = { }, ignorePatterns: ['.eslintrc.js', '**/dist/**', '**/dist-types/**'], rules: { + 'no-shadow': 'off', + 'no-redeclare': 'off', '@typescript-eslint/no-shadow': 'error', '@typescript-eslint/no-redeclare': 'error', diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js index c440e53bc0..5c2206f8c1 100644 --- a/packages/cli/config/eslint.js +++ b/packages/cli/config/eslint.js @@ -45,6 +45,8 @@ module.exports = { }, ignorePatterns: ['.eslintrc.js', '**/dist/**', '**/dist-types/**'], rules: { + 'no-shadow': 'off', + 'no-redeclare': 'off', '@typescript-eslint/no-shadow': 'error', '@typescript-eslint/no-redeclare': 'error', diff --git a/packages/cli/src/commands/config/print.ts b/packages/cli/src/commands/config/print.ts index 56dcd3f753..930f2c98ca 100644 --- a/packages/cli/src/commands/config/print.ts +++ b/packages/cli/src/commands/config/print.ts @@ -63,8 +63,8 @@ function serializeConfigData( } const sanitizedConfigs = schema.process(appConfigs, { - valueTransform: (value, { visibility }) => - visibility === 'secret' ? '' : value, + valueTransform: (value, context) => + context.visibility === 'secret' ? '' : value, }); return ConfigReader.fromConfigs(sanitizedConfigs).get(); diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts index 987d4f6f31..31803d4827 100644 --- a/packages/cli/src/commands/versions/bump.test.ts +++ b/packages/cli/src/commands/versions/bump.test.ts @@ -108,7 +108,7 @@ describe('bump', () => { paths.targetDir = '/'; jest .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...paths) => resolvePath('/', ...paths)); + .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => JSON.stringify({ type: 'inspect', @@ -204,7 +204,7 @@ describe('bump', () => { paths.targetDir = '/'; jest .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...paths) => resolvePath('/', ...paths)); + .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'runPlain').mockImplementation(async () => ''); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); diff --git a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts index 0478337c61..fed8b39a19 100644 --- a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts +++ b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts @@ -41,7 +41,7 @@ export class LinkedPackageResolvePlugin implements ResolvePlugin { callback: () => void, ) => { const pkg = this.packages.find( - pkg => data.path && isChildPath(pkg.location, data.path), + pkge => data.path && isChildPath(pkge.location, data.path), ); if (!pkg) { callback(); diff --git a/packages/cli/src/lib/bundler/paths.ts b/packages/cli/src/lib/bundler/paths.ts index f38416ca4e..c8d48a7199 100644 --- a/packages/cli/src/lib/bundler/paths.ts +++ b/packages/cli/src/lib/bundler/paths.ts @@ -42,14 +42,14 @@ export type BundlingPathsOptions = { export function resolveBundlingPaths(options: BundlingPathsOptions) { const { entry } = options; - const resolveTargetModule = (path: string) => { + const resolveTargetModule = (pathString: string) => { for (const ext of ['mjs', 'js', 'ts', 'tsx', 'jsx']) { - const filePath = paths.resolveTarget(`${path}.${ext}`); + const filePath = paths.resolveTarget(`${pathString}.${ext}`); if (fs.pathExistsSync(filePath)) { return filePath; } } - return paths.resolveTarget(`${path}.js`); + return paths.resolveTarget(`${pathString}.js`); }; let targetPublic = undefined; diff --git a/packages/config-loader/src/lib/schema/collect.ts b/packages/config-loader/src/lib/schema/collect.ts index 0e53562875..611413931e 100644 --- a/packages/config-loader/src/lib/schema/collect.ts +++ b/packages/config-loader/src/lib/schema/collect.ts @@ -118,7 +118,9 @@ export async function collectConfigSchemas( } await Promise.all( - depNames.map(name => processItem({ name, parentPath: pkgPath })), + depNames.map(depName => + processItem({ name: depName, parentPath: pkgPath }), + ), ); } diff --git a/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts index 517bf82ae7..71671edba6 100644 --- a/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts +++ b/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts @@ -27,11 +27,11 @@ type Options = { provider: AuthProvider & { id: string }; }; -export type DirectAuthResponse = { - userId: string; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; +// export type DirectAuthResponse = { +// userId: string; +// profile: ProfileInfo; +// backstageIdentity: BackstageIdentity; +// }; export class DirectAuthConnector { private readonly discoveryApi: DiscoveryApi; diff --git a/packages/core-api/src/routing/FlatRoutes.tsx b/packages/core-api/src/routing/FlatRoutes.tsx index a6783964a7..cefb347290 100644 --- a/packages/core-api/src/routing/FlatRoutes.tsx +++ b/packages/core-api/src/routing/FlatRoutes.tsx @@ -25,8 +25,8 @@ type RouteObject = { // Similar to the same function from react-router, this collects routes from the // children, but only the first level of routes -function createRoutesFromChildren(children: ReactNode): RouteObject[] { - return Children.toArray(children) +function createRoutesFromChildren(childrenNode: ReactNode): RouteObject[] { + return Children.toArray(childrenNode) .flatMap(child => { if (!isValidElement(child)) { return []; diff --git a/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx b/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx index a1b4e1a97f..ef1f596f83 100644 --- a/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx +++ b/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx @@ -38,13 +38,13 @@ const useStyles = makeStyles(theme => ({ }, })); -export type Tab = { +export type objectTab = { id: string; label: string; }; type HeaderTabsProps = { - tabs: Tab[]; + tabs: objectTab[]; onChange?: (index: number) => void; selectedIndex?: number; }; diff --git a/packages/e2e-test/src/commands/run.ts b/packages/e2e-test/src/commands/run.ts index 2ddcc333d2..5f4894f9e9 100644 --- a/packages/e2e-test/src/commands/run.ts +++ b/packages/e2e-test/src/commands/run.ts @@ -104,11 +104,11 @@ async function buildDistWorkspace(workspaceName: string, rootDir: string) { { helpers: { version(name: string) { - const pkg = require(`${name}/package.json`); - if (!pkg) { + const pkge = require(`${name}/package.json`); + if (!pkge) { throw new Error(`No version available for package ${name}`); } - return pkg.version; + return pkge.version; }, }, }, diff --git a/packages/integration/src/ScmIntegrations.test.ts b/packages/integration/src/ScmIntegrations.test.ts index b43e69eba4..69eb6cc367 100644 --- a/packages/integration/src/ScmIntegrations.test.ts +++ b/packages/integration/src/ScmIntegrations.test.ts @@ -43,10 +43,10 @@ describe('ScmIntegrations', () => { } as GitLabIntegrationConfig); const i = new ScmIntegrations({ - azure: basicIntegrations([azure], i => i.config.host), - bitbucket: basicIntegrations([bitbucket], i => i.config.host), - github: basicIntegrations([github], i => i.config.host), - gitlab: basicIntegrations([gitlab], i => i.config.host), + azure: basicIntegrations([azure], item => item.config.host), + bitbucket: basicIntegrations([bitbucket], item => item.config.host), + github: basicIntegrations([github], item => item.config.host), + gitlab: basicIntegrations([gitlab], item => item.config.host), }); it('can get the specifics', () => { diff --git a/packages/integration/src/github/GithubCredentialsProvider.test.ts b/packages/integration/src/github/GithubCredentialsProvider.test.ts index f708f75184..3eb1d33306 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.test.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.test.ts @@ -215,21 +215,21 @@ describe('GithubCredentialsProvider tests', () => { }); it('should return the default token if no app is configured', async () => { - const github = GithubCredentialsProvider.create({ + const githubID = GithubCredentialsProvider.create({ host: 'github.com', apps: [], token: 'fallback_token', }); await expect( - github.getCredentials({ + githubID.getCredentials({ url: 'https://github.com/404/foobar', }), ).resolves.toEqual(expect.objectContaining({ token: 'fallback_token' })); }); it('should return the configured token if listing installations throws', async () => { - const github = GithubCredentialsProvider.create({ + const githubID = GithubCredentialsProvider.create({ host: 'github.com', apps: [ { @@ -245,19 +245,19 @@ describe('GithubCredentialsProvider tests', () => { octokit.apps.listInstallations.mockRejectedValue({ status: 304 }); await expect( - github.getCredentials({ + githubID.getCredentials({ url: 'https://github.com/backstage', }), ).resolves.toEqual(expect.objectContaining({ token: 'hardcoded_token' })); }); it('should return undefined if no token or apps are configured', async () => { - const github = GithubCredentialsProvider.create({ + const githubID = GithubCredentialsProvider.create({ host: 'github.com', }); await expect( - github.getCredentials({ + githubID.getCredentials({ url: 'https://github.com/backstage', }), ).resolves.toEqual({ headers: undefined, token: undefined }); diff --git a/packages/integration/src/github/GithubCredentialsProvider.ts b/packages/integration/src/github/GithubCredentialsProvider.ts index dfb809ee5f..a22c69909b 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.ts @@ -177,7 +177,7 @@ export class GithubAppCredentialsMux { ), ); - const result = results.find(result => result.credentials); + const result = results.find(resultItem => resultItem.credentials); if (result) { return result.credentials!.accessToken; } diff --git a/packages/techdocs-common/src/stages/publish/local.test.ts b/packages/techdocs-common/src/stages/publish/local.test.ts index efd56285ef..d444d25540 100644 --- a/packages/techdocs-common/src/stages/publish/local.test.ts +++ b/packages/techdocs-common/src/stages/publish/local.test.ts @@ -28,9 +28,9 @@ jest.mock('fs-extra', () => { const fsOriginal = jest.requireActual('fs-extra'); return { ...fsOriginal, - access: jest.fn().mockImplementation((path, checkType, callback) => { + access: jest.fn().mockImplementation((paths, checkType, callback) => { if ( - path.includes('http://localhost:7000/static') && + paths.includes('http://localhost:7000/static') && checkType === fs.constants.F_OK ) { callback(); diff --git a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx index d59699ab9a..19fb886d74 100644 --- a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx +++ b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx @@ -74,8 +74,8 @@ paths: type: 'openapi', title: 'OpenAPI', rawLanguage: 'yaml', - component: definition => ( - + component: definitionString => ( + ), }); diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index 76fc63c9b3..b03183cf34 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -124,7 +124,7 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog { // produce tens of thousands of entities, and those are too large batch // sizes to reasonably send to the database. const batches = Object.values(requestsByKindAndNamespace) - .map(requests => chunk(requests, BATCH_SIZE)) + .map(request => chunk(request, BATCH_SIZE)) .flat(); // Bound the number of concurrent batches. We want a bit of concurrency for diff --git a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts index 6eb7a45c63..28200021cc 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AwsOrganizationCloudAccountProcessor.test.ts @@ -65,7 +65,10 @@ describe('AwsOrganizationCloudAccountProcessor', () => { }); it('filters out accounts not in specified location target', async () => { - const location = { type: 'aws-cloud-accounts', target: 'o-1vl18kc5a3' }; + const locationTest = { + type: 'aws-cloud-accounts', + target: 'o-1vl18kc5a3', + }; listAccounts.mockImplementation(() => Promise.resolve({ Accounts: [ @@ -83,11 +86,11 @@ describe('AwsOrganizationCloudAccountProcessor', () => { NextToken: undefined, }), ); - await processor.readLocation(location, false, emit); + await processor.readLocation(locationTest, false, emit); expect(emit).toBeCalledTimes(1); expect(emit).toBeCalledWith({ type: 'entity', - location, + location: locationTest, entity: { apiVersion: 'backstage.io/v1alpha1', kind: 'Resource', diff --git a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts index c75a46874d..ef94c54010 100644 --- a/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/BuiltinKindsEntityProcessor.ts @@ -66,8 +66,8 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor { async validateEntityKind(entity: Entity): Promise { for (const validator of this.validators) { - const result = await validator.check(entity); - if (result) { + const results = await validator.check(entity); + if (results) { return true; } } diff --git a/plugins/catalog-import/src/components/ImportComponentForm.tsx b/plugins/catalog-import/src/components/ImportComponentForm.tsx index c233be5a8c..0e59fed3c9 100644 --- a/plugins/catalog-import/src/components/ImportComponentForm.tsx +++ b/plugins/catalog-import/src/components/ImportComponentForm.tsx @@ -71,8 +71,8 @@ export const RegisterComponentForm = ({ const onSubmit = async (formData: Record) => { const { componentLocation: target } = formData; - async function saveCatalogFileConfig(target: string) { - const data = await catalogApi.addLocation({ target }); + async function saveCatalogFileConfig(targetString: string) { + const data = await catalogApi.addLocation({ target: targetString }); saveConfig({ type: 'file', location: data.location.target, @@ -80,12 +80,12 @@ export const RegisterComponentForm = ({ }); } - async function trySaveRepositoryConfig(target: string) { - const existingCatalog = await checkForExistingCatalogInfo(target); + async function trySaveRepositoryConfig(targetString: string) { + const existingCatalog = await checkForExistingCatalogInfo(targetString); if (existingCatalog.exists) { - const targetUrl = target.endsWith('/') - ? `${target}${existingCatalog.url}` - : `${target}/${existingCatalog.url}`; + const targetUrl = targetString.endsWith('/') + ? `${targetString}${existingCatalog.url}` + : `${targetString}/${existingCatalog.url}`; await saveCatalogFileConfig(targetUrl); } else { saveConfig({ diff --git a/plugins/catalog-import/src/components/ImportComponentPage.tsx b/plugins/catalog-import/src/components/ImportComponentPage.tsx index e284e95456..adae137a50 100644 --- a/plugins/catalog-import/src/components/ImportComponentPage.tsx +++ b/plugins/catalog-import/src/components/ImportComponentPage.tsx @@ -46,20 +46,20 @@ function manifestGenerationAvailable(configApi: ConfigApi): boolean { function repositories(configApi: ConfigApi): string[] { const integrations = configApi.getConfig('integrations'); - const repositories = []; + const repos = []; if (integrations.has('github')) { - repositories.push('GitHub'); + repos.push('GitHub'); } if (integrations.has('bitbucket')) { - repositories.push('Bitbucket'); + repos.push('Bitbucket'); } if (integrations.has('gitlab')) { - repositories.push('GitLab'); + repos.push('GitLab'); } if (integrations.has('azure')) { - repositories.push('Azure'); + repos.push('Azure'); } - return repositories; + return repos; } export const ImportComponentPage = ({ diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index 09b8d02c60..7f09cf557b 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -53,8 +53,10 @@ const Route: (props: SubRoute) => null = () => null; // This causes all mount points that are discovered within this route to use the path of the route itself attachComponentData(Route, 'core.gatherMountPoints', true); -export function createSubRoutesFromChildren(children: ReactNode): SubRoute[] { - return Children.toArray(children).flatMap(child => { +export function createSubRoutesFromChildren( + childrenNode: ReactNode, +): SubRoute[] { + return Children.toArray(childrenNode).flatMap(child => { if (!isValidElement(child)) { return []; } diff --git a/plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx b/plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx index a2209ba492..47f7b12eb3 100644 --- a/plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/TabbedLayout.tsx @@ -52,12 +52,12 @@ export const TabbedLayout = ({ routes }: { routes: SubRoute[] }) => { [routes], ); - const onTabChange = (index: number) => + const onTabChange = (tabIndex: number) => // Remove trailing /* // And remove leading / for relative navigation // Note! route resolves relative to the position in the React tree, // not relative to current location - navigate(routes[index].path.replace(/\/\*$/, '').replace(/^\//, '')); + navigate(routes[tabIndex].path.replace(/\/\*$/, '').replace(/^\//, '')); return ( <> diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx index f36bc29cee..3333a85d29 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx @@ -35,8 +35,8 @@ type SwitchCase = { children: JSX.Element; }; -function createSwitchCasesFromChildren(children: ReactNode): SwitchCase[] { - return Children.toArray(children).flatMap(child => { +function createSwitchCasesFromChildren(childrenNode: ReactNode): SwitchCase[] { + return Children.toArray(childrenNode).flatMap(child => { if (!isValidElement(child)) { return []; } diff --git a/plugins/catalog/src/components/isOwnerOf.ts b/plugins/catalog/src/components/isOwnerOf.ts index 455e06dc13..d135e4030a 100644 --- a/plugins/catalog/src/components/isOwnerOf.ts +++ b/plugins/catalog/src/components/isOwnerOf.ts @@ -34,13 +34,13 @@ export function isOwnerOf(owner: Entity, owned: Entity) { const owners = getEntityRelations(owned, RELATION_OWNED_BY); - for (const owner of owners) { + for (const ownerItem of owners) { if ( possibleOwners.find( o => - owner.kind.toLowerCase() === o.kind.toLowerCase() && - owner.namespace.toLowerCase() === o.namespace.toLowerCase() && - owner.name.toLowerCase() === o.name.toLowerCase(), + ownerItem.kind.toLowerCase() === o.kind.toLowerCase() && + ownerItem.namespace.toLowerCase() === o.namespace.toLowerCase() && + ownerItem.name.toLowerCase() === o.name.toLowerCase(), ) !== undefined ) { return true; diff --git a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx index aaadbed09d..e836c3ef01 100644 --- a/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx +++ b/plugins/cost-insights/src/components/AlertActionCardList/AlertActionCardList.tsx @@ -18,11 +18,11 @@ import { Paper, Divider } from '@material-ui/core'; import { AlertActionCard } from './AlertActionCard'; import { Alert } from '../../types'; -type AlertActionCardList = { +type AlertActionCardListType = { alerts: Array; }; -export const AlertActionCardList = ({ alerts }: AlertActionCardList) => ( +export const AlertActionCardList = ({ alerts }: AlertActionCardListType) => ( {alerts.map((alert, index) => ( diff --git a/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx index c4402893f3..71bb08d563 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx @@ -18,7 +18,7 @@ import React, { PropsWithChildren } from 'react'; import { Box, Typography } from '@material-ui/core'; import { useBarChartLabelStyles } from '../../utils/styles'; -type BarChartLabel = { +type BarChartLabelType = { x: number; y: number; height: number; @@ -33,7 +33,7 @@ export const BarChartLabel = ({ width, details, children, -}: PropsWithChildren) => { +}: PropsWithChildren) => { const classes = useBarChartLabelStyles(); const translateX = width * -0.5; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx b/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx index 11a295965e..9576e74b42 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { ButtonBase } from '@material-ui/core'; import { useBarChartStepperStyles as useStyles } from '../../utils/styles'; -export type BarChartSteps = { +export type BarChartStepsType = { steps: number; activeStep: number; onClick: (index: number) => void; @@ -28,7 +28,7 @@ export const BarChartSteps = ({ steps, activeStep, onClick, -}: BarChartSteps) => { +}: BarChartStepsType) => { const classes = useStyles(); const handleOnClick = (index: number) => ( event: React.MouseEvent, diff --git a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx index 0ef35bf04c..381827e4e2 100644 --- a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx +++ b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx @@ -83,28 +83,31 @@ export const ProductInsights = ({ useEffect(() => { async function getAllProductInsights( - group: string, - project: Maybe, - products: Product[], - lastCompleteBillingDate: string, + groupId: string, + projectId: Maybe, + productsId: Product[], + lastCompleteBillingDateId: string, ) { try { dispatchLoadingProducts(true); const responses = await Promise.allSettled( - products.map(product => + productsId.map(product => client.getProductInsights({ - group: group, - project: project, + group: groupId, + project: projectId, product: product.kind, - intervals: intervalsOf(DEFAULT_DURATION, lastCompleteBillingDate), + intervals: intervalsOf( + DEFAULT_DURATION, + lastCompleteBillingDateId, + ), }), ), ).then(settledResponseOf); - const initialStates = initialStatesOf(products, responses).sort( + const initialStatesNow = initialStatesOf(productsId, responses).sort( totalAggregationSort, ); - setStates(initialStates); + setStates(initialStatesNow); } catch (e) { setError(e); } finally { @@ -125,8 +128,8 @@ export const ProductInsights = ({ useEffect( function handleOnLoaded() { if (onceRef.current) { - const products = initialStates.map(state => state.product); - onLoaded(products); + const currentProducts = initialStates.map(state => state.product); + onLoaded(currentProducts); } else { onceRef.current = true; } diff --git a/plugins/cost-insights/src/utils/change.ts b/plugins/cost-insights/src/utils/change.ts index fc50439aad..143751fa89 100644 --- a/plugins/cost-insights/src/utils/change.ts +++ b/plugins/cost-insights/src/utils/change.ts @@ -26,10 +26,10 @@ import { DateAggregation, } from '../types'; import dayjs, { OpUnitType } from 'dayjs'; -import duration from 'dayjs/plugin/duration'; +import durationPlugin from 'dayjs/plugin/duration'; import { inclusiveStartDateOf } from './duration'; -dayjs.extend(duration); +dayjs.extend(durationPlugin); // Used for displaying status colors export function growthOf(ratio: number, amount?: number) { diff --git a/plugins/jenkins/src/components/useBuilds.ts b/plugins/jenkins/src/components/useBuilds.ts index db7c7ddd55..a1ff3a1eef 100644 --- a/plugins/jenkins/src/components/useBuilds.ts +++ b/plugins/jenkins/src/components/useBuilds.ts @@ -36,17 +36,17 @@ export function useBuilds(owner: string, repo: string, branch?: string) { const { loading, value: builds, retry } = useAsyncRetry(async () => { try { - let builds; + let build; if (branch) { - builds = await api.getLastBuild(`${owner}/${repo}/${branch}`); + build = await api.getLastBuild(`${owner}/${repo}/${branch}`); } else { - builds = await api.getFolder(`${owner}/${repo}`); + build = await api.getFolder(`${owner}/${repo}`); } - const size = Array.isArray(builds) ? builds?.[0].build_num! : 1; + const size = Array.isArray(build) ? build?.[0].build_num! : 1; setTotal(size); - return builds || []; + return build || []; } catch (e) { errorApi.post(e); throw e; diff --git a/plugins/kubernetes-backend/src/service/getKubernetesObjectsForServiceHandler.ts b/plugins/kubernetes-backend/src/service/getKubernetesObjectsForServiceHandler.ts index 2dd1e32b96..811a3c8acb 100644 --- a/plugins/kubernetes-backend/src/service/getKubernetesObjectsForServiceHandler.ts +++ b/plugins/kubernetes-backend/src/service/getKubernetesObjectsForServiceHandler.ts @@ -85,18 +85,18 @@ export const handleGetKubernetesObjectsForService: GetKubernetesObjectsForServic ] || `backstage.io/kubernetes-id=${requestBody.entity.metadata.name}`; return Promise.all( - clusterDetailsDecoratedForAuth.map(clusterDetails => { + clusterDetailsDecoratedForAuth.map(clusterDetailsItem => { return fetcher .fetchObjectsForService({ serviceId, - clusterDetails, + clusterDetails: clusterDetailsItem, objectTypesToFetch, labelSelector, } as ObjectFetchParams) .then(result => { return { cluster: { - name: clusterDetails.name, + name: clusterDetailsItem.name, }, resources: result.responses, errors: result.errors, diff --git a/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.tsx b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.tsx index 6bacad9b16..8c1365769f 100644 --- a/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.tsx +++ b/plugins/kubernetes/src/components/DeploymentsAccordions/DeploymentDrawer.tsx @@ -32,8 +32,8 @@ export const DeploymentDrawer = ({ object={deployment} expanded={expanded} kind="Deployment" - renderObject={(deployment: V1Deployment) => { - const conditions = (deployment.status?.conditions ?? []) + renderObject={(deploymentObj: V1Deployment) => { + const conditions = (deploymentObj.status?.conditions ?? []) .map(renderCondition) .reduce((accum, next) => { accum[next[0]] = next[1]; @@ -41,10 +41,10 @@ export const DeploymentDrawer = ({ }, {} as { [key: string]: React.ReactNode }); return { - strategy: deployment.spec?.strategy ?? '???', - minReadySeconds: deployment.spec?.minReadySeconds ?? '???', + strategy: deploymentObj.spec?.strategy ?? '???', + minReadySeconds: deploymentObj.spec?.minReadySeconds ?? '???', progressDeadlineSeconds: - deployment.spec?.progressDeadlineSeconds ?? '???', + deploymentObj.spec?.progressDeadlineSeconds ?? '???', ...conditions, }; }} diff --git a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.tsx b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.tsx index 00ab2cad84..26dcdfd249 100644 --- a/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.tsx +++ b/plugins/kubernetes/src/components/HorizontalPodAutoscalers/HorizontalPodAutoscalerDrawer.tsx @@ -32,16 +32,16 @@ export const HorizontalPodAutoscalerDrawer = ({ kind="HorizontalPodAutoscaler" object={hpa} expanded={expanded} - renderObject={(hpa: V1HorizontalPodAutoscaler) => { + renderObject={(hpaObject: V1HorizontalPodAutoscaler) => { return { targetCPUUtilizationPercentage: - hpa.spec?.targetCPUUtilizationPercentage, + hpaObject.spec?.targetCPUUtilizationPercentage, currentCPUUtilizationPercentage: - hpa.status?.currentCPUUtilizationPercentage, - minReplicas: hpa.spec?.minReplicas, - maxReplicas: hpa.spec?.maxReplicas, - currentReplicas: hpa.status?.currentReplicas, - desiredReplicas: hpa.status?.desiredReplicas, + hpaObject.status?.currentCPUUtilizationPercentage, + minReplicas: hpaObject.spec?.minReplicas, + maxReplicas: hpaObject.spec?.maxReplicas, + currentReplicas: hpaObject.status?.currentReplicas, + desiredReplicas: hpaObject.status?.desiredReplicas, }; }} > diff --git a/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx b/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx index 9e63f22fda..d619700ac2 100644 --- a/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx +++ b/plugins/kubernetes/src/components/IngressesAccordions/IngressDrawer.tsx @@ -31,8 +31,8 @@ export const IngressDrawer = ({ object={ingress} expanded={expanded} kind="Ingress" - renderObject={(ingress: ExtensionsV1beta1Ingress) => { - return ingress.spec || {}; + renderObject={(ingressObject: ExtensionsV1beta1Ingress) => { + return ingressObject.spec || {}; }} > { - const phase = pod.status?.phase ?? 'unknown'; + renderObject={(podObject: V1Pod) => { + const phase = podObject.status?.phase ?? 'unknown'; const ports = - pod.spec?.containers?.map(c => { + podObject.spec?.containers?.map(c => { return { [c.name]: c.ports, }; }) ?? 'N/A'; - const conditions = (pod.status?.conditions ?? []) + const conditions = (podObject.status?.conditions ?? []) .map(renderCondition) .reduce((accum, next) => { accum[next[0]] = next[1]; @@ -55,11 +55,11 @@ export const PodDrawer = ({ }, {} as { [key: string]: React.ReactNode }); return { - images: imageChips(pod), + images: imageChips(podObject), phase: phase, - 'Containers Ready': containersReady(pod), - 'Total Restarts': totalRestarts(pod), - 'Container Statuses': containerStatuses(pod), + 'Containers Ready': containersReady(podObject), + 'Total Restarts': totalRestarts(podObject), + 'Container Statuses': containerStatuses(podObject), ...conditions, 'Exposed ports': ports, }; diff --git a/plugins/kubernetes/src/components/ServicesAccordions/ServiceDrawer.tsx b/plugins/kubernetes/src/components/ServicesAccordions/ServiceDrawer.tsx index d48f17813a..d0962c6314 100644 --- a/plugins/kubernetes/src/components/ServicesAccordions/ServiceDrawer.tsx +++ b/plugins/kubernetes/src/components/ServicesAccordions/ServiceDrawer.tsx @@ -31,8 +31,8 @@ export const ServiceDrawer = ({ object={service} expanded={expanded} kind="Service" - renderObject={(service: V1Service) => { - return service.spec || {}; + renderObject={(serviceObject: V1Service) => { + return serviceObject.spec || {}; }} > { export const containersReady = (pod: V1Pod): string => { const containerStatuses = pod.status?.containerStatuses ?? []; - const containersReady = containerStatuses.filter(cs => cs.ready).length; + const containersReadyItem = containerStatuses.filter(cs => cs.ready).length; - return `${containersReady}/${containerStatuses.length}`; + return `${containersReadyItem}/${containerStatuses.length}`; }; export const totalRestarts = (pod: V1Pod): number => { @@ -47,8 +47,8 @@ export const totalRestarts = (pod: V1Pod): number => { }; export const containerStatuses = (pod: V1Pod): ReactNode => { - const containerStatuses = pod.status?.containerStatuses ?? []; - const errors = containerStatuses.reduce((accum, next) => { + const containerStatusesItem = pod.status?.containerStatuses ?? []; + const errors = containerStatusesItem.reduce((accum, next) => { if (next.state === undefined) { return accum; } diff --git a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx index 779cb1d92d..0bf104bf61 100644 --- a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx +++ b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx @@ -73,12 +73,12 @@ export const GroupProfileCard = ({ } = group; const parent = group?.relations ?.filter(r => r.type === RELATION_CHILD_OF) - ?.map(group => group.target.name) + ?.map(groupItem => groupItem.target.name) .toString(); const childrens = group?.relations ?.filter(r => r.type === RELATION_PARENT_OF) - ?.map(group => group.target.name); + ?.map(groupItem => groupItem.target.name); const displayName = profile?.displayName ?? name; diff --git a/plugins/org/src/components/isOwnerOf.ts b/plugins/org/src/components/isOwnerOf.ts index dd7c7d6805..51b3f41ae8 100644 --- a/plugins/org/src/components/isOwnerOf.ts +++ b/plugins/org/src/components/isOwnerOf.ts @@ -38,13 +38,13 @@ export function isOwnerOf(owner: Entity, owned: Entity) { const owners = getEntityRelations(owned, RELATION_OWNED_BY); - for (const owner of owners) { + for (const ownerItem of owners) { if ( possibleOwners.find( o => - owner.kind.toLowerCase() === o.kind.toLowerCase() && - owner.namespace.toLowerCase() === o.namespace.toLowerCase() && - owner.name.toLowerCase() === o.name.toLowerCase(), + ownerItem.kind.toLowerCase() === o.kind.toLowerCase() && + ownerItem.namespace.toLowerCase() === o.namespace.toLowerCase() && + ownerItem.name.toLowerCase() === o.name.toLowerCase(), ) !== undefined ) { return true; diff --git a/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx b/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx index faa480f6e2..6fd036330a 100644 --- a/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx +++ b/plugins/pagerduty/src/components/Escalation/EscalationPolicy.tsx @@ -32,11 +32,11 @@ export const EscalationPolicy = ({ policyId }: Props) => { const { value: users, loading, error } = useAsync(async () => { const oncalls = await api.getOnCallByPolicyId(policyId); - const users = oncalls + const usersItem = oncalls .sort((a, b) => a.escalation_level - b.escalation_level) .map(oncall => oncall.user); - return users; + return usersItem; }); if (error) { diff --git a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx index 4fa88a1b9a..c390ed1f82 100644 --- a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx +++ b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx @@ -52,11 +52,11 @@ export const TriggerDialog = ({ const [description, setDescription] = useState(''); const [{ value, loading, error }, handleTriggerAlarm] = useAsyncFn( - async (description: string) => + async (descriptions: string) => await api.triggerAlarm({ integrationKey, source: window.location.toString(), - description, + description: descriptions, userName, }), ); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts index ff74fffdd7..d2fdedccf2 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts @@ -93,12 +93,12 @@ describe('BitbucketPreparer', () => { }); it('calls the clone command with the correct arguments if an app password is provided for a repository', async () => { - const preparer = BitbucketPreparer.fromConfig({ + const preparerCheck = BitbucketPreparer.fromConfig({ host: 'bitbucket.org', username: 'fake-user', appPassword: 'fake-password', }); - await preparer.prepare(mockEntity, { logger }); + await preparerCheck.prepare(mockEntity, { logger }); expect(Git.fromAuth).toHaveBeenCalledWith({ logger, @@ -128,12 +128,12 @@ describe('BitbucketPreparer', () => { }); it('calls the clone command with with token for auth method', async () => { - const preparer = BitbucketPreparer.fromConfig({ + const preparerCheck = BitbucketPreparer.fromConfig({ host: 'bitbucket.org', token: 'fake-token', }); - await preparer.prepare(mockEntity, { logger }); + await preparerCheck.prepare(mockEntity, { logger }); expect(Git.fromAuth).toHaveBeenCalledWith({ logger, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts index b43545f5fc..3221c5d4ca 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts @@ -104,12 +104,12 @@ describe('GitHubPreparer', () => { }); it('return the temp directory with the path to the folder if it is specified', async () => { - const preparer = GithubPreparer.fromConfig({ + const preparerCheck = GithubPreparer.fromConfig({ host: 'github.com', token: 'fake-token', }); mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { + const response = await preparerCheck.prepare(mockEntity, { logger: getVoidLogger(), }); expect(response.split('\\').join('/')).toMatch( @@ -118,13 +118,13 @@ describe('GitHubPreparer', () => { }); it('return the working directory with the path to the folder if it is specified', async () => { - const preparer = GithubPreparer.fromConfig({ + const preparerCheck = GithubPreparer.fromConfig({ host: 'github.com', token: 'fake-token', }); mockEntity.spec.path = './template/test/1/2/3'; - const response = await preparer.prepare(mockEntity, { + const response = await preparerCheck.prepare(mockEntity, { workingDirectory: '/workDir', logger: getVoidLogger(), }); diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index 528d42b94f..0d66fbf92b 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -30,7 +30,7 @@ import { Theme as MuiTheme } from '@rjsf/material-ui'; import React, { useState } from 'react'; const Form = withTheme(MuiTheme); -type Step = { +type StepType = { schema: JSONSchema; label: string; } & Partial, 'schema'>>; @@ -39,7 +39,7 @@ type Props = { /** * Steps for the form, each contains label and form schema */ - steps: Step[]; + steps: StepType[]; formData: Record; onChange: (e: IChangeEvent) => void; onReset: () => void; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 60f5ad8dec..9b089fae43 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -92,8 +92,8 @@ export const TemplatePage = () => { ); const [jobId, setJobId] = useState(null); - const job = useJobPolling(jobId, async job => { - if (!job.metadata.catalogInfoUrl) { + const job = useJobPolling(jobId, async jobItem => { + if (!jobItem.metadata.catalogInfoUrl) { errorApi.post( new Error(`No catalogInfoUrl returned from the scaffolder`), ); @@ -103,7 +103,9 @@ export const TemplatePage = () => { try { const { entities: [createdEntity], - } = await catalogApi.addLocation({ target: job.metadata.catalogInfoUrl }); + } = await catalogApi.addLocation({ + target: jobItem.metadata.catalogInfoUrl, + }); const resolvedPath = generatePath( `/catalog/${entityRoute.path}`, @@ -122,8 +124,8 @@ export const TemplatePage = () => { const handleCreate = async () => { try { - const jobId = await scaffolderApi.scaffold(templateName, formState); - setJobId(jobId); + const Id = await scaffolderApi.scaffold(templateName, formState); + setJobId(Id); setModalOpen(true); } catch (e) { errorApi.post(e); diff --git a/plugins/search/src/components/SearchResult/SearchResult.tsx b/plugins/search/src/components/SearchResult/SearchResult.tsx index f81cd4f66d..4b82679fcc 100644 --- a/plugins/search/src/components/SearchResult/SearchResult.tsx +++ b/plugins/search/src/components/SearchResult/SearchResult.tsx @@ -59,10 +59,10 @@ type TableHeaderProps = { handleToggleFilters: () => void; }; -type Filters = { - selected: string; - checked: Array; -}; +// type Filters = { +// selected: string; +// checked: Array; +// }; // TODO: move out column to make the search result component more generic const columns: TableColumn[] = [ From c9d6493bef0aa7de4ac36d33336ee24140372754 Mon Sep 17 00:00:00 2001 From: Debajyoti Halder Date: Fri, 29 Jan 2021 12:15:35 +0530 Subject: [PATCH 146/297] Add missing eslint rule --- packages/cli/config/eslint.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js index 5c2206f8c1..37bd7bed94 100644 --- a/packages/cli/config/eslint.js +++ b/packages/cli/config/eslint.js @@ -49,7 +49,7 @@ module.exports = { 'no-redeclare': 'off', '@typescript-eslint/no-shadow': 'error', '@typescript-eslint/no-redeclare': 'error', - + 'no-undef': 'off', 'import/newline-after-import': 'error', 'import/no-duplicates': 'warn', 'import/no-extraneous-dependencies': [ From 1236f229091ff88b09d391ede1f63b6f111e2501 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 29 Jan 2021 10:51:28 +0100 Subject: [PATCH 147/297] Update changeset --- .changeset/wicked-beds-buy.md | 59 ++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/.changeset/wicked-beds-buy.md b/.changeset/wicked-beds-buy.md index 3218269ff0..ddcf4f880e 100644 --- a/.changeset/wicked-beds-buy.md +++ b/.changeset/wicked-beds-buy.md @@ -2,9 +2,60 @@ '@backstage/plugin-scaffolder-backend': minor --- -The scaffolder is updated to generate a unique workspace directory inside the temp folder which gets cleaned up afterwards. +The scaffolder is updated to generate a unique workspace directory inside the temp folder. This directory is cleaned up by the job processor after each run. -prepare/template/publish steps is refactored to operate on known directories(`checkout/`, `template/`, `result/`) inside the generated temp directory. -Updates preparers to take the template url instead of the entire template. This is done primarily to allow for backwards compatibility between v1 and v2 scaffolder templates. +The prepare/template/publish steps have been refactored to operate on known directories, `template/` and `result/`, inside the temporary workspace path. -Fix broken configuration GitHub actions in Create React App template. +Updated preparers to accept the template url instead of the entire template. This is done primarily to allow for backwards compatibility between v1 and v2 scaffolder templates. + +Fixes broken GitHub actions templating in the Create React App template. + +#### For those with **custom** preparers, templates, or publishers + +The preparer interface has changed, the prepare method now only takes a single argument, and doesn't return anything. As part of this change the preparers were refactored to accept a URL pointing to the target directory, rather than computing that from the template entity. + +The `workingDirectory` option was also removed, and replaced with a `workspacePath` option. The difference between the two is that `workingDirectory` was a place for the preparer to create temporary directories, while the `workspacePath` is the specific folder were the entire templating process for a single template job takes place. Instead of returning a path to the folder were the prepared contents were placed, the contents are put at the `/template` path. + +```diff +type PreparerOptions = { +- workingDirectory?: string; ++ /** ++ * Full URL to the directory containg template data ++ */ ++ url: string; ++ /** ++ * The workspace path that will eventually be the the root of the new repo ++ */ ++ workspacePath: string; + logger: Logger; +}; + +-prepare(template: TemplateEntityV1alpha1, opts?: PreparerOptions): Promise ++prepare(opts: PreparerOptions): Promise; +``` + +Instead of returning a path to the folder were the templaters contents were placed, the contents are put at the `/result` path. All templaters now also expect the source template to be present in the `template` directory within the `workspacePath`. + +```diff +export type TemplaterRunOptions = { +- directory: string; ++ workspacePath: string; + values: TemplaterValues; + logStream?: Writable; + dockerClient: Docker; +}; + +-public async run(options: TemplaterRunOptions): Promise ++public async run(options: TemplaterRunOptions): Promise +``` + +Just like the preparer and templaters, the publishers have also switched to using `workspacePath`. The root of the new repo is expected to be located at `/result`. + +```diff +export type PublisherOptions = { + values: TemplaterValues; +- directory: string; ++ workspacePath: string; + logger: Logger; +}; +``` From 019fe39a0873ed053b86c44a8778b65f4f824d80 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Tue, 26 Jan 2021 18:53:29 +0100 Subject: [PATCH 148/297] Createa @backstage/plugin-catalog-common-react package --- .changeset/wet-suits-live.md | 19 ++++++ .changeset/wild-cows-exercise.md | 8 +++ packages/app/package.json | 1 + .../app/src/components/catalog/EntityPage.tsx | 21 +++---- packages/create-app/src/lib/versions.ts | 2 + .../default-app/packages/app/package.json.hbs | 1 + .../app/src/components/catalog/EntityPage.tsx | 4 +- plugins/api-docs/package.json | 2 +- .../ApiExplorerPage/ApiExplorerPage.test.tsx | 5 +- .../ApiExplorerPage/ApiExplorerPage.tsx | 2 +- .../ApisCards/ConsumedApisCard.test.tsx | 5 +- .../ApisCards/ProvidedApisCard.test.tsx | 5 +- .../ConsumingComponentsCard.test.tsx | 5 +- .../ProvidingComponentsCard.test.tsx | 5 +- .../src/components/EntityLink/EntityLink.tsx | 5 +- .../src/components/useRelatedEntities.ts | 2 +- plugins/catalog-common-react/.eslintrc.js | 3 + plugins/catalog-common-react/README.md | 17 ++++++ plugins/catalog-common-react/package.json | 60 +++++++++++++++++++ plugins/catalog-common-react/src/api.ts | 24 ++++++++ .../EntityRefLink/EntityRefLink.test.tsx | 0 .../EntityRefLink/EntityRefLink.tsx | 3 - .../EntityRefLink/EntityRefLinks.test.tsx | 0 .../EntityRefLink/EntityRefLinks.tsx | 1 - .../components/EntityRefLink/format.test.ts | 0 .../src/components/EntityRefLink/format.ts | 0 .../src/components/EntityRefLink/index.ts | 0 .../src/components/index.ts | 16 +++++ .../catalog-common-react/src/hooks/index.ts | 17 ++++++ .../src/hooks/useEntity.ts | 4 +- .../src/hooks}/useEntityCompoundName.ts | 0 plugins/catalog-common-react/src/index.ts | 26 ++++++++ .../src/routes.ts | 0 .../catalog-common-react/src/setupTests.ts | 18 ++++++ plugins/catalog-import/package.json | 2 +- .../src/components/ComponentConfigDisplay.tsx | 35 ++++++----- .../components/ImportComponentForm.test.tsx | 13 ++-- .../src/components/ImportComponentForm.tsx | 8 +-- .../components/ImportComponentPage.test.tsx | 15 ++--- plugins/catalog/package.json | 3 +- .../src/components/AboutCard/AboutContent.tsx | 2 +- .../components/CatalogPage/CatalogPage.tsx | 6 +- .../components/CatalogTable/CatalogTable.tsx | 10 ++-- .../EntityLayout/EntityLayout.test.tsx | 16 ++--- .../components/EntityLayout/EntityLayout.tsx | 24 ++++---- .../EntityLoaderProvider.tsx | 5 +- .../EntityPageLayout/EntityPageLayout.tsx | 7 ++- .../EntityProvider/EntityProvider.tsx | 2 +- .../EntitySwitch/EntitySwitch.test.tsx | 8 +-- .../components/EntitySwitch/EntitySwitch.tsx | 10 ++-- plugins/catalog/src/components/Router.tsx | 9 ++- .../UnregisterEntityDialog.tsx | 6 +- plugins/catalog/src/components/useOwnUser.ts | 4 +- plugins/catalog/src/extensions.tsx | 5 +- .../src/filter/EntityFilterGroupsProvider.tsx | 2 +- plugins/catalog/src/index.ts | 8 +-- plugins/catalog/src/plugin.ts | 15 ++--- plugins/circleci/package.json | 2 +- plugins/circleci/src/state/useBuilds.ts | 4 +- plugins/cloudbuild/package.json | 1 - plugins/github-actions/package.json | 1 - plugins/jenkins/package.json | 2 +- .../components/useProjectSlugFromEntity.ts | 2 +- plugins/kafka/package.json | 2 +- .../useConsumerGroupsForEntity.test.tsx | 8 +-- .../useConsumerGroupsForEntity.ts | 2 +- ...useConsumerGroupsOffsetsForEntity.test.tsx | 12 ++-- plugins/lighthouse/package.json | 2 +- .../AuditList/AuditListForEntity.test.tsx | 19 +++--- .../Cards/LastLighthouseAuditCard.test.tsx | 14 ++--- .../src/hooks/useWebsiteForEntity.test.tsx | 12 ++-- .../src/hooks/useWebsiteForEntity.ts | 6 +- plugins/org/package.json | 2 +- .../Group/GroupProfile/GroupProfileCard.tsx | 2 +- .../MembersList/MembersListCard.test.tsx | 9 ++- .../Group/MembersList/MembersListCard.tsx | 5 +- .../Cards/OwnershipCard/OwnershipCard.tsx | 2 +- .../User/UserProfileCard/UserProfileCard.tsx | 2 +- plugins/register-component/package.json | 2 +- .../RegisterComponentPage.test.tsx | 2 +- .../RegisterComponentPage.tsx | 20 +++---- .../RegisterComponentResultDialog.tsx | 5 +- plugins/rollbar/package.json | 2 +- .../RollbarHome/RollbarHome.test.tsx | 5 +- .../RollbarProjectPage.test.tsx | 7 ++- plugins/rollbar/src/hooks/useCatalogEntity.ts | 4 +- .../rollbar/src/hooks/useRollbarEntities.ts | 4 +- plugins/scaffolder/package.json | 2 +- .../ScaffolderPage/ScaffolderPage.tsx | 2 +- .../TemplatePage/TemplatePage.test.tsx | 5 +- .../components/TemplatePage/TemplatePage.tsx | 8 +-- plugins/search/package.json | 2 +- plugins/search/src/apis.ts | 2 +- .../components/SearchResult/SearchResult.tsx | 16 +++-- plugins/sentry/dev/index.tsx | 6 +- plugins/sentry/package.json | 2 +- plugins/sentry/src/extensions.tsx | 4 +- plugins/techdocs/package.json | 2 +- .../reader/components/TechDocsHome.test.tsx | 5 +- .../src/reader/components/TechDocsHome.tsx | 2 +- 100 files changed, 488 insertions(+), 228 deletions(-) create mode 100644 .changeset/wet-suits-live.md create mode 100644 .changeset/wild-cows-exercise.md create mode 100644 plugins/catalog-common-react/.eslintrc.js create mode 100644 plugins/catalog-common-react/README.md create mode 100644 plugins/catalog-common-react/package.json create mode 100644 plugins/catalog-common-react/src/api.ts rename plugins/{catalog => catalog-common-react}/src/components/EntityRefLink/EntityRefLink.test.tsx (100%) rename plugins/{catalog => catalog-common-react}/src/components/EntityRefLink/EntityRefLink.tsx (91%) rename plugins/{catalog => catalog-common-react}/src/components/EntityRefLink/EntityRefLinks.test.tsx (100%) rename plugins/{catalog => catalog-common-react}/src/components/EntityRefLink/EntityRefLinks.tsx (96%) rename plugins/{catalog => catalog-common-react}/src/components/EntityRefLink/format.test.ts (100%) rename plugins/{catalog => catalog-common-react}/src/components/EntityRefLink/format.ts (100%) rename plugins/{catalog => catalog-common-react}/src/components/EntityRefLink/index.ts (100%) create mode 100644 plugins/catalog-common-react/src/components/index.ts create mode 100644 plugins/catalog-common-react/src/hooks/index.ts rename plugins/{catalog => catalog-common-react}/src/hooks/useEntity.ts (94%) rename plugins/{catalog/src/components => catalog-common-react/src/hooks}/useEntityCompoundName.ts (100%) create mode 100644 plugins/catalog-common-react/src/index.ts rename plugins/{catalog => catalog-common-react}/src/routes.ts (100%) create mode 100644 plugins/catalog-common-react/src/setupTests.ts diff --git a/.changeset/wet-suits-live.md b/.changeset/wet-suits-live.md new file mode 100644 index 0000000000..c10a226367 --- /dev/null +++ b/.changeset/wet-suits-live.md @@ -0,0 +1,19 @@ +--- +'@backstage/plugin-api-docs': patch +'@backstage/plugin-catalog-import': patch +'@backstage/plugin-circleci': patch +'@backstage/plugin-cloudbuild': patch +'@backstage/plugin-github-actions': patch +'@backstage/plugin-jenkins': patch +'@backstage/plugin-kafka': patch +'@backstage/plugin-lighthouse': patch +'@backstage/plugin-org': patch +'@backstage/plugin-register-component': patch +'@backstage/plugin-rollbar': patch +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-search': patch +'@backstage/plugin-sentry': patch +'@backstage/plugin-techdocs': patch +--- + +Switch dependency from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-common-react`. diff --git a/.changeset/wild-cows-exercise.md b/.changeset/wild-cows-exercise.md new file mode 100644 index 0000000000..411d23daee --- /dev/null +++ b/.changeset/wild-cows-exercise.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-catalog': minor +'@backstage/create-app': minor +--- + +`@backstage/plugin-catalog` stopped exporting hooks and helpers for other +plugins. They are migrated to `@backstage/plugin-catalog-common-react`. +Change both your dependencies and imports to the new package. diff --git a/packages/app/package.json b/packages/app/package.json index 36fe7651e6..72ca698f84 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -9,6 +9,7 @@ "@backstage/core": "^0.5.0", "@backstage/plugin-api-docs": "^0.4.3", "@backstage/plugin-catalog": "^0.2.14", + "@backstage/plugin-catalog-common-react": "^0.0.1", "@backstage/plugin-catalog-import": "^0.3.6", "@backstage/plugin-circleci": "^0.2.6", "@backstage/plugin-cloudbuild": "^0.2.7", diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index a1ab181211..756d36391f 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -27,11 +27,8 @@ import { ProvidedApisCard, ProvidingComponentsCard, } from '@backstage/plugin-api-docs'; -import { - AboutCard, - EntityPageLayout, - useEntity, -} from '@backstage/plugin-catalog'; +import { AboutCard, EntityPageLayout } from '@backstage/plugin-catalog'; +import { useEntity } from '@backstage/plugin-catalog-common-react'; import { isPluginApplicableToEntity as isCircleCIAvailable, Router as CircleCIRouter, @@ -50,6 +47,7 @@ import { LatestRunCard as JenkinsLatestRunCard, Router as JenkinsRouter, } from '@backstage/plugin-jenkins'; +import { Router as KafkaRouter } from '@backstage/plugin-kafka'; import { Router as KubernetesRouter } from '@backstage/plugin-kubernetes'; import { EmbeddedRouter as LighthouseRouter, @@ -57,13 +55,16 @@ import { LastLighthouseAuditCard, } from '@backstage/plugin-lighthouse'; import { - OwnershipCard, - MembersListCard, GroupProfileCard, + MembersListCard, + OwnershipCard, UserProfileCard, } from '@backstage/plugin-org'; +import { + isPluginApplicableToEntity as isPagerDutyAvailable, + PagerDutyCard, +} from '@backstage/plugin-pagerduty'; import { Router as SentryRouter } from '@backstage/plugin-sentry'; -import { Router as KafkaRouter } from '@backstage/plugin-kafka'; import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs'; import { Button, Grid } from '@material-ui/core'; import { @@ -82,10 +83,6 @@ import { PullRequestsStatsCard, Router as PullRequestsRouter, } from '@roadiehq/backstage-plugin-github-pull-requests'; -import { - isPluginApplicableToEntity as isPagerDutyAvailable, - PagerDutyCard, -} from '@backstage/plugin-pagerduty'; import { isPluginApplicableToEntity as isTravisCIAvailable, RecentTravisCIBuildsWidget, diff --git a/packages/create-app/src/lib/versions.ts b/packages/create-app/src/lib/versions.ts index a703fb9305..4723a4b78b 100644 --- a/packages/create-app/src/lib/versions.ts +++ b/packages/create-app/src/lib/versions.ts @@ -42,6 +42,7 @@ import { version as pluginApiDocs } from '../../../../plugins/api-docs/package.j import { version as pluginAppBackend } from '../../../../plugins/app-backend/package.json'; import { version as pluginAuthBackend } from '../../../../plugins/auth-backend/package.json'; import { version as pluginCatalog } from '../../../../plugins/catalog/package.json'; +import { version as pluginCatalogCommonReact } from '../../../../plugins/catalog-common-react/package.json'; import { version as pluginCatalogBackend } from '../../../../plugins/catalog-backend/package.json'; import { version as pluginCatalogImport } from '../../../../plugins/catalog-import/package.json'; import { version as pluginCircleci } from '../../../../plugins/circleci/package.json'; @@ -68,6 +69,7 @@ export const packageVersions = { '@backstage/plugin-app-backend': pluginAppBackend, '@backstage/plugin-auth-backend': pluginAuthBackend, '@backstage/plugin-catalog': pluginCatalog, + '@backstage/plugin-catalog-common-react': pluginCatalogCommonReact, '@backstage/plugin-catalog-backend': pluginCatalogBackend, '@backstage/plugin-catalog-import': pluginCatalogImport, '@backstage/plugin-circleci': pluginCircleci, diff --git a/packages/create-app/templates/default-app/packages/app/package.json.hbs b/packages/create-app/templates/default-app/packages/app/package.json.hbs index e40ed6f03d..99e613bc40 100644 --- a/packages/create-app/templates/default-app/packages/app/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/app/package.json.hbs @@ -10,6 +10,7 @@ "@backstage/core": "^{{version '@backstage/core'}}", "@backstage/plugin-api-docs": "^{{version '@backstage/plugin-api-docs'}}", "@backstage/plugin-catalog": "^{{version '@backstage/plugin-catalog'}}", + "@backstage/plugin-catalog-common-react": "^{{version '@backstage/plugin-catalog-common-react'}}", "@backstage/plugin-catalog-import": "^{{version '@backstage/plugin-catalog-import'}}", "@backstage/plugin-scaffolder": "^{{version '@backstage/plugin-scaffolder'}}", "@backstage/plugin-techdocs": "^{{version '@backstage/plugin-techdocs'}}", diff --git a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx index b5e384f7a9..409c7919db 100644 --- a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx @@ -24,8 +24,10 @@ import { } from '@backstage/plugin-api-docs'; import { AboutCard, EntityPageLayout, - useEntity } from '@backstage/plugin-catalog'; +import { + useEntity +} from '@backstage/plugin-catalog-common-react'; import { isPluginApplicableToEntity as isCircleCIAvailable, Router as CircleCIRouter } from '@backstage/plugin-circleci'; diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index 5e3c42bfdf..bdc292f71e 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -32,7 +32,7 @@ "@asyncapi/react-component": "^0.18.2", "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.14", + "@backstage/plugin-catalog-common-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-icons/font": "^1.0.2", "@material-ui/core": "^4.11.0", diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx index 10495bb957..e5e72af9cc 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx @@ -16,7 +16,10 @@ import { Entity } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { + CatalogApi, + catalogApiRef, +} from '@backstage/plugin-catalog-common-react'; import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import React from 'react'; diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx index a602877525..3779f6a9de 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx @@ -15,7 +15,7 @@ */ import { Content, ContentHeader, SupportButton, useApi } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; import { Button } from '@material-ui/core'; import React from 'react'; import { Link as RouterLink } from 'react-router-dom'; diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx index a4a1e15511..870528b91d 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx @@ -16,7 +16,10 @@ import { Entity, RELATION_CONSUMES_API } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { + CatalogApi, + catalogApiRef, +} from '@backstage/plugin-catalog-common-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx index 1f42ff9060..a149fcde88 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx @@ -16,7 +16,10 @@ import { Entity, RELATION_PROVIDES_API } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { + CatalogApi, + catalogApiRef, +} from '@backstage/plugin-catalog-common-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx index 606ff7e77b..428f4d91fe 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx @@ -16,7 +16,10 @@ import { Entity, RELATION_API_CONSUMED_BY } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { + CatalogApi, + catalogApiRef, +} from '@backstage/plugin-catalog-common-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx index d1cec1722a..3f4b7a545b 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx @@ -16,7 +16,10 @@ import { Entity, RELATION_API_PROVIDED_BY } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { + CatalogApi, + catalogApiRef, +} from '@backstage/plugin-catalog-common-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; diff --git a/plugins/api-docs/src/components/EntityLink/EntityLink.tsx b/plugins/api-docs/src/components/EntityLink/EntityLink.tsx index 98b2103882..db0437ed83 100644 --- a/plugins/api-docs/src/components/EntityLink/EntityLink.tsx +++ b/plugins/api-docs/src/components/EntityLink/EntityLink.tsx @@ -15,7 +15,10 @@ */ import { Entity } from '@backstage/catalog-model'; -import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog'; +import { + entityRoute, + entityRouteParams, +} from '@backstage/plugin-catalog-common-react'; import { Link } from '@material-ui/core'; import React, { PropsWithChildren } from 'react'; import { generatePath, Link as RouterLink } from 'react-router-dom'; diff --git a/plugins/api-docs/src/components/useRelatedEntities.ts b/plugins/api-docs/src/components/useRelatedEntities.ts index 847ec30578..4c161e8025 100644 --- a/plugins/api-docs/src/components/useRelatedEntities.ts +++ b/plugins/api-docs/src/components/useRelatedEntities.ts @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; import { useApi } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; import { useAsyncRetry } from 'react-use'; // TODO: Maybe this hook is interesting for others too? diff --git a/plugins/catalog-common-react/.eslintrc.js b/plugins/catalog-common-react/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/catalog-common-react/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/catalog-common-react/README.md b/plugins/catalog-common-react/README.md new file mode 100644 index 0000000000..574e23cf62 --- /dev/null +++ b/plugins/catalog-common-react/README.md @@ -0,0 +1,17 @@ +# Catalog Client + +Contains a frontend and backend compatible client for communicating with the +Backstage Catalog. + +Backend code may import and use this package directly. + +However, frontend code will not want to import this package directly - use the +`@backstage/plugin-catalog` package instead, which re-exports all of the types +and classes from this package. Thereby, you will also gain access to its +`catalogApiRef`. + +## Links + +- [Default frontend part of the catalog](https://github.com/spotify/backstage/tree/master/plugins/catalog) +- [Default backend part of the catalog](https://github.com/spotify/backstage/tree/master/plugins/catalog-backend) +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/catalog-common-react/package.json b/plugins/catalog-common-react/package.json new file mode 100644 index 0000000000..11b8d3b1f4 --- /dev/null +++ b/plugins/catalog-common-react/package.json @@ -0,0 +1,60 @@ +{ + "name": "@backstage/plugin-catalog-common-react", + "version": "0.0.1", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/plugin-catalog-common-react" + }, + "keywords": [ + "backstage" + ], + "scripts": { + "build": "backstage-cli build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/catalog-client": "^0.3.5", + "@backstage/catalog-model": "^0.7.0", + "@backstage/core": "^0.5.0", + "@material-ui/core": "^4.11.0", + "@types/react": "^16.9", + "react": "^16.13.1", + "react-router": "6.0.0-beta.0", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^15.3.3" + }, + "devDependencies": { + "@backstage/cli": "^0.4.7", + "@backstage/dev-utils": "^0.1.8", + "@backstage/test-utils": "^0.1.6", + "@microsoft/microsoft-graph-types": "^1.25.0", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^10.4.1", + "@testing-library/react-hooks": "^3.3.0", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", + "cross-fetch": "^3.0.6", + "msw": "^0.21.2", + "react-test-renderer": "^16.13.1" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/catalog-common-react/src/api.ts b/plugins/catalog-common-react/src/api.ts new file mode 100644 index 0000000000..9379fa7fb3 --- /dev/null +++ b/plugins/catalog-common-react/src/api.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CatalogApi } from '@backstage/catalog-client'; +import { createApiRef } from '@backstage/core'; + +export const catalogApiRef = createApiRef({ + id: 'plugin.catalog.service', + description: + 'Used by the Catalog plugin to make requests to accompanying backend', +}); diff --git a/plugins/catalog/src/components/EntityRefLink/EntityRefLink.test.tsx b/plugins/catalog-common-react/src/components/EntityRefLink/EntityRefLink.test.tsx similarity index 100% rename from plugins/catalog/src/components/EntityRefLink/EntityRefLink.test.tsx rename to plugins/catalog-common-react/src/components/EntityRefLink/EntityRefLink.test.tsx diff --git a/plugins/catalog/src/components/EntityRefLink/EntityRefLink.tsx b/plugins/catalog-common-react/src/components/EntityRefLink/EntityRefLink.tsx similarity index 91% rename from plugins/catalog/src/components/EntityRefLink/EntityRefLink.tsx rename to plugins/catalog-common-react/src/components/EntityRefLink/EntityRefLink.tsx index 64b2bb8615..ae6f807275 100644 --- a/plugins/catalog/src/components/EntityRefLink/EntityRefLink.tsx +++ b/plugins/catalog-common-react/src/components/EntityRefLink/EntityRefLink.tsx @@ -31,9 +31,6 @@ type EntityRefLinkProps = { children?: React.ReactNode; }; -// TODO: This component is private for now, as it should probably belong into -// some kind of helper module for the catalog plugin to avoid a dependency on -// the catalog plugin itself. export const EntityRefLink = ({ entityRef, defaultKind, diff --git a/plugins/catalog/src/components/EntityRefLink/EntityRefLinks.test.tsx b/plugins/catalog-common-react/src/components/EntityRefLink/EntityRefLinks.test.tsx similarity index 100% rename from plugins/catalog/src/components/EntityRefLink/EntityRefLinks.test.tsx rename to plugins/catalog-common-react/src/components/EntityRefLink/EntityRefLinks.test.tsx diff --git a/plugins/catalog/src/components/EntityRefLink/EntityRefLinks.tsx b/plugins/catalog-common-react/src/components/EntityRefLink/EntityRefLinks.tsx similarity index 96% rename from plugins/catalog/src/components/EntityRefLink/EntityRefLinks.tsx rename to plugins/catalog-common-react/src/components/EntityRefLink/EntityRefLinks.tsx index 3c8beaec6f..fb99fad176 100644 --- a/plugins/catalog/src/components/EntityRefLink/EntityRefLinks.tsx +++ b/plugins/catalog-common-react/src/components/EntityRefLink/EntityRefLinks.tsx @@ -22,7 +22,6 @@ type EntityRefLinksProps = { defaultKind?: string; }; -// TODO: Move into a shared helper package export const EntityRefLinks = ({ entityRefs, defaultKind, diff --git a/plugins/catalog/src/components/EntityRefLink/format.test.ts b/plugins/catalog-common-react/src/components/EntityRefLink/format.test.ts similarity index 100% rename from plugins/catalog/src/components/EntityRefLink/format.test.ts rename to plugins/catalog-common-react/src/components/EntityRefLink/format.test.ts diff --git a/plugins/catalog/src/components/EntityRefLink/format.ts b/plugins/catalog-common-react/src/components/EntityRefLink/format.ts similarity index 100% rename from plugins/catalog/src/components/EntityRefLink/format.ts rename to plugins/catalog-common-react/src/components/EntityRefLink/format.ts diff --git a/plugins/catalog/src/components/EntityRefLink/index.ts b/plugins/catalog-common-react/src/components/EntityRefLink/index.ts similarity index 100% rename from plugins/catalog/src/components/EntityRefLink/index.ts rename to plugins/catalog-common-react/src/components/EntityRefLink/index.ts diff --git a/plugins/catalog-common-react/src/components/index.ts b/plugins/catalog-common-react/src/components/index.ts new file mode 100644 index 0000000000..7719984d2b --- /dev/null +++ b/plugins/catalog-common-react/src/components/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './EntityRefLink'; diff --git a/plugins/catalog-common-react/src/hooks/index.ts b/plugins/catalog-common-react/src/hooks/index.ts new file mode 100644 index 0000000000..77de70b8d4 --- /dev/null +++ b/plugins/catalog-common-react/src/hooks/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 { EntityContext, useEntity, useEntityFromUrl } from './useEntity'; +export { useEntityCompoundName } from './useEntityCompoundName'; diff --git a/plugins/catalog/src/hooks/useEntity.ts b/plugins/catalog-common-react/src/hooks/useEntity.ts similarity index 94% rename from plugins/catalog/src/hooks/useEntity.ts rename to plugins/catalog-common-react/src/hooks/useEntity.ts index 289c4e26aa..faeeca11ed 100644 --- a/plugins/catalog/src/hooks/useEntity.ts +++ b/plugins/catalog-common-react/src/hooks/useEntity.ts @@ -18,8 +18,8 @@ import { errorApiRef, useApi } from '@backstage/core'; import { createContext, useContext, useEffect } from 'react'; import { useNavigate } from 'react-router'; import { useAsync } from 'react-use'; -import { useEntityCompoundName } from '../components/useEntityCompoundName'; -import { catalogApiRef } from '../plugin'; +import { useEntityCompoundName } from './useEntityCompoundName'; +import { catalogApiRef } from '../api'; type EntityLoadingStatus = { entity?: Entity; diff --git a/plugins/catalog/src/components/useEntityCompoundName.ts b/plugins/catalog-common-react/src/hooks/useEntityCompoundName.ts similarity index 100% rename from plugins/catalog/src/components/useEntityCompoundName.ts rename to plugins/catalog-common-react/src/hooks/useEntityCompoundName.ts diff --git a/plugins/catalog-common-react/src/index.ts b/plugins/catalog-common-react/src/index.ts new file mode 100644 index 0000000000..b0a9743ea4 --- /dev/null +++ b/plugins/catalog-common-react/src/index.ts @@ -0,0 +1,26 @@ +/* + * 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 '@backstage/catalog-client'; +export { catalogApiRef } from './api'; +export * from './components'; +export * from './hooks'; +export { + catalogRouteRef, + entityRoute, + entityRouteParams, + entityRouteRef, + rootRoute, +} from './routes'; diff --git a/plugins/catalog/src/routes.ts b/plugins/catalog-common-react/src/routes.ts similarity index 100% rename from plugins/catalog/src/routes.ts rename to plugins/catalog-common-react/src/routes.ts diff --git a/plugins/catalog-common-react/src/setupTests.ts b/plugins/catalog-common-react/src/setupTests.ts new file mode 100644 index 0000000000..aea2220869 --- /dev/null +++ b/plugins/catalog-common-react/src/setupTests.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index 5a4dbf8798..6deb066705 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -33,7 +33,7 @@ "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", "@backstage/integration": "^0.3.1", - "@backstage/plugin-catalog": "^0.2.14", + "@backstage/plugin-catalog-common-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx b/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx index 96b1bf2298..ff8d28e654 100644 --- a/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx +++ b/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx @@ -14,32 +14,35 @@ * limitations under the License. */ -import React, { useCallback, useState } from 'react'; -import { - Button, - CircularProgress, - Grid, - Link, - List, - ListItem, - Typography, - Divider, -} from '@material-ui/core'; -import { useGithubRepos } from '../util/useGithubRepos'; -import { ConfigSpec } from './ImportComponentPage'; +import { Entity } from '@backstage/catalog-model'; import { errorApiRef, RouteRef, StructuredMetadataTable, useApi, } from '@backstage/core'; -import { PartialEntity } from '../util/types'; +import { + entityRoute, + entityRouteParams, +} from '@backstage/plugin-catalog-common-react'; +import { + Button, + CircularProgress, + Divider, + Grid, + Link, + List, + ListItem, + Typography, +} from '@material-ui/core'; +import React, { useCallback, useState } from 'react'; import { generatePath, resolvePath } from 'react-router'; -import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog'; -import { Entity } from '@backstage/catalog-model'; import { Link as RouterLink } from 'react-router-dom'; import * as YAML from 'yaml'; +import { PartialEntity } from '../util/types'; import { urlType } from '../util/urls'; +import { useGithubRepos } from '../util/useGithubRepos'; +import { ConfigSpec } from './ImportComponentPage'; const getEntityCatalogPath = ({ entity, diff --git a/plugins/catalog-import/src/components/ImportComponentForm.test.tsx b/plugins/catalog-import/src/components/ImportComponentForm.test.tsx index 91a5208bee..d86db71f92 100644 --- a/plugins/catalog-import/src/components/ImportComponentForm.test.tsx +++ b/plugins/catalog-import/src/components/ImportComponentForm.test.tsx @@ -13,18 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { RegisterComponentForm } from './ImportComponentForm'; import { ApiProvider, ApiRegistry, DiscoveryApi, errorApiRef, } from '@backstage/core'; -import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog'; +import { + catalogApiRef, + CatalogClient, +} from '@backstage/plugin-catalog-common-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import React from 'react'; import { catalogImportApiRef, CatalogImportClient } from '../api'; -import { fireEvent, waitFor, screen } from '@testing-library/react'; +import { RegisterComponentForm } from './ImportComponentForm'; describe('', () => { let apis: ApiRegistry; diff --git a/plugins/catalog-import/src/components/ImportComponentForm.tsx b/plugins/catalog-import/src/components/ImportComponentForm.tsx index c233be5a8c..25bea7fbe3 100644 --- a/plugins/catalog-import/src/components/ImportComponentForm.tsx +++ b/plugins/catalog-import/src/components/ImportComponentForm.tsx @@ -15,6 +15,7 @@ */ import { errorApiRef, useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; import { BackstageTheme } from '@backstage/theme'; import { Button, @@ -26,11 +27,10 @@ import { makeStyles } from '@material-ui/core/styles'; import React from 'react'; import { useForm } from 'react-hook-form'; import { useMountedState } from 'react-use'; -import { ComponentIdValidators } from '../util/validate'; -import { useGithubRepos } from '../util/useGithubRepos'; -import { ConfigSpec } from './ImportComponentPage'; -import { catalogApiRef } from '@backstage/plugin-catalog'; import { urlType } from '../util/urls'; +import { useGithubRepos } from '../util/useGithubRepos'; +import { ComponentIdValidators } from '../util/validate'; +import { ConfigSpec } from './ImportComponentPage'; const useStyles = makeStyles(theme => ({ form: { diff --git a/plugins/catalog-import/src/components/ImportComponentPage.test.tsx b/plugins/catalog-import/src/components/ImportComponentPage.test.tsx index 932f9aafa0..9d7dd1eae1 100644 --- a/plugins/catalog-import/src/components/ImportComponentPage.test.tsx +++ b/plugins/catalog-import/src/components/ImportComponentPage.test.tsx @@ -13,22 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { msw, renderInTestApp } from '@backstage/test-utils'; -import { ImportComponentPage } from './ImportComponentPage'; import { ApiProvider, ApiRegistry, configApiRef, errorApiRef, } from '@backstage/core'; -import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog'; -import { catalogImportApiRef, CatalogImportClient } from '../api'; - +import { + catalogApiRef, + CatalogClient, +} from '@backstage/plugin-catalog-common-react'; +import { msw, renderInTestApp } from '@backstage/test-utils'; import { fireEvent, screen, waitFor } from '@testing-library/react'; - import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import React from 'react'; +import { catalogImportApiRef, CatalogImportClient } from '../api'; +import { ImportComponentPage } from './ImportComponentPage'; let codeSearchMockResponse: () => Promise<{ data: { diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 90bdd2f3c7..0f250d5a41 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -33,6 +33,7 @@ "@backstage/catalog-client": "^0.3.5", "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", + "@backstage/plugin-catalog-common-react": "^0.0.1", "@backstage/plugin-scaffolder": "^0.4.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", @@ -68,4 +69,4 @@ "files": [ "dist" ] -} +} \ No newline at end of file diff --git a/plugins/catalog/src/components/AboutCard/AboutContent.tsx b/plugins/catalog/src/components/AboutCard/AboutContent.tsx index 82652b3986..22dfc3c2a3 100644 --- a/plugins/catalog/src/components/AboutCard/AboutContent.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutContent.tsx @@ -19,9 +19,9 @@ import { RELATION_OWNED_BY, RELATION_PART_OF, } from '@backstage/catalog-model'; +import { EntityRefLinks } from '@backstage/plugin-catalog-common-react'; import { Chip, Grid, makeStyles, Typography } from '@material-ui/core'; import React from 'react'; -import { EntityRefLinks } from '../EntityRefLink'; import { getEntityRelations } from '../getEntityRelations'; import { AboutField } from './AboutField'; diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index afb812ed06..de280e7e1b 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -22,6 +22,7 @@ import { SupportButton, useApi, } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder'; import { Button, makeStyles } from '@material-ui/core'; import SettingsIcon from '@material-ui/icons/Settings'; @@ -30,14 +31,13 @@ import React, { useCallback, useMemo, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter'; import { useStarredEntities } from '../../hooks/useStarredEntities'; -import { catalogApiRef } from '../../plugin'; import { ButtonGroup, CatalogFilter } from '../CatalogFilter/CatalogFilter'; import { CatalogTable } from '../CatalogTable/CatalogTable'; +import { isOwnerOf } from '../isOwnerOf'; import { ResultsFilter } from '../ResultsFilter/ResultsFilter'; +import { useOwnUser } from '../useOwnUser'; import CatalogLayout from './CatalogLayout'; import { CatalogTabs, LabeledComponentType } from './CatalogTabs'; -import { useOwnUser } from '../useOwnUser'; -import { isOwnerOf } from '../isOwnerOf'; const useStyles = makeStyles(theme => ({ contentWrapper: { diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 18c61c2a04..52d0ab9178 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -20,6 +20,11 @@ import { RELATION_PART_OF, } from '@backstage/catalog-model'; import { Table, TableColumn, TableProps } from '@backstage/core'; +import { + EntityRefLink, + EntityRefLinks, + formatEntityRefTitle, +} from '@backstage/plugin-catalog-common-react'; import { Chip } from '@material-ui/core'; import Edit from '@material-ui/icons/Edit'; import OpenInNew from '@material-ui/icons/OpenInNew'; @@ -28,11 +33,6 @@ import React from 'react'; import { findLocationForEntityMeta } from '../../data/utils'; import { useStarredEntities } from '../../hooks/useStarredEntities'; import { createEditLink } from '../createEditLink'; -import { - EntityRefLink, - EntityRefLinks, - formatEntityRefTitle, -} from '../EntityRefLink'; import { favouriteEntityIcon, favouriteEntityTooltip, diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx index 5d1f010740..ba52c9a114 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx @@ -13,22 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { EntityLayout } from './EntityLayout'; +import { CatalogApi } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; import { AlertApi, alertApiRef, ApiProvider, ApiRegistry, } from '@backstage/core'; +import { + catalogApiRef, + EntityContext, +} from '@backstage/plugin-catalog-common-react'; import { renderInTestApp, withLogCollector } from '@backstage/test-utils'; import { fireEvent } from '@testing-library/react'; +import React from 'react'; import { act } from 'react-dom/test-utils'; -import { Routes, Route } from 'react-router'; -import { Entity } from '@backstage/catalog-model'; -import { EntityContext } from '../../hooks/useEntity'; -import { catalogApiRef } from '../../plugin'; -import { CatalogApi } from '@backstage/catalog-client'; +import { Route, Routes } from 'react-router'; +import { EntityLayout } from './EntityLayout'; const mockEntityData = { loading: false, diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index 09b8d02c60..24efdebb96 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -14,15 +14,6 @@ * limitations under the License. */ -import React, { - Children, - Fragment, - PropsWithChildren, - ReactNode, - isValidElement, - useContext, - useState, -} from 'react'; import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { attachComponentData, @@ -32,14 +23,25 @@ import { Page, Progress, } from '@backstage/core'; +import { + EntityContext, + useEntityCompoundName, +} from '@backstage/plugin-catalog-common-react'; import { Box } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; +import React, { + Children, + Fragment, + isValidElement, + PropsWithChildren, + ReactNode, + useContext, + useState, +} from 'react'; import { useNavigate } from 'react-router'; -import { EntityContext } from '../../hooks/useEntity'; import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog'; -import { useEntityCompoundName } from '../useEntityCompoundName'; import { TabbedLayout } from './TabbedLayout'; type SubRoute = { diff --git a/plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx b/plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx index 98b8295047..555595e818 100644 --- a/plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx +++ b/plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx @@ -13,8 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { + EntityContext, + useEntityFromUrl, +} from '@backstage/plugin-catalog-common-react'; import React, { ReactNode } from 'react'; -import { useEntityFromUrl, EntityContext } from '../../hooks/useEntity'; export const EntityLoaderProvider = ({ children }: { children: ReactNode }) => { const { entity, loading, error } = useEntityFromUrl(); diff --git a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx index eb75593ffc..cb4f9bc644 100644 --- a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx @@ -19,11 +19,14 @@ import { Box } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import React, { PropsWithChildren, useContext, useState } from 'react'; import { useNavigate } from 'react-router'; -import { EntityContext } from '../../hooks/useEntity'; +import { + EntityContext, + useEntityCompoundName, +} from '@backstage/plugin-catalog-common-react'; import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog'; -import { useEntityCompoundName } from '../useEntityCompoundName'; + import { Tabbed } from './Tabbed'; const EntityPageTitle = ({ diff --git a/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx b/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx index 9135b54638..836e2aa752 100644 --- a/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx +++ b/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; import React, { ReactNode } from 'react'; -import { EntityContext } from '../../hooks/useEntity'; +import { EntityContext } from '@backstage/plugin-catalog-common-react'; type EntityProviderProps = { entity: Entity; diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx index 292cd37ede..4be4713075 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx @@ -14,12 +14,12 @@ * limitations under the License. */ -import React from 'react'; -import { render } from '@testing-library/react'; -import { EntityContext } from '../../hooks/useEntity'; import { Entity } from '@backstage/catalog-model'; -import { EntitySwitch } from './EntitySwitch'; +import { EntityContext } from '@backstage/plugin-catalog-common-react'; +import { render } from '@testing-library/react'; +import React from 'react'; import { isKind } from './conditions'; +import { EntitySwitch } from './EntitySwitch'; describe('EntitySwitch', () => { it('should switch child when entity switches', () => { diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx index f36bc29cee..4312fba5db 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx @@ -14,16 +14,16 @@ * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-common-react'; import { - ReactNode, - PropsWithChildren, Children, Fragment, - useMemo, isValidElement, + PropsWithChildren, + ReactNode, + useMemo, } from 'react'; -import { useEntity } from '../../hooks/useEntity'; -import { Entity } from '@backstage/catalog-model'; const EntitySwitchCase = (_: { if?: (entity: Entity) => boolean; diff --git a/plugins/catalog/src/components/Router.tsx b/plugins/catalog/src/components/Router.tsx index 6a4593fb80..b54dfef01c 100644 --- a/plugins/catalog/src/components/Router.tsx +++ b/plugins/catalog/src/components/Router.tsx @@ -15,15 +15,18 @@ */ import { ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { Content } from '@backstage/core'; +import { + entityRoute, + rootRoute, + useEntity, +} from '@backstage/plugin-catalog-common-react'; import { Link, Typography } from '@material-ui/core'; import React, { ComponentType } from 'react'; import { Navigate, Route, Routes, useParams } from 'react-router'; -import { useEntity } from '../hooks/useEntity'; -import { entityRoute, rootRoute } from '../routes'; import { CatalogPage } from './CatalogPage'; +import { EntityLoaderProvider } from './EntityLoaderProvider'; import { EntityNotFound } from './EntityNotFound'; import { EntityPageLayout } from './EntityPageLayout'; -import { EntityLoaderProvider } from './EntityLoaderProvider'; const DefaultEntityPage = () => ( diff --git a/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx b/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx index 2c8fcfd6e9..5e4212ee1b 100644 --- a/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx +++ b/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx @@ -16,6 +16,10 @@ import { Entity, ORIGIN_LOCATION_ANNOTATION } from '@backstage/catalog-model'; import { alertApiRef, configApiRef, Progress, useApi } from '@backstage/core'; +import { + catalogApiRef, + formatEntityRefTitle, +} from '@backstage/plugin-catalog-common-react'; import { Button, Dialog, @@ -31,8 +35,6 @@ import Alert from '@material-ui/lab/Alert'; import React from 'react'; import { useAsync } from 'react-use'; import { AsyncState } from 'react-use/lib/useAsync'; -import { catalogApiRef } from '../../plugin'; -import { formatEntityRefTitle } from '../EntityRefLink'; type Props = { open: boolean; diff --git a/plugins/catalog/src/components/useOwnUser.ts b/plugins/catalog/src/components/useOwnUser.ts index 9aa4bda99c..43d658e8d0 100644 --- a/plugins/catalog/src/components/useOwnUser.ts +++ b/plugins/catalog/src/components/useOwnUser.ts @@ -15,10 +15,10 @@ */ import { UserEntity } from '@backstage/catalog-model'; +import { identityApiRef, useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; import { useAsync } from 'react-use'; import { AsyncState } from 'react-use/lib/useAsync'; -import { identityApiRef, useApi } from '@backstage/core'; -import { catalogApiRef } from '../plugin'; /** * Get the catalog User entity (if any) that matches the logged-in user. diff --git a/plugins/catalog/src/extensions.tsx b/plugins/catalog/src/extensions.tsx index dcb9ba2aff..2a214dde23 100644 --- a/plugins/catalog/src/extensions.tsx +++ b/plugins/catalog/src/extensions.tsx @@ -15,7 +15,10 @@ */ import { createRoutableExtension } from '@backstage/core'; -import { catalogRouteRef, entityRouteRef } from './routes'; +import { + catalogRouteRef, + entityRouteRef, +} from '@backstage/plugin-catalog-common-react'; import { plugin } from './plugin'; export const CatalogIndexPage = plugin.provide( diff --git a/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx index edda8eecb5..2d954fb2d2 100644 --- a/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx +++ b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx @@ -16,9 +16,9 @@ import { Entity } from '@backstage/catalog-model'; import { useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useAsyncFn } from 'react-use'; -import { catalogApiRef } from '../plugin'; import { filterGroupsContext, FilterGroupsContext } from './context'; import { EntityFilterFn, diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index 2152933daf..5a93b80539 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -14,15 +14,11 @@ * limitations under the License. */ -export * from '@backstage/catalog-client'; export { AboutCard } from './components/AboutCard'; -export { EntityPageLayout } from './components/EntityPageLayout'; export { EntityLayout } from './components/EntityLayout'; +export { EntityPageLayout } from './components/EntityPageLayout'; export { EntityProvider } from './components/EntityProvider'; export * from './components/EntitySwitch'; export { Router } from './components/Router'; -export { useEntityCompoundName } from './components/useEntityCompoundName'; -export { EntityContext, useEntity } from './hooks/useEntity'; -export { catalogApiRef, plugin } from './plugin'; -export * from './routes'; export * from './extensions'; +export { plugin } from './plugin'; diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index 0c2b3b18c5..be57a2e9f9 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -14,20 +14,17 @@ * limitations under the License. */ -import { CatalogApi, CatalogClient } from '@backstage/catalog-client'; +import { CatalogClient } from '@backstage/catalog-client'; import { createApiFactory, - createApiRef, createPlugin, discoveryApiRef, } from '@backstage/core'; -import { catalogRouteRef, entityRouteRef } from './routes'; - -export const catalogApiRef = createApiRef({ - id: 'plugin.catalog.service', - description: - 'Used by the Catalog plugin to make requests to accompanying backend', -}); +import { + catalogApiRef, + catalogRouteRef, + entityRouteRef, +} from '@backstage/plugin-catalog-common-react'; export const plugin = createPlugin({ id: 'catalog', diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index ddae98a566..b731146e40 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.14", + "@backstage/plugin-catalog-common-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/circleci/src/state/useBuilds.ts b/plugins/circleci/src/state/useBuilds.ts index c172a88985..b7143f1cb5 100644 --- a/plugins/circleci/src/state/useBuilds.ts +++ b/plugins/circleci/src/state/useBuilds.ts @@ -15,14 +15,14 @@ */ import { errorApiRef, useApi } from '@backstage/core'; +import { useEntity } from '@backstage/plugin-catalog-common-react'; import { BuildSummary, GitType } from 'circleci-api'; +import { getOr } from 'lodash/fp'; import { useCallback, useEffect, useState } from 'react'; import { useAsyncRetry } from 'react-use'; import { circleCIApiRef } from '../api'; import type { CITableBuildInfo } from '../components/BuildsPage/lib/CITable'; -import { useEntity } from '@backstage/plugin-catalog'; import { CIRCLECI_ANNOTATION } from '../constants'; -import { getOr } from 'lodash/fp'; const makeReadableStatus = (status: string | undefined) => { if (!status) return ''; diff --git a/plugins/cloudbuild/package.json b/plugins/cloudbuild/package.json index 9a05a652b9..3e5bbef3a9 100644 --- a/plugins/cloudbuild/package.json +++ b/plugins/cloudbuild/package.json @@ -32,7 +32,6 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index 7b7cf539f9..beb35b3cb0 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -35,7 +35,6 @@ "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", "@backstage/integration": "^0.3.1", - "@backstage/plugin-catalog": "^0.2.14", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index a99b9937b1..75dec65ee2 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.14", + "@backstage/plugin-catalog-common-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/jenkins/src/components/useProjectSlugFromEntity.ts b/plugins/jenkins/src/components/useProjectSlugFromEntity.ts index 1dca918990..2c5f138402 100644 --- a/plugins/jenkins/src/components/useProjectSlugFromEntity.ts +++ b/plugins/jenkins/src/components/useProjectSlugFromEntity.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useEntity } from '@backstage/plugin-catalog'; +import { useEntity } from '@backstage/plugin-catalog-common-react'; import { JENKINS_ANNOTATION } from '../constants'; export const useProjectSlugFromEntity = () => { diff --git a/plugins/kafka/package.json b/plugins/kafka/package.json index eef14439fd..03f602c049 100644 --- a/plugins/kafka/package.json +++ b/plugins/kafka/package.json @@ -22,7 +22,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.14", + "@backstage/plugin-catalog-common-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx index 3bee4e2e88..595905d7a3 100644 --- a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { PropsWithChildren } from 'react'; -import { renderHook } from '@testing-library/react-hooks'; -import { useConsumerGroupsForEntity } from './useConsumerGroupsForEntity'; -import { EntityContext } from '@backstage/plugin-catalog'; import { Entity } from '@backstage/catalog-model'; +import { EntityContext } from '@backstage/plugin-catalog-common-react'; +import { renderHook } from '@testing-library/react-hooks'; +import React, { PropsWithChildren } from 'react'; +import { useConsumerGroupsForEntity } from './useConsumerGroupsForEntity'; describe('useConsumerGroupOffsets', () => { let entity: Entity; diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.ts b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.ts index 522a6273fe..c81573e2ff 100644 --- a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.ts +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useEntity } from '@backstage/plugin-catalog'; +import { useEntity } from '@backstage/plugin-catalog-common-react'; import { useMemo } from 'react'; import { KAFKA_CONSUMER_GROUP_ANNOTATION } from '../../constants'; diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx index b7e3a29e80..0d9846dba5 100644 --- a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx @@ -13,19 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { PropsWithChildren } from 'react'; -import { renderHook } from '@testing-library/react-hooks'; -import { EntityContext } from '@backstage/plugin-catalog'; import { Entity } from '@backstage/catalog-model'; -import * as data from './__fixtures__/consumer-group-offsets.json'; +import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; +import { EntityContext } from '@backstage/plugin-catalog-common-react'; +import { renderHook } from '@testing-library/react-hooks'; +import { when } from 'jest-when'; +import React, { PropsWithChildren } from 'react'; import { ConsumerGroupOffsetsResponse, KafkaApi, kafkaApiRef, } from '../../api/types'; -import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; import { useConsumerGroupsOffsetsForEntity } from './useConsumerGroupsOffsetsForEntity'; -import { when } from 'jest-when'; +import * as data from './__fixtures__/consumer-group-offsets.json'; const consumerGroupOffsets = data as ConsumerGroupOffsetsResponse; diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 3463842bc5..c1df09ac1d 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -34,7 +34,7 @@ "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.14", + "@backstage/plugin-catalog-common-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx index 15a4ba3255..cd4f1f9e5f 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx @@ -14,23 +14,22 @@ * limitations under the License. */ -import React from 'react'; -import { render } from '@testing-library/react'; +import { Entity } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; +import { EntityContext } from '@backstage/plugin-catalog-common-react'; +import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; import { lighthouseApiRef, LighthouseRestApi, WebsiteListResponse, } from '../../api'; - -import * as data from '../../__fixtures__/website-list-response.json'; -import { EntityContext } from '@backstage/plugin-catalog'; -import { Entity } from '@backstage/catalog-model'; -import { AuditListForEntity } from './AuditListForEntity'; -import { lightTheme } from '@backstage/theme'; -import { ThemeProvider } from '@material-ui/core'; -import { MemoryRouter } from 'react-router-dom'; import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity'; +import * as data from '../../__fixtures__/website-list-response.json'; +import { AuditListForEntity } from './AuditListForEntity'; jest.mock('../../hooks/useWebsiteForEntity', () => ({ useWebsiteForEntity: jest.fn(), diff --git a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx index 42c213b42e..c9405a25c3 100644 --- a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx +++ b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx @@ -14,21 +14,21 @@ * limitations under the License. */ -import React from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { EntityContext } from '@backstage/plugin-catalog-common-react'; +import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; import { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; import { AuditCompleted, LighthouseCategoryId, WebsiteListResponse, } from '../../api'; -import { EntityContext } from '@backstage/plugin-catalog'; -import { Entity } from '@backstage/catalog-model'; -import { LastLighthouseAuditCard } from './LastLighthouseAuditCard'; -import { lightTheme } from '@backstage/theme'; -import { ThemeProvider } from '@material-ui/core'; import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity'; -import { MemoryRouter } from 'react-router-dom'; import * as data from '../../__fixtures__/website-list-response.json'; +import { LastLighthouseAuditCard } from './LastLighthouseAuditCard'; jest.mock('../../hooks/useWebsiteForEntity', () => ({ useWebsiteForEntity: jest.fn(), diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx index c1773921cf..aa9c9c3557 100644 --- a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx +++ b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx @@ -13,14 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { PropsWithChildren } from 'react'; -import { renderHook } from '@testing-library/react-hooks'; -import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; -import { lighthouseApiRef, WebsiteListResponse } from '../api'; -import { useWebsiteForEntity } from './useWebsiteForEntity'; -import { EntityContext } from '@backstage/plugin-catalog'; import { Entity } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; +import { EntityContext } from '@backstage/plugin-catalog-common-react'; +import { renderHook } from '@testing-library/react-hooks'; +import React, { PropsWithChildren } from 'react'; +import { lighthouseApiRef, WebsiteListResponse } from '../api'; import * as data from '../__fixtures__/website-list-response.json'; +import { useWebsiteForEntity } from './useWebsiteForEntity'; const websiteListResponse = data as WebsiteListResponse; const website = websiteListResponse.items[0]; diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts b/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts index 08d9925b7a..da351a6c82 100644 --- a/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts +++ b/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useEntity } from '@backstage/plugin-catalog'; -import { LIGHTHOUSE_WEBSITE_URL_ANNOTATION } from '../../constants'; import { errorApiRef, useApi } from '@backstage/core'; -import { lighthouseApiRef } from '../api'; +import { useEntity } from '@backstage/plugin-catalog-common-react'; import { useAsync } from 'react-use'; +import { LIGHTHOUSE_WEBSITE_URL_ANNOTATION } from '../../constants'; +import { lighthouseApiRef } from '../api'; // For the sake of simplicity we assume that an entity has only one website url. This is to avoid encoding a list // type in an annotation which is a plain string. diff --git a/plugins/org/package.json b/plugins/org/package.json index fbb3bd1afb..7cc55dbf60 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -22,7 +22,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.14", + "@backstage/plugin-catalog-common-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx index 779cb1d92d..0238fea7f6 100644 --- a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx +++ b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx @@ -21,7 +21,7 @@ import { RELATION_PARENT_OF, } from '@backstage/catalog-model'; import { Avatar, InfoCard } from '@backstage/core'; -import { entityRouteParams } from '@backstage/plugin-catalog'; +import { entityRouteParams } from '@backstage/plugin-catalog-common-react'; import { Box, Grid, Link, Tooltip, Typography } from '@material-ui/core'; import AccountTreeIcon from '@material-ui/icons/AccountTree'; import EmailIcon from '@material-ui/icons/Email'; diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx index 535b2acd0e..59260f93fc 100644 --- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx @@ -14,11 +14,14 @@ * limitations under the License. */ +import { Entity, GroupEntity } from '@backstage/catalog-model'; +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + CatalogApi, + catalogApiRef, +} from '@backstage/plugin-catalog-common-react'; import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; import React from 'react'; -import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; -import { Entity, GroupEntity } from '@backstage/catalog-model'; import { MembersListCard } from './MembersListCard'; describe('MemberTab Test', () => { diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx index 04a137d515..cd001d6e96 100644 --- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx @@ -20,7 +20,10 @@ import { UserEntity, } from '@backstage/catalog-model'; import { Avatar, InfoCard, Progress, useApi } from '@backstage/core'; -import { catalogApiRef, entityRouteParams } from '@backstage/plugin-catalog'; +import { + catalogApiRef, + entityRouteParams, +} from '@backstage/plugin-catalog-common-react'; import { Box, createStyles, diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx index a61dd55161..3d459d7cb4 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { InfoCard, useApi, Progress } from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; import { useAsync } from 'react-use'; import Alert from '@material-ui/lab/Alert'; import { diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx index 99048c0cd8..f1897efed1 100644 --- a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx @@ -19,7 +19,7 @@ import { UserEntity, } from '@backstage/catalog-model'; import { Avatar, InfoCard } from '@backstage/core'; -import { entityRouteParams } from '@backstage/plugin-catalog'; +import { entityRouteParams } from '@backstage/plugin-catalog-common-react'; import { Box, Grid, Link, Tooltip, Typography } from '@material-ui/core'; import EmailIcon from '@material-ui/icons/Email'; import GroupIcon from '@material-ui/icons/Group'; diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index 29b629de9b..e12a27529f 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -32,7 +32,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.14", + "@backstage/plugin-catalog-common-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx index 6b097085e9..c2d52f25d9 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx @@ -20,7 +20,7 @@ import { createRouteRef, errorApiRef, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; import { render, screen } from '@testing-library/react'; diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx index d0b56598b2..651af14723 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx @@ -14,23 +14,23 @@ * limitations under the License. */ -import React, { useState } from 'react'; -import { Grid, makeStyles } from '@material-ui/core'; +import { Entity, Location } from '@backstage/catalog-model'; import { - InfoCard, - Page, Content, - useApi, + ContentHeader, errorApiRef, Header, - SupportButton, - ContentHeader, + InfoCard, + Page, RouteRef, + SupportButton, + useApi, } from '@backstage/core'; -import { RegisterComponentForm } from '../RegisterComponentForm'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; +import { Grid, makeStyles } from '@material-ui/core'; +import React, { useState } from 'react'; import { useMountedState } from 'react-use'; -import { Entity, Location } from '@backstage/catalog-model'; +import { RegisterComponentForm } from '../RegisterComponentForm'; import { RegisterComponentResultDialog } from '../RegisterComponentResultDialog'; const useStyles = makeStyles(theme => ({ diff --git a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx index c31d1344cb..aef07c8172 100644 --- a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx +++ b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx @@ -16,7 +16,10 @@ import { Entity } from '@backstage/catalog-model'; import { RouteRef, StructuredMetadataTable } from '@backstage/core'; -import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog'; +import { + entityRoute, + entityRouteParams, +} from '@backstage/plugin-catalog-common-react'; import { Button, Dialog, diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index f3a21709db..fbb5994dcd 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.14", + "@backstage/plugin-catalog-common-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx b/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx index ff3e909bf7..cb42be7e32 100644 --- a/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx +++ b/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx @@ -21,7 +21,10 @@ import { ConfigApi, configApiRef, } from '@backstage/core'; -import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog'; +import { + catalogApiRef, + CatalogApi, +} from '@backstage/plugin-catalog-common-react'; import { wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import { RollbarApi, rollbarApiRef } from '../../api/RollbarApi'; diff --git a/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx b/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx index a1c54dfd5a..b6e6126b27 100644 --- a/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx +++ b/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx @@ -14,19 +14,22 @@ * limitations under the License. */ -import * as React from 'react'; import { ApiProvider, ApiRegistry, ConfigApi, configApiRef, } from '@backstage/core'; +import { + CatalogApi, + catalogApiRef, +} from '@backstage/plugin-catalog-common-react'; import { wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; +import * as React from 'react'; import { RollbarApi, rollbarApiRef } from '../../api/RollbarApi'; import { RollbarTopActiveItem } from '../../api/types'; import { RollbarProjectPage } from './RollbarProjectPage'; -import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog'; describe('RollbarProjectPage component', () => { const items: RollbarTopActiveItem[] = [ diff --git a/plugins/rollbar/src/hooks/useCatalogEntity.ts b/plugins/rollbar/src/hooks/useCatalogEntity.ts index 172d057738..3d539f19cc 100644 --- a/plugins/rollbar/src/hooks/useCatalogEntity.ts +++ b/plugins/rollbar/src/hooks/useCatalogEntity.ts @@ -14,12 +14,12 @@ * limitations under the License. */ -import { useAsync } from 'react-use'; import { useApi } from '@backstage/core'; import { catalogApiRef, useEntityCompoundName, -} from '@backstage/plugin-catalog'; +} from '@backstage/plugin-catalog-common-react'; +import { useAsync } from 'react-use'; export function useCatalogEntity() { const catalogApi = useApi(catalogApiRef); diff --git a/plugins/rollbar/src/hooks/useRollbarEntities.ts b/plugins/rollbar/src/hooks/useRollbarEntities.ts index 5979c5832a..24e2d100bb 100644 --- a/plugins/rollbar/src/hooks/useRollbarEntities.ts +++ b/plugins/rollbar/src/hooks/useRollbarEntities.ts @@ -14,9 +14,9 @@ * limitations under the License. */ +import { configApiRef, useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; import { useAsync } from 'react-use'; -import { useApi, configApiRef } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; import { ROLLBAR_ANNOTATION } from '../constants'; export function useRollbarEntities() { diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index d81d176232..ded0544d3a 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -32,7 +32,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.14", + "@backstage/plugin-catalog-common-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index 39999f938f..6178877330 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -27,7 +27,7 @@ import { useApi, WarningPanel, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; import { Button, Grid, Link, Typography } from '@material-ui/core'; import React, { useEffect } from 'react'; import { Link as RouterLink } from 'react-router-dom'; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index 62963b58b8..793895e699 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -14,7 +14,10 @@ * limitations under the License. */ import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { + CatalogApi, + catalogApiRef, +} from '@backstage/plugin-catalog-common-react'; import { renderInTestApp, renderWithEffects } from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 60f5ad8dec..360abd441c 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -27,19 +27,19 @@ import { catalogApiRef, entityRoute, entityRouteParams, -} from '@backstage/plugin-catalog'; +} from '@backstage/plugin-catalog-common-react'; import { LinearProgress } from '@material-ui/core'; import { IChangeEvent } from '@rjsf/core'; -import React, { useState, useCallback } from 'react'; +import parseGitUrl from 'git-url-parse'; +import React, { useCallback, useState } from 'react'; import { generatePath, Navigate } from 'react-router'; import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; import { scaffolderApiRef } from '../../api'; import { rootRoute } from '../../routes'; +import { useJobPolling } from '../hooks/useJobPolling'; import { JobStatusModal } from '../JobStatusModal'; import { MultistepJsonForm } from '../MultistepJsonForm'; -import { useJobPolling } from '../hooks/useJobPolling'; -import parseGitUrl from 'git-url-parse'; const useTemplate = ( templateName: string, diff --git a/plugins/search/package.json b/plugins/search/package.json index 9409a416a0..1185646d67 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -30,8 +30,8 @@ }, "dependencies": { "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.14", "@backstage/catalog-model": "^0.7.0", + "@backstage/plugin-catalog-common-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/search/src/apis.ts b/plugins/search/src/apis.ts index e74741e27b..34793777a8 100644 --- a/plugins/search/src/apis.ts +++ b/plugins/search/src/apis.ts @@ -14,8 +14,8 @@ * limitations under the License. */ -import { CatalogApi } from '@backstage/plugin-catalog'; import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { CatalogApi } from '@backstage/plugin-catalog-common-react'; export type Result = { name: string; diff --git a/plugins/search/src/components/SearchResult/SearchResult.tsx b/plugins/search/src/components/SearchResult/SearchResult.tsx index f81cd4f66d..72e592302c 100644 --- a/plugins/search/src/components/SearchResult/SearchResult.tsx +++ b/plugins/search/src/components/SearchResult/SearchResult.tsx @@ -13,23 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { useState, useEffect } from 'react'; -import { useAsync } from 'react-use'; - -import { makeStyles, Typography, Grid, Divider } from '@material-ui/core'; -import { Alert } from '@material-ui/lab'; import { - Link, EmptyState, + Link, Progress, Table, TableColumn, useApi, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; - -import { FiltersButton, Filters, FiltersState } from '../Filters'; +import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; +import { Divider, Grid, makeStyles, Typography } from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import React, { useEffect, useState } from 'react'; +import { useAsync } from 'react-use'; import SearchApi, { Result, SearchResults } from '../../apis'; +import { Filters, FiltersButton, FiltersState } from '../Filters'; const useStyles = makeStyles(theme => ({ searchQuery: { diff --git a/plugins/sentry/dev/index.tsx b/plugins/sentry/dev/index.tsx index c9313911b2..98b687b4bf 100644 --- a/plugins/sentry/dev/index.tsx +++ b/plugins/sentry/dev/index.tsx @@ -15,17 +15,17 @@ */ import { Entity } from '@backstage/catalog-model'; -import { EntityProvider } from '@backstage/plugin-catalog'; import { Content, Header, Page } from '@backstage/core'; import { createDevApp, EntityGridItem } from '@backstage/dev-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-common-react'; import { Grid } from '@material-ui/core'; import React from 'react'; import { + EntitySentryCard, + EntitySentryContent, MockSentryApi, SentryApi, sentryApiRef, - EntitySentryCard, - EntitySentryContent, } from '../src'; import { SENTRY_PROJECT_SLUG_ANNOTATION } from '../src/components/useProjectSlug'; diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index 52343ad332..2d5edaabb5 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.14", + "@backstage/plugin-catalog-common-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/sentry/src/extensions.tsx b/plugins/sentry/src/extensions.tsx index a66080f2b6..b606beed37 100644 --- a/plugins/sentry/src/extensions.tsx +++ b/plugins/sentry/src/extensions.tsx @@ -14,12 +14,12 @@ * limitations under the License. */ -import React from 'react'; import { createComponentExtension, createRoutableExtension, } from '@backstage/core'; -import { useEntity } from '@backstage/plugin-catalog'; +import { useEntity } from '@backstage/plugin-catalog-common-react'; +import React from 'react'; import { plugin, rootRouteRef } from './plugin'; export const EntitySentryContent = plugin.provide( diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 2c1e0e5911..5bd8ac0d9d 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog": "^0.2.14", + "@backstage/plugin-catalog-common-react": "^0.0.1", "@backstage/test-utils": "^0.1.6", "@backstage/theme": "^0.2.2", "@backstage/techdocs-common": "^0.3.6", diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx index 4979f027a6..bfca7aefe3 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx @@ -15,7 +15,10 @@ */ import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { + CatalogApi, + catalogApiRef, +} from '@backstage/plugin-catalog-common-react'; import { wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import React from 'react'; diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.tsx index 9f569dd167..63dd0dceae 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.tsx @@ -22,7 +22,7 @@ import { Progress, useApi, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; import { Grid } from '@material-ui/core'; import React from 'react'; import { generatePath, useNavigate } from 'react-router-dom'; From c677643afa8ecd48aa630493d8523ec41fc29359 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Tue, 26 Jan 2021 19:24:04 +0100 Subject: [PATCH 149/297] Rename from @backstage/plugin-catalog-common-react to @backstage/plugin-catalog-react --- .changeset/wet-suits-live.md | 2 +- .changeset/wild-cows-exercise.md | 2 +- packages/app/package.json | 2 +- packages/app/src/components/catalog/EntityPage.tsx | 2 +- packages/create-app/src/lib/versions.ts | 4 ++-- .../templates/default-app/packages/app/package.json.hbs | 2 +- .../packages/app/src/components/catalog/EntityPage.tsx | 2 +- plugins/api-docs/package.json | 2 +- .../src/components/ApiExplorerPage/ApiExplorerPage.test.tsx | 5 +---- .../src/components/ApiExplorerPage/ApiExplorerPage.tsx | 2 +- .../src/components/ApisCards/ConsumedApisCard.test.tsx | 5 +---- .../src/components/ApisCards/ProvidedApisCard.test.tsx | 5 +---- .../ComponentsCards/ConsumingComponentsCard.test.tsx | 5 +---- .../ComponentsCards/ProvidingComponentsCard.test.tsx | 5 +---- plugins/api-docs/src/components/EntityLink/EntityLink.tsx | 2 +- plugins/api-docs/src/components/useRelatedEntities.ts | 2 +- plugins/catalog-import/package.json | 2 +- .../catalog-import/src/components/ComponentConfigDisplay.tsx | 2 +- .../src/components/ImportComponentForm.test.tsx | 5 +---- .../catalog-import/src/components/ImportComponentForm.tsx | 2 +- .../src/components/ImportComponentPage.test.tsx | 5 +---- plugins/{catalog-common-react => catalog-react}/.eslintrc.js | 0 plugins/{catalog-common-react => catalog-react}/README.md | 0 plugins/{catalog-common-react => catalog-react}/package.json | 2 +- plugins/{catalog-common-react => catalog-react}/src/api.ts | 0 .../src/components/EntityRefLink/EntityRefLink.test.tsx | 0 .../src/components/EntityRefLink/EntityRefLink.tsx | 0 .../src/components/EntityRefLink/EntityRefLinks.test.tsx | 0 .../src/components/EntityRefLink/EntityRefLinks.tsx | 0 .../src/components/EntityRefLink/format.test.ts | 0 .../src/components/EntityRefLink/format.ts | 0 .../src/components/EntityRefLink/index.ts | 0 .../src/components/index.ts | 0 .../src/hooks/index.ts | 0 .../src/hooks/useEntity.ts | 0 .../src/hooks/useEntityCompoundName.ts | 0 plugins/{catalog-common-react => catalog-react}/src/index.ts | 0 .../{catalog-common-react => catalog-react}/src/routes.ts | 0 .../src/setupTests.ts | 0 plugins/catalog/package.json | 2 +- plugins/catalog/src/components/AboutCard/AboutContent.tsx | 2 +- plugins/catalog/src/components/CatalogPage/CatalogPage.tsx | 2 +- plugins/catalog/src/components/CatalogTable/CatalogTable.tsx | 2 +- .../src/components/EntityLayout/EntityLayout.test.tsx | 5 +---- plugins/catalog/src/components/EntityLayout/EntityLayout.tsx | 2 +- .../components/EntityLoaderProvider/EntityLoaderProvider.tsx | 2 +- .../src/components/EntityPageLayout/EntityPageLayout.tsx | 2 +- .../catalog/src/components/EntityProvider/EntityProvider.tsx | 2 +- .../src/components/EntitySwitch/EntitySwitch.test.tsx | 2 +- plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx | 2 +- plugins/catalog/src/components/Router.tsx | 2 +- .../UnregisterEntityDialog/UnregisterEntityDialog.tsx | 2 +- plugins/catalog/src/components/useOwnUser.ts | 2 +- plugins/catalog/src/extensions.tsx | 2 +- plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx | 2 +- plugins/catalog/src/plugin.ts | 2 +- plugins/circleci/package.json | 2 +- plugins/circleci/src/state/useBuilds.ts | 2 +- plugins/jenkins/package.json | 2 +- plugins/jenkins/src/components/useProjectSlugFromEntity.ts | 2 +- plugins/kafka/package.json | 2 +- .../ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx | 2 +- .../ConsumerGroupOffsets/useConsumerGroupsForEntity.ts | 2 +- .../useConsumerGroupsOffsetsForEntity.test.tsx | 2 +- plugins/lighthouse/package.json | 2 +- .../src/components/AuditList/AuditListForEntity.test.tsx | 2 +- .../src/components/Cards/LastLighthouseAuditCard.test.tsx | 2 +- plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx | 2 +- plugins/lighthouse/src/hooks/useWebsiteForEntity.ts | 2 +- plugins/org/package.json | 2 +- .../components/Cards/Group/GroupProfile/GroupProfileCard.tsx | 2 +- .../Cards/Group/MembersList/MembersListCard.test.tsx | 5 +---- .../components/Cards/Group/MembersList/MembersListCard.tsx | 2 +- .../org/src/components/Cards/OwnershipCard/OwnershipCard.tsx | 2 +- .../Cards/User/UserProfileCard/UserProfileCard.tsx | 2 +- plugins/register-component/package.json | 2 +- .../RegisterComponentPage/RegisterComponentPage.test.tsx | 2 +- .../RegisterComponentPage/RegisterComponentPage.tsx | 2 +- .../RegisterComponentResultDialog.tsx | 2 +- plugins/rollbar/package.json | 2 +- .../rollbar/src/components/RollbarHome/RollbarHome.test.tsx | 5 +---- .../RollbarProjectPage/RollbarProjectPage.test.tsx | 5 +---- plugins/rollbar/src/hooks/useCatalogEntity.ts | 2 +- plugins/rollbar/src/hooks/useRollbarEntities.ts | 2 +- plugins/scaffolder/package.json | 2 +- .../src/components/ScaffolderPage/ScaffolderPage.tsx | 2 +- .../src/components/TemplatePage/TemplatePage.test.tsx | 5 +---- .../scaffolder/src/components/TemplatePage/TemplatePage.tsx | 2 +- plugins/search/package.json | 2 +- plugins/search/src/apis.ts | 2 +- plugins/search/src/components/SearchResult/SearchResult.tsx | 2 +- plugins/sentry/dev/index.tsx | 2 +- plugins/sentry/package.json | 2 +- plugins/sentry/src/extensions.tsx | 2 +- plugins/techdocs/package.json | 2 +- plugins/techdocs/src/reader/components/TechDocsHome.test.tsx | 5 +---- plugins/techdocs/src/reader/components/TechDocsHome.tsx | 2 +- 97 files changed, 81 insertions(+), 120 deletions(-) rename plugins/{catalog-common-react => catalog-react}/.eslintrc.js (100%) rename plugins/{catalog-common-react => catalog-react}/README.md (100%) rename plugins/{catalog-common-react => catalog-react}/package.json (96%) rename plugins/{catalog-common-react => catalog-react}/src/api.ts (100%) rename plugins/{catalog-common-react => catalog-react}/src/components/EntityRefLink/EntityRefLink.test.tsx (100%) rename plugins/{catalog-common-react => catalog-react}/src/components/EntityRefLink/EntityRefLink.tsx (100%) rename plugins/{catalog-common-react => catalog-react}/src/components/EntityRefLink/EntityRefLinks.test.tsx (100%) rename plugins/{catalog-common-react => catalog-react}/src/components/EntityRefLink/EntityRefLinks.tsx (100%) rename plugins/{catalog-common-react => catalog-react}/src/components/EntityRefLink/format.test.ts (100%) rename plugins/{catalog-common-react => catalog-react}/src/components/EntityRefLink/format.ts (100%) rename plugins/{catalog-common-react => catalog-react}/src/components/EntityRefLink/index.ts (100%) rename plugins/{catalog-common-react => catalog-react}/src/components/index.ts (100%) rename plugins/{catalog-common-react => catalog-react}/src/hooks/index.ts (100%) rename plugins/{catalog-common-react => catalog-react}/src/hooks/useEntity.ts (100%) rename plugins/{catalog-common-react => catalog-react}/src/hooks/useEntityCompoundName.ts (100%) rename plugins/{catalog-common-react => catalog-react}/src/index.ts (100%) rename plugins/{catalog-common-react => catalog-react}/src/routes.ts (100%) rename plugins/{catalog-common-react => catalog-react}/src/setupTests.ts (100%) diff --git a/.changeset/wet-suits-live.md b/.changeset/wet-suits-live.md index c10a226367..64fcd58d97 100644 --- a/.changeset/wet-suits-live.md +++ b/.changeset/wet-suits-live.md @@ -16,4 +16,4 @@ '@backstage/plugin-techdocs': patch --- -Switch dependency from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-common-react`. +Switch dependency from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-react`. diff --git a/.changeset/wild-cows-exercise.md b/.changeset/wild-cows-exercise.md index 411d23daee..cbc347888f 100644 --- a/.changeset/wild-cows-exercise.md +++ b/.changeset/wild-cows-exercise.md @@ -4,5 +4,5 @@ --- `@backstage/plugin-catalog` stopped exporting hooks and helpers for other -plugins. They are migrated to `@backstage/plugin-catalog-common-react`. +plugins. They are migrated to `@backstage/plugin-catalog-react`. Change both your dependencies and imports to the new package. diff --git a/packages/app/package.json b/packages/app/package.json index 72ca698f84..3fb89d96c6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -9,7 +9,7 @@ "@backstage/core": "^0.5.0", "@backstage/plugin-api-docs": "^0.4.3", "@backstage/plugin-catalog": "^0.2.14", - "@backstage/plugin-catalog-common-react": "^0.0.1", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/plugin-catalog-import": "^0.3.6", "@backstage/plugin-circleci": "^0.2.6", "@backstage/plugin-cloudbuild": "^0.2.7", diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 756d36391f..0372c8759c 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -28,7 +28,7 @@ import { ProvidingComponentsCard, } from '@backstage/plugin-api-docs'; import { AboutCard, EntityPageLayout } from '@backstage/plugin-catalog'; -import { useEntity } from '@backstage/plugin-catalog-common-react'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { isPluginApplicableToEntity as isCircleCIAvailable, Router as CircleCIRouter, diff --git a/packages/create-app/src/lib/versions.ts b/packages/create-app/src/lib/versions.ts index 4723a4b78b..e6a047921c 100644 --- a/packages/create-app/src/lib/versions.ts +++ b/packages/create-app/src/lib/versions.ts @@ -42,7 +42,7 @@ import { version as pluginApiDocs } from '../../../../plugins/api-docs/package.j import { version as pluginAppBackend } from '../../../../plugins/app-backend/package.json'; import { version as pluginAuthBackend } from '../../../../plugins/auth-backend/package.json'; import { version as pluginCatalog } from '../../../../plugins/catalog/package.json'; -import { version as pluginCatalogCommonReact } from '../../../../plugins/catalog-common-react/package.json'; +import { version as pluginCatalogReact } from '../../../../plugins/catalog-react/package.json'; import { version as pluginCatalogBackend } from '../../../../plugins/catalog-backend/package.json'; import { version as pluginCatalogImport } from '../../../../plugins/catalog-import/package.json'; import { version as pluginCircleci } from '../../../../plugins/circleci/package.json'; @@ -69,7 +69,7 @@ export const packageVersions = { '@backstage/plugin-app-backend': pluginAppBackend, '@backstage/plugin-auth-backend': pluginAuthBackend, '@backstage/plugin-catalog': pluginCatalog, - '@backstage/plugin-catalog-common-react': pluginCatalogCommonReact, + '@backstage/plugin-catalog-react': pluginCatalogReact, '@backstage/plugin-catalog-backend': pluginCatalogBackend, '@backstage/plugin-catalog-import': pluginCatalogImport, '@backstage/plugin-circleci': pluginCircleci, diff --git a/packages/create-app/templates/default-app/packages/app/package.json.hbs b/packages/create-app/templates/default-app/packages/app/package.json.hbs index 99e613bc40..c7561fe091 100644 --- a/packages/create-app/templates/default-app/packages/app/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/app/package.json.hbs @@ -10,7 +10,7 @@ "@backstage/core": "^{{version '@backstage/core'}}", "@backstage/plugin-api-docs": "^{{version '@backstage/plugin-api-docs'}}", "@backstage/plugin-catalog": "^{{version '@backstage/plugin-catalog'}}", - "@backstage/plugin-catalog-common-react": "^{{version '@backstage/plugin-catalog-common-react'}}", + "@backstage/plugin-catalog-react": "^{{version '@backstage/plugin-catalog-react'}}", "@backstage/plugin-catalog-import": "^{{version '@backstage/plugin-catalog-import'}}", "@backstage/plugin-scaffolder": "^{{version '@backstage/plugin-scaffolder'}}", "@backstage/plugin-techdocs": "^{{version '@backstage/plugin-techdocs'}}", diff --git a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx index 409c7919db..a3a3715ab0 100644 --- a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx @@ -27,7 +27,7 @@ import { } from '@backstage/plugin-catalog'; import { useEntity -} from '@backstage/plugin-catalog-common-react'; +} from '@backstage/plugin-catalog-react'; import { isPluginApplicableToEntity as isCircleCIAvailable, Router as CircleCIRouter } from '@backstage/plugin-circleci'; diff --git a/plugins/api-docs/package.json b/plugins/api-docs/package.json index bdc292f71e..4bd7bd1727 100644 --- a/plugins/api-docs/package.json +++ b/plugins/api-docs/package.json @@ -32,7 +32,7 @@ "@asyncapi/react-component": "^0.18.2", "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog-common-react": "^0.0.1", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-icons/font": "^1.0.2", "@material-ui/core": "^4.11.0", diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx index e5e72af9cc..67462b630c 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.test.tsx @@ -16,10 +16,7 @@ import { Entity } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core'; -import { - CatalogApi, - catalogApiRef, -} from '@backstage/plugin-catalog-common-react'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import React from 'react'; diff --git a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx index 3779f6a9de..df404ce7cd 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/ApiExplorerPage.tsx @@ -15,7 +15,7 @@ */ import { Content, ContentHeader, SupportButton, useApi } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { Button } from '@material-ui/core'; import React from 'react'; import { Link as RouterLink } from 'react-router-dom'; diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx index 870528b91d..d406e160fc 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx @@ -16,10 +16,7 @@ import { Entity, RELATION_CONSUMES_API } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { - CatalogApi, - catalogApiRef, -} from '@backstage/plugin-catalog-common-react'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx index a149fcde88..7b6ce6e18a 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx @@ -16,10 +16,7 @@ import { Entity, RELATION_PROVIDES_API } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { - CatalogApi, - catalogApiRef, -} from '@backstage/plugin-catalog-common-react'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx index 428f4d91fe..99a8c0dc28 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx @@ -16,10 +16,7 @@ import { Entity, RELATION_API_CONSUMED_BY } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { - CatalogApi, - catalogApiRef, -} from '@backstage/plugin-catalog-common-react'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx index 3f4b7a545b..a3341ad8d0 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx @@ -16,10 +16,7 @@ import { Entity, RELATION_API_PROVIDED_BY } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { - CatalogApi, - catalogApiRef, -} from '@backstage/plugin-catalog-common-react'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; diff --git a/plugins/api-docs/src/components/EntityLink/EntityLink.tsx b/plugins/api-docs/src/components/EntityLink/EntityLink.tsx index db0437ed83..dba6bb0062 100644 --- a/plugins/api-docs/src/components/EntityLink/EntityLink.tsx +++ b/plugins/api-docs/src/components/EntityLink/EntityLink.tsx @@ -18,7 +18,7 @@ import { Entity } from '@backstage/catalog-model'; import { entityRoute, entityRouteParams, -} from '@backstage/plugin-catalog-common-react'; +} from '@backstage/plugin-catalog-react'; import { Link } from '@material-ui/core'; import React, { PropsWithChildren } from 'react'; import { generatePath, Link as RouterLink } from 'react-router-dom'; diff --git a/plugins/api-docs/src/components/useRelatedEntities.ts b/plugins/api-docs/src/components/useRelatedEntities.ts index 4c161e8025..5c01d38189 100644 --- a/plugins/api-docs/src/components/useRelatedEntities.ts +++ b/plugins/api-docs/src/components/useRelatedEntities.ts @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; import { useApi } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { useAsyncRetry } from 'react-use'; // TODO: Maybe this hook is interesting for others too? diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index 6deb066705..4c2017cda7 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -33,7 +33,7 @@ "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", "@backstage/integration": "^0.3.1", - "@backstage/plugin-catalog-common-react": "^0.0.1", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx b/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx index ff8d28e654..9fb170c85c 100644 --- a/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx +++ b/plugins/catalog-import/src/components/ComponentConfigDisplay.tsx @@ -24,7 +24,7 @@ import { import { entityRoute, entityRouteParams, -} from '@backstage/plugin-catalog-common-react'; +} from '@backstage/plugin-catalog-react'; import { Button, CircularProgress, diff --git a/plugins/catalog-import/src/components/ImportComponentForm.test.tsx b/plugins/catalog-import/src/components/ImportComponentForm.test.tsx index d86db71f92..344dd9d470 100644 --- a/plugins/catalog-import/src/components/ImportComponentForm.test.tsx +++ b/plugins/catalog-import/src/components/ImportComponentForm.test.tsx @@ -19,10 +19,7 @@ import { DiscoveryApi, errorApiRef, } from '@backstage/core'; -import { - catalogApiRef, - CatalogClient, -} from '@backstage/plugin-catalog-common-react'; +import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { fireEvent, screen, waitFor } from '@testing-library/react'; import React from 'react'; diff --git a/plugins/catalog-import/src/components/ImportComponentForm.tsx b/plugins/catalog-import/src/components/ImportComponentForm.tsx index 25bea7fbe3..a3d355b33f 100644 --- a/plugins/catalog-import/src/components/ImportComponentForm.tsx +++ b/plugins/catalog-import/src/components/ImportComponentForm.tsx @@ -15,7 +15,7 @@ */ import { errorApiRef, useApi } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { BackstageTheme } from '@backstage/theme'; import { Button, diff --git a/plugins/catalog-import/src/components/ImportComponentPage.test.tsx b/plugins/catalog-import/src/components/ImportComponentPage.test.tsx index 9d7dd1eae1..ad9abbe797 100644 --- a/plugins/catalog-import/src/components/ImportComponentPage.test.tsx +++ b/plugins/catalog-import/src/components/ImportComponentPage.test.tsx @@ -19,10 +19,7 @@ import { configApiRef, errorApiRef, } from '@backstage/core'; -import { - catalogApiRef, - CatalogClient, -} from '@backstage/plugin-catalog-common-react'; +import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog-react'; import { msw, renderInTestApp } from '@backstage/test-utils'; import { fireEvent, screen, waitFor } from '@testing-library/react'; import { rest } from 'msw'; diff --git a/plugins/catalog-common-react/.eslintrc.js b/plugins/catalog-react/.eslintrc.js similarity index 100% rename from plugins/catalog-common-react/.eslintrc.js rename to plugins/catalog-react/.eslintrc.js diff --git a/plugins/catalog-common-react/README.md b/plugins/catalog-react/README.md similarity index 100% rename from plugins/catalog-common-react/README.md rename to plugins/catalog-react/README.md diff --git a/plugins/catalog-common-react/package.json b/plugins/catalog-react/package.json similarity index 96% rename from plugins/catalog-common-react/package.json rename to plugins/catalog-react/package.json index 11b8d3b1f4..fc5f7a9d55 100644 --- a/plugins/catalog-common-react/package.json +++ b/plugins/catalog-react/package.json @@ -1,5 +1,5 @@ { - "name": "@backstage/plugin-catalog-common-react", + "name": "@backstage/plugin-catalog-react", "version": "0.0.1", "main": "src/index.ts", "types": "src/index.ts", diff --git a/plugins/catalog-common-react/src/api.ts b/plugins/catalog-react/src/api.ts similarity index 100% rename from plugins/catalog-common-react/src/api.ts rename to plugins/catalog-react/src/api.ts diff --git a/plugins/catalog-common-react/src/components/EntityRefLink/EntityRefLink.test.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.test.tsx similarity index 100% rename from plugins/catalog-common-react/src/components/EntityRefLink/EntityRefLink.test.tsx rename to plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.test.tsx diff --git a/plugins/catalog-common-react/src/components/EntityRefLink/EntityRefLink.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx similarity index 100% rename from plugins/catalog-common-react/src/components/EntityRefLink/EntityRefLink.tsx rename to plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx diff --git a/plugins/catalog-common-react/src/components/EntityRefLink/EntityRefLinks.test.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.test.tsx similarity index 100% rename from plugins/catalog-common-react/src/components/EntityRefLink/EntityRefLinks.test.tsx rename to plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.test.tsx diff --git a/plugins/catalog-common-react/src/components/EntityRefLink/EntityRefLinks.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.tsx similarity index 100% rename from plugins/catalog-common-react/src/components/EntityRefLink/EntityRefLinks.tsx rename to plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.tsx diff --git a/plugins/catalog-common-react/src/components/EntityRefLink/format.test.ts b/plugins/catalog-react/src/components/EntityRefLink/format.test.ts similarity index 100% rename from plugins/catalog-common-react/src/components/EntityRefLink/format.test.ts rename to plugins/catalog-react/src/components/EntityRefLink/format.test.ts diff --git a/plugins/catalog-common-react/src/components/EntityRefLink/format.ts b/plugins/catalog-react/src/components/EntityRefLink/format.ts similarity index 100% rename from plugins/catalog-common-react/src/components/EntityRefLink/format.ts rename to plugins/catalog-react/src/components/EntityRefLink/format.ts diff --git a/plugins/catalog-common-react/src/components/EntityRefLink/index.ts b/plugins/catalog-react/src/components/EntityRefLink/index.ts similarity index 100% rename from plugins/catalog-common-react/src/components/EntityRefLink/index.ts rename to plugins/catalog-react/src/components/EntityRefLink/index.ts diff --git a/plugins/catalog-common-react/src/components/index.ts b/plugins/catalog-react/src/components/index.ts similarity index 100% rename from plugins/catalog-common-react/src/components/index.ts rename to plugins/catalog-react/src/components/index.ts diff --git a/plugins/catalog-common-react/src/hooks/index.ts b/plugins/catalog-react/src/hooks/index.ts similarity index 100% rename from plugins/catalog-common-react/src/hooks/index.ts rename to plugins/catalog-react/src/hooks/index.ts diff --git a/plugins/catalog-common-react/src/hooks/useEntity.ts b/plugins/catalog-react/src/hooks/useEntity.ts similarity index 100% rename from plugins/catalog-common-react/src/hooks/useEntity.ts rename to plugins/catalog-react/src/hooks/useEntity.ts diff --git a/plugins/catalog-common-react/src/hooks/useEntityCompoundName.ts b/plugins/catalog-react/src/hooks/useEntityCompoundName.ts similarity index 100% rename from plugins/catalog-common-react/src/hooks/useEntityCompoundName.ts rename to plugins/catalog-react/src/hooks/useEntityCompoundName.ts diff --git a/plugins/catalog-common-react/src/index.ts b/plugins/catalog-react/src/index.ts similarity index 100% rename from plugins/catalog-common-react/src/index.ts rename to plugins/catalog-react/src/index.ts diff --git a/plugins/catalog-common-react/src/routes.ts b/plugins/catalog-react/src/routes.ts similarity index 100% rename from plugins/catalog-common-react/src/routes.ts rename to plugins/catalog-react/src/routes.ts diff --git a/plugins/catalog-common-react/src/setupTests.ts b/plugins/catalog-react/src/setupTests.ts similarity index 100% rename from plugins/catalog-common-react/src/setupTests.ts rename to plugins/catalog-react/src/setupTests.ts diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 0f250d5a41..6752bcdfe6 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -33,7 +33,7 @@ "@backstage/catalog-client": "^0.3.5", "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog-common-react": "^0.0.1", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/plugin-scaffolder": "^0.4.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", diff --git a/plugins/catalog/src/components/AboutCard/AboutContent.tsx b/plugins/catalog/src/components/AboutCard/AboutContent.tsx index 22dfc3c2a3..bb55e00e93 100644 --- a/plugins/catalog/src/components/AboutCard/AboutContent.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutContent.tsx @@ -19,7 +19,7 @@ import { RELATION_OWNED_BY, RELATION_PART_OF, } from '@backstage/catalog-model'; -import { EntityRefLinks } from '@backstage/plugin-catalog-common-react'; +import { EntityRefLinks } from '@backstage/plugin-catalog-react'; import { Chip, Grid, makeStyles, Typography } from '@material-ui/core'; import React from 'react'; import { getEntityRelations } from '../getEntityRelations'; diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index de280e7e1b..0384c75c96 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -22,7 +22,7 @@ import { SupportButton, useApi, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder'; import { Button, makeStyles } from '@material-ui/core'; import SettingsIcon from '@material-ui/icons/Settings'; diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 52d0ab9178..ed0becf270 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -24,7 +24,7 @@ import { EntityRefLink, EntityRefLinks, formatEntityRefTitle, -} from '@backstage/plugin-catalog-common-react'; +} from '@backstage/plugin-catalog-react'; import { Chip } from '@material-ui/core'; import Edit from '@material-ui/icons/Edit'; import OpenInNew from '@material-ui/icons/OpenInNew'; diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx index ba52c9a114..0d8ae56262 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx @@ -21,10 +21,7 @@ import { ApiProvider, ApiRegistry, } from '@backstage/core'; -import { - catalogApiRef, - EntityContext, -} from '@backstage/plugin-catalog-common-react'; +import { catalogApiRef, EntityContext } from '@backstage/plugin-catalog-react'; import { renderInTestApp, withLogCollector } from '@backstage/test-utils'; import { fireEvent } from '@testing-library/react'; import React from 'react'; diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index 24efdebb96..4f7cb09cb6 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -26,7 +26,7 @@ import { import { EntityContext, useEntityCompoundName, -} from '@backstage/plugin-catalog-common-react'; +} from '@backstage/plugin-catalog-react'; import { Box } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import React, { diff --git a/plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx b/plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx index 555595e818..97a4263e0d 100644 --- a/plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx +++ b/plugins/catalog/src/components/EntityLoaderProvider/EntityLoaderProvider.tsx @@ -16,7 +16,7 @@ import { EntityContext, useEntityFromUrl, -} from '@backstage/plugin-catalog-common-react'; +} from '@backstage/plugin-catalog-react'; import React, { ReactNode } from 'react'; export const EntityLoaderProvider = ({ children }: { children: ReactNode }) => { diff --git a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx index cb4f9bc644..1f42cc7cc1 100644 --- a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx @@ -22,7 +22,7 @@ import { useNavigate } from 'react-router'; import { EntityContext, useEntityCompoundName, -} from '@backstage/plugin-catalog-common-react'; +} from '@backstage/plugin-catalog-react'; import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog'; diff --git a/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx b/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx index 836e2aa752..e17252e0a0 100644 --- a/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx +++ b/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; import React, { ReactNode } from 'react'; -import { EntityContext } from '@backstage/plugin-catalog-common-react'; +import { EntityContext } from '@backstage/plugin-catalog-react'; type EntityProviderProps = { entity: Entity; diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx index 4be4713075..20b9aaba4f 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; -import { EntityContext } from '@backstage/plugin-catalog-common-react'; +import { EntityContext } from '@backstage/plugin-catalog-react'; import { render } from '@testing-library/react'; import React from 'react'; import { isKind } from './conditions'; diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx index 4312fba5db..d5593edcc2 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; -import { useEntity } from '@backstage/plugin-catalog-common-react'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { Children, Fragment, diff --git a/plugins/catalog/src/components/Router.tsx b/plugins/catalog/src/components/Router.tsx index b54dfef01c..3e51b74dc2 100644 --- a/plugins/catalog/src/components/Router.tsx +++ b/plugins/catalog/src/components/Router.tsx @@ -19,7 +19,7 @@ import { entityRoute, rootRoute, useEntity, -} from '@backstage/plugin-catalog-common-react'; +} from '@backstage/plugin-catalog-react'; import { Link, Typography } from '@material-ui/core'; import React, { ComponentType } from 'react'; import { Navigate, Route, Routes, useParams } from 'react-router'; diff --git a/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx b/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx index 5e4212ee1b..06ea1eee27 100644 --- a/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx +++ b/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx @@ -19,7 +19,7 @@ import { alertApiRef, configApiRef, Progress, useApi } from '@backstage/core'; import { catalogApiRef, formatEntityRefTitle, -} from '@backstage/plugin-catalog-common-react'; +} from '@backstage/plugin-catalog-react'; import { Button, Dialog, diff --git a/plugins/catalog/src/components/useOwnUser.ts b/plugins/catalog/src/components/useOwnUser.ts index 43d658e8d0..29d8a0d11f 100644 --- a/plugins/catalog/src/components/useOwnUser.ts +++ b/plugins/catalog/src/components/useOwnUser.ts @@ -16,7 +16,7 @@ import { UserEntity } from '@backstage/catalog-model'; import { identityApiRef, useApi } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { useAsync } from 'react-use'; import { AsyncState } from 'react-use/lib/useAsync'; diff --git a/plugins/catalog/src/extensions.tsx b/plugins/catalog/src/extensions.tsx index 2a214dde23..1eaa30b769 100644 --- a/plugins/catalog/src/extensions.tsx +++ b/plugins/catalog/src/extensions.tsx @@ -18,7 +18,7 @@ import { createRoutableExtension } from '@backstage/core'; import { catalogRouteRef, entityRouteRef, -} from '@backstage/plugin-catalog-common-react'; +} from '@backstage/plugin-catalog-react'; import { plugin } from './plugin'; export const CatalogIndexPage = plugin.provide( diff --git a/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx index 2d954fb2d2..69ae9b83d6 100644 --- a/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx +++ b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx @@ -16,7 +16,7 @@ import { Entity } from '@backstage/catalog-model'; import { useApi } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useAsyncFn } from 'react-use'; import { filterGroupsContext, FilterGroupsContext } from './context'; diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index be57a2e9f9..69a1285ab4 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -24,7 +24,7 @@ import { catalogApiRef, catalogRouteRef, entityRouteRef, -} from '@backstage/plugin-catalog-common-react'; +} from '@backstage/plugin-catalog-react'; export const plugin = createPlugin({ id: 'catalog', diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index b731146e40..f128540784 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog-common-react": "^0.0.1", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/circleci/src/state/useBuilds.ts b/plugins/circleci/src/state/useBuilds.ts index b7143f1cb5..7968379ee4 100644 --- a/plugins/circleci/src/state/useBuilds.ts +++ b/plugins/circleci/src/state/useBuilds.ts @@ -15,7 +15,7 @@ */ import { errorApiRef, useApi } from '@backstage/core'; -import { useEntity } from '@backstage/plugin-catalog-common-react'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { BuildSummary, GitType } from 'circleci-api'; import { getOr } from 'lodash/fp'; import { useCallback, useEffect, useState } from 'react'; diff --git a/plugins/jenkins/package.json b/plugins/jenkins/package.json index 75dec65ee2..7456d4be53 100644 --- a/plugins/jenkins/package.json +++ b/plugins/jenkins/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog-common-react": "^0.0.1", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/jenkins/src/components/useProjectSlugFromEntity.ts b/plugins/jenkins/src/components/useProjectSlugFromEntity.ts index 2c5f138402..ac2d6c7e8f 100644 --- a/plugins/jenkins/src/components/useProjectSlugFromEntity.ts +++ b/plugins/jenkins/src/components/useProjectSlugFromEntity.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useEntity } from '@backstage/plugin-catalog-common-react'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { JENKINS_ANNOTATION } from '../constants'; export const useProjectSlugFromEntity = () => { diff --git a/plugins/kafka/package.json b/plugins/kafka/package.json index 03f602c049..546d20bc67 100644 --- a/plugins/kafka/package.json +++ b/plugins/kafka/package.json @@ -22,7 +22,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog-common-react": "^0.0.1", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx index 595905d7a3..a28d1af15d 100644 --- a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import { Entity } from '@backstage/catalog-model'; -import { EntityContext } from '@backstage/plugin-catalog-common-react'; +import { EntityContext } from '@backstage/plugin-catalog-react'; import { renderHook } from '@testing-library/react-hooks'; import React, { PropsWithChildren } from 'react'; import { useConsumerGroupsForEntity } from './useConsumerGroupsForEntity'; diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.ts b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.ts index c81573e2ff..11155dca10 100644 --- a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.ts +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsForEntity.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useEntity } from '@backstage/plugin-catalog-common-react'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { useMemo } from 'react'; import { KAFKA_CONSUMER_GROUP_ANNOTATION } from '../../constants'; diff --git a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx index 0d9846dba5..9609654a90 100644 --- a/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx +++ b/plugins/kafka/src/components/ConsumerGroupOffsets/useConsumerGroupsOffsetsForEntity.test.tsx @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; -import { EntityContext } from '@backstage/plugin-catalog-common-react'; +import { EntityContext } from '@backstage/plugin-catalog-react'; import { renderHook } from '@testing-library/react-hooks'; import { when } from 'jest-when'; import React, { PropsWithChildren } from 'react'; diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index c1df09ac1d..5b45bfa48b 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -34,7 +34,7 @@ "@backstage/catalog-model": "^0.7.0", "@backstage/config": "^0.1.2", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog-common-react": "^0.0.1", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx index cd4f1f9e5f..483d630db8 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx @@ -16,7 +16,7 @@ import { Entity } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; -import { EntityContext } from '@backstage/plugin-catalog-common-react'; +import { EntityContext } from '@backstage/plugin-catalog-react'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; import { render } from '@testing-library/react'; diff --git a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx index c9405a25c3..1322736321 100644 --- a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx +++ b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; -import { EntityContext } from '@backstage/plugin-catalog-common-react'; +import { EntityContext } from '@backstage/plugin-catalog-react'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; import { render } from '@testing-library/react'; diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx index aa9c9c3557..e22a64bdc1 100644 --- a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx +++ b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; -import { EntityContext } from '@backstage/plugin-catalog-common-react'; +import { EntityContext } from '@backstage/plugin-catalog-react'; import { renderHook } from '@testing-library/react-hooks'; import React, { PropsWithChildren } from 'react'; import { lighthouseApiRef, WebsiteListResponse } from '../api'; diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts b/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts index da351a6c82..e6713cf320 100644 --- a/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts +++ b/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { errorApiRef, useApi } from '@backstage/core'; -import { useEntity } from '@backstage/plugin-catalog-common-react'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { useAsync } from 'react-use'; import { LIGHTHOUSE_WEBSITE_URL_ANNOTATION } from '../../constants'; import { lighthouseApiRef } from '../api'; diff --git a/plugins/org/package.json b/plugins/org/package.json index 7cc55dbf60..e376dea5bc 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -22,7 +22,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog-common-react": "^0.0.1", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx index 0238fea7f6..f5a1acd5e6 100644 --- a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx +++ b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx @@ -21,7 +21,7 @@ import { RELATION_PARENT_OF, } from '@backstage/catalog-model'; import { Avatar, InfoCard } from '@backstage/core'; -import { entityRouteParams } from '@backstage/plugin-catalog-common-react'; +import { entityRouteParams } from '@backstage/plugin-catalog-react'; import { Box, Grid, Link, Tooltip, Typography } from '@material-ui/core'; import AccountTreeIcon from '@material-ui/icons/AccountTree'; import EmailIcon from '@material-ui/icons/Email'; diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx index 59260f93fc..6815fd0e55 100644 --- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx @@ -16,10 +16,7 @@ import { Entity, GroupEntity } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { - CatalogApi, - catalogApiRef, -} from '@backstage/plugin-catalog-common-react'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; import React from 'react'; import { MembersListCard } from './MembersListCard'; diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx index cd001d6e96..f644e7db7e 100644 --- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx @@ -23,7 +23,7 @@ import { Avatar, InfoCard, Progress, useApi } from '@backstage/core'; import { catalogApiRef, entityRouteParams, -} from '@backstage/plugin-catalog-common-react'; +} from '@backstage/plugin-catalog-react'; import { Box, createStyles, diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx index 3d459d7cb4..4f59c4be69 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { InfoCard, useApi, Progress } from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; -import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { useAsync } from 'react-use'; import Alert from '@material-ui/lab/Alert'; import { diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx index f1897efed1..bdd0cd15f6 100644 --- a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx @@ -19,7 +19,7 @@ import { UserEntity, } from '@backstage/catalog-model'; import { Avatar, InfoCard } from '@backstage/core'; -import { entityRouteParams } from '@backstage/plugin-catalog-common-react'; +import { entityRouteParams } from '@backstage/plugin-catalog-react'; import { Box, Grid, Link, Tooltip, Typography } from '@material-ui/core'; import EmailIcon from '@material-ui/icons/Email'; import GroupIcon from '@material-ui/icons/Group'; diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index e12a27529f..39c431b3cd 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -32,7 +32,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog-common-react": "^0.0.1", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx index c2d52f25d9..e9da8ef84d 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.test.tsx @@ -20,7 +20,7 @@ import { createRouteRef, errorApiRef, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; import { render, screen } from '@testing-library/react'; diff --git a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx index 651af14723..8dbf712559 100644 --- a/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx +++ b/plugins/register-component/src/components/RegisterComponentPage/RegisterComponentPage.tsx @@ -26,7 +26,7 @@ import { SupportButton, useApi, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { Grid, makeStyles } from '@material-ui/core'; import React, { useState } from 'react'; import { useMountedState } from 'react-use'; diff --git a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx index aef07c8172..87248cb6db 100644 --- a/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx +++ b/plugins/register-component/src/components/RegisterComponentResultDialog/RegisterComponentResultDialog.tsx @@ -19,7 +19,7 @@ import { RouteRef, StructuredMetadataTable } from '@backstage/core'; import { entityRoute, entityRouteParams, -} from '@backstage/plugin-catalog-common-react'; +} from '@backstage/plugin-catalog-react'; import { Button, Dialog, diff --git a/plugins/rollbar/package.json b/plugins/rollbar/package.json index fbb5994dcd..7ba72dad0d 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog-common-react": "^0.0.1", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx b/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx index cb42be7e32..5bd885c488 100644 --- a/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx +++ b/plugins/rollbar/src/components/RollbarHome/RollbarHome.test.tsx @@ -21,10 +21,7 @@ import { ConfigApi, configApiRef, } from '@backstage/core'; -import { - catalogApiRef, - CatalogApi, -} from '@backstage/plugin-catalog-common-react'; +import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog-react'; import { wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import { RollbarApi, rollbarApiRef } from '../../api/RollbarApi'; diff --git a/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx b/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx index b6e6126b27..6aeee782c4 100644 --- a/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx +++ b/plugins/rollbar/src/components/RollbarProjectPage/RollbarProjectPage.test.tsx @@ -20,10 +20,7 @@ import { ConfigApi, configApiRef, } from '@backstage/core'; -import { - CatalogApi, - catalogApiRef, -} from '@backstage/plugin-catalog-common-react'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import * as React from 'react'; diff --git a/plugins/rollbar/src/hooks/useCatalogEntity.ts b/plugins/rollbar/src/hooks/useCatalogEntity.ts index 3d539f19cc..e39a7a75f7 100644 --- a/plugins/rollbar/src/hooks/useCatalogEntity.ts +++ b/plugins/rollbar/src/hooks/useCatalogEntity.ts @@ -18,7 +18,7 @@ import { useApi } from '@backstage/core'; import { catalogApiRef, useEntityCompoundName, -} from '@backstage/plugin-catalog-common-react'; +} from '@backstage/plugin-catalog-react'; import { useAsync } from 'react-use'; export function useCatalogEntity() { diff --git a/plugins/rollbar/src/hooks/useRollbarEntities.ts b/plugins/rollbar/src/hooks/useRollbarEntities.ts index 24e2d100bb..3f3415b176 100644 --- a/plugins/rollbar/src/hooks/useRollbarEntities.ts +++ b/plugins/rollbar/src/hooks/useRollbarEntities.ts @@ -15,7 +15,7 @@ */ import { configApiRef, useApi } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { useAsync } from 'react-use'; import { ROLLBAR_ANNOTATION } from '../constants'; diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index ded0544d3a..d174991bb7 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -32,7 +32,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog-common-react": "^0.0.1", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx index 6178877330..75ee2d07df 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/ScaffolderPage.tsx @@ -27,7 +27,7 @@ import { useApi, WarningPanel, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { Button, Grid, Link, Typography } from '@material-ui/core'; import React, { useEffect } from 'react'; import { Link as RouterLink } from 'react-router-dom'; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index 793895e699..f3f97be877 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -14,10 +14,7 @@ * limitations under the License. */ import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; -import { - CatalogApi, - catalogApiRef, -} from '@backstage/plugin-catalog-common-react'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp, renderWithEffects } from '@backstage/test-utils'; import { lightTheme } from '@backstage/theme'; import { ThemeProvider } from '@material-ui/core'; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 360abd441c..64351c7ec8 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -27,7 +27,7 @@ import { catalogApiRef, entityRoute, entityRouteParams, -} from '@backstage/plugin-catalog-common-react'; +} from '@backstage/plugin-catalog-react'; import { LinearProgress } from '@material-ui/core'; import { IChangeEvent } from '@rjsf/core'; import parseGitUrl from 'git-url-parse'; diff --git a/plugins/search/package.json b/plugins/search/package.json index 1185646d67..28627b88ea 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -31,7 +31,7 @@ "dependencies": { "@backstage/core": "^0.5.0", "@backstage/catalog-model": "^0.7.0", - "@backstage/plugin-catalog-common-react": "^0.0.1", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/search/src/apis.ts b/plugins/search/src/apis.ts index 34793777a8..9d88227a44 100644 --- a/plugins/search/src/apis.ts +++ b/plugins/search/src/apis.ts @@ -15,7 +15,7 @@ */ import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; -import { CatalogApi } from '@backstage/plugin-catalog-common-react'; +import { CatalogApi } from '@backstage/plugin-catalog-react'; export type Result = { name: string; diff --git a/plugins/search/src/components/SearchResult/SearchResult.tsx b/plugins/search/src/components/SearchResult/SearchResult.tsx index 72e592302c..3c43c1d24c 100644 --- a/plugins/search/src/components/SearchResult/SearchResult.tsx +++ b/plugins/search/src/components/SearchResult/SearchResult.tsx @@ -21,7 +21,7 @@ import { TableColumn, useApi, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { Divider, Grid, makeStyles, Typography } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import React, { useEffect, useState } from 'react'; diff --git a/plugins/sentry/dev/index.tsx b/plugins/sentry/dev/index.tsx index 98b687b4bf..87e03605e4 100644 --- a/plugins/sentry/dev/index.tsx +++ b/plugins/sentry/dev/index.tsx @@ -17,7 +17,7 @@ import { Entity } from '@backstage/catalog-model'; import { Content, Header, Page } from '@backstage/core'; import { createDevApp, EntityGridItem } from '@backstage/dev-utils'; -import { EntityProvider } from '@backstage/plugin-catalog-common-react'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; import { Grid } from '@material-ui/core'; import React from 'react'; import { diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index 2d5edaabb5..38e1c55ed0 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog-common-react": "^0.0.1", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/sentry/src/extensions.tsx b/plugins/sentry/src/extensions.tsx index b606beed37..ea63cd472a 100644 --- a/plugins/sentry/src/extensions.tsx +++ b/plugins/sentry/src/extensions.tsx @@ -18,7 +18,7 @@ import { createComponentExtension, createRoutableExtension, } from '@backstage/core'; -import { useEntity } from '@backstage/plugin-catalog-common-react'; +import { useEntity } from '@backstage/plugin-catalog-react'; import React from 'react'; import { plugin, rootRouteRef } from './plugin'; diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 5bd8ac0d9d..90c8fd3840 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -33,7 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", - "@backstage/plugin-catalog-common-react": "^0.0.1", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/test-utils": "^0.1.6", "@backstage/theme": "^0.2.2", "@backstage/techdocs-common": "^0.3.6", diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx index bfca7aefe3..fb951f0a19 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.test.tsx @@ -15,10 +15,7 @@ */ import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { - CatalogApi, - catalogApiRef, -} from '@backstage/plugin-catalog-common-react'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import React from 'react'; diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.tsx index 63dd0dceae..2d8b5c7dcc 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.tsx @@ -22,7 +22,7 @@ import { Progress, useApi, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-common-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { Grid } from '@material-ui/core'; import React from 'react'; import { generatePath, useNavigate } from 'react-router-dom'; From c7b89e15571e6c1a596c50e0d24ae929d278641b Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Tue, 26 Jan 2021 19:40:03 +0100 Subject: [PATCH 150/297] Move EntityProvider to plugin-catalog-react --- packages/dev-utils/package.json | 2 +- .../src/components/EntityGridItem/EntityGridItem.tsx | 6 +++--- .../src/components/EntityProvider/EntityProvider.tsx | 2 +- .../src/components/EntityProvider/index.ts | 0 plugins/catalog-react/src/components/index.ts | 1 + .../src/components/CatalogFilter/AllServicesCount.tsx | 2 +- .../src/components/CatalogFilter/CatalogFilter.test.tsx | 2 +- .../catalog/src/components/CatalogPage/CatalogPage.test.tsx | 2 +- .../src/components/ResultsFilter/ResultsFilter.test.tsx | 2 +- plugins/catalog/src/filter/useEntityFilterGroup.test.tsx | 2 +- plugins/catalog/src/index.ts | 1 - 11 files changed, 11 insertions(+), 11 deletions(-) rename plugins/{catalog => catalog-react}/src/components/EntityProvider/EntityProvider.tsx (93%) rename plugins/{catalog => catalog-react}/src/components/EntityProvider/index.ts (100%) diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json index 93a5247c82..eff2d6e218 100644 --- a/packages/dev-utils/package.json +++ b/packages/dev-utils/package.json @@ -31,7 +31,7 @@ "dependencies": { "@backstage/core": "^0.5.0", "@backstage/catalog-model": "^0.7.0", - "@backstage/plugin-catalog": "^0.2.14", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/test-utils": "^0.1.5", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", diff --git a/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.tsx b/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.tsx index 077a8f85e8..825a207064 100644 --- a/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.tsx +++ b/packages/dev-utils/src/components/EntityGridItem/EntityGridItem.tsx @@ -14,11 +14,11 @@ * limitations under the License. */ -import React from 'react'; -import { Grid, GridProps, makeStyles } from '@material-ui/core'; import { Entity } from '@backstage/catalog-model'; -import { EntityProvider } from '@backstage/plugin-catalog'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; import { BackstageTheme } from '@backstage/theme'; +import { Grid, GridProps, makeStyles } from '@material-ui/core'; +import React from 'react'; const useStyles = makeStyles(theme => ({ root: ({ entity }) => ({ diff --git a/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx b/plugins/catalog-react/src/components/EntityProvider/EntityProvider.tsx similarity index 93% rename from plugins/catalog/src/components/EntityProvider/EntityProvider.tsx rename to plugins/catalog-react/src/components/EntityProvider/EntityProvider.tsx index e17252e0a0..45cdd56d04 100644 --- a/plugins/catalog/src/components/EntityProvider/EntityProvider.tsx +++ b/plugins/catalog-react/src/components/EntityProvider/EntityProvider.tsx @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; import React, { ReactNode } from 'react'; -import { EntityContext } from '@backstage/plugin-catalog-react'; +import { EntityContext } from '../../hooks'; type EntityProviderProps = { entity: Entity; diff --git a/plugins/catalog/src/components/EntityProvider/index.ts b/plugins/catalog-react/src/components/EntityProvider/index.ts similarity index 100% rename from plugins/catalog/src/components/EntityProvider/index.ts rename to plugins/catalog-react/src/components/EntityProvider/index.ts diff --git a/plugins/catalog-react/src/components/index.ts b/plugins/catalog-react/src/components/index.ts index 7719984d2b..fcfa8207b3 100644 --- a/plugins/catalog-react/src/components/index.ts +++ b/plugins/catalog-react/src/components/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export * from './EntityRefLink'; +export * from './EntityProvider'; diff --git a/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx b/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx index e78cedbe2d..efacfa4320 100644 --- a/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx +++ b/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx @@ -15,10 +15,10 @@ */ import { useApi } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { CircularProgress, useTheme } from '@material-ui/core'; import React from 'react'; import { useAsync } from 'react-use'; -import { catalogApiRef } from '../../plugin'; export const AllServicesCount = () => { const theme = useTheme(); diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx index b92fc3d4bc..0cc2108985 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx @@ -23,11 +23,11 @@ import { identityApiRef, storageApiRef, } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; import { fireEvent, render, waitFor } from '@testing-library/react'; import React from 'react'; import { EntityFilterGroupsProvider } from '../../filter'; -import { catalogApiRef } from '../../plugin'; import { ButtonGroup, CatalogFilter } from './CatalogFilter'; describe('Catalog Filter', () => { diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index 28fb1b08c7..42c9cf1117 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -28,11 +28,11 @@ import { ProfileInfo, storageApiRef, } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; import { fireEvent, render } from '@testing-library/react'; import React from 'react'; import { EntityFilterGroupsProvider } from '../../filter'; -import { catalogApiRef } from '../../plugin'; import { CatalogPage } from './CatalogPage'; describe('CatalogPage', () => { diff --git a/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx b/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx index d105979600..9be1c995e8 100644 --- a/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx +++ b/plugins/catalog/src/components/ResultsFilter/ResultsFilter.test.tsx @@ -23,11 +23,11 @@ import { identityApiRef, storageApiRef, } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import React from 'react'; import { EntityFilterGroupsProvider } from '../../filter'; -import { catalogApiRef } from '../../plugin'; import { ResultsFilter } from './ResultsFilter'; describe('Results Filter', () => { diff --git a/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx b/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx index e9af063160..685fc751d2 100644 --- a/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx +++ b/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx @@ -15,10 +15,10 @@ */ import { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { MockStorageApi } from '@backstage/test-utils'; import { act, renderHook } from '@testing-library/react-hooks'; import React from 'react'; -import { catalogApiRef } from '../plugin'; import { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider'; import { FilterGroup, FilterGroupStatesReady } from './types'; import { useEntityFilterGroup } from './useEntityFilterGroup'; diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index 5a93b80539..80367f80e4 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -17,7 +17,6 @@ export { AboutCard } from './components/AboutCard'; export { EntityLayout } from './components/EntityLayout'; export { EntityPageLayout } from './components/EntityPageLayout'; -export { EntityProvider } from './components/EntityProvider'; export * from './components/EntitySwitch'; export { Router } from './components/Router'; export * from './extensions'; From a243dcbcd839027a17228976714e3432c49b9081 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Wed, 27 Jan 2021 09:39:40 +0100 Subject: [PATCH 151/297] Update readme --- .changeset/wet-suits-live.md | 1 + plugins/catalog-react/README.md | 17 +++++++---------- plugins/catalog/package.json | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/.changeset/wet-suits-live.md b/.changeset/wet-suits-live.md index 64fcd58d97..27b8df8a84 100644 --- a/.changeset/wet-suits-live.md +++ b/.changeset/wet-suits-live.md @@ -14,6 +14,7 @@ '@backstage/plugin-search': patch '@backstage/plugin-sentry': patch '@backstage/plugin-techdocs': patch +'@backstage/dev-utils': patch --- Switch dependency from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-react`. diff --git a/plugins/catalog-react/README.md b/plugins/catalog-react/README.md index 574e23cf62..65ab45b9b2 100644 --- a/plugins/catalog-react/README.md +++ b/plugins/catalog-react/README.md @@ -1,17 +1,14 @@ -# Catalog Client +# Catalog React -Contains a frontend and backend compatible client for communicating with the -Backstage Catalog. +WORK IN PROGRESS -Backend code may import and use this package directly. +This is shared code of the frontend part of the default catalog plugin. -However, frontend code will not want to import this package directly - use the -`@backstage/plugin-catalog` package instead, which re-exports all of the types -and classes from this package. Thereby, you will also gain access to its -`catalogApiRef`. +It will implement the core API for handling your catalog of software, and +supplies components that can be reused by third-party plugins. ## Links -- [Default frontend part of the catalog](https://github.com/spotify/backstage/tree/master/plugins/catalog) -- [Default backend part of the catalog](https://github.com/spotify/backstage/tree/master/plugins/catalog-backend) +- [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/catalog) +- [Backend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend) - [The Backstage homepage](https://backstage.io) diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 6752bcdfe6..bf30e28571 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -69,4 +69,4 @@ "files": [ "dist" ] -} \ No newline at end of file +} From 93cd587142eecd6b359b63fb3d30efa8a4baf15a Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Fri, 29 Jan 2021 10:56:23 +0100 Subject: [PATCH 152/297] Fix build --- plugins/catalog-react/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index fc5f7a9d55..3c0402a194 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -40,10 +40,9 @@ "react-use": "^15.3.3" }, "devDependencies": { - "@backstage/cli": "^0.4.7", + "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", - "@microsoft/microsoft-graph-types": "^1.25.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/react-hooks": "^3.3.0", From 5207834e78965cebc13375a260d2037d57678b7a Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 29 Jan 2021 11:17:33 +0100 Subject: [PATCH 153/297] chore: need to run yarn install on build as the base image node modules are outdated --- .tugboat/config.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index 2be043cd78..09422032bc 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -10,6 +10,5 @@ services: - echo "yarn --cwd ${TUGBOAT_ROOT} start-backend --config ${TUGBOAT_ROOT}/app-config.yaml --config ${TUGBOAT_ROOT}/.tugboat/tugboat.app-config.production.yaml" >> /etc/service/node/run - chmod +x /etc/service/node/run build: - - yarn workspace example-app build - update: - yarn install + - yarn workspace example-app build From f44137454fb5e8e4285ed48d6c1735c26582e142 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 29 Jan 2021 11:20:27 +0100 Subject: [PATCH 154/297] chore: should install with frozen lockfile --- .tugboat/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tugboat/config.yml b/.tugboat/config.yml index 09422032bc..594a1044ac 100644 --- a/.tugboat/config.yml +++ b/.tugboat/config.yml @@ -10,5 +10,5 @@ services: - echo "yarn --cwd ${TUGBOAT_ROOT} start-backend --config ${TUGBOAT_ROOT}/app-config.yaml --config ${TUGBOAT_ROOT}/.tugboat/tugboat.app-config.production.yaml" >> /etc/service/node/run - chmod +x /etc/service/node/run build: - - yarn install + - yarn install --frozen-lockfile - yarn workspace example-app build From 0cb07157006249136674ed9229f298c2ccd0eb82 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 29 Jan 2021 13:42:53 +0100 Subject: [PATCH 155/297] Scaffolder: Fix tests in windows --- .../src/scaffolder/jobs/processor.test.ts | 25 ++++++++++-------- .../scaffolder/stages/prepare/file.test.ts | 4 +-- .../scaffolder/stages/publish/azure.test.ts | 9 +++++-- .../stages/publish/bitbucket.test.ts | 13 +++++++--- .../scaffolder/stages/publish/github.test.ts | 26 ++++++++++++------- .../scaffolder/stages/publish/gitlab.test.ts | 13 +++++++--- .../stages/templater/cookiecutter.test.ts | 26 +++++++++++-------- 7 files changed, 72 insertions(+), 44 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts index 0492400db6..5ff2bd1640 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import os from 'os'; import { JobProcessor } from './processor'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { StageInput } from './types'; @@ -68,9 +69,11 @@ describe('JobProcessor', () => { }, }; + const workingDirectory = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + describe('create', () => { it('creates should create a new job with a unique id', async () => { - const processor = new JobProcessor('/tmp'); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, @@ -84,7 +87,7 @@ describe('JobProcessor', () => { }); it('should setup the correct context for the job', async () => { - const processor = new JobProcessor('/tmp'); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, @@ -97,7 +100,7 @@ describe('JobProcessor', () => { }); it('should set the status as pending', async () => { - const processor = new JobProcessor('/tmp'); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, @@ -120,7 +123,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor('/tmp'); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, @@ -139,12 +142,12 @@ describe('JobProcessor', () => { describe('get', () => { it('return undefined for when the job does not exist', () => { - const processor = new JobProcessor('/tmp'); + const processor = new JobProcessor(workingDirectory); 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('/tmp'); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -156,7 +159,7 @@ describe('JobProcessor', () => { }); describe('process', () => { it('throws an error when the status of the job is not in pending state', async () => { - const processor = new JobProcessor('/tmp'); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -182,7 +185,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor('/tmp'); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -208,7 +211,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor('/tmp'); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -244,7 +247,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor('/tmp'); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, values: mockValues, @@ -283,7 +286,7 @@ describe('JobProcessor', () => { }, ]; - const processor = new JobProcessor('/tmp'); + const processor = new JobProcessor(workingDirectory); const job = processor.create({ entity: mockEntity, values: mockValues, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts index 408653aee0..ffb89b68b1 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts @@ -18,7 +18,7 @@ import { getVoidLogger } from '@backstage/backend-common'; import fs from 'fs-extra'; import { FilePreparer } from './file'; import os from 'os'; -import { resolve } from 'path'; +import path, { resolve } from 'path'; jest.mock('fs-extra'); @@ -35,7 +35,7 @@ describe('File preparer', () => { workspacePath, }); expect(fs.copy).toHaveBeenCalledWith( - resolve('/path', 'to', 'template'), + path.join('/path', 'to', 'template'), checkoutPath, { recursive: true, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts index 81901809e4..1ce47e04dd 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/azure.test.ts @@ -20,6 +20,8 @@ jest.mock('azure-devops-node-api', () => ({ getPersonalAccessTokenHandler: jest.fn().mockReturnValue(() => {}), })); +import os from 'os'; +import { resolve } from 'path'; import { AzurePublisher } from './azure'; import { WebApi } from 'azure-devops-node-api'; import * as helpers from './helpers'; @@ -28,6 +30,9 @@ import { getVoidLogger } from '@backstage/backend-common'; describe('Azure Publisher', () => { const logger = getVoidLogger(); + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const resultPath = resolve(workspacePath, 'result'); + describe('publish: createRemoteInAzure', () => { it('should use azure-devops-node-api to create a repo in the given project', async () => { const mockGitClient = { @@ -53,7 +58,7 @@ describe('Azure Publisher', () => { storePath: 'https://dev.azure.com/organisation/project/_git/repo', owner: 'bob', }, - workspacePath: '/tmp/test', + workspacePath, logger, }); @@ -74,7 +79,7 @@ describe('Azure Publisher', () => { 'project', ); expect(helpers.initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test/result', + dir: resultPath, remoteUrl: 'https://dev.azure.com/organization/project/_git/repo', auth: { username: 'notempty', password: 'fake-azure-token' }, logger, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts index 3b6ec9da89..419c81ea7e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts @@ -16,6 +16,8 @@ jest.mock('./helpers'); +import os from 'os'; +import { resolve } from 'path'; import { BitbucketPublisher } from './bitbucket'; import { initRepoAndPush } from './helpers'; import { getVoidLogger } from '@backstage/backend-common'; @@ -32,6 +34,9 @@ describe('Bitbucket Publisher', () => { jest.clearAllMocks(); }); + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const resultPath = resolve(workspacePath, 'result'); + describe('publish: createRemoteInBitbucketCloud', () => { it('should create repo in bitbucket cloud', async () => { server.use( @@ -69,7 +74,7 @@ describe('Bitbucket Publisher', () => { storePath: 'https://bitbucket.org/project/repo', owner: 'bob', }, - workspacePath: '/tmp/test', + workspacePath, logger: logger, }); @@ -80,7 +85,7 @@ describe('Bitbucket Publisher', () => { }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test/result', + dir: resultPath, remoteUrl: 'https://bitbucket.org/project/repo', auth: { username: 'fake-user', password: 'fake-token' }, logger: logger, @@ -127,7 +132,7 @@ describe('Bitbucket Publisher', () => { storePath: 'https://bitbucket.mycompany.com/project/repo', owner: 'bob', }, - workspacePath: '/tmp/test', + workspacePath, logger: logger, }); @@ -138,7 +143,7 @@ describe('Bitbucket Publisher', () => { }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test/result', + dir: resultPath, remoteUrl: 'https://bitbucket.mycompany.com/scm/project/repo', auth: { username: 'x-token-auth', password: 'fake-token' }, logger: logger, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts index faa8c49124..30fe8fd65a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/github.test.ts @@ -17,6 +17,9 @@ jest.mock('@octokit/rest'); jest.mock('./helpers'); +import os from 'os'; +import { resolve } from 'path'; + import { getVoidLogger } from '@backstage/backend-common'; import { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; import { GithubPublisher } from './github'; @@ -36,6 +39,9 @@ describe('GitHub Publisher', () => { jest.clearAllMocks(); }); + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const resultPath = resolve(workspacePath, 'result'); + describe('with public repo visibility', () => { describe('publish: createRemoteInGithub', () => { it('should use octokit to create a repo in an organisation if the organisation property is set', async () => { @@ -64,7 +70,7 @@ describe('GitHub Publisher', () => { owner: 'bob', access: 'blam/team', }, - workspacePath: '/tmp/test', + workspacePath, logger, }); @@ -89,7 +95,7 @@ describe('GitHub Publisher', () => { permission: 'admin', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test/result', + dir: resultPath, remoteUrl: 'https://github.com/backstage/backstage.git', auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, @@ -122,7 +128,7 @@ describe('GitHub Publisher', () => { owner: 'bob', access: 'blam', }, - workspacePath: '/tmp/test', + workspacePath, logger, }); @@ -140,7 +146,7 @@ describe('GitHub Publisher', () => { expect(mockGithubClient.repos.addCollaborator).not.toHaveBeenCalled(); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test/result', + dir: resultPath, remoteUrl: 'https://github.com/backstage/backstage.git', auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, @@ -175,7 +181,7 @@ describe('GitHub Publisher', () => { access: 'bob', description: 'description', }, - workspacePath: '/tmp/test', + workspacePath, logger, }); @@ -198,7 +204,7 @@ describe('GitHub Publisher', () => { permission: 'admin', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test/result', + dir: resultPath, remoteUrl: 'https://github.com/backstage/backstage.git', auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, @@ -233,7 +239,7 @@ describe('GitHub Publisher', () => { storePath: 'https://github.com/blam/test', owner: 'bob', }, - workspacePath: '/tmp/test', + workspacePath, logger, }); @@ -249,7 +255,7 @@ describe('GitHub Publisher', () => { visibility: 'internal', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test/result', + dir: resultPath, remoteUrl: 'https://github.com/backstage/backstage.git', auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, @@ -283,7 +289,7 @@ describe('GitHub Publisher', () => { storePath: 'https://github.com/blam/test', owner: 'bob', }, - workspacePath: '/tmp/test', + workspacePath, logger, }); @@ -299,7 +305,7 @@ describe('GitHub Publisher', () => { private: true, }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test/result', + dir: resultPath, remoteUrl: 'https://github.com/backstage/backstage.git', auth: { username: 'fake-token', password: 'x-oauth-basic' }, logger, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts index 99ddbbb2b7..a97c6a6624 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts @@ -20,6 +20,8 @@ jest.mock('@gitbeaker/node', () => ({ jest.mock('./helpers'); +import os from 'os'; +import { resolve } from 'path'; import { GitlabPublisher } from './gitlab'; import { Gitlab } from '@gitbeaker/node'; import { initRepoAndPush } from './helpers'; @@ -47,6 +49,9 @@ describe('GitLab Publisher', () => { ); }); + const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const resultPath = resolve(workspacePath, 'result'); + describe('publish: createRemoteInGitLab', () => { it('should use gitbeaker to create a repo in a namespace if the namespace property is set', async () => { const publisher = await GitlabPublisher.fromConfig({ @@ -68,7 +73,7 @@ describe('GitLab Publisher', () => { storePath: 'https://gitlab.com/blam/test', owner: 'bob', }, - workspacePath: '/tmp/test', + workspacePath, logger, }); @@ -85,7 +90,7 @@ describe('GitLab Publisher', () => { name: 'test', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test/result', + dir: resultPath, remoteUrl: 'mockclone', auth: { username: 'oauth2', password: 'fake-token' }, logger, @@ -111,7 +116,7 @@ describe('GitLab Publisher', () => { storePath: 'https://gitlab.com/blam/test', owner: 'bob', }, - workspacePath: '/tmp/test', + workspacePath, logger, }); @@ -125,7 +130,7 @@ describe('GitLab Publisher', () => { name: 'test', }); expect(initRepoAndPush).toHaveBeenCalledWith({ - dir: '/tmp/test/result', + dir: resultPath, remoteUrl: 'mockclone', auth: { username: 'oauth2', password: 'fake-token' }, logger, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts index f3c716e8a7..8172986d69 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts @@ -24,6 +24,7 @@ jest.mock('fs-extra'); import { CookieCutter } from './cookiecutter'; import fs from 'fs-extra'; +import path from 'path'; import { PassThrough } from 'stream'; import Docker from 'dockerode'; import parseGitUrl from 'git-url-parse'; @@ -56,7 +57,7 @@ describe('CookieCutter Templater', () => { }); expect(fs.writeJson).toBeCalledWith( - 'tempdir/template/cookiecutter.json', + path.join('tempdir', 'template', 'cookiecutter.json'), expect.objectContaining(values), ); }); @@ -87,13 +88,16 @@ describe('CookieCutter Templater', () => { dockerClient: mockDocker, }); - expect(fs.writeJSON).toBeCalledWith('tempdir/template/cookiecutter.json', { - ...existingJson, - ...values, - destination: { - git: expect.objectContaining({ organization: 'org', name: 'repo' }), + expect(fs.writeJSON).toBeCalledWith( + path.join('tempdir', 'template', 'cookiecutter.json'), + { + ...existingJson, + ...values, + destination: { + git: expect.objectContaining({ organization: 'org', name: 'repo' }), + }, }, - }); + ); }); it('should throw an error if the cookiecutter json is malformed and not missing', async () => { @@ -148,8 +152,8 @@ describe('CookieCutter Templater', () => { '/template', '--verbose', ], - templateDir: 'tempdir/template', - resultDir: 'tempdir/intermediate', + templateDir: path.join('tempdir', 'template'), + resultDir: path.join('tempdir', 'intermediate'), logStream: undefined, dockerClient: mockDocker, }); @@ -187,8 +191,8 @@ describe('CookieCutter Templater', () => { '/template', '--verbose', ], - templateDir: 'tempdir/template', - resultDir: 'tempdir/intermediate', + templateDir: path.join('tempdir', 'template'), + resultDir: path.join('tempdir', 'intermediate'), logStream: stream, dockerClient: mockDocker, }); From adaba84f3fb0ad7aadecd2b8aa4b4dc40f445e0d Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 29 Jan 2021 13:51:46 +0100 Subject: [PATCH 156/297] import path instead of resolve --- .../src/scaffolder/stages/prepare/azure.test.ts | 13 ++++++------- .../src/scaffolder/stages/prepare/bitbucket.test.ts | 10 +++++----- .../src/scaffolder/stages/prepare/file.test.ts | 4 ++-- .../src/scaffolder/stages/prepare/github.test.ts | 10 +++++----- .../src/scaffolder/stages/prepare/gitlab.test.ts | 10 +++++----- 5 files changed, 23 insertions(+), 24 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts index 9c4cccb5dc..6637a68e4b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/azure.test.ts @@ -16,7 +16,7 @@ import fs from 'fs-extra'; import os from 'os'; -import { resolve } from 'path'; +import path from 'path'; import { AzurePreparer } from './azure'; import { getVoidLogger, Git } from '@backstage/backend-common'; @@ -37,8 +37,8 @@ describe('AzurePreparer', () => { }); const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; - const checkoutPath = resolve(workspacePath, 'checkout'); - const templatePath = resolve(workspacePath, 'template'); + const checkoutPath = path.resolve(workspacePath, 'checkout'); + const templatePath = path.resolve(workspacePath, 'template'); const prepareOptions = { url: 'https://dev.azure.com/backstage-org/backstage-project/_git/template-repo', @@ -54,7 +54,7 @@ describe('AzurePreparer', () => { username: 'notempty', }); expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); - expect(fs.rmdir).toHaveBeenCalledWith(resolve(templatePath, '.git')); + expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); }); it('calls the clone command with the correct arguments for a repository', async () => { @@ -100,17 +100,16 @@ describe('AzurePreparer', () => { }); it('moves the template from path if it is specified', async () => { - const path = './subdir'; await preparer.prepare({ url: `https://dev.azure.com/backstage-org/backstage-project/_git/template-repo?path=${encodeURIComponent( - path, + './subdir', )}`, logger, workspacePath, }); expect(fs.move).toHaveBeenCalledWith( - resolve(checkoutPath, 'subdir'), + path.resolve(checkoutPath, 'subdir'), templatePath, ); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts index cf08b888f4..beba62eb2a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/bitbucket.test.ts @@ -17,7 +17,7 @@ import fs from 'fs-extra'; import { BitbucketPreparer } from './bitbucket'; import { getVoidLogger, Git } from '@backstage/backend-common'; -import { resolve } from 'path'; +import path from 'path'; import os from 'os'; jest.mock('fs-extra'); @@ -41,8 +41,8 @@ describe('BitbucketPreparer', () => { }); const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; - const checkoutPath = resolve(workspacePath, 'checkout'); - const templatePath = resolve(workspacePath, 'template'); + const checkoutPath = path.resolve(workspacePath, 'checkout'); + const templatePath = path.resolve(workspacePath, 'template'); const prepareOptions = { url: 'https://bitbucket.org/backstage-project/backstage-repo', @@ -58,7 +58,7 @@ describe('BitbucketPreparer', () => { ref: expect.any(String), }); expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); - expect(fs.rmdir).toHaveBeenCalledWith(resolve(templatePath, '.git')); + expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); }); it('calls the clone command with the correct arguments if an app password is provided for a repository', async () => { @@ -92,7 +92,7 @@ describe('BitbucketPreparer', () => { workspacePath, }); expect(fs.move).toHaveBeenCalledWith( - resolve(checkoutPath, '1', '2', '3'), + path.resolve(checkoutPath, '1', '2', '3'), templatePath, ); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts index ffb89b68b1..36be4d705b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts @@ -18,7 +18,7 @@ import { getVoidLogger } from '@backstage/backend-common'; import fs from 'fs-extra'; import { FilePreparer } from './file'; import os from 'os'; -import path, { resolve } from 'path'; +import path from 'path'; jest.mock('fs-extra'); @@ -27,7 +27,7 @@ describe('File preparer', () => { const logger = getVoidLogger(); const preparer = new FilePreparer(); const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; - const checkoutPath = resolve(workspacePath, 'checkout'); + const checkoutPath = path.resolve(workspacePath, 'checkout'); await preparer.prepare({ url: 'file:///path/to/template', diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts index fd8ab1880e..a5fad5eaaa 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts @@ -16,7 +16,7 @@ import fs from 'fs-extra'; import os from 'os'; -import { resolve } from 'path'; +import path from 'path'; import { GithubPreparer } from './github'; import { getVoidLogger, Git } from '@backstage/backend-common'; @@ -24,8 +24,8 @@ jest.mock('fs-extra'); describe('GitHubPreparer', () => { const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; - const checkoutPath = resolve(workspacePath, 'checkout'); - const templatePath = resolve(workspacePath, 'template'); + const checkoutPath = path.resolve(workspacePath, 'checkout'); + const templatePath = path.resolve(workspacePath, 'template'); const mockGitClient = { clone: jest.fn(), @@ -57,7 +57,7 @@ describe('GitHubPreparer', () => { ref: expect.any(String), }); expect(fs.move).toHaveBeenCalledWith( - resolve(checkoutPath, 'templates', 'graphql-starter', 'template'), + path.resolve(checkoutPath, 'templates', 'graphql-starter', 'template'), templatePath, ); expect(fs.rmdir).toHaveBeenCalledWith('/tmp/template/.git'); @@ -77,7 +77,7 @@ describe('GitHubPreparer', () => { ref: 'master', }); expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); - expect(fs.rmdir).toHaveBeenCalledWith(resolve(templatePath, '.git')); + expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); }); it('calls the clone command with token', async () => { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts index 6c04ce3a00..6ed4651e4f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/gitlab.test.ts @@ -15,7 +15,7 @@ */ import fs from 'fs-extra'; import os from 'os'; -import { resolve } from 'path'; +import path from 'path'; import { GitlabPreparer } from './gitlab'; import { getVoidLogger, Git } from '@backstage/backend-common'; @@ -23,8 +23,8 @@ jest.mock('fs-extra'); describe('GitLabPreparer', () => { const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; - const checkoutPath = resolve(workspacePath, 'checkout'); - const templatePath = resolve(workspacePath, 'template'); + const checkoutPath = path.resolve(workspacePath, 'checkout'); + const templatePath = path.resolve(workspacePath, 'template'); const mockGitClient = { clone: jest.fn(), @@ -62,7 +62,7 @@ describe('GitLabPreparer', () => { }); expect(fs.move).toHaveBeenCalledWith(checkoutPath, templatePath); - expect(fs.rmdir).toHaveBeenCalledWith(resolve(templatePath, '.git')); + expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); }); it(`clones the template from a sub directory if specified`, async () => { @@ -73,7 +73,7 @@ describe('GitLabPreparer', () => { workspacePath, }); expect(fs.move).toHaveBeenCalledWith( - resolve(checkoutPath, '1', '2', '3'), + path.resolve(checkoutPath, '1', '2', '3'), templatePath, ); }); From 262f7cf99637dd641fba8f95a5bab4ba0bba6ba6 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 29 Jan 2021 13:53:12 +0100 Subject: [PATCH 157/297] import path instead of resolve --- .../src/scaffolder/stages/publish/gitlab.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts index a97c6a6624..368f1bb29f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts @@ -21,7 +21,7 @@ jest.mock('@gitbeaker/node', () => ({ jest.mock('./helpers'); import os from 'os'; -import { resolve } from 'path'; +import path from 'path'; import { GitlabPublisher } from './gitlab'; import { Gitlab } from '@gitbeaker/node'; import { initRepoAndPush } from './helpers'; @@ -50,7 +50,7 @@ describe('GitLab Publisher', () => { }); const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; - const resultPath = resolve(workspacePath, 'result'); + const resultPath = path.resolve(workspacePath, 'result'); describe('publish: createRemoteInGitLab', () => { it('should use gitbeaker to create a repo in a namespace if the namespace property is set', async () => { From 8e461357022c21299cf89afb182d62a89974a170 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Fri, 29 Jan 2021 14:10:15 +0100 Subject: [PATCH 158/297] docs: Remove broken installation page --- microsite/sidebars.json | 1 - 1 file changed, 1 deletion(-) diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 99bd430b56..f855327627 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -13,7 +13,6 @@ "Getting Started": [ "getting-started/index", "getting-started/running-backstage-locally", - "getting-started/installation", "getting-started/development-environment", "getting-started/create-an-app", { From 26a6be27c023c786015f4cbd5f164e072cbf4044 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Fri, 29 Jan 2021 15:13:48 +0100 Subject: [PATCH 159/297] Fix publishConfig --- plugins/catalog-react/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 3c0402a194..94dc835d14 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -7,8 +7,7 @@ "private": false, "publishConfig": { "access": "public", - "main": "dist/index.cjs.js", - "module": "dist/index.esm.js", + "main": "dist/index.esm.js", "types": "dist/index.d.ts" }, "homepage": "https://backstage.io", From 70906eed4cda0582aea7c4493494333c3afa841d Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Thu, 28 Jan 2021 18:55:36 -0500 Subject: [PATCH 160/297] refactor --- .../src/alerts/KubernetesMigrationAlert.tsx | 9 +- plugins/cost-insights/src/client.ts | 4 +- .../AlertInsights/AlertDialog.test.tsx | 170 +++++++++--------- .../components/AlertInsights/AlertDialog.tsx | 152 +++++----------- .../AlertInsights/AlertInsights.test.tsx | 12 +- .../AlertInsights/AlertInsights.tsx | 119 ++++++------ .../AlertInsights/AlertInsightsHeader.tsx | 3 +- .../AlertInsightsSection.test.tsx | 97 +++++----- .../AlertInsights/AlertInsightsSection.tsx | 26 ++- .../AlertInsightsSectionHeader.tsx | 3 +- .../AlertInsights/AlertStatusSummary.tsx | 130 +++++++------- .../CostInsightsPage/CostInsightsPage.tsx | 45 ++--- .../CostInsightsPage/CostInsightsPageRoot.tsx | 5 +- .../CostOverviewCard/CostOverviewCard.tsx | 4 +- .../ProductInsightsCard.tsx | 3 +- .../src/forms/AlertSnoozeForm.tsx | 26 +-- plugins/cost-insights/src/hooks/index.ts | 1 - plugins/cost-insights/src/hooks/useAlerts.tsx | 76 -------- plugins/cost-insights/src/hooks/useScroll.tsx | 56 +----- .../cost-insights/src/utils/alerts.test.tsx | 109 +++++++++++ plugins/cost-insights/src/utils/alerts.tsx | 124 +++++++++++-- plugins/cost-insights/src/utils/scroll.tsx | 65 +++++++ plugins/cost-insights/src/utils/tests.tsx | 27 --- 23 files changed, 646 insertions(+), 620 deletions(-) delete mode 100644 plugins/cost-insights/src/hooks/useAlerts.tsx create mode 100644 plugins/cost-insights/src/utils/alerts.test.tsx create mode 100644 plugins/cost-insights/src/utils/scroll.tsx diff --git a/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx b/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx index 8c59d0c1a5..31a4d7e0e9 100644 --- a/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx +++ b/plugins/cost-insights/src/alerts/KubernetesMigrationAlert.tsx @@ -72,7 +72,6 @@ export class KubernetesMigrationAlert implements MigrationAlert { // Dialog will not render a form if form property set to null. AcceptForm = null; - // Overrides default Dismiss form with a custom form component. DismissForm: AlertForm< MigrationAlert, @@ -94,7 +93,7 @@ export class KubernetesMigrationAlert implements MigrationAlert { get element() { const subheader = `${pluralize( - 'Compute Engine role', + 'Service', this.data.services.length, true, )}, sorted by cost`; @@ -116,7 +115,7 @@ export class KubernetesMigrationAlert implements MigrationAlert { const alerts = await this.api.getAlerts(options.group); return new Promise(resolve => setTimeout(resolve, 750, [ - ...alerts.slice(0, 2), + ...alerts.filter(a => a.title !== this.title), { title: this.title, subtitle: this.subtitle, @@ -133,7 +132,7 @@ export class KubernetesMigrationAlert implements MigrationAlert { const alerts = await this.api.getAlerts(options.group); return new Promise(resolve => setTimeout(resolve, 750, [ - ...alerts.slice(0, 2), + ...alerts.filter(a => a.title !== this.title), { title: this.title, subtitle: this.subtitle, @@ -148,7 +147,7 @@ export class KubernetesMigrationAlert implements MigrationAlert { const alerts = await this.api.getAlerts(options.group); return new Promise(resolve => setTimeout(resolve, 750, [ - ...alerts.slice(0, 2), + ...alerts.filter(a => a.title !== this.title), { title: this.title, subtitle: this.subtitle, diff --git a/plugins/cost-insights/src/client.ts b/plugins/cost-insights/src/client.ts index 7849214c03..2e70170fc1 100644 --- a/plugins/cost-insights/src/client.ts +++ b/plugins/cost-insights/src/client.ts @@ -183,8 +183,8 @@ export class ExampleCostInsightsClient implements CostInsightsApi { new ProjectGrowthAlert(projectGrowthData), new UnlabeledDataflowAlert(unlabeledDataflowData), new KubernetesMigrationAlert(this, { - startDate: today.format(DEFAULT_DATE_FORMAT), - endDate: today.add(30, 'day').format(DEFAULT_DATE_FORMAT), + startDate: today.subtract(30, 'day').format(DEFAULT_DATE_FORMAT), + endDate: today.format(DEFAULT_DATE_FORMAT), change: { ratio: 0, amount: 0, diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx index 72ff757cee..7061f0736b 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.test.tsx @@ -14,9 +14,10 @@ * limitations under the License. */ import React from 'react'; +import { capitalize } from '@material-ui/core'; import { AlertDialog } from './AlertDialog'; import { render } from '@testing-library/react'; -import { Alert, AlertFormProps } from '../../types'; +import { Alert, AlertFormProps, AlertStatus } from '../../types'; type MockFormDataProps = AlertFormProps; @@ -40,7 +41,7 @@ const dimissableAlert: Alert = { onDismissed: jest.fn(), }; -const acceptAlert: Alert = { +const acceptableAlert: Alert = { title: 'title', subtitle: 'subtitle', onAccepted: jest.fn(), @@ -87,98 +88,87 @@ const nullSnoozeAlert: Alert = { onSnoozed: jest.fn(), SnoozeForm: null, }; + describe('', () => { describe.each` - accepted | dismissed | snoozed | action | text - ${acceptAlert} | ${null} | ${null} | ${['Accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'} - ${null} | ${dimissableAlert} | ${null} | ${['Dismiss', 'dismissed']} | ${'Reason for dismissing?'} - ${null} | ${null} | ${snoozableAlert} | ${['Snooze', 'snoozed']} | ${'For how long?'} - `( - 'Default forms', - ({ accepted, dismissed, snoozed, action: [action, actioned], text }) => { - it(`Displays a default ${action} form`, () => { - const { getByText } = render( - , - ); - expect(getByText(text)).toBeInTheDocument(); - expect(getByText(`${action} this action item?`)).toBeInTheDocument(); - expect( - getByText(`This action item will be ${actioned} for all of Ramones.`), - ).toBeInTheDocument(); - }); - }, - ); + alert | status | action | text + ${acceptableAlert} | ${AlertStatus.Accepted} | ${['accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'} + ${dimissableAlert} | ${AlertStatus.Dismissed} | ${['dismiss', 'dismissed']} | ${'Reason for dismissing?'} + ${snoozableAlert} | ${AlertStatus.Snoozed} | ${['snooze', 'snoozed']} | ${'For how long?'} + `('Default forms', ({ alert, status, action: [action, actioned], text }) => { + it(`Displays a default ${action} form`, () => { + const { getByText } = render( + , + ); + expect(getByText(text)).toBeInTheDocument(); + expect( + getByText(`${capitalize(action)} this action item?`), + ).toBeInTheDocument(); + expect( + getByText(`This action item will be ${actioned} for all of Ramones.`), + ).toBeInTheDocument(); + }); + }); describe.each` - accepted | dismissed | snoozed | action - ${customAcceptAlert} | ${null} | ${null} | ${['Accept', 'accepted']} - ${null} | ${customDismissAlert} | ${null} | ${['Dismiss', 'dismissed']} - ${null} | ${null} | ${customSnoozeAlert} | ${['Snooze', 'snoozed']} - `( - 'Custom forms', - ({ accepted, dismissed, snoozed, action: [Action, actioned] }) => { - it(`Displays a custom ${Action} form`, () => { - const { getByText } = render( - , - ); - expect(getByText(`You. ${Action}. Me.`)).toBeInTheDocument(); - expect(getByText(`${Action} this action item?`)).toBeInTheDocument(); - expect( - getByText(`This action item will be ${actioned} for all of Ramones.`), - ).toBeInTheDocument(); - }); - }, - ); + alert | status | action | text + ${customAcceptAlert} | ${AlertStatus.Accepted} | ${['accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'} + ${customDismissAlert} | ${AlertStatus.Dismissed} | ${['dismiss', 'dismissed']} | ${'Reason for dismissing?'} + ${customSnoozeAlert} | ${AlertStatus.Snoozed} | ${['snooze', 'snoozed']} | ${'For how long?'} + `('Custom forms', ({ alert, status, action: [action, actioned] }) => { + it(`Displays a custom ${capitalize(action)} form`, () => { + const { getByText } = render( + , + ); + expect(getByText(`You. ${capitalize(action)}. Me.`)).toBeInTheDocument(); + expect( + getByText(`${capitalize(action)} this action item?`), + ).toBeInTheDocument(); + expect( + getByText(`This action item will be ${actioned} for all of Ramones.`), + ).toBeInTheDocument(); + }); + }); describe.each` - accepted | dismissed | snoozed | action | text - ${nullAcceptAlert} | ${null} | ${null} | ${['Accept', 'accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'} - ${null} | ${nullDismissAlert} | ${null} | ${['Dismiss', 'dismiss', 'dismissed']} | ${'Reason for dismissing?'} - ${null} | ${null} | ${nullSnoozeAlert} | ${['Snooze', 'snooze', 'snoozed']} | ${'For how long?'} - `( - 'Null forms', - ({ - accepted, - dismissed, - snoozed, - action: [Action, action, actioned], - text, - }) => { - it(`Does NOT display a ${Action} form`, () => { - const { getByText, getByRole, queryByText } = render( - , - ); - expect(queryByText(text)).not.toBeInTheDocument(); - expect(getByRole('button', { name: action })).toBeInTheDocument(); - expect(getByText(`${Action} this action item?`)).toBeInTheDocument(); - expect( - getByText(`This action item will be ${actioned} for all of Ramones.`), - ).toBeInTheDocument(); - }); - }, - ); + alert | status | action | text + ${nullAcceptAlert} | ${AlertStatus.Accepted} | ${['accept', 'accepted']} | ${'My team can commit to making this change soon, or has already.'} + ${nullDismissAlert} | ${AlertStatus.Dismissed} | ${['dismiss', 'dismissed']} | ${'Reason for dismissing?'} + ${nullSnoozeAlert} | ${AlertStatus.Snoozed} | ${['snooze', 'snoozed']} | ${'For how long?'} + `('Null forms', ({ alert, status, action: [action, actioned], text }) => { + it(`Does NOT display a ${capitalize(action)} form`, () => { + const { getByText, getByRole, queryByText } = render( + , + ); + expect(queryByText(text)).not.toBeInTheDocument(); + expect(getByRole('button', { name: action })).toBeInTheDocument(); + expect( + getByText(`${capitalize(action)} this action item?`), + ).toBeInTheDocument(); + expect( + getByText(`This action item will be ${actioned} for all of Ramones.`), + ).toBeInTheDocument(); + }); + }); }); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx index f7154602b9..ab6f9fb9f0 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertDialog.tsx @@ -15,8 +15,8 @@ */ import React, { useEffect, useRef, useState } from 'react'; -import { default as CloseIcon } from '@material-ui/icons/Close'; import { + capitalize, Box, Button, Divider, @@ -26,23 +26,18 @@ import { DialogContent, Typography, } from '@material-ui/core'; -import { - AlertAcceptForm, - AlertDismissForm, - AlertSnoozeForm, -} from '../../forms'; +import { default as CloseIcon } from '@material-ui/icons/Close'; import { useAlertDialogStyles as useStyles } from '../../utils/styles'; -import { choose } from '../../utils/alerts'; -import { Alert, AlertForm, Maybe } from '../../types'; +import { Alert, AlertStatus, Maybe } from '../../types'; +import { choose, formOf } from '../../utils/alerts'; const DEFAULT_FORM_ID = 'alert-form'; type AlertDialogProps = { open: boolean; group: string; - snoozed: Maybe; - accepted: Maybe; - dismissed: Maybe; + alert: Maybe; + status: Maybe; onClose: () => void; onSubmit: (data: any) => void; }; @@ -50,87 +45,50 @@ type AlertDialogProps = { export const AlertDialog = ({ open, group, - snoozed, - accepted, - dismissed, + alert, + status, onClose, onSubmit, }: AlertDialogProps) => { const classes = useStyles(); - const [isButtonDisabled, setDisabled] = useState(true); - const acceptRef = useRef>(null); - const snoozeRef = useRef>(null); - const dismissRef = useRef>(null); + const [isSubmitDisabled, setSubmitDisabled] = useState(true); + const formRef = useRef>(null); useEffect(() => { - if (open) { - setDisabled(true); - } else { - setDisabled(false); - } + setSubmitDisabled(open); }, [open]); function disableSubmit(isDisabled: boolean) { - setDisabled(isDisabled); + setSubmitDisabled(isDisabled); } function onDialogClose() { onClose(); - setDisabled(true); + setSubmitDisabled(true); } - const SnoozeForm: Maybe = snoozed?.SnoozeForm ?? AlertSnoozeForm; - const AcceptForm: Maybe = accepted?.AcceptForm ?? AlertAcceptForm; - const DismissForm: Maybe = - dismissed?.DismissForm ?? AlertDismissForm; - - const isSnoozingEnabled = !!snoozed?.onSnoozed; - const isAcceptingEnabled = !!accepted?.onAccepted; - const isDismissingEnabled = !!dismissed?.onDismissed; - - const isSnoozeFormDisabled = snoozed?.SnoozeForm === null; - const isAcceptFormDisabled = accepted?.AcceptForm === null; - const isDismissFormDisabled = dismissed?.DismissForm === null; - const isFormDisabled = - isSnoozeFormDisabled || isAcceptFormDisabled || isDismissFormDisabled; - - const status = [ - isSnoozingEnabled, - isAcceptingEnabled, - isDismissingEnabled, - ] as const; - - const [Action, action, actioned] = - choose(status, [ - ['Snooze', 'snooze', 'snoozed'], - ['Accept', 'accept', 'accepted'], - ['Dismiss', 'dismiss', 'dismissed'], - ]) ?? []; - - const [title, subtitle] = - choose(status, [ - [snoozed?.title, snoozed?.subtitle], - [accepted?.title, accepted?.subtitle], - [dismissed?.title, dismissed?.subtitle], - ]) ?? []; + const [action, actioned] = choose( + status, + [ + ['snooze', 'snoozed'], + ['accept', 'accepted'], + ['dismiss', 'dismissed'], + ], + ['', ''], + ); const TransitionProps = { mountOnEnter: true, unmountOnExit: true, - // Wait for child component to mount; avoid recycling refs. onEntered() { - if (acceptRef.current) { - acceptRef.current.id = DEFAULT_FORM_ID; - } - if (snoozeRef.current) { - snoozeRef.current.id = DEFAULT_FORM_ID; - } - if (dismissRef.current) { - dismissRef.current.id = DEFAULT_FORM_ID; + if (formRef.current) { + formRef.current.id = DEFAULT_FORM_ID; } }, }; + const Form = formOf(alert, status); + return ( @@ -152,7 +110,7 @@ export const AlertDialog = ({ - {Action} this action item? + {capitalize(action)} this action item? @@ -169,30 +127,14 @@ export const AlertDialog = ({ borderRadius={4} > - {title} + {alert?.title} - {subtitle} + {alert?.subtitle} - {isSnoozingEnabled && !isSnoozeFormDisabled && ( - - )} - {isDismissingEnabled && !isDismissFormDisabled && ( - - )} - {isAcceptingEnabled && !isAcceptFormDisabled && ( - @@ -200,7 +142,18 @@ export const AlertDialog = ({ - {isFormDisabled ? ( + {Form ? ( + + ) : ( - ) : ( - )} diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx index cd8444cb1f..98f75b1d41 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.test.tsx @@ -17,18 +17,12 @@ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react'; import { AlertInsights } from './AlertInsights'; -import { - MockScrollProvider, - MockAlertsProvider, - MockLoadingProvider, -} from '../../utils/tests'; +import { MockScrollProvider, MockLoadingProvider } from '../../utils/tests'; function renderInContext(children: JSX.Element) { return render( - - {children} - + {children} , ); } @@ -47,6 +41,7 @@ describe('', () => { snoozed={[]} accepted={[]} dismissed={[]} + onChange={jest.fn()} />, ); expect( @@ -70,6 +65,7 @@ describe('', () => { ]} accepted={[]} dismissed={[]} + onChange={jest.fn()} />, ); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx index 1bcb684052..59b2dc9eec 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import pluralize from 'pluralize'; import { Box, Grid, Snackbar } from '@material-ui/core'; import { default as MuiAlert } from '@material-ui/lab/Alert'; @@ -24,15 +24,20 @@ import { AlertStatusSummaryButton } from './AlertStatusSummaryButton'; import { AlertInsightsHeader } from './AlertInsightsHeader'; import { AlertInsightsSection } from './AlertInsightsSection'; import { - useAlerts, useScroll, useLoading, ScrollType, MapLoadingToProps, } from '../../hooks'; import { DefaultLoadingAction } from '../../utils/loading'; -import { Alert, AlertOptions, Maybe } from '../../types'; -import { sumOfAllAlerts } from '../../utils/alerts'; +import { Alert, AlertOptions, AlertStatus, Maybe } from '../../types'; +import { + isStatusSnoozed, + isStatusAccepted, + isStatusDismissed, + sumOfAllAlerts, +} from '../../utils/alerts'; +import { ScrollAnchor } from '../../utils/scroll'; type MapLoadingtoAlerts = (isLoading: boolean) => void; @@ -47,6 +52,7 @@ type AlertInsightsProps = { snoozed: Alert[]; accepted: Alert[]; dismissed: Alert[]; + onChange: (alerts: Alert[]) => void; }; export const AlertInsights = ({ @@ -55,10 +61,12 @@ export const AlertInsights = ({ snoozed, accepted, dismissed, + onChange, }: AlertInsightsProps) => { - const [alerts, setAlerts] = useAlerts(); - const [scroll, , ScrollAnchor] = useScroll(); + const [scroll] = useScroll(); + const [alert, setAlert] = useState>(null); const dispatchLoadingAlerts = useLoading(mapLoadingToAlerts); + const [status, setStatus] = useState>(null); // Allow users to pass null values for data. const [data, setData] = useState>(undefined); const [error, setError] = useState>(null); @@ -66,22 +74,19 @@ export const AlertInsights = ({ const [isSummaryOpen, setSummaryOpen] = useState(false); const [isSnackbarOpen, setSnackbarOpen] = useState(false); - const closeDialog = useCallback(() => { - setData(undefined); - setDialogOpen(false); - setAlerts({ dismissed: null, snoozed: null, accepted: null }); - }, [setAlerts]); - useEffect(() => { - async function callHandler( + async function callAlertHook( options: AlertOptions, callback: (options: AlertOptions) => Promise, ) { - closeDialog(); + setAlert(null); + setStatus(null); + setData(undefined); + setDialogOpen(false); dispatchLoadingAlerts(true); try { - const a: Alert[] = await callback(options); - setAlerts({ alerts: a }); + const alerts: Alert[] = await callback(options); + onChange(alerts); } catch (e) { setError(e); } finally { @@ -90,22 +95,20 @@ export const AlertInsights = ({ } const options: AlertOptions = { data, group }; - const onSnoozed = alerts.snoozed?.onSnoozed?.bind(alerts.snoozed) ?? null; - const onAccepted = - alerts.accepted?.onAccepted?.bind(alerts.accepted) ?? null; - const onDismissed = - alerts.dismissed?.onDismissed?.bind(alerts.dismissed) ?? null; + const onSnoozed = alert?.onSnoozed?.bind(alert); + const onAccepted = alert?.onAccepted?.bind(alert); + const onDismissed = alert?.onDismissed?.bind(alert); if (data !== undefined) { - if (onSnoozed) { - callHandler(options, onSnoozed); - } else if (onAccepted) { - callHandler(options, onAccepted); - } else if (onDismissed) { - callHandler(options, onDismissed); + if (isStatusSnoozed(status) && onSnoozed) { + callAlertHook(options, onSnoozed); + } else if (isStatusAccepted(status) && onAccepted) { + callAlertHook(options, onAccepted); + } else if (isStatusDismissed(status) && onDismissed) { + callAlertHook(options, onDismissed); } } - }, [group, data, alerts, setAlerts, closeDialog, dispatchLoadingAlerts]); + }, [group, data, alert, status, onChange, dispatchLoadingAlerts]); useEffect(() => { if (scroll === ScrollType.AlertSummary) { @@ -114,34 +117,38 @@ export const AlertInsights = ({ }, [scroll]); useEffect(() => { - if (error) { - setSnackbarOpen(true); - } else { - setSnackbarOpen(false); - } - }, [error]); + setDialogOpen(!!status); + }, [status]); useEffect(() => { - function toggleDialogOnStatusChange() { - const isAlertSnoozed = !!alerts.snoozed; - const isAlertAccepted = !!alerts.accepted; - const isAlertDismissed = !!alerts.dismissed; + setSnackbarOpen(!!error); + }, [error]); - if (isAlertSnoozed || isAlertDismissed || isAlertAccepted) { - setDialogOpen(true); - } else { - setDialogOpen(false); - } - } + function onSnooze(alert: Alert) { + setAlert(alert); + setStatus(AlertStatus.Snoozed); + } - toggleDialogOnStatusChange(); - }, [alerts.snoozed, alerts.dismissed, alerts.accepted]); + function onAccept(alert: Alert) { + setAlert(alert); + setStatus(AlertStatus.Accepted); + } + + function onDismiss(alert: Alert) { + setAlert(alert); + setStatus(AlertStatus.Dismissed); + } function onSnackbarClose() { setError(null); } - function onDialogSubmit(data: any) { + function onDialogClose() { + setAlert(null); + setStatus(null); + } + + function onDialogFormSubmit(data: any) { setData(data); } @@ -153,7 +160,6 @@ export const AlertInsights = ({ const isAlertStatusSummaryDisplayed = !!total; const isAlertInsightSectionDisplayed = !!active.length; - // AlertInsights will not display if there aren't any active or hidden items. return ( @@ -171,7 +177,13 @@ export const AlertInsights = ({ {active.map((alert, index) => ( - + ))} @@ -195,11 +207,10 @@ export const AlertInsights = ({ { const classes = useStyles(); - const [, , ScrollAnchor] = useScroll(); return ( diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx index f04e1d8b01..937e099356 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.test.tsx @@ -17,8 +17,7 @@ import React from 'react'; import { AlertInsightsSection } from './AlertInsightsSection'; import { render } from '@testing-library/react'; import { Alert } from '../../types'; -import { AlertState } from '../../hooks'; -import { MockScrollProvider, MockAlertsProvider } from '../../utils/tests'; +import { MockScrollProvider } from '../../utils/tests'; const mockAlert: Alert = { subtitle: @@ -27,14 +26,20 @@ const mockAlert: Alert = { url: '/cost-insights/test', }; +function renderInContext(children: JSX.Element) { + return render({children}); +} + describe('', () => { it('Renders alert without exploding', () => { - const { getByText, queryByText } = render( - - - - - , + const { getByText, queryByText } = renderInContext( + , ); expect(getByText(mockAlert.title)).toBeInTheDocument(); expect(getByText(mockAlert.subtitle)).toBeInTheDocument(); @@ -49,12 +54,14 @@ describe('', () => { ...mockAlert, url: undefined, }; - const { queryByText } = render( - - - - - , + const { queryByText } = renderInContext( + , ); expect(queryByText('View Instructions')).not.toBeInTheDocument(); }); @@ -65,19 +72,14 @@ describe('', () => { onSnoozed: jest.fn(), }; - const context: AlertState = { - alerts: [], - snoozed: alert, - dismissed: null, - accepted: null, - }; - - const { queryByText, getByText } = render( - - - - - , + const { queryByText, getByText } = renderInContext( + , ); expect(getByText('Snooze')).toBeInTheDocument(); @@ -90,19 +92,15 @@ describe('', () => { ...mockAlert, onDismissed: jest.fn(), }; - const context: AlertState = { - alerts: [], - snoozed: null, - dismissed: alert, - accepted: null, - }; - const { queryByText, getByText } = render( - - - - - , + const { queryByText, getByText } = renderInContext( + , ); expect(getByText('Dismiss')).toBeInTheDocument(); @@ -116,19 +114,14 @@ describe('', () => { onAccepted: jest.fn(), }; - const context: AlertState = { - alerts: [], - snoozed: null, - dismissed: null, - accepted: alert, - }; - - const { queryByText, getByText } = render( - - - - - , + const { queryByText, getByText } = renderInContext( + , ); expect(getByText('Accept')).toBeInTheDocument(); diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx index dda2d66f3f..d52687ae19 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSection.tsx @@ -19,23 +19,31 @@ import { default as SnoozeIcon } from '@material-ui/icons/AccessTime'; import { default as AcceptIcon } from '@material-ui/icons/Check'; import { default as DismissIcon } from '@material-ui/icons/Delete'; import { AlertInsightsSectionHeader } from './AlertInsightsSectionHeader'; -import { useAlerts } from '../../hooks'; import { Alert } from '../../types'; +import { + isSnoozeEnabled, + isAcceptEnabled, + isDismissEnabled, +} from '../../utils/alerts'; type AlertInsightsSectionProps = { alert: Alert; number: number; + onSnooze: (alert: Alert) => void; + onAccept: (alert: Alert) => void; + onDismiss: (alert: Alert) => void; }; export const AlertInsightsSection = ({ alert, number, + onSnooze, + onAccept, + onDismiss, }: AlertInsightsSectionProps) => { - const [, setAlerts] = useAlerts(); - - const isSnoozeButtonDisplayed = !!alert.onSnoozed; - const isAcceptButtonDisplayed = !!alert.onAccepted; - const isDismissButtonDisplayed = !!alert.onDismissed; + const isSnoozeButtonDisplayed = isSnoozeEnabled(alert); + const isAcceptButtonDisplayed = isAcceptEnabled(alert); + const isDismissButtonDisplayed = isDismissEnabled(alert); const isButtonGroupDisplayed = isSnoozeButtonDisplayed || isAcceptButtonDisplayed || @@ -52,7 +60,7 @@ export const AlertInsightsSection = ({ color="primary" variant="contained" aria-label="accept" - onClick={() => setAlerts({ accepted: alert })} + onClick={() => onAccept(alert)} startIcon={} > Accept @@ -66,7 +74,7 @@ export const AlertInsightsSection = ({ variant="outlined" aria-label="snooze" disableElevation - onClick={() => setAlerts({ snoozed: alert })} + onClick={() => onSnooze(alert)} startIcon={} > Snooze @@ -79,7 +87,7 @@ export const AlertInsightsSection = ({ variant="outlined" aria-label="dismiss" disableElevation - onClick={() => setAlerts({ dismissed: alert })} + onClick={() => onDismiss(alert)} startIcon={} > Dismiss diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx index c463a05609..30b8985934 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsightsSectionHeader.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { Avatar, Box, Button, Grid, Typography } from '@material-ui/core'; import { useAlertInsightsSectionStyles as useStyles } from '../../utils/styles'; -import { useScroll } from '../../hooks'; +import { ScrollAnchor } from '../../utils/scroll'; import { Alert } from '../../types'; type AlertInsightsSectionHeaderProps = { @@ -29,7 +29,6 @@ export const AlertInsightsSectionHeader = ({ alert, number, }: AlertInsightsSectionHeaderProps) => { - const [, , ScrollAnchor] = useScroll(); const classes = useStyles(); const isViewInstructionsButtonDisplayed = !!alert.url; diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx index cb0a149511..caee99dc5e 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertStatusSummary.tsx @@ -15,7 +15,7 @@ */ import React, { Fragment } from 'react'; -import { Avatar, Box, Collapse, Divider } from '@material-ui/core'; +import { Avatar, Box, Collapse, Divider, Tooltip } from '@material-ui/core'; import { default as AcceptIcon } from '@material-ui/icons/Check'; import { default as DismissIcon } from '@material-ui/icons/Delete'; import { default as SnoozeIcon } from '@material-ui/icons/AccessTime'; @@ -23,6 +23,35 @@ import { ActionItemCard } from '../ActionItems'; import { Alert, AlertStatus } from '../../types'; import { useActionItemCardStyles as useStyles } from '../../utils/styles'; +type AlertGroupProps = { + alerts: Alert[]; + status: AlertStatus; + title: string; + icon: JSX.Element; +}; + +const AlertGroup = ({ alerts, status, title, icon }: AlertGroupProps) => { + const classes = useStyles(); + return ( + + {alerts.map((alert, index) => ( + + + {icon} + + } + /> + {index < alerts.length - 1 && } + + ))} + + ); +}; + type AlertStatusSummaryProps = { open: boolean; snoozed: Alert[]; @@ -36,8 +65,6 @@ export const AlertStatusSummary = ({ accepted, dismissed, }: AlertStatusSummaryProps) => { - const classes = useStyles(); - const isSnoozedListDisplayed = !!snoozed.length; const isAcceptedListDisplayed = !!accepted.length; const isDismissedListDisplayed = !!dismissed.length; @@ -45,71 +72,46 @@ export const AlertStatusSummary = ({ return ( {isAcceptedListDisplayed && ( - - {accepted.map((alert, index) => ( - - - {/* Icons indicate alert status. Do not hide from accesibility tree */} - - - } - /> - {index < accepted.length - 1 && } - - ))} - + + } + /> )} {isSnoozedListDisplayed && ( - - {snoozed.map((alert, index) => ( - - - - - } - /> - {index < snoozed.length - 1 && } - - ))} - + + } + /> )} {isDismissedListDisplayed && ( - - {dismissed.map((alert, index) => ( - - - - - } - /> - {index < dismissed.length - 1 && } - - ))} - + + } + /> )} ); diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx index f974fa0ddd..a8ba454d11 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx @@ -44,22 +44,21 @@ import { ProductInsights } from '../ProductInsights'; import { useConfig, useCurrency, - useAlerts, useFilters, useGroups, useLastCompleteBillingDate, useLoading, } from '../../hooks'; -import { Cost, Maybe, MetricData, Product, Project } from '../../types'; +import { Alert, Cost, Maybe, MetricData, Product, Project } from '../../types'; import { mapLoadingToProps } from './selector'; import { ProjectSelect } from '../ProjectSelect'; import { intervalsOf } from '../../utils/duration'; import { useSubtleTypographyStyles } from '../../utils/styles'; import { - isActive, - isAccepted, - isDismissed, - isSnoozed, + isAlertActive, + isAlertAccepted, + isAlertDismissed, + isAlertSnoozed, } from '../../utils/alerts'; export const CostInsightsPage = () => { @@ -68,7 +67,7 @@ export const CostInsightsPage = () => { const config = useConfig(); const groups = useGroups(); const lastCompleteBillingDate = useLastCompleteBillingDate(); - const [alerts, setAlerts] = useAlerts(); + const [alerts, setAlerts] = useState([]); const [currency, setCurrency] = useCurrency(); const [projects, setProjects] = useState>(null); const [products, setProducts] = useState>(null); @@ -78,21 +77,13 @@ export const CostInsightsPage = () => { const { pageFilters, setPageFilters } = useFilters(p => p); - const snoozed = useMemo(() => alerts.alerts.filter(isSnoozed), [ - alerts.alerts, - ]); - const accepted = useMemo(() => alerts.alerts.filter(isAccepted), [ - alerts.alerts, - ]); - const dismissed = useMemo(() => alerts.alerts.filter(isDismissed), [ - alerts.alerts, - ]); - const activeAlerts = useMemo(() => alerts.alerts.filter(isActive), [ - alerts.alerts, - ]); + const active = useMemo(() => alerts.filter(isAlertActive), [alerts]); + const snoozed = useMemo(() => alerts.filter(isAlertSnoozed), [alerts]); + const accepted = useMemo(() => alerts.filter(isAlertAccepted), [alerts]); + const dismissed = useMemo(() => alerts.filter(isAlertDismissed), [alerts]); - const isActionItemsDisplayed = !!activeAlerts.length; - const isAlertInsightsDisplayed = !!alerts.alerts.length; + const isActionItemsDisplayed = !!active.length; + const isAlertInsightsDisplayed = !!alerts.length; const { loadingActions, @@ -150,7 +141,7 @@ export const CostInsightsPage = () => { : client.getGroupDailyCost(pageFilters.group, intervals), ]); setProjects(fetchedProjects); - setAlerts({ alerts: fetchedAlerts }); + setAlerts(fetchedAlerts); setMetricData(fetchedMetricData); setDailyCost(fetchedDailyCost); } else { @@ -175,7 +166,6 @@ export const CostInsightsPage = () => { loadingActions, loadingGroups, loadingBillingDate, - setAlerts, dispatchLoadingInsights, dispatchLoadingInitial, dispatchLoadingNone, @@ -259,7 +249,7 @@ export const CostInsightsPage = () => { @@ -280,14 +270,14 @@ export const CostInsightsPage = () => { owner={pageFilters.group} groups={groups} hasCostData={!!dailyCost.aggregation.length} - alerts={activeAlerts.length} + alerts={active.length} /> { diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx index 1947ca185e..4f506b4076 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx @@ -20,7 +20,6 @@ import { FilterProvider } from '../../hooks/useFilters'; import { LoadingProvider } from '../../hooks/useLoading'; import { GroupsProvider } from '../../hooks/useGroups'; import { CurrencyProvider } from '../../hooks/useCurrency'; -import { AlertsProvider } from '../../hooks/useAlerts'; import { ScrollProvider } from '../../hooks/useScroll'; import { ConfigProvider } from '../../hooks/useConfig'; import { BillingDateProvider } from '../../hooks/useLastCompleteBillingDate'; @@ -35,9 +34,7 @@ export const CostInsightsPageRoot = () => ( - - - + diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx index aedec33457..85a9a9db74 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewCard.tsx @@ -30,12 +30,13 @@ import { CostOverviewBreakdownChart } from './CostOverviewBreakdownChart'; import { CostOverviewHeader } from './CostOverviewHeader'; import { MetricSelect } from '../MetricSelect'; import { PeriodSelect } from '../PeriodSelect'; -import { useConfig, useFilters, useScroll } from '../../hooks'; +import { useConfig, useFilters } from '../../hooks'; import { mapFiltersToProps } from './selector'; import { DefaultNavigation } from '../../utils/navigation'; import { findAlways } from '../../utils/assert'; import { Cost, CostInsightsTheme, Maybe, MetricData } from '../../types'; import { useOverviewTabsStyles } from '../../utils/styles'; +import { ScrollAnchor } from '../../utils/scroll'; export type CostOverviewCardProps = { dailyCostData: Cost; @@ -49,7 +50,6 @@ export const CostOverviewCard = ({ const theme = useTheme(); const styles = useOverviewTabsStyles(theme); const config = useConfig(); - const [, , ScrollAnchor] = useScroll(); const [tabIndex, setTabIndex] = useState(0); const { setDuration, setProject, setMetric, ...filters } = useFilters( mapFiltersToProps, diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx index 4626954bac..d46737cafb 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -34,9 +34,9 @@ import { MapLoadingToProps, useLastCompleteBillingDate, useLoading, - useScroll, } from '../../hooks'; import { findAnyKey } from '../../utils/assert'; +import { ScrollAnchor } from '../../utils/scroll'; type LoadingProps = (isLoading: boolean) => void; @@ -60,7 +60,6 @@ export const ProductInsightsCard = ({ }: PropsWithChildren) => { const classes = useStyles(); const mountedRef = useRef(false); - const [, , ScrollAnchor] = useScroll(); const [error, setError] = useState>(null); const dispatchLoading = useLoading(mapLoadingToProps); const lastCompleteBillingDate = useLastCompleteBillingDate(); diff --git a/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx b/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx index 51218fe844..743b5ddac1 100644 --- a/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx +++ b/plugins/cost-insights/src/forms/AlertSnoozeForm.tsx @@ -24,7 +24,6 @@ import React, { import dayjs from 'dayjs'; import { Box, - Collapse, FormControl, FormControlLabel, RadioGroup, @@ -50,19 +49,18 @@ export const AlertSnoozeForm = forwardRef< AlertSnoozeFormProps >(({ onSubmit, disableSubmit }, ref) => { const classes = useStyles(); - const [error, setError] = useState>(null); const [duration, setDuration] = useState>(Duration.P7D); + useEffect(() => disableSubmit(false), [disableSubmit]); + const onFormSubmit: FormEventHandler = e => { e.preventDefault(); if (duration) { const repeatInterval = 1; - const inclusiveEndDate = dayjs().format(DEFAULT_DATE_FORMAT); + const today = dayjs().format(DEFAULT_DATE_FORMAT); onSubmit({ - intervals: intervalsOf(duration, inclusiveEndDate, repeatInterval), + intervals: intervalsOf(duration, today, repeatInterval), }); - } else { - setError(new Error('Please select an option.')); } }; @@ -73,26 +71,12 @@ export const AlertSnoozeForm = forwardRef< setDuration(value as Duration); }; - useEffect(() => { - function clearErrorOnFormDataChange() { - disableSubmit(false); - setError(prevError => (prevError ? null : prevError)); - } - - clearErrorOnFormDataChange(); - }, [duration, disableSubmit]); - - const isErrorMessageDisplayed = !!error; - return (
- + For how long? - - {error?.message} - >>; -}; - -export const AlertsContext = createContext( - undefined, -); - -export type AlertState = { - alerts: Alert[]; - snoozed: Maybe; - accepted: Maybe; - dismissed: Maybe; -}; - -const initialState: AlertState = { - alerts: [], - snoozed: null, - accepted: null, - dismissed: null, -}; - -const reducer = ( - prevState: AlertState, - action: SetStateAction>, -): AlertState => ({ - ...prevState, - ...action, -}); - -export const AlertsProvider = ({ children }: PropsWithChildren<{}>) => { - const [alerts, setAlerts] = useReducer(reducer, initialState); - - return ( - - {children} - - ); -}; - -export function useAlerts() { - const context = useContext(AlertsContext); - return context - ? ([context.alerts, context.setAlerts] as const) - : assertNever(); -} - -function assertNever(): never { - throw new Error('useAlerts cannot be used outside AlertsContext provider'); -} diff --git a/plugins/cost-insights/src/hooks/useScroll.tsx b/plugins/cost-insights/src/hooks/useScroll.tsx index 7f310d59b0..137762cc2d 100644 --- a/plugins/cost-insights/src/hooks/useScroll.tsx +++ b/plugins/cost-insights/src/hooks/useScroll.tsx @@ -15,12 +15,9 @@ */ import React, { Dispatch, - ElementType, SetStateAction, useState, useContext, - useEffect, - useRef, PropsWithChildren, } from 'react'; import { Maybe } from '../types'; @@ -30,61 +27,16 @@ export type ScrollTo = Maybe; export type ScrollContextProps = { scroll: ScrollTo; setScroll: Dispatch>; - ScrollAnchor: ElementType; }; -export interface ScrollAnchorProps extends ScrollIntoViewOptions { - id: ScrollTo; - top?: number; - left?: number; -} - export const ScrollContext = React.createContext< ScrollContextProps | undefined >(undefined); -export const ScrollAnchor = ({ - id, - block, - inline, - left = 0, - top = -20, - behavior = 'smooth', -}: ScrollAnchorProps) => { - const divRef = useRef(null); - const [scroll, setScroll] = useScroll(); - - useEffect(() => { - function scrollIntoView() { - const options = { - behavior: behavior || 'auto', - block: block || 'start', - inline: inline || 'nearest', - }; - - if (divRef.current && scroll === id) { - divRef.current.scrollIntoView(options); - setScroll(null); - } - } - - scrollIntoView(); - }, [scroll, setScroll, id, behavior, block, inline]); - - return ( -
- ); -}; - export const ScrollProvider = ({ children }: PropsWithChildren<{}>) => { const [scroll, setScroll] = useState(null); - return ( - + {children} ); @@ -101,11 +53,9 @@ export function useScroll() { assertNever(); } - return [context.scroll, context.setScroll, context.ScrollAnchor] as const; + return [context.scroll, context.setScroll] as const; } function assertNever(): never { - throw new Error( - `Cannot use useScroll or ScrollAnchor outside ScrollProvider`, - ); + throw new Error(`Cannot use useScroll outside ScrollProvider`); } diff --git a/plugins/cost-insights/src/utils/alerts.test.tsx b/plugins/cost-insights/src/utils/alerts.test.tsx new file mode 100644 index 0000000000..d821fd9f1f --- /dev/null +++ b/plugins/cost-insights/src/utils/alerts.test.tsx @@ -0,0 +1,109 @@ +/* + * Copyright 2021 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, { ReactNode } from 'react'; +import { formOf } from './alerts'; +import { AlertAcceptForm, AlertDismissForm, AlertSnoozeForm } from '../forms'; +import { Alert, AlertStatus, AlertFormProps } from '../types'; + +type Props = AlertFormProps; + +const createMockForm = (children: ReactNode) => + React.forwardRef((props, ref) => ( + + {children} + + )); + +const snoozeDefault: Alert = { + title: 'title', + subtitle: 'subtitle', + onSnoozed: jest.fn(), +}; + +const snoozeCustom: Alert = { + title: 'title', + subtitle: 'subtitle', + onSnoozed: jest.fn(), + SnoozeForm: createMockForm('Snooze'), +}; + +const snoozeNull: Alert = { + title: 'title', + subtitle: 'subtitle', + onSnoozed: jest.fn(), + SnoozeForm: null, +}; + +const acceptDefault: Alert = { + title: 'title', + subtitle: 'subtitle', + onAccepted: jest.fn(), +}; + +const acceptCustom: Alert = { + title: 'title', + subtitle: 'subtitle', + onAccepted: jest.fn(), + AcceptForm: createMockForm('Accept'), +}; + +const acceptNull: Alert = { + title: 'title', + subtitle: 'subtitle', + onAccepted: jest.fn(), + AcceptForm: null, +}; + +const dismissDefault: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), +}; + +const dismissCustom: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), + DismissForm: createMockForm('Dismiss'), +}; + +const dismissNull: Alert = { + title: 'title', + subtitle: 'subtitle', + onDismissed: jest.fn(), + DismissForm: null, +}; + +describe('formOf', () => { + describe.each` + msg | alert | status | expected + ${'default snooze form'} | ${snoozeDefault} | ${AlertStatus.Snoozed} | ${AlertSnoozeForm} + ${'custom snooze form'} | ${snoozeCustom} | ${AlertStatus.Snoozed} | ${snoozeCustom.SnoozeForm} + ${'null snooze form'} | ${snoozeNull} | ${AlertStatus.Snoozed} | ${null} + ${'default accept form'} | ${acceptDefault} | ${AlertStatus.Accepted} | ${AlertAcceptForm} + ${'custom accept form'} | ${acceptCustom} | ${AlertStatus.Accepted} | ${acceptCustom.AcceptForm} + ${'null accept form'} | ${acceptNull} | ${AlertStatus.Accepted} | ${null} + ${'default dismiss form'} | ${dismissDefault} | ${AlertStatus.Dismissed} | ${AlertDismissForm} + ${'custom dismiss form'} | ${dismissCustom} | ${AlertStatus.Dismissed} | ${dismissCustom.DismissForm} + ${'null dismiss form'} | ${dismissNull} | ${AlertStatus.Dismissed} | ${null} + ${'no form or status'} | ${null} | ${null} | ${null} + `('Should render the correct form', ({ msg, alert, status, expected }) => { + it(`for ${msg}`, () => { + const result = formOf(alert, status); + expect(result).toBe(expected); + }); + }); +}); diff --git a/plugins/cost-insights/src/utils/alerts.tsx b/plugins/cost-insights/src/utils/alerts.tsx index e10740fbfe..56403a664c 100644 --- a/plugins/cost-insights/src/utils/alerts.tsx +++ b/plugins/cost-insights/src/utils/alerts.tsx @@ -14,22 +14,118 @@ * limitations under the License. */ -import { Alert, AlertStatus } from '../types'; +import { Alert, AlertForm, AlertStatus, Maybe } from '../types'; +import { AlertAcceptForm, AlertDismissForm, AlertSnoozeForm } from '../forms'; -const createStatusHandler = (status?: string) => (alert: Alert) => +const createAlertHandler = (status?: AlertStatus) => (alert: Alert) => alert.status === status; -export const isActive = createStatusHandler(); -export const isSnoozed = createStatusHandler(AlertStatus.Snoozed); -export const isAccepted = createStatusHandler(AlertStatus.Accepted); -export const isDismissed = createStatusHandler(AlertStatus.Dismissed); +export const isAlertActive = (alert: Alert) => !hasProperty(alert, 'status'); +export const isAlertSnoozed = createAlertHandler(AlertStatus.Snoozed); +export const isAlertAccepted = createAlertHandler(AlertStatus.Accepted); +export const isAlertDismissed = createAlertHandler(AlertStatus.Dismissed); + +const createStatusHandler = (status: AlertStatus) => (s: Maybe) => + s === status; +export const isStatusSnoozed = createStatusHandler(AlertStatus.Snoozed); +export const isStatusAccepted = createStatusHandler(AlertStatus.Accepted); +export const isStatusDismissed = createStatusHandler(AlertStatus.Dismissed); + +const createAlertEventHandler = ( + onEvent: 'onSnoozed' | 'onAccepted' | 'onDismissed', +) => (alert: Maybe): boolean => hasProperty(alert, onEvent); +export const isSnoozeEnabled = createAlertEventHandler('onSnoozed'); +export const isAcceptEnabled = createAlertEventHandler('onAccepted'); +export const isDismissEnabled = createAlertEventHandler('onDismissed'); + +const createFormEnabledHandler = ( + Form: 'SnoozeForm' | 'AcceptForm' | 'DismissForm', +) => (alert: Maybe): boolean => { + if (!alert) return false; + if (alert[Form] === null) return false; + switch (Form) { + case 'SnoozeForm': + return isSnoozeEnabled(alert); + case 'AcceptForm': + return isAcceptEnabled(alert); + case 'DismissForm': + return isDismissEnabled(alert); + default: + return false; + } +}; +export const isSnoozeFormEnabled = createFormEnabledHandler('SnoozeForm'); +export const isAcceptFormEnabled = createFormEnabledHandler('AcceptForm'); +export const isDismissFormEnabled = createFormEnabledHandler('DismissForm'); + +/** + * Utility for determining if a form is disabled. + * When a form is disabled, the dialog button's type should convert from submit to button. + * @param alert + * @param status + */ +export const isFormDisabled = ( + alert: Maybe, + status: Maybe, +): boolean => { + switch (status) { + case AlertStatus.Snoozed: + return alert?.SnoozeForm === null; + case AlertStatus.Accepted: + return alert?.AcceptForm === null; + case AlertStatus.Dismissed: + return alert?.DismissForm === null; + default: + return false; + } +}; + +export function formOf( + alert: Maybe, + status: Maybe, +): Maybe { + switch (status) { + case AlertStatus.Snoozed: { + const SnoozeForm = alert?.SnoozeForm ?? AlertSnoozeForm; + return isSnoozeFormEnabled(alert) ? SnoozeForm : null; + } + case AlertStatus.Accepted: { + const AcceptForm = alert?.AcceptForm ?? AlertAcceptForm; + return isAcceptFormEnabled(alert) ? AcceptForm : null; + } + case AlertStatus.Dismissed: { + const DismissForm = alert?.DismissForm ?? AlertDismissForm; + return isDismissFormEnabled(alert) ? DismissForm : null; + } + default: + return null; + } +} + +/** + * Utility for choosing from a fixed set of values for a given alert status. + * @param status + * @param values + */ +export function choose( + status: Maybe, + values: [T, T, T], + none: T, +): T { + switch (status) { + case AlertStatus.Snoozed: + return values[0]; + case AlertStatus.Accepted: + return values[1]; + case AlertStatus.Dismissed: + return values[2]; + default: + return none; + } +} + +export function hasProperty(alert: Maybe, prop: keyof Alert): boolean { + return prop in (alert ?? {}); +} export const sumOfAllAlerts = (sum: number, alerts: Alert[]) => sum + alerts.length; - -export function choose( - status: readonly [boolean, boolean, boolean], - values: [T, T, T], -): T | undefined { - const i = status.indexOf(true); - return values[i]; -} diff --git a/plugins/cost-insights/src/utils/scroll.tsx b/plugins/cost-insights/src/utils/scroll.tsx new file mode 100644 index 0000000000..06f01775df --- /dev/null +++ b/plugins/cost-insights/src/utils/scroll.tsx @@ -0,0 +1,65 @@ +/* + * Copyright 2021 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, { useEffect, useRef } from 'react'; +import { ScrollTo, useScroll } from '../hooks/useScroll'; + +/* + Utility component use in conjuction with useScroll that allows scrollable components to control behavior and offset. + 1. ScrollAnchor must be a direct child of a scrollable component. + 2. ScrollAnchor's parent position must be relative. + 3. ScrollAnchor's id must be unique. +*/ + +export interface ScrollAnchorProps extends ScrollIntoViewOptions { + id: ScrollTo; + top?: number; + left?: number; +} + +export const ScrollAnchor = ({ + id, + left = 0, + top = -20, + block = 'start', + inline = 'nearest', + behavior = 'smooth', +}: ScrollAnchorProps) => { + const divRef = useRef(null); + const [scroll, setScroll] = useScroll(); + + useEffect(() => { + function scrollIntoView() { + if (divRef.current && scroll === id) { + divRef.current.scrollIntoView({ + block, + inline, + behavior, + }); + setScroll(null); + } + } + + scrollIntoView(); + }, [scroll, setScroll, id, behavior, block, inline]); + + return ( +
+ ); +}; diff --git a/plugins/cost-insights/src/utils/tests.tsx b/plugins/cost-insights/src/utils/tests.tsx index 13b38a49f2..aae2800573 100644 --- a/plugins/cost-insights/src/utils/tests.tsx +++ b/plugins/cost-insights/src/utils/tests.tsx @@ -22,7 +22,6 @@ import { IdentityApi, identityApiRef, } from '@backstage/core'; -import { AlertsContext, AlertsContextProps } from '../hooks/useAlerts'; import { LoadingContext, LoadingContextProps } from '../hooks/useLoading'; import { GroupsContext, GroupsContextProps } from '../hooks/useGroups'; import { FilterContext, FilterContextProps } from '../hooks/useFilters'; @@ -166,7 +165,6 @@ export const MockScrollProvider = ({ children }: MockScrollProviderProps) => { const defaultContext: ScrollContextProps = { scroll: null, setScroll: jest.fn(), - ScrollAnchor: jest.fn(() =>
), }; return ( @@ -233,28 +231,3 @@ export const MockCostInsightsApiProvider = ({ return {children}; }; - -export type MockAlertsProviderContextProps = PartialPropsWithChildren< - AlertsContextProps ->; - -export const MockAlertsProvider = ({ - children, - ...context -}: MockAlertsProviderContextProps) => { - const defaultContext: AlertsContextProps = { - alerts: { - alerts: [], - snoozed: null, - accepted: null, - dismissed: null, - }, - setAlerts: jest.fn(), - }; - - return ( - - {children} - - ); -}; From c27107c15ed23f242b1b4c4c93e87caaaea645b2 Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Fri, 29 Jan 2021 10:16:41 -0500 Subject: [PATCH 161/297] Refactor to use screen obj --- .../WarningPanel/WarningPanel.test.tsx | 40 ++++++++----------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/packages/core/src/components/WarningPanel/WarningPanel.test.tsx b/packages/core/src/components/WarningPanel/WarningPanel.test.tsx index fd7a5f4349..38ba4bff9b 100644 --- a/packages/core/src/components/WarningPanel/WarningPanel.test.tsx +++ b/packages/core/src/components/WarningPanel/WarningPanel.test.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { fireEvent } from '@testing-library/react'; +import { fireEvent, screen } from '@testing-library/react'; import { renderInTestApp } from '@backstage/test-utils'; import { Typography } from '@material-ui/core'; @@ -27,49 +27,41 @@ const propsMessage = { message: 'Some more info' }; describe('', () => { it('renders without exploding', async () => { - const { getByText } = await renderInTestApp( - , - ); - expect(getByText('Warning: Mock title')).toBeInTheDocument(); + await renderInTestApp(); + expect(screen.getByText('Warning: Mock title')).toBeInTheDocument(); }); it('renders title', async () => { - const { getByText } = await renderInTestApp( - , - ); - const expandIcon = await getByText('Warning: Mock title'); + await renderInTestApp(); + const expandIcon = await screen.getByText('Warning: Mock title'); fireEvent.click(expandIcon); - expect(getByText('Warning: Mock title')).toBeInTheDocument(); - expect(getByText('Some more info')).toBeInTheDocument(); + expect(screen.getByText('Warning: Mock title')).toBeInTheDocument(); + expect(screen.getByText('Some more info')).toBeInTheDocument(); }); it('renders title and children', async () => { - const { getByText } = await renderInTestApp( + await renderInTestApp( Java stacktrace , ); - expect(getByText('Java stacktrace')).toBeInTheDocument(); + expect(screen.getByText('Java stacktrace')).toBeInTheDocument(); }); it('renders message', async () => { - const { getByText } = await renderInTestApp( - , - ); - expect(getByText('Warning')).toBeInTheDocument(); - expect(getByText('Some more info')).toBeInTheDocument(); + await renderInTestApp(); + expect(screen.getByText('Warning')).toBeInTheDocument(); + expect(screen.getByText('Some more info')).toBeInTheDocument(); }); it('renders title, message, and children', async () => { - const { getByText } = await renderInTestApp( + await renderInTestApp( Java stacktrace , ); - expect(getByText('Warning: Mock title')).toBeInTheDocument(); - expect(getByText('Some more info')).toBeInTheDocument(); - expect(getByText('Java stacktrace')).toBeInTheDocument(); - // expect(getByText(/Some more info/)).toBeTruthy(); - // expect(getByText(/Java stacktrace/)).toBeTruthy(); + expect(screen.getByText('Warning: Mock title')).toBeInTheDocument(); + expect(screen.getByText('Some more info')).toBeInTheDocument(); + expect(screen.getByText('Java stacktrace')).toBeInTheDocument(); }); }); From 0dbc8aacad13af965b8f745d9577ebb0e06311f2 Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Fri, 29 Jan 2021 10:16:55 -0500 Subject: [PATCH 162/297] Remove unused styling --- packages/core/src/components/WarningPanel/WarningPanel.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/core/src/components/WarningPanel/WarningPanel.tsx b/packages/core/src/components/WarningPanel/WarningPanel.tsx index 3da110c411..e82c49c49a 100644 --- a/packages/core/src/components/WarningPanel/WarningPanel.tsx +++ b/packages/core/src/components/WarningPanel/WarningPanel.tsx @@ -44,9 +44,6 @@ const ExpandMoreIconStyled = () => { const useStyles = makeStyles(theme => ({ panel: { - // display: 'flex', - // flexDirection: 'column', - // padding: theme.spacing(1.5), backgroundColor: theme.palette.warningBackground, color: theme.palette.warningText, verticalAlign: 'middle', From 03177610a8afb32f8e6e8c7de8d3bc8a011e21d0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Jan 2021 16:58:18 +0100 Subject: [PATCH 163/297] cli: add fix for module resolution in backend bundle config --- packages/cli/src/lib/bundler/config.ts | 31 +++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index c53a8329f5..6fbcc65c9a 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -219,7 +219,7 @@ export async function createBackendConfig( } : {}), externals: [ - nodeExternals({ + nodeExternalsWithResolve({ modulesDir: paths.rootNodeModules, additionalModuleDirs: moduleDirs, allowlist: ['webpack/hot/poll?100', ...localPackageNames], @@ -296,3 +296,32 @@ export async function createBackendConfig( ], }; } + +// This makes the module resolution happen from the context of each non-external module, rather +// than the main entrypoint. This fixes a bug where dependencies would be resolved from the backend +// package rather than each individual backend package and plugin. +// +// TODO(Rugvip): Feature suggestion/contribute this to webpack-externals +function nodeExternalsWithResolve( + options: Parameters[0], +) { + let currentContext: string; + const externals = nodeExternals({ + ...options, + importType(request) { + const resolved = require.resolve(request, { + paths: [currentContext], + }); + return `commonjs ${resolved}`; + }, + }); + + return ( + context: string, + request: string, + callback: webpack.ExternalsFunctionCallback, + ) => { + currentContext = context; + return externals(context, request, callback); + }; +} From bdbeaf605106cb8e2f2082950bb4a2d23ed1b2d7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Jan 2021 16:58:48 +0100 Subject: [PATCH 164/297] config-loader: validate each schema upfront to provide a more precise error message --- packages/config-loader/src/lib/schema/compile.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/config-loader/src/lib/schema/compile.ts b/packages/config-loader/src/lib/schema/compile.ts index 2607d51f80..e340d775a1 100644 --- a/packages/config-loader/src/lib/schema/compile.ts +++ b/packages/config-loader/src/lib/schema/compile.ts @@ -69,6 +69,14 @@ export function compileConfigSchemas( }, }); + for (const schema of schemas) { + try { + ajv.compile(schema.value); + } catch (error) { + throw new Error(`Schema at ${schema.path} is invalid, ${error}`); + } + } + const merged = mergeAllOf( { allOf: schemas.map(_ => _.value) }, { From 9f2b9c2020a55bd103ef7ca2106d61bc9f9d04a1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Jan 2021 16:59:19 +0100 Subject: [PATCH 165/297] app-backend: re-throw config loading errors with instructions for how to fix --- plugins/app-backend/src/lib/config.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/plugins/app-backend/src/lib/config.ts b/plugins/app-backend/src/lib/config.ts index 99590c1e2d..0607956894 100644 --- a/plugins/app-backend/src/lib/config.ts +++ b/plugins/app-backend/src/lib/config.ts @@ -84,13 +84,21 @@ export async function readConfigs(options: ReadOptions): Promise { const schemaPath = resolvePath(appDistDir, '.config-schema.json'); if (await fs.pathExists(schemaPath)) { const serializedSchema = await fs.readJson(schemaPath); - const schema = await loadConfigSchema({ serialized: serializedSchema }); - const frontendConfigs = await schema.process( - [{ data: config.get() as JsonObject, context: 'app' }], - { visibility: ['frontend'] }, - ); - appConfigs.push(...frontendConfigs); + try { + const schema = await loadConfigSchema({ serialized: serializedSchema }); + + const frontendConfigs = await schema.process( + [{ data: config.get() as JsonObject, context: 'app' }], + { visibility: ['frontend'] }, + ); + appConfigs.push(...frontendConfigs); + } catch (error) { + throw new Error( + 'Invalid schema embedded in the app bundle, to fix this issue you need ' + + `to correct the schema and then rebuild the app bundle. ${error}`, + ); + } } return appConfigs; From e9aab60c75aeb3db7b304c6bc17f45ceaf1ddd5a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Jan 2021 17:03:02 +0100 Subject: [PATCH 166/297] added changesets --- .changeset/loud-walls-collect.md | 5 +++++ .changeset/neat-brooms-allow.md | 5 +++++ .changeset/nice-bottles-battle.md | 5 +++++ 3 files changed, 15 insertions(+) create mode 100644 .changeset/loud-walls-collect.md create mode 100644 .changeset/neat-brooms-allow.md create mode 100644 .changeset/nice-bottles-battle.md diff --git a/.changeset/loud-walls-collect.md b/.changeset/loud-walls-collect.md new file mode 100644 index 0000000000..78356eef38 --- /dev/null +++ b/.changeset/loud-walls-collect.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Fixed module resolution of external libraries during backend development. Modules used to be resolved relative to the backend entrypoint, but are now resolved relative to each individual module. diff --git a/.changeset/neat-brooms-allow.md b/.changeset/neat-brooms-allow.md new file mode 100644 index 0000000000..6ea45e99ba --- /dev/null +++ b/.changeset/neat-brooms-allow.md @@ -0,0 +1,5 @@ +--- +'@backstage/config-loader': patch +--- + +Each piece of the configuration schema is now validate upfront, in order to produce more informative errors. diff --git a/.changeset/nice-bottles-battle.md b/.changeset/nice-bottles-battle.md new file mode 100644 index 0000000000..a688241caa --- /dev/null +++ b/.changeset/nice-bottles-battle.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app-backend': patch +--- + +Failures to load the frontend configuration schema now throws an error that includes more context and instructions for how to fix the issue. From 81f4b6330a03f7763ef98e1e63a0f8fdcaa5db2e Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Fri, 29 Jan 2021 17:10:03 +0100 Subject: [PATCH 167/297] backend: Gitlab URL Reader to throw error when apiBaseUrl is missing And add more tests for all other URL Readers --- .../src/reading/BitbucketUrlReader.test.ts | 12 ++++++++++++ .../src/reading/GithubUrlReader.test.ts | 15 +++++++++++++++ .../src/reading/GitlabUrlReader.test.ts | 12 ++++++++++++ .../backend-common/src/reading/GitlabUrlReader.ts | 6 ++++++ 4 files changed, 45 insertions(+) diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index 5571a07898..974c84b2c2 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -242,5 +242,17 @@ describe('BitbucketUrlReader', () => { expect(response.etag).toBe('12ab34cd56ef'); }); + + it('should throw error when apiBaseUrl is missing', () => { + expect(() => { + /* eslint-disable no-new */ + new BitbucketUrlReader( + { + host: 'bitbucket.mycompany.net', + }, + { treeResponseFactory }, + ); + }).toThrowError('must configure an explicit apiBaseUrl'); + }); }); }); diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts index 975be85ccc..fae3f3ba12 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.test.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts @@ -376,5 +376,20 @@ describe('GithubUrlReader', () => { }; await expect(fnGithub).rejects.toThrow(NotFoundError); }); + + it('should throw error when apiBaseUrl is missing', () => { + expect(() => { + /* eslint-disable no-new */ + new GithubUrlReader( + { + host: 'ghe.mycompany.net', + }, + { + treeResponseFactory, + credentialsProvider: mockCredentialsProvider, + }, + ); + }).toThrowError('must configure an explicit apiBaseUrl'); + }); }); }); diff --git a/packages/backend-common/src/reading/GitlabUrlReader.test.ts b/packages/backend-common/src/reading/GitlabUrlReader.test.ts index 90acfd1ab9..b18e7a4294 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.test.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.test.ts @@ -379,5 +379,17 @@ describe('GitlabUrlReader', () => { }; await expect(fnGithub).rejects.toThrow(NotFoundError); }); + + it('should throw error when apiBaseUrl is missing', () => { + expect(() => { + /* eslint-disable no-new */ + new GitlabUrlReader( + { + host: 'gitlab.mycompany.com', + }, + { treeResponseFactory }, + ); + }).toThrowError('must configure an explicit apiBaseUrl'); + }); }); }); diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index 22316f6895..8a763d1259 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -51,6 +51,12 @@ export class GitlabUrlReader implements UrlReader { deps: { treeResponseFactory: ReadTreeResponseFactory }, ) { this.treeResponseFactory = deps.treeResponseFactory; + + if (!config.apiBaseUrl) { + throw new Error( + `GitLab integration for '${config.host}' must configure an explicit apiBaseUrl`, + ); + } } async read(url: string): Promise { From d7f30a800db6e0dcee5de1a08b9ed88428dfe573 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Jan 2021 17:24:14 +0100 Subject: [PATCH 168/297] Update .changeset/neat-brooms-allow.md Co-authored-by: Himanshu Mishra --- .changeset/neat-brooms-allow.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/neat-brooms-allow.md b/.changeset/neat-brooms-allow.md index 6ea45e99ba..a4c69b0945 100644 --- a/.changeset/neat-brooms-allow.md +++ b/.changeset/neat-brooms-allow.md @@ -2,4 +2,4 @@ '@backstage/config-loader': patch --- -Each piece of the configuration schema is now validate upfront, in order to produce more informative errors. +Each piece of the configuration schema is now validated upfront, in order to produce more informative errors. From a22d297954d6c84a40fffeacf08d849f3dde806d Mon Sep 17 00:00:00 2001 From: Ryan Vazquez Date: Fri, 29 Jan 2021 11:42:52 -0500 Subject: [PATCH 169/297] bump cost insights --- plugins/cost-insights/CHANGELOG.md | 6 ++++++ plugins/cost-insights/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/plugins/cost-insights/CHANGELOG.md b/plugins/cost-insights/CHANGELOG.md index a979f1476b..78d4c69da4 100644 --- a/plugins/cost-insights/CHANGELOG.md +++ b/plugins/cost-insights/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/plugin-cost-insights +## 0.7.0 + +### Minor Changes + +- 19172f5a9: add alert hooks + ## 0.6.0 ### Minor Changes diff --git a/plugins/cost-insights/package.json b/plugins/cost-insights/package.json index 72a9e92633..022e778150 100644 --- a/plugins/cost-insights/package.json +++ b/plugins/cost-insights/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-cost-insights", - "version": "0.6.0", + "version": "0.7.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", From 357c1f1e5f5d52aead4a75b0cdb29af4b16f7667 Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Fri, 29 Jan 2021 14:16:26 -0500 Subject: [PATCH 170/297] Clarify link and Backstage case --- docs/features/kubernetes/installation.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/features/kubernetes/installation.md b/docs/features/kubernetes/installation.md index 9e531de655..35a027df62 100644 --- a/docs/features/kubernetes/installation.md +++ b/docs/features/kubernetes/installation.md @@ -7,8 +7,8 @@ description: Installing Kubernetes plugin into Backstage The Kubernetes feature is a plugin to Backstage, and it is exposed as a tab when viewing entities in the software catalog. -If you haven't setup Backstage already, start -[here](../../getting-started/index.md). +If you haven't setup Backstage already, read the +[Getting Started](../../getting-started/index.md). ## Adding the Kubernetes frontend plugin @@ -111,7 +111,7 @@ Backstage app. ## Running Backstage locally Start the frontend and the backend app by -[running backstage locally](../../getting-started/running-backstage-locally.md). +[running Backstage locally](../../getting-started/running-backstage-locally.md). ## Configuration From e763dcf8c49099068dc819911e980febc90a4ce3 Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Fri, 29 Jan 2021 14:17:09 -0500 Subject: [PATCH 171/297] Fix Backstage case --- docs/features/search/README.md | 2 +- .../software-templates/extending/create-your-own-templater.md | 2 +- docs/features/software-templates/index.md | 4 ++-- docs/getting-started/development-environment.md | 2 +- docs/plugins/integrating-plugin-into-service-catalog.md | 2 +- docs/reference/utility-apis/BackstageIdentityApi.md | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/features/search/README.md b/docs/features/search/README.md index 0b13b47979..cd971d9d41 100644 --- a/docs/features/search/README.md +++ b/docs/features/search/README.md @@ -67,7 +67,7 @@ more to come... - As a contributor I should be able to integrate plugin data to the indexing process of Backstage Search by using the standardized API. - As a software engineer I should be able to search for all content (for - example, entities, metadata, documentation) in backstage search. + example, entities, metadata, documentation) in Backstage search. more to come... diff --git a/docs/features/software-templates/extending/create-your-own-templater.md b/docs/features/software-templates/extending/create-your-own-templater.md index 37e68c4153..28769d77e1 100644 --- a/docs/features/software-templates/extending/create-your-own-templater.md +++ b/docs/features/software-templates/extending/create-your-own-templater.md @@ -86,7 +86,7 @@ follows: _note_ Currently the templaters that we provide are basically Docker action containers that are run on top of the skeleton folder. This keeps dependencies -to a minimum for running backstage scaffolder, but you don't _have_ to use +to a minimum for running Backstage scaffolder, but you don't _have_ to use Docker. You can `pip install cookiecutter` to run it locally in your backend. You could create your own templater that spins up an EC2 instance and downloads the folder and does everything using an AMI if you want. It's entirely up to diff --git a/docs/features/software-templates/index.md b/docs/features/software-templates/index.md index 1034ffef0f..898075cb08 100644 --- a/docs/features/software-templates/index.md +++ b/docs/features/software-templates/index.md @@ -39,13 +39,13 @@ internally. ![Enter some variables](../../assets/software-templates/template-picked.png) After filling in these variables, you'll get some more fields to fill out which -are required for backstage usage: the owner (which is a `user` in the backstage +are required for Backstage usage: the owner (which is a `user` in the backstage system), the `storePath` (which right now must be a GitHub Organisation or GitHub user and a non-existing GitHub repository name in the format `organisation/reponame`), and a GitHub team or user account which should be granted admin access to the repository. -![Enter backstage vars](../../assets/software-templates/template-picked-2.png) +![Enter Backstage vars](../../assets/software-templates/template-picked-2.png) ### Run! diff --git a/docs/getting-started/development-environment.md b/docs/getting-started/development-environment.md index b5ff3885b3..01fd7bdc24 100644 --- a/docs/getting-started/development-environment.md +++ b/docs/getting-started/development-environment.md @@ -31,7 +31,7 @@ $ yarn start This should open a local instance of Backstage in your browser, otherwise open one of the URLs printed in the terminal. -By default, backstage will start on port 3000, however you can override this by +By default, Backstage will start on port 3000, however you can override this by setting an environment variable `PORT` on your local machine. e.g. `export PORT=8080` then running `yarn start`. Or `PORT=8080 yarn start`. diff --git a/docs/plugins/integrating-plugin-into-service-catalog.md b/docs/plugins/integrating-plugin-into-service-catalog.md index 51d2331140..01de779635 100644 --- a/docs/plugins/integrating-plugin-into-service-catalog.md +++ b/docs/plugins/integrating-plugin-into-service-catalog.md @@ -59,7 +59,7 @@ accordingly inside `Router.tsx`) ### Import and use router in the APP In the `app/src/components/catalog/EntityPage.tsx` (app === your folder, -containing backstage app) import your created Router: +containing Backstage app) import your created Router: ```tsx import { Router as MyPluginRouter } from '@backstage/plugin-my-plugin; diff --git a/docs/reference/utility-apis/BackstageIdentityApi.md b/docs/reference/utility-apis/BackstageIdentityApi.md index dafdb7d55f..69fe5d26ba 100644 --- a/docs/reference/utility-apis/BackstageIdentityApi.md +++ b/docs/reference/utility-apis/BackstageIdentityApi.md @@ -79,7 +79,7 @@ Referenced by: [getBackstageIdentity](#getbackstageidentity).
 export type BackstageIdentity = {
   /**
-   * The backstage user ID.
+   * The Backstage user ID.
    */
   id: string;
 

From baf5615b51c79d6324601f974e10ad51971b2042 Mon Sep 17 00:00:00 2001
From: Adam Harvey 
Date: Fri, 29 Jan 2021 14:17:26 -0500
Subject: [PATCH 172/297] Clarifications

---
 docs/features/techdocs/getting-started.md | 32 +++++++++++------------
 1 file changed, 16 insertions(+), 16 deletions(-)

diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md
index e1ddfd23f9..5147e17aaa 100644
--- a/docs/features/techdocs/getting-started.md
+++ b/docs/features/techdocs/getting-started.md
@@ -158,7 +158,7 @@ techdocs:
   requestUrl: http://localhost:7000/api/techdocs/
 ```
 
-`requestUrl` is used by TechDocs frontend plugin to discover techdocs-backend
+`requestUrl` is used by TechDocs frontend plugin to discover `techdocs-backend`
 endpoints, and the `storageUrl` is another endpoint in `techdocs-backend` which
 acts as a middleware between TechDocs and the storage (where the static
 generated docs site are stored). These default values should mostly work for
@@ -180,8 +180,8 @@ pipeline, and are being stored in a storage somewhere.
 
 When `techdocs.builder` is set to `'external'`, TechDocs becomes more or less a
 read-only experience where it serves static files from a storage containing all
-the generated documentation. Read more in the "Basic" and "Recommended" setup of
-TechDocs [here](architecture.md)
+the generated documentation. Read more in the "Basic" and "Recommended" sections
+of the [TechDocs Architecture](architecture.md).
 
 ### Choosing storage (publisher)
 
@@ -190,9 +190,9 @@ fetch the sites from. This is managed by a
 [Publisher](./concepts.md#techdocs-publisher). Examples: Google Cloud Storage,
 Amazon S3, or local filesystem of Backstage server.
 
-It is okay to use the local filesystem in a "Basic" setup when you are trying
-out Backstage for the first time. Using Cloud Storage is documented
-[here](./using-cloud-storage.md).
+It is okay to use the local filesystem in a "basic" setup when you are trying
+out Backstage for the first time. At a later time, review
+[Using Cloud Storage](./using-cloud-storage.md).
 
 ```yaml
 techdocs:
@@ -229,23 +229,23 @@ environment is compatible with techdocs.
 You will have to install the `mkdocs` and `mkdocs-techdocs-core` package from
 pip, as well as `graphviz` and `plantuml` from your OS package manager (e.g.
 apt). See our
-[Dockerfile](https://github.com/backstage/techdocs-container/blob/main/Dockerfile)
-for the latest requirements. You should be trying to match your Dockerfile with
-this one.
+[`Dockerfile`](https://github.com/backstage/techdocs-container/blob/main/Dockerfile)
+for the latest requirements. You should be trying to match your `Dockerfile`
+with this one.
 
 Note: We recommend Python version 3.7 or higher.
 
-Caveat: Please install the `mkdocs-techdocs-core` package after all other Python
-packages. The order is important to make sure we get correct version of some of
-the dependencies. For example, we want `Markdown` version to be
-[3.2.2](https://github.com/backstage/backstage/blob/f9f70c225548017b6a14daea75b00fbd399c11eb/packages/techdocs-container/techdocs-core/requirements.txt#L11).
-You can also explicitly install `Markdown==3.2.2` after installing all other
-Python packages.
+> Caveat: Please install the `mkdocs-techdocs-core` package after all other
+> Python packages. The order is important to make sure we get correct version of
+> some of the dependencies. For example, we want `Markdown` version to be
+> [3.2.2](https://github.com/backstage/backstage/blob/f9f70c225548017b6a14daea75b00fbd399c11eb/packages/techdocs-container/techdocs-core/requirements.txt#L11).
+> You can also explicitly install `Markdown==3.2.2` after installing all other
+> Python packages.
 
 ## Running Backstage locally
 
 Start the frontend and the backend app by
-[running backstage locally](../../getting-started/running-backstage-locally.md).
+[running Backstage locally](../../getting-started/running-backstage-locally.md).
 
 Open your browser at [http://localhost:3000/docs/](http://localhost:3000/docs/)
 to see all your documentation sites.

From a14d702a7d9ccba4540b60ffa6c1a240e842fb5b Mon Sep 17 00:00:00 2001
From: Adam Harvey 
Date: Fri, 29 Jan 2021 14:20:55 -0500
Subject: [PATCH 173/297] Clarify create plugin step

---
 docs/plugins/create-a-plugin.md | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/docs/plugins/create-a-plugin.md b/docs/plugins/create-a-plugin.md
index bcb86024fd..4ccd48f17e 100644
--- a/docs/plugins/create-a-plugin.md
+++ b/docs/plugins/create-a-plugin.md
@@ -9,8 +9,9 @@ A Backstage Plugin adds functionality to Backstage.
 ## Create a Plugin
 
 To create a new plugin, make sure you've run `yarn install` and installed
-dependencies, then run the following on your command line (invoking the
-`backstage-cli`).
+dependencies, then run the following on your command line (a shortcut to
+invoking the [`backstage-cli create-plugin`](../cli/commands.md#create-plugin))
+from the root of your project.
 
 ```bash
 yarn create-plugin

From 7c0fcd7404d9b5a0171ba1a7426a81d66aad7479 Mon Sep 17 00:00:00 2001
From: blam 
Date: Sat, 30 Jan 2021 01:44:46 +0100
Subject: [PATCH 174/297] chore: added start command to tugboat to wait for the
 port to become ready before marking as live in tugboat

---
 .tugboat/config.yml | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/.tugboat/config.yml b/.tugboat/config.yml
index 594a1044ac..b8c3c127c8 100644
--- a/.tugboat/config.yml
+++ b/.tugboat/config.yml
@@ -12,3 +12,6 @@ services:
       build:
         - yarn install --frozen-lockfile
         - yarn workspace example-app build
+      start:
+        # wget the endpoint. Will retry every 2 seconds. 30 retries = 1m for service to come up. Plenty.
+        - wget -O /dev/null -o /dev/null --tries=30 --timeout=5 --retry-connrefused http://localhost:7000

From 4c98fbd8c1ec74db1d5849a84c327d0550d5d637 Mon Sep 17 00:00:00 2001
From: Nigel Wright 
Date: Sat, 30 Jan 2021 16:12:35 +1300
Subject: [PATCH 175/297] Fix: defaulted timezone to GMT and added test

---
 .../HomepageTimer/HomepageTimer.test.tsx      | 53 +++++++++++++++++++
 .../layout/HomepageTimer/HomepageTimer.tsx    |  6 +--
 2 files changed, 56 insertions(+), 3 deletions(-)
 create mode 100644 packages/core/src/layout/HomepageTimer/HomepageTimer.test.tsx

diff --git a/packages/core/src/layout/HomepageTimer/HomepageTimer.test.tsx b/packages/core/src/layout/HomepageTimer/HomepageTimer.test.tsx
new file mode 100644
index 0000000000..40b0c266e0
--- /dev/null
+++ b/packages/core/src/layout/HomepageTimer/HomepageTimer.test.tsx
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2021 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 { renderWithEffects } from '@backstage/test-utils';
+import { HomepageTimer } from './HomepageTimer';
+import React from 'react';
+import { lightTheme } from '@backstage/theme';
+import { ThemeProvider } from '@material-ui/core';
+
+import {
+  ApiProvider,
+  ApiRegistry,
+  ConfigReader,
+  ConfigApi,
+  configApiRef,
+} from '@backstage/core-api';
+
+it('changes default timezone to GMT', async () => {
+  const configApi: ConfigApi = new ConfigReader({
+    homepage: {
+      clocks: [
+        {
+          label: 'New York',
+          timezone: 'America/New_Pork',
+        },
+      ],
+    },
+    context: 'test',
+  });
+
+  const rendered = await renderWithEffects(
+    
+      
+        
+      
+    ,
+  );
+
+  expect(rendered.getByText('GMT')).toBeInTheDocument();
+});
diff --git a/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx b/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx
index 219665599c..2adbf8fbd5 100644
--- a/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx
+++ b/packages/core/src/layout/HomepageTimer/HomepageTimer.tsx
@@ -51,10 +51,10 @@ function getTimes(configApi: ConfigApi) {
       } catch (e) {
         // eslint-disable-next-line no-console
         console.warn(
-          `The timezone ${options.timeZone} is invalid. Defaulting to America/Los Angeles`,
+          `The timezone ${options.timeZone} is invalid. Defaulting to GMT`,
         );
-        options.timeZone = 'America/Los_Angeles';
-        label = 'Los Angeles';
+        options.timeZone = 'GMT';
+        label = 'GMT';
       }
 
       const time = d.toLocaleTimeString(lang, options);

From 23d81ecc1b38c42af0154dcfc7ea85bcf81a3511 Mon Sep 17 00:00:00 2001
From: Patrik Oldsberg 
Date: Sat, 30 Jan 2021 16:24:01 +0100
Subject: [PATCH 176/297] app: fix cost-insights dependency

---
 packages/app/package.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/app/package.json b/packages/app/package.json
index 36fe7651e6..bcf048c693 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -12,7 +12,7 @@
     "@backstage/plugin-catalog-import": "^0.3.6",
     "@backstage/plugin-circleci": "^0.2.6",
     "@backstage/plugin-cloudbuild": "^0.2.7",
-    "@backstage/plugin-cost-insights": "^0.6.0",
+    "@backstage/plugin-cost-insights": "^0.7.0",
     "@backstage/plugin-explore": "^0.2.3",
     "@backstage/plugin-gcp-projects": "^0.2.3",
     "@backstage/plugin-github-actions": "^0.3.0",

From 4408806374936b1b1e616066157198488963e0d1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= 
Date: Sat, 30 Jan 2021 20:26:35 +0100
Subject: [PATCH 177/297] make the master workflows manually triggerable

---
 .github/workflows/master-win.yml | 1 +
 .github/workflows/master.yml     | 1 +
 2 files changed, 2 insertions(+)

diff --git a/.github/workflows/master-win.yml b/.github/workflows/master-win.yml
index ca402c48d5..e6a9bf158b 100644
--- a/.github/workflows/master-win.yml
+++ b/.github/workflows/master-win.yml
@@ -1,6 +1,7 @@
 name: Master Build Windows
 
 on:
+  workflow_dispatch:
   push:
     branches: [master]
 
diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml
index 68b08224ac..0a615b7c82 100644
--- a/.github/workflows/master.yml
+++ b/.github/workflows/master.yml
@@ -1,6 +1,7 @@
 name: Main Master Build
 
 on:
+  workflow_dispatch:
   push:
     branches: [master]
 

From 15fffa5fff5c1d1da17f9398c4a7cd60f35874bb Mon Sep 17 00:00:00 2001
From: Himanshu Mishra 
Date: Sun, 31 Jan 2021 16:07:21 +0100
Subject: [PATCH 178/297] chore: sync yarn.lock after TechDocs Azure Storage PR

Follow up of https://github.com/backstage/backstage/pull/4017
---
 yarn.lock | 253 ++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 245 insertions(+), 8 deletions(-)

diff --git a/yarn.lock b/yarn.lock
index c40b1ab675..75291bd66c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -954,6 +954,101 @@
   dependencies:
     tslib "^1.8.0"
 
+"@azure/abort-controller@^1.0.0":
+  version "1.0.2"
+  resolved "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.0.2.tgz#822405c966b2aec16fb62c1b19d37eaccf231995"
+  integrity sha512-XUyTo+bcyxHEf+jlN2MXA7YU9nxVehaubngHV1MIZZaqYmZqykkoeAz/JMMEeR7t3TcyDwbFa3Zw8BZywmIx4g==
+  dependencies:
+    tslib "^2.0.0"
+
+"@azure/core-asynciterator-polyfill@^1.0.0":
+  version "1.0.0"
+  resolved "https://registry.npmjs.org/@azure/core-asynciterator-polyfill/-/core-asynciterator-polyfill-1.0.0.tgz#dcccebb88406e5c76e0e1d52e8cc4c43a68b3ee7"
+  integrity sha512-kmv8CGrPfN9SwMwrkiBK9VTQYxdFQEGe0BmQk+M8io56P9KNzpAxcWE/1fxJj7uouwN4kXF0BHW8DNlgx+wtCg==
+
+"@azure/core-auth@^1.1.3":
+  version "1.1.4"
+  resolved "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.1.4.tgz#af9a334acf3cb9c49e6013e6caf6dc9d43476030"
+  integrity sha512-+j1embyH1jqf04AIfJPdLafd5SC1y6z1Jz4i+USR1XkTp6KM8P5u4/AjmWMVoEQdM/M29PJcRDZcCEWjK9S1bw==
+  dependencies:
+    "@azure/abort-controller" "^1.0.0"
+    tslib "^2.0.0"
+
+"@azure/core-http@^1.2.0":
+  version "1.2.2"
+  resolved "https://registry.npmjs.org/@azure/core-http/-/core-http-1.2.2.tgz#a6f7717184fd2657d3acabd1d64dfdc0bd531ce3"
+  integrity sha512-9eu2OcbR7e44gqBy4U1Uv8NTWgLIMwKXMEGgO2MahsJy5rdTiAhs5fJHQffPq8uX2MFh21iBODwO9R/Xlov88A==
+  dependencies:
+    "@azure/abort-controller" "^1.0.0"
+    "@azure/core-auth" "^1.1.3"
+    "@azure/core-tracing" "1.0.0-preview.9"
+    "@azure/logger" "^1.0.0"
+    "@opentelemetry/api" "^0.10.2"
+    "@types/node-fetch" "^2.5.0"
+    "@types/tunnel" "^0.0.1"
+    form-data "^3.0.0"
+    node-fetch "^2.6.0"
+    process "^0.11.10"
+    tough-cookie "^4.0.0"
+    tslib "^2.0.0"
+    tunnel "^0.0.6"
+    uuid "^8.3.0"
+    xml2js "^0.4.19"
+
+"@azure/core-lro@^1.0.2":
+  version "1.0.3"
+  resolved "https://registry.npmjs.org/@azure/core-lro/-/core-lro-1.0.3.tgz#1ddfb4ecdb81ce87b5f5d972ffe2acbbc46e524e"
+  integrity sha512-Py2crJ84qx1rXkzIwfKw5Ni4WJuzVU7KAF6i1yP3ce8fbynUeu8eEWS4JGtSQgU7xv02G55iPDROifmSDbxeHA==
+  dependencies:
+    "@azure/abort-controller" "^1.0.0"
+    "@azure/core-http" "^1.2.0"
+    events "^3.0.0"
+    tslib "^2.0.0"
+
+"@azure/core-paging@^1.1.1":
+  version "1.1.3"
+  resolved "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.1.3.tgz#3587c9898a0530cacb64bab216d7318468aa5efc"
+  integrity sha512-his7Ah40ThEYORSpIAwuh6B8wkGwO/zG7gqVtmSE4WAJ46e36zUDXTKReUCLBDc6HmjjApQQxxcRFy5FruG79A==
+  dependencies:
+    "@azure/core-asynciterator-polyfill" "^1.0.0"
+
+"@azure/core-tracing@1.0.0-preview.9":
+  version "1.0.0-preview.9"
+  resolved "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.0.0-preview.9.tgz#84f3b85572013f9d9b85e1e5d89787aa180787eb"
+  integrity sha512-zczolCLJ5QG42AEPQ+Qg9SRYNUyB+yZ5dzof4YEc+dyWczO9G2sBqbAjLB7IqrsdHN2apkiB2oXeDKCsq48jug==
+  dependencies:
+    "@opencensus/web-types" "0.0.7"
+    "@opentelemetry/api" "^0.10.2"
+    tslib "^2.0.0"
+
+"@azure/identity@^1.2.2":
+  version "1.2.2"
+  resolved "https://registry.npmjs.org/@azure/identity/-/identity-1.2.2.tgz#00d673c8881778c55777fcc76e822b42466d3fc7"
+  integrity sha512-aYkeNXl52aEHW1iOZQJb3SC7Vvbu87f01iNT+pSVHwj09LpN9+gP/Lb9uoWy36Fgv9WlukM55LbjLSbb1Renqw==
+  dependencies:
+    "@azure/core-http" "^1.2.0"
+    "@azure/core-tracing" "1.0.0-preview.9"
+    "@azure/logger" "^1.0.0"
+    "@azure/msal-node" "1.0.0-beta.3"
+    "@opentelemetry/api" "^0.10.2"
+    axios "^0.21.1"
+    events "^3.0.0"
+    jws "^4.0.0"
+    msal "^1.0.2"
+    open "^7.0.0"
+    qs "^6.7.0"
+    tslib "^2.0.0"
+    uuid "^8.3.0"
+  optionalDependencies:
+    keytar "^5.4.0"
+
+"@azure/logger@^1.0.0":
+  version "1.0.1"
+  resolved "https://registry.npmjs.org/@azure/logger/-/logger-1.0.1.tgz#19b333203d1b2931353d8879e814b64a7274837a"
+  integrity sha512-QYQeaJ+A5x6aMNu8BG5qdsVBnYBop9UMwgUvGihSjf1PdZZXB+c/oMdM2ajKwzobLBh9e9QuMQkN9iL+IxLBLA==
+  dependencies:
+    tslib "^2.0.0"
+
 "@azure/msal-common@^2.1.0":
   version "2.1.0"
   resolved "https://registry.npmjs.org/@azure/msal-common/-/msal-common-2.1.0.tgz#a4bc17e254d6ec524016f13267947dd4ff4a624d"
@@ -961,7 +1056,7 @@
   dependencies:
     debug "^4.1.1"
 
-"@azure/msal-node@^1.0.0-beta.3":
+"@azure/msal-node@1.0.0-beta.3", "@azure/msal-node@^1.0.0-beta.3":
   version "1.0.0-beta.3"
   resolved "https://registry.npmjs.org/@azure/msal-node/-/msal-node-1.0.0-beta.3.tgz#c84c7948028b39e48b901f5fac35bdedcbc8772e"
   integrity sha512-/KfYRfrsOIrZONvo/0Vi5umuqbPBtCWNtmRvkse64uI0C4CP/W4WXwRD42VMws/8LtKvr1I5rYlYgFzt5zDz/A==
@@ -971,6 +1066,21 @@
     jsonwebtoken "^8.5.1"
     uuid "^8.3.0"
 
+"@azure/storage-blob@^12.4.0":
+  version "12.4.0"
+  resolved "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.4.0.tgz#7127ddd9f413105e2c3688691bc4c6245d0806b3"
+  integrity sha512-OnhVSoKD1HzBB79/rFzPbC4w9TdzFXeoOwkX+aIu3rb8qvN0VaqvUqZXSrBCyG2LcLyVkY4MPCJQBrmEUm9kvw==
+  dependencies:
+    "@azure/abort-controller" "^1.0.0"
+    "@azure/core-http" "^1.2.0"
+    "@azure/core-lro" "^1.0.2"
+    "@azure/core-paging" "^1.1.1"
+    "@azure/core-tracing" "1.0.0-preview.9"
+    "@azure/logger" "^1.0.0"
+    "@opentelemetry/api" "^0.10.2"
+    events "^3.0.0"
+    tslib "^2.0.0"
+
 "@babel/code-frame@7.0.0":
   version "7.0.0"
   resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8"
@@ -4917,6 +5027,23 @@
     fast-deep-equal "^3.1.3"
     lodash.clonedeep "^4.5.0"
 
+"@opencensus/web-types@0.0.7":
+  version "0.0.7"
+  resolved "https://registry.npmjs.org/@opencensus/web-types/-/web-types-0.0.7.tgz#4426de1fe5aa8f624db395d2152b902874f0570a"
+  integrity sha512-xB+w7ZDAu3YBzqH44rCmG9/RlrOmFuDPt/bpf17eJr8eZSrLt7nc7LnWdxM9Mmoj/YKMHpxRg28txu3TcpiL+g==
+
+"@opentelemetry/api@^0.10.2":
+  version "0.10.2"
+  resolved "https://registry.npmjs.org/@opentelemetry/api/-/api-0.10.2.tgz#9647b881f3e1654089ff7ea59d587b2d35060654"
+  integrity sha512-GtpMGd6vkzDMYcpu2t9LlhEgMy/SzBwRnz48EejlRArYqZzqSzAsKmegUK7zHgl+EOIaK9mKHhnRaQu3qw20cA==
+  dependencies:
+    "@opentelemetry/context-base" "^0.10.2"
+
+"@opentelemetry/context-base@^0.10.2":
+  version "0.10.2"
+  resolved "https://registry.npmjs.org/@opentelemetry/context-base/-/context-base-0.10.2.tgz#55bea904b2b91aa8a8675df9eaba5961bddb1def"
+  integrity sha512-hZNKjKOYsckoOEgBziGMnBcX0M7EtstnCmwz5jZUOUYwlZ+/xxX6z3jPu1XVO2Jivk0eLfuP9GP+vFD49CMetw==
+
 "@panva/asn1.js@^1.0.0":
   version "1.0.0"
   resolved "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz#dd55ae7b8129e02049f009408b97c61ccf9032f6"
@@ -6949,6 +7076,14 @@
     "@types/node" "*"
     form-data "^3.0.0"
 
+"@types/node-fetch@^2.5.0":
+  version "2.5.8"
+  resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.8.tgz#e199c835d234c7eb0846f6618012e558544ee2fb"
+  integrity sha512-fbjI6ja0N5ZA8TV53RUqzsKNkl9fv8Oj3T7zxW7FGv1GSH7gwJaNF8dzCjrqKaxKeUpTz4yT1DaJFq/omNpGfw==
+  dependencies:
+    "@types/node" "*"
+    form-data "^3.0.0"
+
 "@types/node@*", "@types/node@>= 8":
   version "14.0.26"
   resolved "https://registry.npmjs.org/@types/node/-/node-14.0.26.tgz#22a3b8a46510da8944b67bfc27df02c34a35331c"
@@ -7408,6 +7543,13 @@
   resolved "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.0.tgz#fef1904e4668b6e5ecee60c52cc6a078ffa6697d"
   integrity sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A==
 
+"@types/tunnel@^0.0.1":
+  version "0.0.1"
+  resolved "https://registry.npmjs.org/@types/tunnel/-/tunnel-0.0.1.tgz#0d72774768b73df26f25df9184273a42da72b19c"
+  integrity sha512-AOqu6bQu5MSWwYvehMXLukFHnupHrpZ8nvgae5Ggie9UwzDR1CCwoXgSSWNZJuyOlCdfdsWMA5F2LlmvyoTv8A==
+  dependencies:
+    "@types/node" "*"
+
 "@types/uglify-js@*":
   version "3.0.4"
   resolved "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.0.4.tgz#96beae23df6f561862a830b4288a49e86baac082"
@@ -11882,7 +12024,7 @@ detect-indent@^6.0.0:
   resolved "https://registry.npmjs.org/detect-indent/-/detect-indent-6.0.0.tgz#0abd0f549f69fc6659a254fe96786186b6f528fd"
   integrity sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA==
 
-detect-libc@^1.0.2:
+detect-libc@^1.0.2, detect-libc@^1.0.3:
   version "1.0.3"
   resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
   integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=
@@ -13125,6 +13267,11 @@ expand-brackets@^2.1.4:
     snapdragon "^0.8.1"
     to-regex "^3.0.1"
 
+expand-template@^2.0.3:
+  version "2.0.3"
+  resolved "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c"
+  integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==
+
 expand-tilde@^2.0.0, expand-tilde@^2.0.2:
   version "2.0.2"
   resolved "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502"
@@ -14207,6 +14354,11 @@ gitconfiglocal@^1.0.0:
   dependencies:
     ini "^1.3.2"
 
+github-from-package@0.0.0:
+  version "0.0.0"
+  resolved "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce"
+  integrity sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4=
+
 github-slugger@^1.3.0:
   version "1.3.0"
   resolved "https://registry.npmjs.org/github-slugger/-/github-slugger-1.3.0.tgz#9bd0a95c5efdfc46005e82a906ef8e2a059124c9"
@@ -17253,6 +17405,14 @@ kafkajs@^1.16.0-beta.6:
   resolved "https://registry.npmjs.org/kafkajs/-/kafkajs-1.16.0-beta.6.tgz#650f4d16abd60516aa0c375be613368391216df1"
   integrity sha512-R4DT3s7oCAoxdq3tN8w2WkUxLXcZgLQSjQVSmEZpEmT6hVdZ5AUZWqyLYr1IRbQowsJK4Z7eWJAk68J0JiRlUw==
 
+keytar@^5.4.0:
+  version "5.6.0"
+  resolved "https://registry.npmjs.org/keytar/-/keytar-5.6.0.tgz#7b5d4bd043d17211163640be6c4a27a49b12bb39"
+  integrity sha512-ueulhshHSGoryfRXaIvTj0BV1yB0KddBGhGoqCxSN9LR1Ks1GKuuCdVhF+2/YOs5fMl6MlTI9On1a4DHDXoTow==
+  dependencies:
+    nan "2.14.1"
+    prebuild-install "5.3.3"
+
 keyv@^3.0.0:
   version "3.1.0"
   resolved "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"
@@ -18822,6 +18982,13 @@ ms@2.1.2, ms@^2.0.0, ms@^2.1.1:
   resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
   integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
 
+msal@^1.0.2:
+  version "1.4.4"
+  resolved "https://registry.npmjs.org/msal/-/msal-1.4.4.tgz#3f9b5a4442aa711c12ab8e88b8ed89b293f99711"
+  integrity sha512-aOBD/L6jAsizDFzKxxvXxH0FEDjp6Inr3Ufi/Y2o7KCFKN+akoE2sLeszEb/0Y3VxHxK0F0ea7xQ/HHTomKivw==
+  dependencies:
+    tslib "^1.9.3"
+
 msw@^0.20.5:
   version "0.20.5"
   resolved "https://registry.npmjs.org/msw/-/msw-0.20.5.tgz#b6141080c0d8b17c451d9ca36c28cc47b4ac487a"
@@ -18926,7 +19093,7 @@ mz@^2.5.0, mz@^2.7.0:
     object-assign "^4.0.1"
     thenify-all "^1.0.0"
 
-nan@^2.12.1, nan@^2.14.0:
+nan@2.14.1, nan@^2.12.1, nan@^2.14.0:
   version "2.14.1"
   resolved "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01"
   integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==
@@ -18967,6 +19134,11 @@ nanomatch@^1.2.9:
     snapdragon "^0.8.1"
     to-regex "^3.0.1"
 
+napi-build-utils@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806"
+  integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==
+
 native-url@^0.2.6:
   version "0.2.6"
   resolved "https://registry.npmjs.org/native-url/-/native-url-0.2.6.tgz#ca1258f5ace169c716ff44eccbddb674e10399ae"
@@ -19041,6 +19213,13 @@ nock@^13.0.5:
     lodash.set "^4.3.2"
     propagate "^2.0.0"
 
+node-abi@^2.7.0:
+  version "2.19.3"
+  resolved "https://registry.npmjs.org/node-abi/-/node-abi-2.19.3.tgz#252f5dcab12dad1b5503b2d27eddd4733930282d"
+  integrity sha512-9xZrlyfvKhWme2EXFKQhZRp1yNWT/uI1luYPr3sFl+H4keYY4xR+1jO7mvTTijIsHf1M+QDe9uWuKeEpLInIlg==
+  dependencies:
+    semver "^5.4.1"
+
 node-addon-api@2.0.0:
   version "2.0.0"
   resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz#f9afb8d777a91525244b01775ea0ddbe1125483b"
@@ -19235,6 +19414,11 @@ nodemon@^2.0.2:
     undefsafe "^2.0.3"
     update-notifier "^4.1.0"
 
+noop-logger@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz#94a2b1633c4f1317553007d8966fd0e841b6a4c2"
+  integrity sha1-lKKxYzxPExdVMAfYlm/Q6EG2pMI=
+
 "nopt@2 || 3":
   version "3.0.6"
   resolved "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9"
@@ -19372,7 +19556,7 @@ npm-run-path@^4.0.0:
   dependencies:
     path-key "^3.0.0"
 
-"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.2, npmlog@^4.1.2:
+"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.1, npmlog@^4.0.2, npmlog@^4.1.2:
   version "4.1.2"
   resolved "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
   integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==
@@ -19620,6 +19804,14 @@ onetime@^5.1.0:
   dependencies:
     mimic-fn "^2.1.0"
 
+open@^7.0.0:
+  version "7.3.1"
+  resolved "https://registry.npmjs.org/open/-/open-7.3.1.tgz#111119cb919ca1acd988f49685c4fdd0f4755356"
+  integrity sha512-f2wt9DCBKKjlFbjzGb8MOAW8LH8F0mrs1zc7KTjAJ9PZNQbfenzWbNP1VZJvw6ICMG9r14Ah6yfwPn7T7i646A==
+  dependencies:
+    is-docker "^2.0.0"
+    is-wsl "^2.1.1"
+
 open@^7.0.2, open@^7.0.3:
   version "7.2.1"
   resolved "https://registry.npmjs.org/open/-/open-7.2.1.tgz#07b0ade11a43f2a8ce718480bdf3d7563a095195"
@@ -21044,6 +21236,27 @@ postgres-interval@^1.1.0:
   dependencies:
     xtend "^4.0.0"
 
+prebuild-install@5.3.3:
+  version "5.3.3"
+  resolved "https://registry.npmjs.org/prebuild-install/-/prebuild-install-5.3.3.tgz#ef4052baac60d465f5ba6bf003c9c1de79b9da8e"
+  integrity sha512-GV+nsUXuPW2p8Zy7SarF/2W/oiK8bFQgJcncoJ0d7kRpekEA0ftChjfEaF9/Y+QJEc/wFR7RAEa8lYByuUIe2g==
+  dependencies:
+    detect-libc "^1.0.3"
+    expand-template "^2.0.3"
+    github-from-package "0.0.0"
+    minimist "^1.2.0"
+    mkdirp "^0.5.1"
+    napi-build-utils "^1.0.1"
+    node-abi "^2.7.0"
+    noop-logger "^0.1.1"
+    npmlog "^4.0.1"
+    pump "^3.0.0"
+    rc "^1.2.7"
+    simple-get "^3.0.3"
+    tar-fs "^2.0.0"
+    tunnel-agent "^0.6.0"
+    which-pm-runs "^1.0.0"
+
 precond@0.2:
   version "0.2.3"
   resolved "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz#aa9591bcaa24923f1e0f4849d240f47efc1075ac"
@@ -21311,7 +21524,7 @@ pseudomap@^1.0.2:
   resolved "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
   integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
 
-psl@^1.1.28:
+psl@^1.1.28, psl@^1.1.33:
   version "1.8.0"
   resolved "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24"
   integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==
@@ -21404,6 +21617,11 @@ qs@^6.5.1, qs@^6.6.0, qs@^6.9.1, qs@^6.9.4:
   resolved "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz#9090b290d1f91728d3c22e54843ca44aea5ab687"
   integrity sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==
 
+qs@^6.7.0:
+  version "6.9.6"
+  resolved "https://registry.npmjs.org/qs/-/qs-6.9.6.tgz#26ed3c8243a431b2924aca84cc90471f35d5a0ee"
+  integrity sha512-TIRk4aqYLNoJUbd+g2lEdz5kLWIuTMRagAXxl78Q0RiVjAOugHmeKNGdd3cwo/ktpf9aL9epCfFqWDEKysUlLQ==
+
 qs@~6.5.2:
   version "6.5.2"
   resolved "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
@@ -24558,6 +24776,16 @@ tapable@^1.0.0, tapable@^1.1.3:
   resolved "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2"
   integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==
 
+tar-fs@^2.0.0:
+  version "2.1.1"
+  resolved "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784"
+  integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==
+  dependencies:
+    chownr "^1.1.1"
+    mkdirp-classic "^0.5.2"
+    pump "^3.0.0"
+    tar-stream "^2.1.4"
+
 tar-fs@~2.0.1:
   version "2.0.1"
   resolved "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.1.tgz#e44086c1c60d31a4f0cf893b1c4e155dabfae9e2"
@@ -24993,6 +25221,15 @@ tough-cookie@^3.0.1:
     psl "^1.1.28"
     punycode "^2.1.1"
 
+tough-cookie@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4"
+  integrity sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg==
+  dependencies:
+    psl "^1.1.33"
+    punycode "^2.1.1"
+    universalify "^0.1.2"
+
 tr46@^1.0.1:
   version "1.0.1"
   resolved "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"
@@ -25200,7 +25437,7 @@ tunnel-agent@^0.6.0:
   dependencies:
     safe-buffer "^5.0.1"
 
-tunnel@0.0.6:
+tunnel@0.0.6, tunnel@^0.0.6:
   version "0.0.6"
   resolved "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c"
   integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==
@@ -25513,7 +25750,7 @@ universal-user-agent@^6.0.0:
   resolved "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee"
   integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==
 
-universalify@^0.1.0:
+universalify@^0.1.0, universalify@^0.1.2:
   version "0.1.2"
   resolved "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
   integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
@@ -26460,7 +26697,7 @@ xml-name-validator@^3.0.0:
   resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
   integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==
 
-xml2js@^0.4.23:
+xml2js@^0.4.19, xml2js@^0.4.23:
   version "0.4.23"
   resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66"
   integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==

From 398e1f83ee3391eedda533c10859e7a769670da1 Mon Sep 17 00:00:00 2001
From: Patrik Oldsberg 
Date: Wed, 16 Dec 2020 22:41:41 +0100
Subject: [PATCH 179/297] cli: update frontend plugin template to use new
 extension API

---
 .changeset/cyan-kiwis-suffer.md               |  5 +++++
 .../commands/create-plugin/createPlugin.ts    | 13 +++++------
 .../templates/default-plugin/dev/index.tsx    |  4 ----
 .../default-plugin/dev/index.tsx.hbs          | 11 ++++++++++
 .../ExampleComponent.test.tsx.hbs             | 13 ++++++-----
 .../ExampleComponent/ExampleComponent.tsx.hbs |  6 ++---
 .../src/components/ExampleComponent/index.ts  |  2 +-
 .../ExampleFetchComponent.test.tsx.hbs        | 12 ++++++----
 .../ExampleFetchComponent.tsx.hbs             |  6 ++---
 .../components/ExampleFetchComponent/index.ts |  2 +-
 .../cli/templates/default-plugin/src/index.ts |  1 -
 .../templates/default-plugin/src/index.ts.hbs |  1 +
 .../default-plugin/src/plugin.test.ts.hbs     |  4 ++--
 .../default-plugin/src/plugin.ts.hbs          | 22 +++++++++++--------
 .../default-plugin/src/routes.ts.hbs          |  5 +++++
 15 files changed, 64 insertions(+), 43 deletions(-)
 create mode 100644 .changeset/cyan-kiwis-suffer.md
 delete mode 100644 packages/cli/templates/default-plugin/dev/index.tsx
 create mode 100644 packages/cli/templates/default-plugin/dev/index.tsx.hbs
 delete mode 100644 packages/cli/templates/default-plugin/src/index.ts
 create mode 100644 packages/cli/templates/default-plugin/src/index.ts.hbs
 create mode 100644 packages/cli/templates/default-plugin/src/routes.ts.hbs

diff --git a/.changeset/cyan-kiwis-suffer.md b/.changeset/cyan-kiwis-suffer.md
new file mode 100644
index 0000000000..b59b0e9a32
--- /dev/null
+++ b/.changeset/cyan-kiwis-suffer.md
@@ -0,0 +1,5 @@
+---
+'@backstage/cli': patch
+---
+
+Update `create-plugin` template to use the new composability API, by switching to exporting a single routable extension component.
diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts
index a2005c5580..c9f1664411 100644
--- a/packages/cli/src/commands/create-plugin/createPlugin.ts
+++ b/packages/cli/src/commands/create-plugin/createPlugin.ts
@@ -20,6 +20,7 @@ import chalk from 'chalk';
 import inquirer, { Answers, Question } from 'inquirer';
 import { exec as execCb } from 'child_process';
 import { resolve as resolvePath, join as joinPath } from 'path';
+import camelCase from 'lodash/camelCase';
 import os from 'os';
 import { Command } from 'commander';
 import {
@@ -106,14 +107,10 @@ export async function addPluginDependencyToApp(
 
 export async function addPluginToApp(
   rootDir: string,
-  pluginName: string,
+  pluginVar: string,
   pluginPackage: string,
 ) {
-  const pluginNameCapitalized = pluginName
-    .split('-')
-    .map(name => capitalize(name))
-    .join('');
-  const pluginExport = `export { plugin as ${pluginNameCapitalized} } from '${pluginPackage}';`;
+  const pluginExport = `export { ${pluginVar} } from '${pluginPackage}';`;
   const pluginsFilePath = 'packages/app/src/plugins.ts';
   const pluginsFile = resolvePath(rootDir, pluginsFilePath);
 
@@ -223,6 +220,7 @@ export default async (cmd: Command) => {
   const name = cmd.scope
     ? `@${cmd.scope.replace(/^@/, '')}/plugin-${pluginId}`
     : `plugin-${pluginId}`;
+  const pluginVar = `${camelCase(answers.id)}Plugin`;
   const npmRegistry = cmd.npmRegistry && cmd.scope ? cmd.npmRegistry : '';
   const privatePackage = cmd.private === false ? false : true;
   const isMonoRepo = await fs.pathExists(paths.resolveTargetRoot('lerna.json'));
@@ -259,6 +257,7 @@ export default async (cmd: Command) => {
       tempDir,
       {
         ...answers,
+        pluginVar,
         pluginVersion,
         name,
         privatePackage,
@@ -278,7 +277,7 @@ export default async (cmd: Command) => {
       await addPluginDependencyToApp(paths.targetRoot, name, pluginVersion);
 
       Task.section('Import plugin in app');
-      await addPluginToApp(paths.targetRoot, pluginId, name);
+      await addPluginToApp(paths.targetRoot, pluginVar, name);
     }
 
     if (ownerIds && ownerIds.length) {
diff --git a/packages/cli/templates/default-plugin/dev/index.tsx b/packages/cli/templates/default-plugin/dev/index.tsx
deleted file mode 100644
index 6fce113093..0000000000
--- a/packages/cli/templates/default-plugin/dev/index.tsx
+++ /dev/null
@@ -1,4 +0,0 @@
-import { createDevApp } from '@backstage/dev-utils';
-import { plugin } from '../src/plugin';
-
-createDevApp().registerPlugin(plugin).render();
diff --git a/packages/cli/templates/default-plugin/dev/index.tsx.hbs b/packages/cli/templates/default-plugin/dev/index.tsx.hbs
new file mode 100644
index 0000000000..13705ecd58
--- /dev/null
+++ b/packages/cli/templates/default-plugin/dev/index.tsx.hbs
@@ -0,0 +1,11 @@
+import React from 'react';
+import { createDevApp } from '@backstage/dev-utils';
+import { {{ pluginVar }}, ExamplePage } from '../src/plugin';
+
+createDevApp()
+  .registerPlugin({{ pluginVar }})
+  .addPage({
+    element: ,
+    title: 'Root Page',
+  })
+  .render();
diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs
index e805900f36..e6eab17493 100644
--- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs
+++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs
@@ -1,13 +1,12 @@
 import React from 'react';
 import { render } from '@testing-library/react';
-import ExampleComponent from './ExampleComponent';
+import { ExampleComponent } from './ExampleComponent';
 import { ThemeProvider } from '@material-ui/core';
 import { lightTheme } from '@backstage/theme';
 import { rest } from 'msw';
 import { setupServer } from 'msw/node';
 import { msw } from '@backstage/test-utils';
 
-
 describe('ExampleComponent', () => {
   const server = setupServer();
   // Enable sane handlers for network requests
@@ -15,15 +14,17 @@ describe('ExampleComponent', () => {
 
   // setup mock response
   beforeEach(() => {
-    server.use(rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))))
-  })
+    server.use(
+      rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))),
+    );
+  });
 
   it('should render', () => {
     const rendered = render(
       
         
       ,
-      );
-      expect(rendered.getByText('Welcome to {{ id }}!')).toBeInTheDocument();
+    );
+    expect(rendered.getByText('Welcome to {{ id }}!')).toBeInTheDocument();
   });
 });
diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs
index 5f90f2de1e..dcecebdf46 100644
--- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs
+++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs
@@ -9,9 +9,9 @@ import {
   HeaderLabel,
   SupportButton,
 } from '@backstage/core';
-import ExampleFetchComponent from '../ExampleFetchComponent';
+import { ExampleFetchComponent } from '../ExampleFetchComponent';
 
-const ExampleComponent = () => (
+export const ExampleComponent = () => (
   
     
@@ -36,5 +36,3 @@ const ExampleComponent = () => ( ); - -export default ExampleComponent; diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts b/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts index 520a3bf553..8b8437521b 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts +++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/index.ts @@ -1 +1 @@ -export { default } from './ExampleComponent'; +export { ExampleComponent } from './ExampleComponent'; diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs index 81e1b4be09..fa1e289d8e 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.test.tsx.hbs @@ -1,6 +1,6 @@ import React from 'react'; import { render } from '@testing-library/react'; -import ExampleFetchComponent from './ExampleFetchComponent'; +import { ExampleFetchComponent } from './ExampleFetchComponent'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { msw } from '@backstage/test-utils'; @@ -9,11 +9,15 @@ describe('ExampleFetchComponent', () => { const server = setupServer(); // Enable sane handlers for network requests msw.setupDefaultHandlers(server); - + // setup mock response beforeEach(() => { - server.use(rest.get('https://randomuser.me/*', (_, res, ctx) => res(ctx.status(200), ctx.delay(2000), ctx.json({})))) - }) + server.use( + rest.get('https://randomuser.me/*', (_, res, ctx) => + res(ctx.status(200), ctx.delay(2000), ctx.json({})), + ), + ); + }); it('should render', async () => { const rendered = render(); expect(await rendered.findByTestId('progress')).toBeInTheDocument(); diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs index 8cc5ed2ab7..20dd6d1f57 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs @@ -48,7 +48,7 @@ export const DenseTable = ({ users }: DenseTableProps) => { { title: 'Nationality', field: 'nationality' }, ]; - const data = users.map((user) => { + const data = users.map(user => { return { avatar: ( { ); }; -const ExampleFetchComponent = () => { +export const ExampleFetchComponent = () => { const { value, loading, error } = useAsync(async (): Promise => { const response = await fetch('https://randomuser.me/api/?results=20'); const data = await response.json(); @@ -88,5 +88,3 @@ const ExampleFetchComponent = () => { return ; }; - -export default ExampleFetchComponent; diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts index 3e53453948..41a43e84f1 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts +++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/index.ts @@ -1 +1 @@ -export { default } from './ExampleFetchComponent'; +export { ExampleFetchComponent } from './ExampleFetchComponent'; diff --git a/packages/cli/templates/default-plugin/src/index.ts b/packages/cli/templates/default-plugin/src/index.ts deleted file mode 100644 index 99edba26c3..0000000000 --- a/packages/cli/templates/default-plugin/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { plugin } from './plugin'; diff --git a/packages/cli/templates/default-plugin/src/index.ts.hbs b/packages/cli/templates/default-plugin/src/index.ts.hbs new file mode 100644 index 0000000000..165c8419e1 --- /dev/null +++ b/packages/cli/templates/default-plugin/src/index.ts.hbs @@ -0,0 +1 @@ +export { {{ pluginVar }} } from './plugin'; diff --git a/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs b/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs index c2be0a5301..9d44a9c497 100644 --- a/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs +++ b/packages/cli/templates/default-plugin/src/plugin.test.ts.hbs @@ -1,7 +1,7 @@ -import { plugin } from './plugin'; +import { {{ pluginVar }} } from './plugin'; describe('{{ id }}', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect({{ pluginVar }}).toBeDefined(); }); }); diff --git a/packages/cli/templates/default-plugin/src/plugin.ts.hbs b/packages/cli/templates/default-plugin/src/plugin.ts.hbs index 1bf4d07cdb..2a836592f7 100644 --- a/packages/cli/templates/default-plugin/src/plugin.ts.hbs +++ b/packages/cli/templates/default-plugin/src/plugin.ts.hbs @@ -1,14 +1,18 @@ -import { createPlugin, createRouteRef } from '@backstage/core'; -import ExampleComponent from './components/ExampleComponent'; +import { createPlugin, createRoutableExtension } from '@backstage/core'; -export const rootRouteRef = createRouteRef({ - path: '/{{ id }}', - title: '{{ id }}', -}); +import { rootRouteRef } from './routes'; -export const plugin = createPlugin({ +export const {{ pluginVar }} = createPlugin({ id: '{{ id }}', - register({ router }) { - router.addRoute(rootRouteRef, ExampleComponent); + routes: { + root: rootRouteRef, }, }); + +export const ExamplePage = {{ pluginVar }}.provide( + createRoutableExtension({ + component: () => + import('./components/ExampleComponent').then(m => m.ExampleComponent), + mountPoint: rootRouteRef, + }), +); diff --git a/packages/cli/templates/default-plugin/src/routes.ts.hbs b/packages/cli/templates/default-plugin/src/routes.ts.hbs new file mode 100644 index 0000000000..b2afba074d --- /dev/null +++ b/packages/cli/templates/default-plugin/src/routes.ts.hbs @@ -0,0 +1,5 @@ +import { createRouteRef } from '@backstage/core'; + +export const rootRouteRef = createRouteRef({ + title: '{{ id }}', +}); From 8a8882ce06773b20522888a8c78f9381781e2f3a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 29 Jan 2021 00:34:47 +0100 Subject: [PATCH 180/297] cli: update plugin:diff to not require src/routes.ts --- packages/cli/src/commands/plugin/diff.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/cli/src/commands/plugin/diff.ts b/packages/cli/src/commands/plugin/diff.ts index 416d67869b..fb96250567 100644 --- a/packages/cli/src/commands/plugin/diff.ts +++ b/packages/cli/src/commands/plugin/diff.ts @@ -39,6 +39,11 @@ const fileHandlers = [ patterns: ['package.json'], handler: handlers.packageJson, }, + { + // Not all plugins have routes + patterns: ['src/routes.ts'], + handler: handlers.skip, + }, { // make sure files in 1st level of src/ and dev/ exist patterns: ['.eslintrc.js', /^(src|dev)\/[^/]+$/], From fe3211cf3852caacd8d030d2ce10c1ad31b11042 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 31 Jan 2021 15:12:29 +0100 Subject: [PATCH 181/297] cli: make create-plugin add extension to app if possible --- .../commands/create-plugin/createPlugin.ts | 48 ++++++++++++++++++- .../default-plugin/dev/index.tsx.hbs | 4 +- .../templates/default-plugin/src/index.ts.hbs | 2 +- .../default-plugin/src/plugin.ts.hbs | 2 +- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index c9f1664411..affbf64922 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -21,6 +21,7 @@ import inquirer, { Answers, Question } from 'inquirer'; import { exec as execCb } from 'child_process'; import { resolve as resolvePath, join as joinPath } from 'path'; import camelCase from 'lodash/camelCase'; +import upperFirst from 'lodash/upperFirst'; import os from 'os'; import { Command } from 'commander'; import { @@ -105,7 +106,7 @@ export async function addPluginDependencyToApp( }); } -export async function addPluginToApp( +export async function addPluginImportToApp( rootDir: string, pluginVar: string, pluginPackage: string, @@ -123,6 +124,46 @@ export async function addPluginToApp( }); } +export async function addPluginExtensionToApp( + pluginId: string, + extensionName: string, + pluginPackage: string, +) { + const pluginsFilePath = paths.resolveTargetRoot('packages/app/src/App.tsx'); + if (!(await fs.pathExists(pluginsFilePath))) { + return; + } + + await Task.forItem('processing', pluginsFilePath, async () => { + const content = await fs.readFile(pluginsFilePath, 'utf8'); + const revLines = content.split('\n').reverse(); + + const lastImportIndex = revLines.findIndex(line => + line.match(/ from ("|').*("|')/), + ); + const lastRouteIndex = revLines.findIndex(line => + line.match(/<\/FlatRoutes/), + ); + + if (lastImportIndex !== -1 && lastRouteIndex !== -1) { + revLines.splice( + lastImportIndex, + 0, + `import { ${extensionName} } from '${pluginPackage}';`, + ); + const [indentation] = revLines[lastRouteIndex + 1].match(/^\s*/) ?? []; + revLines.splice( + lastRouteIndex + 1, + 0, + `${indentation}}/>`, + ); + + const newContent = revLines.reverse().join('\n'); + await fs.writeFile(pluginsFilePath, newContent, 'utf8'); + } + }); +} + async function cleanUp(tempDir: string) { await Task.forItem('remove', 'temporary directory', async () => { await fs.remove(tempDir); @@ -221,6 +262,7 @@ export default async (cmd: Command) => { ? `@${cmd.scope.replace(/^@/, '')}/plugin-${pluginId}` : `plugin-${pluginId}`; const pluginVar = `${camelCase(answers.id)}Plugin`; + const extensionName = `${upperFirst(camelCase(answers.id))}Page`; const npmRegistry = cmd.npmRegistry && cmd.scope ? cmd.npmRegistry : ''; const privatePackage = cmd.private === false ? false : true; const isMonoRepo = await fs.pathExists(paths.resolveTargetRoot('lerna.json')); @@ -259,6 +301,7 @@ export default async (cmd: Command) => { ...answers, pluginVar, pluginVersion, + extensionName, name, privatePackage, npmRegistry, @@ -277,7 +320,8 @@ export default async (cmd: Command) => { await addPluginDependencyToApp(paths.targetRoot, name, pluginVersion); Task.section('Import plugin in app'); - await addPluginToApp(paths.targetRoot, pluginVar, name); + await addPluginImportToApp(paths.targetRoot, pluginVar, name); + await addPluginExtensionToApp(pluginId, extensionName, name); } if (ownerIds && ownerIds.length) { diff --git a/packages/cli/templates/default-plugin/dev/index.tsx.hbs b/packages/cli/templates/default-plugin/dev/index.tsx.hbs index 13705ecd58..ade00a1613 100644 --- a/packages/cli/templates/default-plugin/dev/index.tsx.hbs +++ b/packages/cli/templates/default-plugin/dev/index.tsx.hbs @@ -1,11 +1,11 @@ import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { {{ pluginVar }}, ExamplePage } from '../src/plugin'; +import { {{ pluginVar }}, {{ extensionName }} } from '../src/plugin'; createDevApp() .registerPlugin({{ pluginVar }}) .addPage({ - element: , + element: <{{ extensionName }} />, title: 'Root Page', }) .render(); diff --git a/packages/cli/templates/default-plugin/src/index.ts.hbs b/packages/cli/templates/default-plugin/src/index.ts.hbs index 165c8419e1..be4881efaf 100644 --- a/packages/cli/templates/default-plugin/src/index.ts.hbs +++ b/packages/cli/templates/default-plugin/src/index.ts.hbs @@ -1 +1 @@ -export { {{ pluginVar }} } from './plugin'; +export { {{ pluginVar }}, {{ extensionName }} } from './plugin'; diff --git a/packages/cli/templates/default-plugin/src/plugin.ts.hbs b/packages/cli/templates/default-plugin/src/plugin.ts.hbs index 2a836592f7..0ae5356fd1 100644 --- a/packages/cli/templates/default-plugin/src/plugin.ts.hbs +++ b/packages/cli/templates/default-plugin/src/plugin.ts.hbs @@ -9,7 +9,7 @@ export const {{ pluginVar }} = createPlugin({ }, }); -export const ExamplePage = {{ pluginVar }}.provide( +export const {{ extensionName }} = {{ pluginVar }}.provide( createRoutableExtension({ component: () => import('./components/ExampleComponent').then(m => m.ExampleComponent), From ceef4dd897879a9456ae1bcfe85cb583f02795f1 Mon Sep 17 00:00:00 2001 From: Ioannis Georgoulas Date: Sun, 31 Jan 2021 18:43:28 +0000 Subject: [PATCH 182/297] [Catalog Import] Export APIs --- .changeset/catalog-import-export-api-snow-fight.md | 5 +++++ plugins/catalog-import/src/index.ts | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/catalog-import-export-api-snow-fight.md diff --git a/.changeset/catalog-import-export-api-snow-fight.md b/.changeset/catalog-import-export-api-snow-fight.md new file mode 100644 index 0000000000..dce9811f10 --- /dev/null +++ b/.changeset/catalog-import-export-api-snow-fight.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-import': patch +--- + +Export _api_ (Client, API, ref) from the catalog import plugin. diff --git a/plugins/catalog-import/src/index.ts b/plugins/catalog-import/src/index.ts index ff7857cacd..dd8ca2bbb4 100644 --- a/plugins/catalog-import/src/index.ts +++ b/plugins/catalog-import/src/index.ts @@ -16,3 +16,4 @@ export { plugin } from './plugin'; export { Router } from './components/Router'; +export * from './api'; From a26668913ccc9c31fc7c5655309bd7c667d6ee65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 30 Jan 2021 20:15:09 +0100 Subject: [PATCH 183/297] scaffolder-backend: attempt to fix windows test errors in master --- .changeset/little-cherries-hug.md | 5 +++ plugins/scaffolder-backend/package.json | 2 ++ .../src/scaffolder/jobs/processor.test.ts | 32 ++++++++++++++++--- .../src/scaffolder/jobs/processor.ts | 6 ++-- .../src/scaffolder/stages/prepare/file.ts | 3 +- .../scaffolder/stages/prepare/github.test.ts | 2 +- .../stages/templater/cookiecutter.test.ts | 4 +-- .../stages/templater/cookiecutter.ts | 4 +-- 8 files changed, 44 insertions(+), 14 deletions(-) create mode 100644 .changeset/little-cherries-hug.md diff --git a/.changeset/little-cherries-hug.md b/.changeset/little-cherries-hug.md new file mode 100644 index 0000000000..5fb42cbdad --- /dev/null +++ b/.changeset/little-cherries-hug.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Attempt to fix windows test errors in master diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index 57b62be2cd..7af9f567e3 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -62,7 +62,9 @@ "@backstage/cli": "^0.5.0", "@backstage/test-utils": "^0.1.5", "@types/fs-extra": "^9.0.1", + "@types/mock-fs": "^4.13.0", "@types/supertest": "^2.0.8", + "mock-fs": "^4.13.0", "supertest": "^4.0.2", "yaml": "^1.10.0", "msw": "^0.21.2" diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts index 5ff2bd1640..c782037ec8 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.test.ts @@ -13,12 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import os from 'os'; -import { JobProcessor } from './processor'; import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; -import { StageInput } from './types'; -import { RequiredTemplateValues } from '../stages/templater'; import parseGitUrl from 'git-url-parse'; +import mockFs from 'mock-fs'; +import os from 'os'; +import { RequiredTemplateValues } from '../stages/templater'; +import { makeLogStream } from './logger'; +import { JobProcessor } from './processor'; +import { StageInput } from './types'; describe('JobProcessor', () => { const mockEntity: TemplateEntityV1alpha1 = { @@ -32,7 +34,7 @@ describe('JobProcessor', () => { 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', + 'A GraphQL starter template for backstage to get you up and running\nthe best practices with GraphQL\n', uid: '9cf16bad-16e0-4213-b314-c4eec773c50b', etag: 'ZTkxMjUxMjUtYWY3Yi00MjU2LWFkYWMtZTZjNjU5ZjJhOWM2', @@ -71,6 +73,25 @@ describe('JobProcessor', () => { const workingDirectory = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + // NOTE(freben): Without this line, mock-fs makes winston/logform break. + // There are a number of reported issues with logform and its use of dynamic + // strings for imports. It confuses webpack. The basic fix is to trigger + // those imports before mock-fs runs. I wanted to add a mock dir + // 'node_modules': mockFs.passthrough(), but that doesn't seem to be a thing + // in mock-fs 4. + // Probable REAL fix: https://github.com/winstonjs/logform/pull/117 + makeLogStream({}); + + beforeEach(() => { + mockFs({ + [workingDirectory]: mockFs.directory(), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + describe('create', () => { it('creates should create a new job with a unique id', async () => { const processor = new JobProcessor(workingDirectory); @@ -157,6 +178,7 @@ describe('JobProcessor', () => { 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(workingDirectory); diff --git a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts index 08423872f2..425fc07946 100644 --- a/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts +++ b/plugins/scaffolder-backend/src/scaffolder/jobs/processor.ts @@ -131,7 +131,7 @@ export class JobProcessor implements Processor { try { for (const stage of job.stages) { - // Create a logger for each stage so we can create seperate + // Create a logger for each stage so we can create separate // Streams for each step. const { logger, log, stream } = makeLogStream({ id: job.id, @@ -151,8 +151,8 @@ export class JobProcessor implements Processor { 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 the handler returns something, then let's merge this onto the + // context for the next stage to use as it might be relevant. if (handlerResponse) { job.context = { ...job.context, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts index cbf81bdb06..4d49ba222e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.ts @@ -15,6 +15,7 @@ */ import fs from 'fs-extra'; import path from 'path'; +import { fileURLToPath } from 'url'; import { InputError } from '@backstage/backend-common'; import { PreparerBase, PreparerOptions } from './types'; @@ -27,7 +28,7 @@ export class FilePreparer implements PreparerBase { const checkoutDir = path.join(workspacePath, 'checkout'); await fs.ensureDir(checkoutDir); - const templatePath = url.slice('file://'.length); + const templatePath = fileURLToPath(url); await fs.copy(templatePath, checkoutDir, { recursive: true, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts index a5fad5eaaa..301f015c1e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/github.test.ts @@ -60,7 +60,7 @@ describe('GitHubPreparer', () => { path.resolve(checkoutPath, 'templates', 'graphql-starter', 'template'), templatePath, ); - expect(fs.rmdir).toHaveBeenCalledWith('/tmp/template/.git'); + expect(fs.rmdir).toHaveBeenCalledWith(path.resolve(templatePath, '.git')); }); it('calls the clone command with the correct arguments for a repository when no path is provided', async () => { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts index 8172986d69..1a3ad76499 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.test.ts @@ -227,8 +227,8 @@ describe('CookieCutter Templater', () => { args: expect.arrayContaining([ '--no-input', '-o', - 'tempdir/intermediate', - 'tempdir/template', + path.join('tempdir', 'intermediate'), + path.join('tempdir', 'template'), '--verbose', ]), logStream: stream, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts index d19112561a..09d4faa37c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/templater/cookiecutter.ts @@ -26,7 +26,7 @@ export class CookieCutter implements TemplaterBase { directory: string, ): Promise> { try { - return await fs.readJSON(`${directory}/cookiecutter.json`); + return await fs.readJSON(path.join(directory, 'cookiecutter.json')); } catch (ex) { if (ex.code !== 'ENOENT') { throw ex; @@ -54,7 +54,7 @@ export class CookieCutter implements TemplaterBase { ...values, }; - await fs.writeJSON(`${templateDir}/cookiecutter.json`, cookieInfo); + await fs.writeJSON(path.join(templateDir, 'cookiecutter.json'), cookieInfo); const cookieCutterInstalled = await commandExists('cookiecutter'); if (cookieCutterInstalled) { From 55b298c52a1b8a9c1c16cae929b864835a270de7 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Sun, 31 Jan 2021 12:30:01 +0100 Subject: [PATCH 184/297] scaffolder: Fix test on windows; incorrect file URL --- .../src/scaffolder/stages/prepare/file.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts index 36be4d705b..0c94edce42 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/prepare/file.test.ts @@ -26,16 +26,17 @@ describe('File preparer', () => { it('prepares templates from a file path', async () => { const logger = getVoidLogger(); const preparer = new FilePreparer(); - const workspacePath = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp'; + const root = os.platform() === 'win32' ? 'C:\\' : '/'; + const workspacePath = path.join(root, 'tmp'); const checkoutPath = path.resolve(workspacePath, 'checkout'); await preparer.prepare({ - url: 'file:///path/to/template', + url: `file:///${root}path/to/template`, logger, workspacePath, }); expect(fs.copy).toHaveBeenCalledWith( - path.join('/path', 'to', 'template'), + path.join(root, 'path', 'to', 'template'), checkoutPath, { recursive: true, From 58a28f0b87752e769aaf545a88f1ec1e25fca8ad Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 31 Jan 2021 20:01:54 +0100 Subject: [PATCH 185/297] docs: update plugin create and reference docs for top-level routable extensions --- docs/architecture.drawio | 1 + docs/plugins/plugin-development.md | 67 ++++++++++++++++++--------- docs/plugins/structure-of-a-plugin.md | 40 +++++++++------- docs/reference/createPlugin-router.md | 31 ------------- docs/reference/createPlugin.md | 14 ++---- 5 files changed, 74 insertions(+), 79 deletions(-) create mode 100644 docs/architecture.drawio delete mode 100644 docs/reference/createPlugin-router.md diff --git a/docs/architecture.drawio b/docs/architecture.drawio new file mode 100644 index 0000000000..dbe2a5f6fa --- /dev/null +++ b/docs/architecture.drawio @@ -0,0 +1 @@ +7VpLc6M4EP41PiaFBLbh6LxmLlubSqZ2Z+amBQVUBYgScmzPrx9hJIMQGJzEhtTOxUaNnp++bnU3mtm3yfYLQ1n0Fw1wPINWsJ3ZdzMIAfQ88VdIdqXEs2EpCBkJZKVK8Ex+YSm0pHRNApxrFTmlMSeZLvRpmmKfazLEGN3o1V5orI+aoRAbgmcfxab0XxLwqJS6cFnJv2ISRmpksJALTpCqLFeSRyigm5rIvp/Zt4xSXj4l21scF+ApXMp2Dx1vDxNjOOVDGvwk7Nv6n4erTfLNgzcr9/HvH5sru+zlFcVrueBVlsn58p0CId+QJEapKN3I+phxvO2cCDgsT/AC0wRzthNVZANXAiIZARayvKnwPdSJathCRwqR3NPw0HW1bPEgV34CCs4gFBhdpwEu+rEEEJuIcPycIb94uxHkF7KIJ2LcOzAUp+4t6QQP6uA5JnZt0J0NOduEzsAtFMBlnXhIxUX/qerWqXxyFv2EsuFFCQVNvbrfcpzmhKb5aMRyjsPo6CgCZ1xqKSbVMHyia46f8MtEEQSuDqEzNoLgsyPYRkLQpsrnw9CA8IHRVKhyIKSP8Tok6buPy7cgBVtsnHVRG2eSqwOOC1ELfqZz05ucZh6HDyygjh8cFz8wPdPWB+C0COga+I3voPRosM5AAMamoNUN4UQRbPjJYGQOgoWBoAGcOGhXRRgvSn6M8pz4OlYByqM9rhVwODBC+iGwiWHpmvl4wJZzxEJ8rMOOgKUG9LwFaCVjOEacvOqLaENfjvBIiVjeYZu9xjZbjf0rlykbwVrCQPWjGtqNhuWyjYb7vT+s8h10WL6fDmKf2e67VLN94UdRuIZzVb7b1t/e7VRpS/h31Yl4rjcTxapVUVCNzs63oXQblW9LnW/eqXRTNs7R+3EvzD7zRJy6R9F/IF42VgKmV1uciCxF8dSh7D8ZLxx2ms7F6baw1aQdt2iFLXuWQ1DGIxpSsXv3lbTTwvYZ2MnYSm9MW3lg1k63ne81lmA+zFoKwqBdrVpWVMiPzLcxzsI6bVq2Vl88lBP4UMsNB+Sga2px+A5j1d3HooBiEqaFNglGYiYEhaUhPopX8kVCgmCvBKKPrOg52YbFZ6/r8ksTLP+LbhXr9x/CgH0t8L1JKfcjKX+JSfb1zFbtqunvt+SMnBaqw7MZtXnr+TDpoLORj3THTumaIdPkIWy4Ke7IcTs0w4xVRibrmzSV2DWV+LLwfb7MEVhMTYvbHeVpYzgxNbZND1mo8QPyOZW9TxDDiemybWbRDeSGRhkqlBgSZczOHAsobvQGA+VZMFqizr2eL4ED3fJ3sdTY8eY8SiMfA5r0OXMiRV3w+rCs7ttj0jdm/ZafI+tnW7MRydtkWTNqGErXnuzhR8WxDTdq2RPHNr0u7wJxrG1eGjIU508cW23GaHEs+N9uVNellJ1uWj5+Y2bFtRt1U7ZUuOq+sX3/Gw== \ No newline at end of file diff --git a/docs/plugins/plugin-development.md b/docs/plugins/plugin-development.md index 109f6ed6ba..bfeed58c8f 100644 --- a/docs/plugins/plugin-development.md +++ b/docs/plugins/plugin-development.md @@ -23,35 +23,60 @@ browser APIs or by depending on external modules to do the work. ### Routing -Each plugin is responsible for registering its components to corresponding -routes in the app. +Each plugin can export routable extensions, which are then imported into the app +and mounted at a path. -The app will call the `createPlugin` method on each plugin, passing in a -`router` object with a set of methods on it. +First you will need a `RouteRef` instance to serve as the mount point of your +extensions. This can be used within your own plugin to create a link to the +extension page using `useRouteRef`, as well as for other plugins to link to your +extension. -```jsx +It is best to place these in a separate top-level `src/routes.ts` file, in order +to avoid import cycles, for example like this: + +```tsx +/* src/routes.ts */ +import { createRouteRef } from '@backstage/core'; + +// Note: This route ref is for internal use only, don't export it from the plugin +export const rootRouteRef = createRouteRef({ + title: 'Example Page', +}); +``` + +Now that we have a `RouteRef`, we import it into `src/plugin.ts`, create our +plugin instance with `createPlugin`, as well as create and wrap our routable +extension using `createRoutableExtension` from `@backstage/core`: + +```tsx +/* src/plugin.ts */ import { createPlugin, createRouteRef } from '@backstage/core'; import ExampleComponent from './components/ExampleComponent'; -export const rootRouteRef = createRouteRef({ - path: '/new-plugin', - title: 'New plugin', -}); - -export const plugin = createPlugin({ - id: 'new-plugin', - register({ router }) { - router.addRoute(rootRouteRef, ExampleComponent); +// Create a plugin instance and export this from your plugin package +export const examplePlugin = createPlugin({ + id: 'example', + routes: { + root: rootRouteRef, // This is where the route ref should be exported for usage in the app }, }); + +// This creates a routable extension, which are typically full pages of content. +// Each extension should also be exported from your plugin package. +export const ExamplePage = examplePlugin.provide( + createRoutableExtension({ + // The component needs to be lazy-loaded. It's what will actually be rendered in the end. + component: () => + import('./components/ExampleComponent').then(m => m.ExampleComponent), + // This binds the extension to this route ref, which allows for routing within and across plugin extensions + mountPoint: rootRouteRef, + }), +); ``` -#### `router` API +This extension can then be imported and used in the app as follow, typically +placed within the top-level ``: -```typescript -addRoute( - target: RouteRef, - Component: ComponentType, - options?: RouteOptions, -): void; +```tsx +} /> ``` diff --git a/docs/plugins/structure-of-a-plugin.md b/docs/plugins/structure-of-a-plugin.md index 3f631a9a46..2aacd02cf2 100644 --- a/docs/plugins/structure-of-a-plugin.md +++ b/docs/plugins/structure-of-a-plugin.md @@ -28,6 +28,7 @@ new-plugin/ index.ts plugin.test.ts plugin.ts + routes.ts jest.config.js jest.setup.ts package.json @@ -56,26 +57,30 @@ package.json to declare the plugin dependencies, metadata and scripts. In the `src` folder we get to the interesting bits. Check out the `plugin.ts`: ```jsx -import { createPlugin, createRouteRef } from '@backstage/core'; -import ExampleComponent from './components/ExampleComponent'; +import { createPlugin, createRoutableExtension } from '@backstage/core'; -export const rootRouteRef = createRouteRef({ - path: '/new-plugin', - title: 'New plugin', -}); +import { rootRouteRef } from './routes'; -export const plugin = createPlugin({ - id: 'new-plugin', - register({ router }) { - router.addRoute(rootRouteRef, ExampleComponent); +export const examplePlugin = createPlugin({ + id: 'example', + routes: { + root: rootRouteRef, }, }); + +export const ExamplePage = examplePlugin.provide( + createRoutableExtension({ + component: () => + import('./components/ExampleComponent').then(m => m.ExampleComponent), + mountPoint: rootRouteRef, + }), +); ``` -This is where the plugin is created and where it hooks into the app by declaring -what component should be shown on what URL. See reference docs for -[createPlugin](../reference/createPlugin.md) or -[router](../reference/createPlugin-router.md). +This is where the plugin is created and where it creates and exports extensions +that can be imported and used the app. See reference docs for +[createPlugin](../reference/createPlugin.md) or introduction to the new +[Composability System](./composability.md). ## Components @@ -91,12 +96,15 @@ You may tweak these components, rename them and/or replace them completely. ## Connecting the plugin to the Backstage app -There are two things needed for a Backstage app to start making use of a plugin. +There are three things needed for a Backstage app to start making use of a +plugin. 1. Add plugin as dependency in `app/package.json` 2. `import` plugin in `app/src/plugins.ts` +3. Import and use one or more plugin extensions, for example in + `app/src/App.tsx`. -Luckily these two steps happen automatically when you create a plugin with the +Luckily these three steps happen automatically when you create a plugin with the Backstage CLI. ## Talking to the outside world diff --git a/docs/reference/createPlugin-router.md b/docs/reference/createPlugin-router.md deleted file mode 100644 index 0ef5bdbd0f..0000000000 --- a/docs/reference/createPlugin-router.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -id: createPlugin-router -title: createPlugin - router -description: Documentation on createPlugin - router ---- - -The router that is passed to the `register` function makes it possible for -plugins to hook into routing of the Backstage app and provide the end users with -new views to navigate to. This is done by utilising the following methods on the -`router`: - -```typescript -addRoute( - target: RouteRef, - Component: ComponentType, - options?: RouteOptions, -): void; -``` - -## RouteRef - -`addRoute` method is using mutable RouteRefs, which can be created as following: - -```ts -import { createRouteRef } from '@backstage/core'; - -const myPluginRouteRef = createRouteRef({ - path: '/my-plugin', - title: 'My Plugin', -}); -``` diff --git a/docs/reference/createPlugin.md b/docs/reference/createPlugin.md index 4a58a5ecdc..e3df66fa12 100644 --- a/docs/reference/createPlugin.md +++ b/docs/reference/createPlugin.md @@ -17,32 +17,24 @@ type PluginConfig = { }; type PluginHooks = { - router: RouterHooks; + featureFlags: FeatureFlagsHooks; }; ``` -- [Read more about the router here](createPlugin-router.md) - [Read more about feature flags here](createPlugin-feature-flags.md) ## Example Uses ### Creating a basic plugin -Showcasing adding a route and a feature flag. +Showcasing adding a feature flag. ```jsx -import { createPlugin, createRouteRef } from '@backstage/core'; -import ExampleComponent from './components/ExampleComponent'; - -export const rootRouteRef = createRouteRef({ - path: '/new-plugin', - title: 'New Plugin', -}); +import { createPlugin } from '@backstage/core'; export default createPlugin({ id: 'new-plugin', register({ router, featureFlags }) { - router.addRoute(rootRouteRef, ExampleComponent); featureFlags.register('enable-example-component'); }, }); From 3fa63a36984f79080c765f784b6cbc29273924cc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Feb 2021 05:04:15 +0000 Subject: [PATCH 186/297] chore(deps): bump @rollup/plugin-commonjs from 16.0.0 to 17.1.0 Bumps [@rollup/plugin-commonjs](https://github.com/rollup/plugins) from 16.0.0 to 17.1.0. - [Release notes](https://github.com/rollup/plugins/releases) - [Commits](https://github.com/rollup/plugins/compare/commonjs-v16.0.0...commonjs-v17.1.0) Signed-off-by: dependabot[bot] --- packages/cli/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 35ad049741..ed373f6058 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,7 +35,7 @@ "@lerna/package-graph": "^3.18.5", "@lerna/project": "^3.18.0", "@octokit/request": "^5.4.12", - "@rollup/plugin-commonjs": "^16.0.0", + "@rollup/plugin-commonjs": "^17.1.0", "@rollup/plugin-json": "^4.0.2", "@rollup/plugin-node-resolve": "^9.0.0", "@rollup/plugin-yaml": "^2.1.1", diff --git a/yarn.lock b/yarn.lock index 75291bd66c..2ac7dffa6a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5234,10 +5234,10 @@ react-router-dom "6.0.0-beta.0" react-use "^15.3.3" -"@rollup/plugin-commonjs@^16.0.0": - version "16.0.0" - resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-16.0.0.tgz#169004d56cd0f0a1d0f35915d31a036b0efe281f" - integrity sha512-LuNyypCP3msCGVQJ7ki8PqYdpjfEkE/xtFa5DqlF+7IBD0JsfMZ87C58heSwIMint58sAUZbt3ITqOmdQv/dXw== +"@rollup/plugin-commonjs@^17.1.0": + version "17.1.0" + resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-17.1.0.tgz#757ec88737dffa8aa913eb392fade2e45aef2a2d" + integrity sha512-PoMdXCw0ZyvjpCMT5aV4nkL0QywxP29sODQsSGeDpr/oI49Qq9tRtAsb/LbYbDzFlOydVEqHmmZWFtXJEAX9ew== dependencies: "@rollup/pluginutils" "^3.1.0" commondir "^1.0.1" From 24e47ef1e3b1d84ea7ef0cf8f24fd9477374c22b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 1 Feb 2021 10:17:42 +0100 Subject: [PATCH 187/297] catalog-backend: throw correct error for not allowed entities --- .changeset/poor-ligers-flow.md | 5 +++++ plugins/catalog-backend/src/ingestion/LocationReaders.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/poor-ligers-flow.md diff --git a/.changeset/poor-ligers-flow.md b/.changeset/poor-ligers-flow.md new file mode 100644 index 0000000000..1c3a8a742b --- /dev/null +++ b/.changeset/poor-ligers-flow.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Throw `NotAllowedError` when registering locations with entities of disallowed kinds diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts index 046b6bdf76..a789e669d0 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { UrlReader } from '@backstage/backend-common'; +import { NotAllowedError, UrlReader } from '@backstage/backend-common'; import { Entity, EntityPolicy, @@ -102,7 +102,7 @@ export class LocationReaders implements LocationReader { } else { output.errors.push({ location: item.location, - error: new Error( + error: new NotAllowedError( `Entity of kind ${item.entity.kind} is not allowed from location ${item.location.type} ${item.location.target}`, ), }); From 978d4ab5a25cae186f4a08b43c2eb6bdd362ca7d Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 1 Feb 2021 10:54:53 +0100 Subject: [PATCH 188/297] docs(software-templates): updating docs for storePath --- docs/features/software-templates/index.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/features/software-templates/index.md b/docs/features/software-templates/index.md index 898075cb08..026c3f4bfb 100644 --- a/docs/features/software-templates/index.md +++ b/docs/features/software-templates/index.md @@ -40,10 +40,9 @@ internally. After filling in these variables, you'll get some more fields to fill out which are required for Backstage usage: the owner (which is a `user` in the backstage -system), the `storePath` (which right now must be a GitHub Organisation or -GitHub user and a non-existing GitHub repository name in the format -`organisation/reponame`), and a GitHub team or user account which should be -granted admin access to the repository. +system), and the `storePath` which is a destination URL to create for the +provider for instance http://github.com/backstage/my-new-repository, or +https://gitlab.com/myorg/myrepo. ![Enter Backstage vars](../../assets/software-templates/template-picked-2.png) From b32e65fd519e787eb53c6d85cd4f8d30fb46bf0a Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Mon, 1 Feb 2021 11:15:28 +0100 Subject: [PATCH 189/297] Only reexport CatalogApi from catalog-client --- plugins/catalog-react/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/catalog-react/src/index.ts b/plugins/catalog-react/src/index.ts index b0a9743ea4..9b6e13bb26 100644 --- a/plugins/catalog-react/src/index.ts +++ b/plugins/catalog-react/src/index.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from '@backstage/catalog-client'; +export type { CatalogApi } from '@backstage/catalog-client'; export { catalogApiRef } from './api'; export * from './components'; export * from './hooks'; From 59828b177f0952d0602a7cb62400b028a2cf724e Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Mon, 1 Feb 2021 11:27:31 +0100 Subject: [PATCH 190/297] Add catalog-client as dev dependency as it's used in tests --- plugins/catalog-import/package.json | 1 + .../catalog-import/src/components/ImportComponentForm.test.tsx | 3 ++- .../catalog-import/src/components/ImportComponentPage.test.tsx | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index 4c2017cda7..7f3cc3d1e3 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -49,6 +49,7 @@ "yaml": "^1.10.0" }, "devDependencies": { + "@backstage/catalog-client": "^0.3.5", "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", diff --git a/plugins/catalog-import/src/components/ImportComponentForm.test.tsx b/plugins/catalog-import/src/components/ImportComponentForm.test.tsx index 344dd9d470..38b1065325 100644 --- a/plugins/catalog-import/src/components/ImportComponentForm.test.tsx +++ b/plugins/catalog-import/src/components/ImportComponentForm.test.tsx @@ -13,13 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { CatalogClient } from '@backstage/catalog-client'; import { ApiProvider, ApiRegistry, DiscoveryApi, errorApiRef, } from '@backstage/core'; -import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { fireEvent, screen, waitFor } from '@testing-library/react'; import React from 'react'; diff --git a/plugins/catalog-import/src/components/ImportComponentPage.test.tsx b/plugins/catalog-import/src/components/ImportComponentPage.test.tsx index ad9abbe797..f6ef07e1b2 100644 --- a/plugins/catalog-import/src/components/ImportComponentPage.test.tsx +++ b/plugins/catalog-import/src/components/ImportComponentPage.test.tsx @@ -13,13 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { CatalogClient } from '@backstage/catalog-client'; import { ApiProvider, ApiRegistry, configApiRef, errorApiRef, } from '@backstage/core'; -import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { msw, renderInTestApp } from '@backstage/test-utils'; import { fireEvent, screen, waitFor } from '@testing-library/react'; import { rest } from 'msw'; From f27b40e6affaf26344c5a256ff72470f6a91a427 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Mon, 1 Feb 2021 11:42:23 +0100 Subject: [PATCH 191/297] docs: TechDocs HOW TO migrate from basic to recommended approach --- docs/features/techdocs/architecture.md | 3 ++- docs/features/techdocs/how-to-guides.md | 30 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/features/techdocs/architecture.md b/docs/features/techdocs/architecture.md index 92cd78b071..b69672246a 100644 --- a/docs/features/techdocs/architecture.md +++ b/docs/features/techdocs/architecture.md @@ -12,7 +12,8 @@ out-of-the box experience. TechDocs Architecture diagram > Note: See below for our recommended deployment architecture which takes care -> of stability, scalability and speed. +> of stability, scalability and speed. Also look at the +> [HOW TO migrate guide](how-to-guides.md#how-to-migrate-from-techdocs-basic-to-recommended-deployment-approach). When you open a TechDocs site in Backstage, the [TechDocs Reader](./concepts.md#techdocs-reader) makes a request to diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index b32ff40589..2cb769b0e3 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -5,6 +5,36 @@ sidebar_label: "HOW TO" guides description: TechDocs "HOW TO" guides related to TechDocs --- +## How to migrate from TechDocs Basic to Recommended deployment approach? + +The main difference between TechDocs Basic and Recommended deployment approach +is where the docs are generated and stored. In Basic or the out-of-the-box +setup, docs are generated and stored at the server running your Backstage +instance. But the recommended setup is to generate docs on CI/CD and store the +generated sites to an external storage (e.g. AWS S3 or GCS). TechDocs in your +Backstage instance should turn into read-only mode. Read more details and the +benefits in the [TechDocs Architecture](architecture.md). + +Here are the steps needed to switch from the Basic to Recommended setup - + +### 1. Prepare a cloud storage + +Choose a cloud storage provider like AWS, Google Cloud or Microsoft Azure. +Follow the detailed instructions for +[using cloud storage](using-cloud-storage.md) in TechDocs. + +### 2. Publish to storage from CI/CD + +Start publishing your TechDocs sites from the CI/CD workflow of each repository +containing the source markdown files. Read the detailed instructions for +[configuring CI/CD](configuring-ci-cd.md). + +### 3. Switch TechDocs to read-only mode + +In your Backstage instance's `app-config.yaml`, set `techdocs.builder` from +`'local'` to `'external'`. By doing this, TechDocs will not try to generate +docs. Look at [TechDocs configuration](configuration.md) for reference. + ## How to use URL Reader in TechDocs Prepare step? If TechDocs is configured to generate docs, it will first download the From ae508116d9a6530cfa0cd191463cecd6638cf11b Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Mon, 1 Feb 2021 12:02:44 +0100 Subject: [PATCH 192/297] docs: TechDocs revamp techdocs architecture page Remove/Update features which have been implemented. --- docs/features/techdocs/architecture.md | 80 ++++++++++---------------- 1 file changed, 29 insertions(+), 51 deletions(-) diff --git a/docs/features/techdocs/architecture.md b/docs/features/techdocs/architecture.md index b69672246a..8d25b4047f 100644 --- a/docs/features/techdocs/architecture.md +++ b/docs/features/techdocs/architecture.md @@ -17,28 +17,26 @@ out-of-the box experience. When you open a TechDocs site in Backstage, the [TechDocs Reader](./concepts.md#techdocs-reader) makes a request to -`techdocs-backend` with the entity ID and the path of the current page you are -looking at. In response, it receives the static files (HTML, CSS, JSON, etc.) to -render on the page in TechDocs/Backstage. +`techdocs-backend` plugin with the entity ID and the path of the current page +you are looking at. In response, it receives the static files (HTML, CSS, JSON, +etc.) to render on the page in TechDocs/Backstage. The static files consist of HTML, CSS and Images generated by MkDocs. We remove all the JavaScript before adding them to Backstage for security reasons. And -there are some additional techdocs metadata JSON files that TechDocs needs to -render a site. +there is an additional `techdocs_metadata.json` file that TechDocs needs to +render a site. It's important that you use either +[techdocs-cli](https://github.com/backstage/techdocs-cli) or +[techdocs-container](https://github.com/backstage/techdocs-container) to +generate the docs for the expected output. The TechDocs Reader then applies a list of "Transformers" (see [Concepts](./concepts.md)) which modify the generated static HTML files for a number of use cases e.g. Remove certain headers, filter out some HTML tags, etc. Currently, we use the Backstage server's (or techdocs-backend's) local file -system to store the generated files. Publishing to an external storage system -(AWS S3, GCS, etc.) is also possible, but has not been implemented yet. - -A word about `UrlReader` vs Git preparer - Right now, we have two ways to fetch -files from its source repository for docs site generation. 1. By using Git -and 2. By directly using Source control (GitHub, Azure, etc.) APIs. This work is -heavily in progress. Please reach out to us on Discord in the #docs-like-code -channel to talk about it. +system to store the generated files. However, it's ideal to use an external +storage system (e.g. AWS S3, GCS or Azure Blob Storage). Read more in +[using cloud storage](using-cloud-storage.md). ## Recommended deployment @@ -50,22 +48,16 @@ The key difference in the recommended deployment approach is where the docs are built. We assume each entity lives in a repository somewhere (GitHub, GitLab, etc.). We -recommend using a CI/CD pipeline with the repository that has a dedicated -step/job to generate docs for TechDocs. The generated static files are then -stored in a cloud storage solution of your choice. -[Track progress here](https://github.com/backstage/backstage/issues/3096). +recommend [using a CI/CD pipeline](configuring-ci-cd.md) with the repository +that has a dedicated step/job to generate docs for TechDocs. The generated +static files are then [stored in a cloud storage](using-cloud-storage.md) +solution of your choice. Similar to how it is done in the Basic setup, the TechDocs Reader requests `techdocs-backend` plugin for the docs site. `techdocs-backend` then requests your configured storage solution for the necessary files and returns them to TechDocs Reader. -We will provide instructions, scripts and/or templates (e.g. GitHub Actions) to -generate docs in your CI/CD system. -[Track progress here.](https://github.com/backstage/backstage/issues/3400) You -will be able to use `techdocs-cli` to generate docs and publish the generated -docs site files to your cloud storage system. - Note about caching: We have noticed internally that some storage providers can be quite slow, which is why we are recommending a cache that sits between the TechDocs Reader and the Storage. @@ -98,7 +90,7 @@ permissions to publish the generated docs site files. **Q: Why do you have separate "basic" and "recommended" deployment approaches?** A: The basic or out-of-the-box setup is what you get when you create a new app -or do a git clone of the Backstage repository. We want the first experience to +or do a `git clone` of the Backstage repository. We want the first experience to _just work magically_ so that you can have your first experience with TechDocs which is smooth. However, if you decide to deploy Backstage/TechDocs for production use, the basic setup would work but there are going to be downsides @@ -110,19 +102,21 @@ and we welcome such "Alternative" ideas from the community. **Q: Why don't you recommend techdocs-backend local filesystem to serve static files?** -A: It would make scaling a Backstage instance harder. Think about the case where -we have distributed Backstage deployments. Using a separate file storage system -for TechDocs makes it easier to do some operations like delete a docs site and -wipe its contents. +A: It makes scaling a Backstage instance harder. Think about the case where we +have distributed Backstage deployments (e.g. multiple Kubernetes pods of your +Backstage app). Using a separate/central file storage system for TechDocs is +necessary to make sure sites are persistent when the servers/pods are restarted +and avoid duplicating sites per instance. By having an external storage, we are +making it easier to do some operations like delete a docs site or wipe its +contents. **Q: Why aren't docs built on the fly i.e. when users visits a page, generate docs site in real-time?** -A: Generating the content from Markdown on the fly is not optimal (although that -is how the basic out-of-the-box setup is implemented). Storage solutions act as -a cache for the generated static content. TechDocs is also currently built on -MkDocs which does not allow us to generate docs per-page, so we would have to -build all docs for a entity on every request. +A: Generating the content from Markdown on the fly is not optimal. Storage +solutions act as a cache for the generated static content. TechDocs is also +currently built on MkDocs which does not allow us to generate docs per-page, so +we would have to build all docs for a entity on every request. **Q. Can you use the techdocs plugin without the techdocs-backend plugin?** @@ -147,21 +141,5 @@ We currently depend on MkDocs to parse doc sites written in Markdown. And we store the generated static assets and re-use it later to render in Backstage. A better (futuristic) approach will be to directly parse whatever type of source files you have in your docs repository and directly render in Backstage in -real-time. - -# Features status - -Status of all the features mentioned above. - -**In place ✅** - -- Basic setup with techdocs-backend file server as storage. -- Basic setup with cloud storage solution. -- `techdocs-cli` is able to generate docs in CI/CD environment. -- `techdocs-cli` is able to publish docs site to any storage. - -**Work in progress 🚧** - -**Not implemented yet ❌** - -- `techdocs-backend` integration with Backstage access control management. +real-time. You can read more in this +[RFC - Simplifying TechDocs Frontend Architecture](https://github.com/backstage/backstage/issues/3998). From 7fc89bae29cb71ea937f0d76261231e23a792169 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Fri, 29 Jan 2021 16:14:24 +0100 Subject: [PATCH 193/297] Display owner and system as entity page links in the tables of the `api-docs` plugin --- .changeset/empty-hairs-fetch.md | 13 +++ .../ApiExplorerTable/ApiExplorerTable.tsx | 82 ++++++++++++++++--- .../src/components/ApisCards/ApisTable.tsx | 81 +++++++++++++++--- .../ApisCards/ConsumedApisCard.test.tsx | 27 +++++- .../ApisCards/ProvidedApisCard.test.tsx | 27 +++++- .../ComponentsCards/ComponentsTable.tsx | 79 ++++++++++++++++-- .../ConsumingComponentsCard.test.tsx | 27 +++++- .../ProvidingComponentsCard.test.tsx | 27 +++++- .../components/EntityLink/EntityLink.test.tsx | 41 ---------- .../src/components/EntityLink/EntityLink.tsx | 43 ---------- plugins/catalog-react/src/index.ts | 1 + .../src/utils}/getEntityRelations.test.ts | 0 .../src/utils}/getEntityRelations.ts | 0 .../src/utils}/index.ts | 4 +- .../src/utils}/isOwnerOf.test.ts | 0 .../src/utils}/isOwnerOf.ts | 0 .../src/components/AboutCard/AboutContent.tsx | 6 +- .../components/CatalogPage/CatalogPage.tsx | 3 +- .../components/CatalogTable/CatalogTable.tsx | 2 +- .../Cards/OwnershipCard/OwnershipCard.tsx | 13 ++- .../org/src/components/getEntityRelations.ts | 42 ---------- plugins/org/src/components/isOwnerOf.ts | 55 ------------- 22 files changed, 339 insertions(+), 234 deletions(-) create mode 100644 .changeset/empty-hairs-fetch.md delete mode 100644 plugins/api-docs/src/components/EntityLink/EntityLink.test.tsx delete mode 100644 plugins/api-docs/src/components/EntityLink/EntityLink.tsx rename plugins/{catalog/src/components => catalog-react/src/utils}/getEntityRelations.test.ts (100%) rename plugins/{catalog/src/components => catalog-react/src/utils}/getEntityRelations.ts (100%) rename plugins/{api-docs/src/components/EntityLink => catalog-react/src/utils}/index.ts (85%) rename plugins/{catalog/src/components => catalog-react/src/utils}/isOwnerOf.test.ts (100%) rename plugins/{catalog/src/components => catalog-react/src/utils}/isOwnerOf.ts (100%) delete mode 100644 plugins/org/src/components/getEntityRelations.ts delete mode 100644 plugins/org/src/components/isOwnerOf.ts diff --git a/.changeset/empty-hairs-fetch.md b/.changeset/empty-hairs-fetch.md new file mode 100644 index 0000000000..aedeb8a853 --- /dev/null +++ b/.changeset/empty-hairs-fetch.md @@ -0,0 +1,13 @@ +--- +'@backstage/plugin-api-docs': patch +'@backstage/plugin-catalog': patch +'@backstage/plugin-catalog-react': patch +'@backstage/plugin-org': patch +--- + +Display owner and system as entity page links in the tables of the `api-docs` +plugin. + +Move `isOwnerOf` and `getEntityRelations` from `@backstage/plugin-catalog` to +`@backstage/plugin-catalog-react` and export it from there to use it by other +plugins. diff --git a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx index 149f7470c3..83af0ef216 100644 --- a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx +++ b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx @@ -14,7 +14,13 @@ * limitations under the License. */ -import { ApiEntityV1alpha1, Entity } from '@backstage/catalog-model'; +import { + ApiEntityV1alpha1, + Entity, + EntityName, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { Table, TableColumn, @@ -22,24 +28,54 @@ import { TableState, useQueryParamState, } from '@backstage/core'; +import { + EntityRefLink, + EntityRefLinks, + formatEntityRefTitle, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; import { Chip } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import React from 'react'; import { ApiTypeTitle } from '../ApiDefinitionCard'; -import { EntityLink } from '../EntityLink'; -const columns: TableColumn[] = [ +type EntityRow = ApiEntityV1alpha1 & { + row: { + partOfSystemRelationTitle?: string; + partOfSystemRelations: EntityName[]; + ownedByRelationsTitle?: string; + ownedByRelations: EntityName[]; + }; +}; + +const columns: TableColumn[] = [ { title: 'Name', field: 'metadata.name', highlight: true, - render: (entity: any) => ( - {entity.metadata.name} + render: entity => ( + {entity.metadata.name} + ), + }, + { + title: 'System', + field: 'row.partOfSystemRelationTitle', + render: entity => ( + ), }, { title: 'Owner', - field: 'spec.owner', + field: 'row.ownedByRelationsTitle', + render: entity => ( + + ), }, { title: 'Lifecycle', @@ -48,9 +84,7 @@ const columns: TableColumn[] = [ { title: 'Type', field: 'spec.type', - render: (entity: Entity) => ( - - ), + render: entity => , }, { title: 'Description', @@ -62,7 +96,7 @@ const columns: TableColumn[] = [ cellStyle: { padding: '0px 16px 0px 20px', }, - render: (entity: Entity) => ( + render: entity => ( <> {entity.metadata.tags && entity.metadata.tags.map(t => ( @@ -123,8 +157,32 @@ export const ApiExplorerTable = ({ ); } + const rows = entities.map(e => { + const partOfSystemRelations = getEntityRelations(e, RELATION_PART_OF, { + kind: 'system', + }); + const ownedByRelations = getEntityRelations(e, RELATION_OWNED_BY); + + return { + ...(e as ApiEntityV1alpha1), + row: { + ownedByRelationsTitle: ownedByRelations + .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) + .join(', '), + ownedByRelations, + partOfSystemRelationTitle: + partOfSystemRelations.length > 0 + ? formatEntityRefTitle(partOfSystemRelations[0], { + defaultKind: 'system', + }) + : undefined, + partOfSystemRelations, + }, + }; + }); + return ( - isLoading={loading} columns={columns} options={{ @@ -134,7 +192,7 @@ export const ApiExplorerTable = ({ padding: 'dense', showEmptyDataSourceMessage: !loading, }} - data={entities} + data={rows} filters={filters} initialState={queryParamState} onStateChange={setQueryParamState} diff --git a/plugins/api-docs/src/components/ApisCards/ApisTable.tsx b/plugins/api-docs/src/components/ApisCards/ApisTable.tsx index 7db62433eb..56ba697234 100644 --- a/plugins/api-docs/src/components/ApisCards/ApisTable.tsx +++ b/plugins/api-docs/src/components/ApisCards/ApisTable.tsx @@ -14,24 +14,59 @@ * limitations under the License. */ -import { ApiEntity } from '@backstage/catalog-model'; +import { + ApiEntity, + EntityName, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { Table, TableColumn } from '@backstage/core'; +import { + EntityRefLink, + EntityRefLinks, + formatEntityRefTitle, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; import React from 'react'; import { ApiTypeTitle } from '../ApiDefinitionCard'; -import { EntityLink } from '../EntityLink'; -const columns: TableColumn[] = [ +type EntityRow = ApiEntity & { + row: { + partOfSystemRelationTitle?: string; + partOfSystemRelations: EntityName[]; + ownedByRelationsTitle?: string; + ownedByRelations: EntityName[]; + }; +}; + +const columns: TableColumn[] = [ { title: 'Name', field: 'metadata.name', highlight: true, - render: (entity: any) => ( - {entity.metadata.name} + render: entity => ( + {entity.metadata.name} + ), + }, + { + title: 'System', + field: 'row.partOfSystemRelationTitle', + render: entity => ( + ), }, { title: 'Owner', - field: 'spec.owner', + field: 'row.ownedByRelationsTitle', + render: entity => ( + + ), }, { title: 'Lifecycle', @@ -40,7 +75,7 @@ const columns: TableColumn[] = [ { title: 'Type', field: 'spec.type', - render: (entity: ApiEntity) => , + render: entity => , }, { title: 'Description', @@ -65,8 +100,35 @@ export const ApisTable = ({ entities, title, variant = 'gridItem' }: Props) => { tableStyle.height = 'calc(100% - 10px)'; } + const rows = entities + // TODO: For now we skip all APIs that we can't find without a warning! + .filter(e => e !== undefined) + .map(e => { + const partOfSystemRelations = getEntityRelations(e, RELATION_PART_OF, { + kind: 'system', + }); + const ownedByRelations = getEntityRelations(e, RELATION_OWNED_BY); + + return { + ...(e as ApiEntity), + row: { + ownedByRelationsTitle: ownedByRelations + .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) + .join(', '), + ownedByRelations, + partOfSystemRelationTitle: + partOfSystemRelations.length > 0 + ? formatEntityRefTitle(partOfSystemRelations[0], { + defaultKind: 'system', + }) + : undefined, + partOfSystemRelations, + }, + }; + }); + return ( - + columns={columns} title={title} style={tableStyle} @@ -77,8 +139,7 @@ export const ApisTable = ({ entities, title, variant = 'gridItem' }: Props) => { actionsColumnIndex: -1, padding: 'dense', }} - // TODO: For now we skip all APIs that we can't find without a warning! - data={entities.filter(e => e !== undefined) as ApiEntity[]} + data={rows} /> ); }; diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx index d406e160fc..54cf08a429 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx @@ -14,7 +14,12 @@ * limitations under the License. */ -import { Entity, RELATION_CONSUMES_API } from '@backstage/catalog-model'; +import { + Entity, + RELATION_CONSUMES_API, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; @@ -99,10 +104,27 @@ describe('', () => { }, spec: { type: 'openapi', - owner: 'Test', lifecycle: 'production', definition: '...', }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'System', + name: 'MySystem', + namespace: 'default', + }, + }, + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'Test', + namespace: 'default', + }, + }, + ], }); apiDocsConfig.getApiDefinitionWidget.mockReturnValue({ type: 'openapi', @@ -121,6 +143,7 @@ describe('', () => { expect(getByText(/target-name/i)).toBeInTheDocument(); expect(getByText(/OpenAPI/)).toBeInTheDocument(); expect(getByText(/Test/i)).toBeInTheDocument(); + expect(getByText(/MySystem/i)).toBeInTheDocument(); expect(getByText(/production/i)).toBeInTheDocument(); }); }); diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx index 7b6ce6e18a..4300aeff6d 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx @@ -14,7 +14,12 @@ * limitations under the License. */ -import { Entity, RELATION_PROVIDES_API } from '@backstage/catalog-model'; +import { + Entity, + RELATION_OWNED_BY, + RELATION_PART_OF, + RELATION_PROVIDES_API, +} from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; @@ -99,10 +104,27 @@ describe('', () => { }, spec: { type: 'openapi', - owner: 'Test', lifecycle: 'production', definition: '...', }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'System', + name: 'MySystem', + namespace: 'default', + }, + }, + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'Test', + namespace: 'default', + }, + }, + ], }); apiDocsConfig.getApiDefinitionWidget.mockReturnValue({ type: 'openapi', @@ -120,6 +142,7 @@ describe('', () => { expect(getByText(/Provided APIs/i)).toBeInTheDocument(); expect(getByText(/target-name/i)).toBeInTheDocument(); expect(getByText(/OpenAPI/)).toBeInTheDocument(); + expect(getByText(/MySystem/)).toBeInTheDocument(); expect(getByText(/Test/i)).toBeInTheDocument(); expect(getByText(/production/i)).toBeInTheDocument(); }); diff --git a/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx b/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx index 1b62a56d10..2a6115f512 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx @@ -14,23 +14,58 @@ * limitations under the License. */ -import { ComponentEntity } from '@backstage/catalog-model'; +import { + ComponentEntity, + EntityName, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { Table, TableColumn } from '@backstage/core'; +import { + EntityRefLink, + EntityRefLinks, + formatEntityRefTitle, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; import React from 'react'; -import { EntityLink } from '../EntityLink'; -const columns: TableColumn[] = [ +type EntityRow = ComponentEntity & { + row: { + partOfSystemRelationTitle?: string; + partOfSystemRelations: EntityName[]; + ownedByRelationsTitle?: string; + ownedByRelations: EntityName[]; + }; +}; + +const columns: TableColumn[] = [ { title: 'Name', field: 'metadata.name', highlight: true, - render: (entity: any) => ( - {entity.metadata.name} + render: entity => ( + {entity.metadata.name} + ), + }, + { + title: 'System', + field: 'row.partOfSystemRelationTitle', + render: entity => ( + ), }, { title: 'Owner', - field: 'spec.owner', + field: 'row.ownedByRelationsTitle', + render: entity => ( + + ), }, { title: 'Lifecycle', @@ -68,8 +103,35 @@ export const ComponentsTable = ({ tableStyle.height = 'calc(100% - 10px)'; } + const rows = entities + // TODO: For now we skip all Components that we can't find without a warning! + .filter(e => e !== undefined) + .map(e => { + const partOfSystemRelations = getEntityRelations(e, RELATION_PART_OF, { + kind: 'system', + }); + const ownedByRelations = getEntityRelations(e, RELATION_OWNED_BY); + + return { + ...(e as ComponentEntity), + row: { + ownedByRelationsTitle: ownedByRelations + .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) + .join(', '), + ownedByRelations, + partOfSystemRelationTitle: + partOfSystemRelations.length > 0 + ? formatEntityRefTitle(partOfSystemRelations[0], { + defaultKind: 'system', + }) + : undefined, + partOfSystemRelations, + }, + }; + }); + return ( - + columns={columns} title={title} style={tableStyle} @@ -80,8 +142,7 @@ export const ComponentsTable = ({ actionsColumnIndex: -1, padding: 'dense', }} - // TODO: For now we skip all APIs that we can't find without a warning! - data={entities.filter(e => e !== undefined) as ComponentEntity[]} + data={rows} /> ); }; diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx index 99a8c0dc28..de66b2af06 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx @@ -14,7 +14,12 @@ * limitations under the License. */ -import { Entity, RELATION_API_CONSUMED_BY } from '@backstage/catalog-model'; +import { + Entity, + RELATION_API_CONSUMED_BY, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; @@ -104,9 +109,26 @@ describe('', () => { }, spec: { type: 'service', - owner: 'Test', lifecycle: 'production', }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'System', + name: 'MySystem', + namespace: 'default', + }, + }, + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'Test', + namespace: 'default', + }, + }, + ], }); const { getByText } = await renderInTestApp( @@ -119,6 +141,7 @@ describe('', () => { expect(getByText(/Consumers/i)).toBeInTheDocument(); expect(getByText(/target-name/i)).toBeInTheDocument(); expect(getByText(/Test/i)).toBeInTheDocument(); + expect(getByText(/MySystem/i)).toBeInTheDocument(); expect(getByText(/production/i)).toBeInTheDocument(); }); }); diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx index a3341ad8d0..b7b1616e00 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx @@ -14,7 +14,12 @@ * limitations under the License. */ -import { Entity, RELATION_API_PROVIDED_BY } from '@backstage/catalog-model'; +import { + Entity, + RELATION_API_PROVIDED_BY, + RELATION_OWNED_BY, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; @@ -104,9 +109,26 @@ describe('', () => { }, spec: { type: 'service', - owner: 'Test', lifecycle: 'production', }, + relations: [ + { + type: RELATION_PART_OF, + target: { + kind: 'System', + name: 'MySystem', + namespace: 'default', + }, + }, + { + type: RELATION_OWNED_BY, + target: { + kind: 'Group', + name: 'Test', + namespace: 'default', + }, + }, + ], }); const { getByText } = await renderInTestApp( @@ -119,6 +141,7 @@ describe('', () => { expect(getByText(/Providers/i)).toBeInTheDocument(); expect(getByText(/target-name/i)).toBeInTheDocument(); expect(getByText(/Test/i)).toBeInTheDocument(); + expect(getByText(/MySystem/i)).toBeInTheDocument(); expect(getByText(/production/i)).toBeInTheDocument(); }); }); diff --git a/plugins/api-docs/src/components/EntityLink/EntityLink.test.tsx b/plugins/api-docs/src/components/EntityLink/EntityLink.test.tsx deleted file mode 100644 index 9221b96604..0000000000 --- a/plugins/api-docs/src/components/EntityLink/EntityLink.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Entity } from '@backstage/catalog-model'; -import { renderInTestApp } from '@backstage/test-utils'; -import React from 'react'; -import { EntityLink } from './EntityLink'; - -describe('', () => { - it('provides link to entity page', async () => { - const entity: Entity = { - apiVersion: 'v1', - kind: 'Component', - metadata: { - name: 'my-name', - namespace: 'my-namespace', - }, - }; - const { getByText } = await renderInTestApp( - inner, - ); - expect(getByText(/inner/i)).toBeInTheDocument(); - expect(getByText(/inner/i)).toHaveAttribute( - 'href', - '/catalog/my-namespace/component/my-name', - ); - }); -}); diff --git a/plugins/api-docs/src/components/EntityLink/EntityLink.tsx b/plugins/api-docs/src/components/EntityLink/EntityLink.tsx deleted file mode 100644 index dba6bb0062..0000000000 --- a/plugins/api-docs/src/components/EntityLink/EntityLink.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Entity } from '@backstage/catalog-model'; -import { - entityRoute, - entityRouteParams, -} from '@backstage/plugin-catalog-react'; -import { Link } from '@material-ui/core'; -import React, { PropsWithChildren } from 'react'; -import { generatePath, Link as RouterLink } from 'react-router-dom'; - -type Props = { - entity: Entity; -}; - -// TODO: Could be useful for others too, as part of the catalog plugin -export const EntityLink = ({ entity, children }: PropsWithChildren) => { - return ( - - {children} - - ); -}; diff --git a/plugins/catalog-react/src/index.ts b/plugins/catalog-react/src/index.ts index 9b6e13bb26..af3eca4e0a 100644 --- a/plugins/catalog-react/src/index.ts +++ b/plugins/catalog-react/src/index.ts @@ -24,3 +24,4 @@ export { entityRouteRef, rootRoute, } from './routes'; +export * from './utils'; diff --git a/plugins/catalog/src/components/getEntityRelations.test.ts b/plugins/catalog-react/src/utils/getEntityRelations.test.ts similarity index 100% rename from plugins/catalog/src/components/getEntityRelations.test.ts rename to plugins/catalog-react/src/utils/getEntityRelations.test.ts diff --git a/plugins/catalog/src/components/getEntityRelations.ts b/plugins/catalog-react/src/utils/getEntityRelations.ts similarity index 100% rename from plugins/catalog/src/components/getEntityRelations.ts rename to plugins/catalog-react/src/utils/getEntityRelations.ts diff --git a/plugins/api-docs/src/components/EntityLink/index.ts b/plugins/catalog-react/src/utils/index.ts similarity index 85% rename from plugins/api-docs/src/components/EntityLink/index.ts rename to plugins/catalog-react/src/utils/index.ts index 44875e4634..2efb35703e 100644 --- a/plugins/api-docs/src/components/EntityLink/index.ts +++ b/plugins/catalog-react/src/utils/index.ts @@ -13,5 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -export { EntityLink } from './EntityLink'; +export { getEntityRelations } from './getEntityRelations'; +export { isOwnerOf } from './isOwnerOf'; diff --git a/plugins/catalog/src/components/isOwnerOf.test.ts b/plugins/catalog-react/src/utils/isOwnerOf.test.ts similarity index 100% rename from plugins/catalog/src/components/isOwnerOf.test.ts rename to plugins/catalog-react/src/utils/isOwnerOf.test.ts diff --git a/plugins/catalog/src/components/isOwnerOf.ts b/plugins/catalog-react/src/utils/isOwnerOf.ts similarity index 100% rename from plugins/catalog/src/components/isOwnerOf.ts rename to plugins/catalog-react/src/utils/isOwnerOf.ts diff --git a/plugins/catalog/src/components/AboutCard/AboutContent.tsx b/plugins/catalog/src/components/AboutCard/AboutContent.tsx index bb55e00e93..75b7827111 100644 --- a/plugins/catalog/src/components/AboutCard/AboutContent.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutContent.tsx @@ -19,10 +19,12 @@ import { RELATION_OWNED_BY, RELATION_PART_OF, } from '@backstage/catalog-model'; -import { EntityRefLinks } from '@backstage/plugin-catalog-react'; +import { + EntityRefLinks, + getEntityRelations, +} from '@backstage/plugin-catalog-react'; import { Chip, Grid, makeStyles, Typography } from '@material-ui/core'; import React from 'react'; -import { getEntityRelations } from '../getEntityRelations'; import { AboutField } from './AboutField'; const useStyles = makeStyles({ diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index 0384c75c96..1298d5e190 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -22,7 +22,7 @@ import { SupportButton, useApi, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { catalogApiRef, isOwnerOf } from '@backstage/plugin-catalog-react'; import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder'; import { Button, makeStyles } from '@material-ui/core'; import SettingsIcon from '@material-ui/icons/Settings'; @@ -33,7 +33,6 @@ import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter'; import { useStarredEntities } from '../../hooks/useStarredEntities'; import { ButtonGroup, CatalogFilter } from '../CatalogFilter/CatalogFilter'; import { CatalogTable } from '../CatalogTable/CatalogTable'; -import { isOwnerOf } from '../isOwnerOf'; import { ResultsFilter } from '../ResultsFilter/ResultsFilter'; import { useOwnUser } from '../useOwnUser'; import CatalogLayout from './CatalogLayout'; diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index ed0becf270..049b2a2052 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -24,6 +24,7 @@ import { EntityRefLink, EntityRefLinks, formatEntityRefTitle, + getEntityRelations, } from '@backstage/plugin-catalog-react'; import { Chip } from '@material-ui/core'; import Edit from '@material-ui/icons/Edit'; @@ -37,7 +38,6 @@ import { favouriteEntityIcon, favouriteEntityTooltip, } from '../FavouriteEntity/FavouriteEntity'; -import { getEntityRelations } from '../getEntityRelations'; type EntityRow = Entity & { row: { diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx index 4f59c4be69..60f06bd53a 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx @@ -14,12 +14,10 @@ * limitations under the License. */ -import React from 'react'; -import { InfoCard, useApi, Progress } from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { useAsync } from 'react-use'; -import Alert from '@material-ui/lab/Alert'; +import { InfoCard, Progress, useApi } from '@backstage/core'; +import { catalogApiRef, isOwnerOf } from '@backstage/plugin-catalog-react'; +import { pageTheme } from '@backstage/theme'; import { Box, createStyles, @@ -28,8 +26,9 @@ import { Theme, Typography, } from '@material-ui/core'; -import { pageTheme } from '@backstage/theme'; -import { isOwnerOf } from '../../isOwnerOf'; +import Alert from '@material-ui/lab/Alert'; +import React from 'react'; +import { useAsync } from 'react-use'; type EntitiesKinds = 'Component' | 'API'; type EntitiesTypes = diff --git a/plugins/org/src/components/getEntityRelations.ts b/plugins/org/src/components/getEntityRelations.ts deleted file mode 100644 index 9230aaebee..0000000000 --- a/plugins/org/src/components/getEntityRelations.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Entity, EntityName } from '@backstage/catalog-model'; - -// TODO: this file is copied from /packages/app/catalog/src/components/getEntityRelations.ts and -// should be replaced once common relation-functions are introduced. - -/** - * Get the related entity references. - */ -export function getEntityRelations( - entity: Entity | undefined, - relationType: string, - filter?: { kind: string }, -): EntityName[] { - let entityNames = - entity?.relations - ?.filter(r => r.type === relationType) - ?.map(r => r.target) || []; - - if (filter?.kind) { - entityNames = entityNames?.filter( - e => e.kind.toLowerCase() === filter.kind.toLowerCase(), - ); - } - - return entityNames; -} diff --git a/plugins/org/src/components/isOwnerOf.ts b/plugins/org/src/components/isOwnerOf.ts deleted file mode 100644 index dd7c7d6805..0000000000 --- a/plugins/org/src/components/isOwnerOf.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - Entity, - EntityName, - getEntityName, - RELATION_MEMBER_OF, - RELATION_OWNED_BY, -} from '@backstage/catalog-model'; -import { getEntityRelations } from './getEntityRelations'; - -// TODO: this file is copied from /packages/app/catalog/src/components/isOwnerOf.ts and -// should be replaced once common relation-functions are introduced. - -/** - * Check if one entity is owned by another. Returns true, if the entity is owned by the - * owner directly, or if the entity is owned by a group that the owner is a member of. - */ -export function isOwnerOf(owner: Entity, owned: Entity) { - const possibleOwners: EntityName[] = [ - ...getEntityRelations(owner, RELATION_MEMBER_OF, { kind: 'group' }), - ...(owner ? [getEntityName(owner)] : []), - ]; - - const owners = getEntityRelations(owned, RELATION_OWNED_BY); - - for (const owner of owners) { - if ( - possibleOwners.find( - o => - owner.kind.toLowerCase() === o.kind.toLowerCase() && - owner.namespace.toLowerCase() === o.namespace.toLowerCase() && - owner.name.toLowerCase() === o.name.toLowerCase(), - ) !== undefined - ) { - return true; - } - } - - return false; -} From 5ac9df899520bb4d35364636fc923f0825c9cf48 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Mon, 1 Feb 2021 12:19:04 +0100 Subject: [PATCH 194/297] Ports the Fossa Plugin to the new plugin model Towards #3424 --- .changeset/pretty-melons-prove.md | 8 ++ plugins/fossa/README.md | 8 +- plugins/fossa/dev/index.tsx | 132 +++++++++++++++--------------- plugins/fossa/package.json | 1 + plugins/fossa/src/extensions.tsx | 35 ++++++++ plugins/fossa/src/index.ts | 4 +- plugins/fossa/src/plugin.test.ts | 4 +- plugins/fossa/src/plugin.ts | 2 +- 8 files changed, 119 insertions(+), 75 deletions(-) create mode 100644 .changeset/pretty-melons-prove.md create mode 100644 plugins/fossa/src/extensions.tsx diff --git a/.changeset/pretty-melons-prove.md b/.changeset/pretty-melons-prove.md new file mode 100644 index 0000000000..7048757e5a --- /dev/null +++ b/.changeset/pretty-melons-prove.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-fossa': minor +--- + +Port FOSSA plugin to new extension model. + +If you are using the FOSSA plugin adjust the plugin import from `plugin` to +`fossaPlugin` and replace `` with ``. diff --git a/plugins/fossa/README.md b/plugins/fossa/README.md index 489aef90ab..6e53ced0cf 100644 --- a/plugins/fossa/README.md +++ b/plugins/fossa/README.md @@ -19,21 +19,21 @@ yarn add @backstage/plugin-fossa ```js // packages/app/src/plugins.ts -export { plugin as Fossa } from '@backstage/plugin-fossa'; +export { fossaPlugin } from '@backstage/plugin-fossa'; ``` -3. Add the `FossaCard` to the EntityPage: +3. Add the `EntityFossaCard` to the EntityPage: ```jsx // packages/app/src/components/catalog/EntityPage.tsx -import { FossaCard } from '@backstage/plugin-fossa'; +import { EntityFossaCard } from '@backstage/plugin-fossa'; const OverviewContent = ({ entity }: { entity: Entity }) => ( // ... - + // ... diff --git a/plugins/fossa/dev/index.tsx b/plugins/fossa/dev/index.tsx index 7846324b25..06c71c48b7 100644 --- a/plugins/fossa/dev/index.tsx +++ b/plugins/fossa/dev/index.tsx @@ -14,21 +14,28 @@ * limitations under the License. */ -import { createDevApp } from '@backstage/dev-utils'; -import { - Content, - createPlugin, - createRouteRef, - Header, - Page, -} from '@backstage/core'; -import React from 'react'; -import { Grid } from '@material-ui/core'; -import { FossaApi, fossaApiRef } from '../src/api'; -import { FossaCard } from '../src'; import { Entity } from '@backstage/catalog-model'; +import { Content, Header, Page } from '@backstage/core'; +import { createDevApp } from '@backstage/dev-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { Grid } from '@material-ui/core'; +import React from 'react'; +import { EntityFossaCard } from '../src'; +import { FossaApi, fossaApiRef } from '../src/api'; import { FOSSA_PROJECT_NAME_ANNOTATION } from '../src/components/useProjectName'; +const entity = (name?: string) => + ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + annotations: { + [FOSSA_PROJECT_NAME_ANNOTATION]: name, + }, + name: name, + }, + } as Entity); + createDevApp() .registerApi({ api: fossaApiRef, @@ -76,58 +83,51 @@ createDevApp() }, } as FossaApi), }) - .registerPlugin( - createPlugin({ - id: 'fossa-demo', - register({ router }) { - const entity = (name?: string) => - ({ - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - annotations: { - [FOSSA_PROJECT_NAME_ANNOTATION]: name, - }, - name: name, - }, - } as Entity); - - const ExamplePage = () => ( - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - ); - - router.addRoute( - createRouteRef({ path: '/', title: 'Fossa' }), - ExamplePage, - ); - }, - }), - ) + .addPage({ + title: 'Entity Content', + element: ( + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ), + }) .render(); diff --git a/plugins/fossa/package.json b/plugins/fossa/package.json index b69039fa00..879f12c6f6 100644 --- a/plugins/fossa/package.json +++ b/plugins/fossa/package.json @@ -33,6 +33,7 @@ "dependencies": { "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/fossa/src/extensions.tsx b/plugins/fossa/src/extensions.tsx new file mode 100644 index 0000000000..e2bfeed134 --- /dev/null +++ b/plugins/fossa/src/extensions.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 { createComponentExtension } from '@backstage/core'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { fossaPlugin } from './plugin'; + +export const EntityFossaCard = fossaPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/FossaCard').then(({ FossaCard }) => { + const EntityFossaCard = () => { + const { entity } = useEntity(); + return ; + }; + return EntityFossaCard; + }), + }, + }), +); diff --git a/plugins/fossa/src/index.ts b/plugins/fossa/src/index.ts index c35c534122..f1b645c29b 100644 --- a/plugins/fossa/src/index.ts +++ b/plugins/fossa/src/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export { plugin } from './plugin'; -export * from './components'; +export { fossaPlugin } from './plugin'; +export { EntityFossaCard } from './extensions'; diff --git a/plugins/fossa/src/plugin.test.ts b/plugins/fossa/src/plugin.test.ts index 4f9b00a02b..9aed1387c5 100644 --- a/plugins/fossa/src/plugin.test.ts +++ b/plugins/fossa/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { fossaPlugin } from './plugin'; describe('fossa', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(fossaPlugin).toBeDefined(); }); }); diff --git a/plugins/fossa/src/plugin.ts b/plugins/fossa/src/plugin.ts index d1fe621018..cb1de3911f 100644 --- a/plugins/fossa/src/plugin.ts +++ b/plugins/fossa/src/plugin.ts @@ -22,7 +22,7 @@ import { } from '@backstage/core'; import { fossaApiRef, FossaClient } from './api'; -export const plugin = createPlugin({ +export const fossaPlugin = createPlugin({ id: 'fossa', apis: [ createApiFactory({ From ac14e108d7486c2c29a802e9c8b4822a4a07209f Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Mon, 1 Feb 2021 12:32:37 +0100 Subject: [PATCH 195/297] Simplify code --- .../src/components/FossaCard/FossaCard.tsx | 21 +++++++------------ plugins/fossa/src/extensions.tsx | 11 +--------- 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/plugins/fossa/src/components/FossaCard/FossaCard.tsx b/plugins/fossa/src/components/FossaCard/FossaCard.tsx index d1ac06d365..4cc27f5d03 100644 --- a/plugins/fossa/src/components/FossaCard/FossaCard.tsx +++ b/plugins/fossa/src/components/FossaCard/FossaCard.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import React from 'react'; import { EmptyState, InfoCard, @@ -22,15 +21,16 @@ import { Progress, useApi, } from '@backstage/core'; -import { useAsync } from 'react-use'; -import { Entity } from '@backstage/catalog-model'; -import { fossaApiRef } from '../../api'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { Grid, Tooltip } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { fossaApiRef } from '../../api'; import { FOSSA_PROJECT_NAME_ANNOTATION, useProjectName, } from '../useProjectName'; -import { Grid, Tooltip } from '@material-ui/core'; const useStyles = makeStyles(theme => ({ numberError: { @@ -65,13 +65,8 @@ const useStyles = makeStyles(theme => ({ }, })); -export const FossaCard = ({ - entity, - variant = 'gridItem', -}: { - entity: Entity; - variant?: string; -}) => { +export const FossaCard = () => { + const { entity } = useEntity(); const fossaApi = useApi(fossaApiRef); const projectTitle = useProjectName(entity); @@ -96,7 +91,7 @@ export const FossaCard = ({ - import('./components/FossaCard').then(({ FossaCard }) => { - const EntityFossaCard = () => { - const { entity } = useEntity(); - return ; - }; - return EntityFossaCard; - }), + lazy: () => import('./components/FossaCard').then(m => m.FossaCard), }, }), ); From 3bdd6eac0780d3d7298b50cb129e53a340b94e72 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Mon, 1 Feb 2021 13:36:46 +0100 Subject: [PATCH 196/297] techdocs: Fix broken tests on Windows https://github.com/backstage/backstage/runs/1805649470\?check_suite_focus\=true --- .../src/stages/publish/azureBlobStorage.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts index 9d2ee1ba9b..80f3cb0cf7 100644 --- a/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts +++ b/packages/techdocs-common/src/stages/publish/azureBlobStorage.test.ts @@ -115,10 +115,8 @@ describe('AzureBlobStoragePublish', () => { directory: wrongPathToGeneratedDirectory, }) .catch(error => - expect(error).toEqual( - new Error( - `Unable to upload file(s) to Azure Blob Storage. Error Failed to read template directory: ENOENT, no such file or directory '${wrongPathToGeneratedDirectory}'`, - ), + expect(error.message).toContain( + 'Unable to upload file(s) to Azure Blob Storage. Error Failed to read template directory: ENOENT, no such file or directory', ), ); mockFs.restore(); From 5d42c0421825f0f1e6d97cd4ff6bc989c8969cdb Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Mon, 1 Feb 2021 13:41:14 +0100 Subject: [PATCH 197/297] Update docs/features/software-templates/index.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- docs/features/software-templates/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/software-templates/index.md b/docs/features/software-templates/index.md index 026c3f4bfb..701b38276a 100644 --- a/docs/features/software-templates/index.md +++ b/docs/features/software-templates/index.md @@ -41,7 +41,7 @@ internally. After filling in these variables, you'll get some more fields to fill out which are required for Backstage usage: the owner (which is a `user` in the backstage system), and the `storePath` which is a destination URL to create for the -provider for instance http://github.com/backstage/my-new-repository, or +provider, for instance `https://github.com/backstage/my-new-repository`, or https://gitlab.com/myorg/myrepo. ![Enter Backstage vars](../../assets/software-templates/template-picked-2.png) From c0a5e2710aff50cc9c123e94d26351b280f2119c Mon Sep 17 00:00:00 2001 From: Ben Lambert Date: Mon, 1 Feb 2021 13:41:20 +0100 Subject: [PATCH 198/297] Update docs/features/software-templates/index.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- docs/features/software-templates/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/software-templates/index.md b/docs/features/software-templates/index.md index 701b38276a..12ce6e3ed3 100644 --- a/docs/features/software-templates/index.md +++ b/docs/features/software-templates/index.md @@ -42,7 +42,7 @@ After filling in these variables, you'll get some more fields to fill out which are required for Backstage usage: the owner (which is a `user` in the backstage system), and the `storePath` which is a destination URL to create for the provider, for instance `https://github.com/backstage/my-new-repository`, or -https://gitlab.com/myorg/myrepo. +`https://gitlab.com/myorg/myrepo`. ![Enter Backstage vars](../../assets/software-templates/template-picked-2.png) From e3d9da115c72f6fe78e7da5188f81dd0a6d166b9 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Mon, 1 Feb 2021 13:53:18 +0100 Subject: [PATCH 199/297] Correctly set the table value for sort/filtering --- .../ApiExplorerTable/ApiExplorerTable.tsx | 13 +++++++------ .../api-docs/src/components/ApisCards/ApisTable.tsx | 13 +++++++------ .../components/ComponentsCards/ComponentsTable.tsx | 13 +++++++------ .../src/components/CatalogTable/CatalogTable.tsx | 13 +++++++------ 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx index 83af0ef216..7aa09a2c02 100644 --- a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx +++ b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx @@ -170,12 +170,13 @@ export const ApiExplorerTable = ({ .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) .join(', '), ownedByRelations, - partOfSystemRelationTitle: - partOfSystemRelations.length > 0 - ? formatEntityRefTitle(partOfSystemRelations[0], { - defaultKind: 'system', - }) - : undefined, + partOfSystemRelationTitle: partOfSystemRelations + .map(r => + formatEntityRefTitle(r, { + defaultKind: 'system', + }), + ) + .join(', '), partOfSystemRelations, }, }; diff --git a/plugins/api-docs/src/components/ApisCards/ApisTable.tsx b/plugins/api-docs/src/components/ApisCards/ApisTable.tsx index 56ba697234..0595044327 100644 --- a/plugins/api-docs/src/components/ApisCards/ApisTable.tsx +++ b/plugins/api-docs/src/components/ApisCards/ApisTable.tsx @@ -116,12 +116,13 @@ export const ApisTable = ({ entities, title, variant = 'gridItem' }: Props) => { .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) .join(', '), ownedByRelations, - partOfSystemRelationTitle: - partOfSystemRelations.length > 0 - ? formatEntityRefTitle(partOfSystemRelations[0], { - defaultKind: 'system', - }) - : undefined, + partOfSystemRelationTitle: partOfSystemRelations + .map(r => + formatEntityRefTitle(r, { + defaultKind: 'system', + }), + ) + .join(', '), partOfSystemRelations, }, }; diff --git a/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx b/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx index 2a6115f512..1ac82991eb 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx @@ -119,12 +119,13 @@ export const ComponentsTable = ({ .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) .join(', '), ownedByRelations, - partOfSystemRelationTitle: - partOfSystemRelations.length > 0 - ? formatEntityRefTitle(partOfSystemRelations[0], { - defaultKind: 'system', - }) - : undefined, + partOfSystemRelationTitle: partOfSystemRelations + .map(r => + formatEntityRefTitle(r, { + defaultKind: 'system', + }), + ) + .join(', '), partOfSystemRelations, }, }; diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 049b2a2052..6f91b250df 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -180,12 +180,13 @@ export const CatalogTable = ({ .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) .join(', '), ownedByRelations, - partOfSystemRelationTitle: - partOfSystemRelations.length > 0 - ? formatEntityRefTitle(partOfSystemRelations[0], { - defaultKind: 'system', - }) - : undefined, + partOfSystemRelationTitle: partOfSystemRelations + .map(r => + formatEntityRefTitle(r, { + defaultKind: 'system', + }), + ) + .join(', '), partOfSystemRelations, }, }; From e646d8065a0fb79f8c0883839c0cb46dbee36f64 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Mon, 1 Feb 2021 14:29:24 +0100 Subject: [PATCH 200/297] Change EntityRow to have an entity and a resolved field instead of combining them --- .../ApiExplorerTable/ApiExplorerTable.tsx | 43 ++++++++------- .../src/components/ApisCards/ApisTable.tsx | 47 +++++++++------- .../ComponentsCards/ComponentsTable.tsx | 45 ++++++++------- .../components/CatalogTable/CatalogTable.tsx | 55 ++++++++++--------- 4 files changed, 101 insertions(+), 89 deletions(-) diff --git a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx index 7aa09a2c02..6baacf5961 100644 --- a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx +++ b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx @@ -39,8 +39,9 @@ import { Alert } from '@material-ui/lab'; import React from 'react'; import { ApiTypeTitle } from '../ApiDefinitionCard'; -type EntityRow = ApiEntityV1alpha1 & { - row: { +type EntityRow = { + entity: ApiEntityV1alpha1; + resolved: { partOfSystemRelationTitle?: string; partOfSystemRelations: EntityName[]; ownedByRelationsTitle?: string; @@ -51,52 +52,52 @@ type EntityRow = ApiEntityV1alpha1 & { const columns: TableColumn[] = [ { title: 'Name', - field: 'metadata.name', + field: 'entity.metadata.name', highlight: true, - render: entity => ( + render: ({ entity }) => ( {entity.metadata.name} ), }, { title: 'System', - field: 'row.partOfSystemRelationTitle', - render: entity => ( + field: 'resolved.partOfSystemRelationTitle', + render: ({ resolved }) => ( ), }, { title: 'Owner', - field: 'row.ownedByRelationsTitle', - render: entity => ( + field: 'resolved.ownedByRelationsTitle', + render: ({ resolved }) => ( ), }, { title: 'Lifecycle', - field: 'spec.lifecycle', + field: 'entity.spec.lifecycle', }, { title: 'Type', - field: 'spec.type', - render: entity => , + field: 'entity.spec.type', + render: ({ entity }) => , }, { title: 'Description', - field: 'metadata.description', + field: 'entity.metadata.description', }, { title: 'Tags', - field: 'metadata.tags', + field: 'entity.metadata.tags', cellStyle: { padding: '0px 16px 0px 20px', }, - render: entity => ( + render: ({ entity }) => ( <> {entity.metadata.tags && entity.metadata.tags.map(t => ( @@ -157,15 +158,15 @@ export const ApiExplorerTable = ({ ); } - const rows = entities.map(e => { - const partOfSystemRelations = getEntityRelations(e, RELATION_PART_OF, { + const rows = entities.map(entity => { + const partOfSystemRelations = getEntityRelations(entity, RELATION_PART_OF, { kind: 'system', }); - const ownedByRelations = getEntityRelations(e, RELATION_OWNED_BY); + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); return { - ...(e as ApiEntityV1alpha1), - row: { + entity: entity as ApiEntityV1alpha1, + resolved: { ownedByRelationsTitle: ownedByRelations .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) .join(', '), diff --git a/plugins/api-docs/src/components/ApisCards/ApisTable.tsx b/plugins/api-docs/src/components/ApisCards/ApisTable.tsx index 0595044327..30716a61fb 100644 --- a/plugins/api-docs/src/components/ApisCards/ApisTable.tsx +++ b/plugins/api-docs/src/components/ApisCards/ApisTable.tsx @@ -30,8 +30,9 @@ import { import React from 'react'; import { ApiTypeTitle } from '../ApiDefinitionCard'; -type EntityRow = ApiEntity & { - row: { +type EntityRow = { + entity: ApiEntity; + resolved: { partOfSystemRelationTitle?: string; partOfSystemRelations: EntityName[]; ownedByRelationsTitle?: string; @@ -42,44 +43,44 @@ type EntityRow = ApiEntity & { const columns: TableColumn[] = [ { title: 'Name', - field: 'metadata.name', + field: 'entity.metadata.name', highlight: true, - render: entity => ( + render: ({ entity }) => ( {entity.metadata.name} ), }, { title: 'System', - field: 'row.partOfSystemRelationTitle', - render: entity => ( + field: 'resolved.partOfSystemRelationTitle', + render: ({ resolved }) => ( ), }, { title: 'Owner', - field: 'row.ownedByRelationsTitle', - render: entity => ( + field: 'resolved.ownedByRelationsTitle', + render: ({ resolved }) => ( ), }, { title: 'Lifecycle', - field: 'spec.lifecycle', + field: 'entity.spec.lifecycle', }, { title: 'Type', - field: 'spec.type', - render: entity => , + field: 'entity.spec.type', + render: ({ entity }) => , }, { title: 'Description', - field: 'metadata.description', + field: 'entity.metadata.description', width: 'auto', }, ]; @@ -103,15 +104,19 @@ export const ApisTable = ({ entities, title, variant = 'gridItem' }: Props) => { const rows = entities // TODO: For now we skip all APIs that we can't find without a warning! .filter(e => e !== undefined) - .map(e => { - const partOfSystemRelations = getEntityRelations(e, RELATION_PART_OF, { - kind: 'system', - }); - const ownedByRelations = getEntityRelations(e, RELATION_OWNED_BY); + .map(entity => { + const partOfSystemRelations = getEntityRelations( + entity, + RELATION_PART_OF, + { + kind: 'system', + }, + ); + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); return { - ...(e as ApiEntity), - row: { + entity: entity as ApiEntity, + resolved: { ownedByRelationsTitle: ownedByRelations .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) .join(', '), diff --git a/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx b/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx index 1ac82991eb..bd9b0c765f 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ComponentsTable.tsx @@ -29,8 +29,9 @@ import { } from '@backstage/plugin-catalog-react'; import React from 'react'; -type EntityRow = ComponentEntity & { - row: { +type EntityRow = { + entity: ComponentEntity; + resolved: { partOfSystemRelationTitle?: string; partOfSystemRelations: EntityName[]; ownedByRelationsTitle?: string; @@ -41,43 +42,43 @@ type EntityRow = ComponentEntity & { const columns: TableColumn[] = [ { title: 'Name', - field: 'metadata.name', + field: 'entity.metadata.name', highlight: true, - render: entity => ( + render: ({ entity }) => ( {entity.metadata.name} ), }, { title: 'System', - field: 'row.partOfSystemRelationTitle', - render: entity => ( + field: 'resolved.partOfSystemRelationTitle', + render: ({ resolved }) => ( ), }, { title: 'Owner', - field: 'row.ownedByRelationsTitle', - render: entity => ( + field: 'resolved.ownedByRelationsTitle', + render: ({ resolved }) => ( ), }, { title: 'Lifecycle', - field: 'spec.lifecycle', + field: 'entity.spec.lifecycle', }, { title: 'Type', - field: 'spec.type', + field: 'entity.spec.type', }, { title: 'Description', - field: 'metadata.description', + field: 'entity.metadata.description', width: 'auto', }, ]; @@ -106,15 +107,19 @@ export const ComponentsTable = ({ const rows = entities // TODO: For now we skip all Components that we can't find without a warning! .filter(e => e !== undefined) - .map(e => { - const partOfSystemRelations = getEntityRelations(e, RELATION_PART_OF, { - kind: 'system', - }); - const ownedByRelations = getEntityRelations(e, RELATION_OWNED_BY); + .map(entity => { + const partOfSystemRelations = getEntityRelations( + entity, + RELATION_PART_OF, + { + kind: 'system', + }, + ); + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); return { - ...(e as ComponentEntity), - row: { + entity: entity as ComponentEntity, + resolved: { ownedByRelationsTitle: ownedByRelations .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) .join(', '), diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 6f91b250df..5fc4445053 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -39,8 +39,9 @@ import { favouriteEntityTooltip, } from '../FavouriteEntity/FavouriteEntity'; -type EntityRow = Entity & { - row: { +type EntityRow = { + entity: Entity; + resolved: { partOfSystemRelationTitle?: string; partOfSystemRelations: EntityName[]; ownedByRelationsTitle?: string; @@ -51,47 +52,47 @@ type EntityRow = Entity & { const columns: TableColumn[] = [ { title: 'Name', - field: 'metadata.name', + field: 'entity.metadata.name', highlight: true, - render: entity => ( + render: ({ entity }) => ( {entity.metadata.name} ), }, { title: 'System', - field: 'row.partOfSystemRelationTitle', - render: entity => ( + field: 'resolved.partOfSystemRelationTitle', + render: ({ resolved }) => ( ), }, { title: 'Owner', - field: 'row.ownedByRelationsTitle', - render: entity => ( + field: 'resolved.ownedByRelationsTitle', + render: ({ resolved }) => ( ), }, { title: 'Lifecycle', - field: 'spec.lifecycle', + field: 'entity.spec.lifecycle', }, { title: 'Description', - field: 'metadata.description', + field: 'entity.metadata.description', }, { title: 'Tags', - field: 'metadata.tags', + field: 'entity.metadata.tags', cellStyle: { padding: '0px 16px 0px 20px', }, - render: entity => ( + render: ({ entity }) => ( <> {entity.metadata.tags && entity.metadata.tags.map(t => ( @@ -133,9 +134,9 @@ export const CatalogTable = ({ ); } - const actions: TableProps['actions'] = [ - (rowData: Entity) => { - const location = findLocationForEntityMeta(rowData.metadata); + const actions: TableProps['actions'] = [ + ({ entity }) => { + const location = findLocationForEntityMeta(entity.metadata); return { icon: () => , tooltip: 'View', @@ -145,8 +146,8 @@ export const CatalogTable = ({ }, }; }, - (rowData: Entity) => { - const location = findLocationForEntityMeta(rowData.metadata); + ({ entity }) => { + const location = findLocationForEntityMeta(entity.metadata); return { icon: () => , tooltip: 'Edit', @@ -156,26 +157,26 @@ export const CatalogTable = ({ }, }; }, - (rowData: Entity) => { - const isStarred = isStarredEntity(rowData); + ({ entity }) => { + const isStarred = isStarredEntity(entity); return { cellStyle: { paddingLeft: '1em' }, icon: () => favouriteEntityIcon(isStarred), tooltip: favouriteEntityTooltip(isStarred), - onClick: () => toggleStarredEntity(rowData), + onClick: () => toggleStarredEntity(entity), }; }, ]; - const rows = entities.map(e => { - const partOfSystemRelations = getEntityRelations(e, RELATION_PART_OF, { + const rows = entities.map(entity => { + const partOfSystemRelations = getEntityRelations(entity, RELATION_PART_OF, { kind: 'system', }); - const ownedByRelations = getEntityRelations(e, RELATION_OWNED_BY); + const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY); return { - ...e, - row: { + entity, + resolved: { ownedByRelationsTitle: ownedByRelations .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) .join(', '), From e3a11945eaa1fda3b29791d248d35228f17c028f Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Fri, 29 Jan 2021 17:34:51 -0500 Subject: [PATCH 201/297] Add repoVisibility for GitLab repos --- .../scaffolder/stages/publish/gitlab.test.ts | 26 ++++++++++++------- .../src/scaffolder/stages/publish/gitlab.ts | 20 +++++++++++--- .../scaffolder/stages/publish/publishers.ts | 19 ++++++++++---- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts index 368f1bb29f..61c8480efb 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.test.ts @@ -54,11 +54,14 @@ describe('GitLab Publisher', () => { describe('publish: createRemoteInGitLab', () => { it('should use gitbeaker to create a repo in a namespace if the namespace property is set', async () => { - const publisher = await GitlabPublisher.fromConfig({ - host: 'gitlab.com', - token: 'fake-token', - baseUrl: 'https://gitlab.hosted.com', - }); + const publisher = await GitlabPublisher.fromConfig( + { + host: 'gitlab.com', + token: 'fake-token', + baseUrl: 'https://gitlab.hosted.com', + }, + { repoVisibility: 'public' }, + ); mockGitlabClient.Namespaces.show.mockResolvedValue({ id: 42, @@ -88,6 +91,7 @@ describe('GitLab Publisher', () => { expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ namespace_id: 42, name: 'test', + visibility: 'public', }); expect(initRepoAndPush).toHaveBeenCalledWith({ dir: resultPath, @@ -98,10 +102,13 @@ describe('GitLab Publisher', () => { }); it('should use gitbeaker to create a repo in the authed user if the namespace property is not set', async () => { - const publisher = await GitlabPublisher.fromConfig({ - host: 'gitlab.com', - token: 'fake-token', - }); + const publisher = await GitlabPublisher.fromConfig( + { + host: 'gitlab.com', + token: 'fake-token', + }, + { repoVisibility: 'public' }, + ); mockGitlabClient.Namespaces.show.mockResolvedValue({}); mockGitlabClient.Users.current.mockResolvedValue({ @@ -128,6 +135,7 @@ describe('GitLab Publisher', () => { expect(mockGitlabClient.Projects.create).toHaveBeenCalledWith({ namespace_id: 21, name: 'test', + visibility: 'public', }); expect(initRepoAndPush).toHaveBeenCalledWith({ dir: resultPath, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts index ace5791b1f..4b10d653da 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/gitlab.ts @@ -22,18 +22,31 @@ import parseGitUrl from 'git-url-parse'; import path from 'path'; import { GitLabIntegrationConfig } from '@backstage/integration'; +export type RepoVisibilityOptions = 'private' | 'internal' | 'public'; + export class GitlabPublisher implements PublisherBase { - static async fromConfig(config: GitLabIntegrationConfig) { + static async fromConfig( + config: GitLabIntegrationConfig, + { repoVisibility }: { repoVisibility: RepoVisibilityOptions }, + ) { if (!config.token) { return undefined; } const client = new Gitlab({ host: config.baseUrl, token: config.token }); - return new GitlabPublisher({ token: config.token, client }); + return new GitlabPublisher({ + token: config.token, + client, + repoVisibility, + }); } constructor( - private readonly config: { token: string; client: GitlabClient }, + private readonly config: { + token: string; + client: GitlabClient; + repoVisibility: RepoVisibilityOptions; + }, ) {} async publish({ @@ -85,6 +98,7 @@ export class GitlabPublisher implements PublisherBase { const project = (await this.config.client.Projects.create({ namespace_id: targetNamespace, name: name, + visibility: this.config.repoVisibility, })) as { http_url_to_repo: string }; return project?.http_url_to_repo; diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts index 79eb3b7708..ac8a87ef8d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts @@ -98,7 +98,13 @@ export class Publishers implements PublisherBuilder { } for (const integration of scm.gitlab.list()) { - const publisher = await GitlabPublisher.fromConfig(integration.config); + const repoVisibility = (config.getOptionalString( + 'scaffolder.gitlab.visibility', + ) ?? 'public') as RepoVisibilityOptions; + + const publisher = await GitlabPublisher.fromConfig(integration.config, { + repoVisibility, + }); if (publisher) { publishers.register(integration.config.host, publisher); @@ -107,10 +113,13 @@ export class Publishers implements PublisherBuilder { publishers.register( integration.config.host, - await GitlabPublisher.fromConfig({ - token: config.getOptionalString('scaffolder.gitlab.token') ?? '', - host: integration.config.host, - }), + await GitlabPublisher.fromConfig( + { + token: config.getOptionalString('scaffolder.gitlab.token') ?? '', + host: integration.config.host, + }, + { repoVisibility }, + ), ); } } From a4e526dfe38909c672fb870867f2c51de475902a Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sat, 30 Jan 2021 09:31:53 -0500 Subject: [PATCH 202/297] Bitbucket repo visibility --- .../stages/publish/bitbucket.test.ts | 24 +++++---- .../scaffolder/stages/publish/bitbucket.ts | 11 +++- .../scaffolder/stages/publish/publishers.ts | 52 +++++++++++++------ 3 files changed, 62 insertions(+), 25 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts index 419c81ea7e..4cf5e83ba3 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.test.ts @@ -63,11 +63,14 @@ describe('Bitbucket Publisher', () => { ), ); - const publisher = await BitbucketPublisher.fromConfig({ - host: 'bitbucket.org', - username: 'fake-user', - appPassword: 'fake-token', - }); + const publisher = await BitbucketPublisher.fromConfig( + { + host: 'bitbucket.org', + username: 'fake-user', + appPassword: 'fake-token', + }, + { repoVisibility: 'private' }, + ); const result = await publisher.publish({ values: { @@ -122,10 +125,13 @@ describe('Bitbucket Publisher', () => { ), ); - const publisher = await BitbucketPublisher.fromConfig({ - host: 'bitbucket.mycompany.com', - token: 'fake-token', - }); + const publisher = await BitbucketPublisher.fromConfig( + { + host: 'bitbucket.mycompany.com', + token: 'fake-token', + }, + { repoVisibility: 'private' }, + ); const result = await publisher.publish({ values: { diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts index 2a274144ff..373920c005 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/bitbucket.ts @@ -21,17 +21,23 @@ import { BitbucketIntegrationConfig } from '@backstage/integration'; import parseGitUrl from 'git-url-parse'; import path from 'path'; +export type RepoVisibilityOptions = 'private' | 'public'; + // TODO(blam): We should probably start to use a bitbucket client here that we can change // the baseURL to point at on-prem or public bitbucket versions like we do for // github and ghe. There's to much logic and not enough types here for us to say that this way is better than using // a supported bitbucket client if one exists. export class BitbucketPublisher implements PublisherBase { - static async fromConfig(config: BitbucketIntegrationConfig) { + static async fromConfig( + config: BitbucketIntegrationConfig, + { repoVisibility }: { repoVisibility: RepoVisibilityOptions }, + ) { return new BitbucketPublisher({ host: config.host, token: config.token, appPassword: config.appPassword, username: config.username, + repoVisibility, }); } @@ -41,6 +47,7 @@ export class BitbucketPublisher implements PublisherBase { token?: string; appPassword?: string; username?: string; + repoVisibility: RepoVisibilityOptions; }, ) {} @@ -101,6 +108,7 @@ export class BitbucketPublisher implements PublisherBase { body: JSON.stringify({ scm: 'git', description: description, + is_private: this.config.repoVisibility === 'private', }), headers: { Authorization: `Basic ${buffer.toString('base64')}`, @@ -144,6 +152,7 @@ export class BitbucketPublisher implements PublisherBase { body: JSON.stringify({ name: name, description: description, + is_private: this.config.repoVisibility === 'private', }), headers: { Authorization: `Bearer ${this.config.token}`, diff --git a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts index ac8a87ef8d..30201e6dca 100644 --- a/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/stages/publish/publishers.ts @@ -16,10 +16,19 @@ import { Config } from '@backstage/config'; import { PublisherBase, PublisherBuilder } from './types'; -import { GithubPublisher, RepoVisibilityOptions } from './github'; -import { GitlabPublisher } from './gitlab'; +import { + GithubPublisher, + RepoVisibilityOptions as GithubRepoVisibilityOptions, +} from './github'; +import { + GitlabPublisher, + RepoVisibilityOptions as GitlabRepoVisibilityOptions, +} from './gitlab'; import { AzurePublisher } from './azure'; -import { BitbucketPublisher } from './bitbucket'; +import { + BitbucketPublisher, + RepoVisibilityOptions as BitbucketRepoVisibilityOptions, +} from './bitbucket'; import { Logger } from 'winston'; import { ScmIntegrations } from '@backstage/integration'; @@ -74,7 +83,7 @@ export class Publishers implements PublisherBuilder { for (const integration of scm.github.list()) { const repoVisibility = (config.getOptionalString( 'scaffolder.github.visibility', - ) ?? 'public') as RepoVisibilityOptions; + ) ?? 'public') as GithubRepoVisibilityOptions; const publisher = await GithubPublisher.fromConfig(integration.config, { repoVisibility, @@ -100,7 +109,7 @@ export class Publishers implements PublisherBuilder { for (const integration of scm.gitlab.list()) { const repoVisibility = (config.getOptionalString( 'scaffolder.gitlab.visibility', - ) ?? 'public') as RepoVisibilityOptions; + ) ?? 'public') as GitlabRepoVisibilityOptions; const publisher = await GitlabPublisher.fromConfig(integration.config, { repoVisibility, @@ -125,7 +134,16 @@ export class Publishers implements PublisherBuilder { } for (const integration of scm.bitbucket.list()) { - const publisher = await BitbucketPublisher.fromConfig(integration.config); + const repoVisibility = (config.getOptionalString( + 'scaffolder.bitbucket.visibility', + ) ?? 'public') as BitbucketRepoVisibilityOptions; + + const publisher = await BitbucketPublisher.fromConfig( + integration.config, + { + repoVisibility, + }, + ); if (publisher) { publishers.register(integration.config.host, publisher); @@ -134,15 +152,19 @@ export class Publishers implements PublisherBuilder { publishers.register( integration.config.host, - await BitbucketPublisher.fromConfig({ - token: config.getOptionalString('scaffolder.bitbucket.token') ?? '', - username: - config.getOptionalString('scaffolder.bitbucket.username') ?? '', - appPassword: - config.getOptionalString('scaffolder.bitbucket.appPassword') ?? - '', - host: integration.config.host, - }), + await BitbucketPublisher.fromConfig( + { + token: + config.getOptionalString('scaffolder.bitbucket.token') ?? '', + username: + config.getOptionalString('scaffolder.bitbucket.username') ?? '', + appPassword: + config.getOptionalString('scaffolder.bitbucket.appPassword') ?? + '', + host: integration.config.host, + }, + { repoVisibility }, + ), ); } } From 2d56971596819d7160d881b1b65502f3d14c7f0a Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sat, 30 Jan 2021 10:25:41 -0500 Subject: [PATCH 203/297] add scaffolder visibility config for GitLab and BitBucket --- app-config.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app-config.yaml b/app-config.yaml index f71649659b..763dd0c258 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -243,6 +243,7 @@ scaffolder: baseUrl: https://gitlab.com token: $env: GITLAB_TOKEN + visibility: public # or 'internal' or 'private' azure: baseUrl: https://dev.azure.com/{your-organization} api: @@ -255,6 +256,7 @@ scaffolder: $env: BITBUCKET_USERNAME token: $env: BITBUCKET_TOKEN + visibility: public # or or 'private' auth: environment: development From 529d16d278f3996a517d55d8be3fafa2f84063df Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sat, 30 Jan 2021 10:26:26 -0500 Subject: [PATCH 204/297] changeset --- .changeset/sour-gorillas-fail.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .changeset/sour-gorillas-fail.md diff --git a/.changeset/sour-gorillas-fail.md b/.changeset/sour-gorillas-fail.md new file mode 100644 index 0000000000..c6394ed76e --- /dev/null +++ b/.changeset/sour-gorillas-fail.md @@ -0,0 +1,23 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +# Repo visibility for GitLab and BitBucket repos + +**NOTE: This changes default repo visibility from `private` to `public` for GitLab and BitBucket** which +is consistent with the GitHub default. If you were counting on `private` visibility, you'll need to update +your scaffolder config to use `private`. + +This adds repo visibility feature parity with GitHub for GitLab and BitBucket. + +To configure the repo visibility, set scaffolder._type_.visibility as in this example: + +```yaml +scaffolder: + github: + visibility: private # 'public' or 'internal' or 'private' (default is 'public') + gitlab: + visibility: public # 'public' or 'internal' or 'private' (default is 'public') + bitbucket: + visibility: public # 'public' or 'private' (default is 'public') +``` From c3609ebb44f8a28cca7d6696743e824960e67b31 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Mon, 1 Feb 2021 10:26:33 -0500 Subject: [PATCH 205/297] add visibility to schema --- plugins/scaffolder-backend/config.d.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/scaffolder-backend/config.d.ts b/plugins/scaffolder-backend/config.d.ts index 28e2515471..55ed959edc 100644 --- a/plugins/scaffolder-backend/config.d.ts +++ b/plugins/scaffolder-backend/config.d.ts @@ -19,9 +19,11 @@ export interface Config { scaffolder?: { github?: { [key: string]: string; + visiblity?: string; }; gitlab?: { api: { [key: string]: string }; + visiblity?: string; }; azure?: { baseUrl: string; @@ -29,6 +31,7 @@ export interface Config { }; bitbucket?: { api: { [key: string]: string }; + visiblity?: string; }; }; } From 77fe4ef3cd107bd9ffc7acb505d1bf2dd4631747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 1 Feb 2021 17:09:03 +0100 Subject: [PATCH 206/297] docs: add a miniature article about observability --- docs/plugins/observability.md | 21 +++++++++++++++++++++ microsite/sidebars.json | 3 ++- mkdocs.yml | 1 + 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 docs/plugins/observability.md diff --git a/docs/plugins/observability.md b/docs/plugins/observability.md new file mode 100644 index 0000000000..f52dd130bd --- /dev/null +++ b/docs/plugins/observability.md @@ -0,0 +1,21 @@ +--- +id: observability +title: Observability +# prettier-ignore +description: Adding Observability to Your Plugin +--- + +This article briefly describes the observability option that are available to a +Backstage integrator. + +## Google Analytics + +There is a basic Google Analytics integration built into Backstage. You can +enable it by adding the following to your app configuration: + +```yaml +app: + googleAnalyticsTrackingId: UA-000000-0 +``` + +Replace the tracking ID with your own. diff --git a/microsite/sidebars.json b/microsite/sidebars.json index f855327627..ebce8d900c 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -129,7 +129,8 @@ "ids": [ "plugins/publishing", "plugins/publish-private", - "plugins/add-to-marketplace" + "plugins/add-to-marketplace", + "plugins/observability" ] } ], diff --git a/mkdocs.yml b/mkdocs.yml index 6236d1c1a0..a303fd9978 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -80,6 +80,7 @@ nav: - Publishing: - Open source and npm: 'plugins/publishing.md' - Private/internal (non-open source): 'plugins/publish-private.md' + - Observability: 'plugins/observability.md' - Configuration: - Overview: 'conf/index.md' - Reading Configuration: 'conf/reading.md' From 885e695803cf113571313d368911ac114e39e1ce Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Mon, 1 Feb 2021 17:28:36 +0100 Subject: [PATCH 207/297] Display the namespace of an entity if required --- .../src/components/ApiExplorerTable/ApiExplorerTable.tsx | 8 ++++++-- .../catalog/src/components/CatalogTable/CatalogTable.tsx | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx index 6baacf5961..101c28332a 100644 --- a/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx +++ b/plugins/api-docs/src/components/ApiExplorerTable/ApiExplorerTable.tsx @@ -42,6 +42,7 @@ import { ApiTypeTitle } from '../ApiDefinitionCard'; type EntityRow = { entity: ApiEntityV1alpha1; resolved: { + name: string; partOfSystemRelationTitle?: string; partOfSystemRelations: EntityName[]; ownedByRelationsTitle?: string; @@ -52,10 +53,10 @@ type EntityRow = { const columns: TableColumn[] = [ { title: 'Name', - field: 'entity.metadata.name', + field: 'resolved.name', highlight: true, render: ({ entity }) => ( - {entity.metadata.name} + ), }, { @@ -167,6 +168,9 @@ export const ApiExplorerTable = ({ return { entity: entity as ApiEntityV1alpha1, resolved: { + name: formatEntityRefTitle(entity, { + defaultKind: 'API', + }), ownedByRelationsTitle: ownedByRelations .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) .join(', '), diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 5fc4445053..30a19145f2 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -42,6 +42,7 @@ import { type EntityRow = { entity: Entity; resolved: { + name: string; partOfSystemRelationTitle?: string; partOfSystemRelations: EntityName[]; ownedByRelationsTitle?: string; @@ -52,10 +53,10 @@ type EntityRow = { const columns: TableColumn[] = [ { title: 'Name', - field: 'entity.metadata.name', + field: 'resolved.name', highlight: true, render: ({ entity }) => ( - {entity.metadata.name} + ), }, { @@ -177,6 +178,9 @@ export const CatalogTable = ({ return { entity, resolved: { + name: formatEntityRefTitle(entity, { + defaultKind: 'Component', + }), ownedByRelationsTitle: ownedByRelations .map(r => formatEntityRefTitle(r, { defaultKind: 'group' })) .join(', '), From 2bf3ad4ce56f6ddecc20e6d2386be44d1485d658 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Mon, 1 Feb 2021 11:45:38 -0500 Subject: [PATCH 208/297] better document the config options --- plugins/scaffolder-backend/config.d.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder-backend/config.d.ts b/plugins/scaffolder-backend/config.d.ts index 55ed959edc..3b31b4f951 100644 --- a/plugins/scaffolder-backend/config.d.ts +++ b/plugins/scaffolder-backend/config.d.ts @@ -19,11 +19,17 @@ export interface Config { scaffolder?: { github?: { [key: string]: string; - visiblity?: string; + /** + * The visibility to set on created repositories. + */ + visiblity?: 'public' | 'internal' | 'private'; }; gitlab?: { api: { [key: string]: string }; - visiblity?: string; + /** + * The visibility to set on created repositories. + */ + visiblity?: 'public' | 'internal' | 'private'; }; azure?: { baseUrl: string; @@ -31,7 +37,10 @@ export interface Config { }; bitbucket?: { api: { [key: string]: string }; - visiblity?: string; + /** + * The visibility to set on created repositories. + */ + visiblity?: 'public' | 'private'; }; }; } From bad33a2ec241636c0ae93af268042649749aa5c5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 20 Jan 2021 00:12:19 +0100 Subject: [PATCH 209/297] gcp-projects: port to new composability API --- plugins/gcp-projects/package.json | 2 +- .../GcpProjectsPage/GcpProjectsPage.tsx | 29 +++++++ .../src/components/GcpProjectsPage/index.ts | 17 ++++ plugins/gcp-projects/src/index.ts | 2 +- plugins/gcp-projects/src/plugin.ts | 27 +++--- plugins/gcp-projects/src/routes.ts | 30 +++++++ yarn.lock | 83 ++----------------- 7 files changed, 97 insertions(+), 93 deletions(-) create mode 100644 plugins/gcp-projects/src/components/GcpProjectsPage/GcpProjectsPage.tsx create mode 100644 plugins/gcp-projects/src/components/GcpProjectsPage/index.ts create mode 100644 plugins/gcp-projects/src/routes.ts diff --git a/plugins/gcp-projects/package.json b/plugins/gcp-projects/package.json index 085e08e2a3..d48295e204 100644 --- a/plugins/gcp-projects/package.json +++ b/plugins/gcp-projects/package.json @@ -37,7 +37,7 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-router-dom": "^5.2.0", + "react-router-dom": "^6.0.0-beta.0", "react-use": "^15.3.3" }, "devDependencies": { diff --git a/plugins/gcp-projects/src/components/GcpProjectsPage/GcpProjectsPage.tsx b/plugins/gcp-projects/src/components/GcpProjectsPage/GcpProjectsPage.tsx new file mode 100644 index 0000000000..158347a9ff --- /dev/null +++ b/plugins/gcp-projects/src/components/GcpProjectsPage/GcpProjectsPage.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Route, Routes } from 'react-router-dom'; +import { NewProjectPage } from '../NewProjectPage'; +import { ProjectDetailsPage } from '../ProjectDetailsPage'; +import { ProjectListPage } from '../ProjectListPage'; + +export const GcpProjectsPage = () => ( + + } /> + } /> + } /> + +); diff --git a/plugins/gcp-projects/src/components/GcpProjectsPage/index.ts b/plugins/gcp-projects/src/components/GcpProjectsPage/index.ts new file mode 100644 index 0000000000..a39db43c16 --- /dev/null +++ b/plugins/gcp-projects/src/components/GcpProjectsPage/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 { GcpProjectsPage } from './GcpProjectsPage'; diff --git a/plugins/gcp-projects/src/index.ts b/plugins/gcp-projects/src/index.ts index d67bc6a864..7c3f752b30 100644 --- a/plugins/gcp-projects/src/index.ts +++ b/plugins/gcp-projects/src/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { plugin, GcpProjectsPage } from './plugin'; export * from './api'; diff --git a/plugins/gcp-projects/src/plugin.ts b/plugins/gcp-projects/src/plugin.ts index 2baf3bce7d..b263365816 100644 --- a/plugins/gcp-projects/src/plugin.ts +++ b/plugins/gcp-projects/src/plugin.ts @@ -17,29 +17,20 @@ import { createApiFactory, createPlugin, - createRouteRef, + createRoutableExtension, googleAuthApiRef, } from '@backstage/core'; import { gcpApiRef, GcpClient } from './api'; import { NewProjectPage } from './components/NewProjectPage'; import { ProjectDetailsPage } from './components/ProjectDetailsPage'; import { ProjectListPage } from './components/ProjectListPage'; - -export const rootRouteRef = createRouteRef({ - path: '/gcp-projects', - title: 'GCP Projects', -}); -export const projectRouteRef = createRouteRef({ - path: '/gcp-projects/project', - title: 'GCP Project Page', -}); -export const newProjectRouteRef = createRouteRef({ - path: '/gcp-projects/new', - title: 'GCP Project Page', -}); +import { rootRouteRef, projectRouteRef, newProjectRouteRef } from './routes'; export const plugin = createPlugin({ id: 'gcp-projects', + routes: { + root: rootRouteRef, + }, apis: [ createApiFactory({ api: gcpApiRef, @@ -55,3 +46,11 @@ export const plugin = createPlugin({ router.addRoute(newProjectRouteRef, NewProjectPage); }, }); + +export const GcpProjectsPage = plugin.provide( + createRoutableExtension({ + component: () => + import('./components/GcpProjectsPage').then(m => m.GcpProjectsPage), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/gcp-projects/src/routes.ts b/plugins/gcp-projects/src/routes.ts new file mode 100644 index 0000000000..ff9bf9d85c --- /dev/null +++ b/plugins/gcp-projects/src/routes.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createRouteRef } from '@backstage/core'; + +export const rootRouteRef = createRouteRef({ + path: '/gcp-projects', + title: 'GCP Projects', +}); +export const projectRouteRef = createRouteRef({ + path: '/gcp-projects/project', + title: 'GCP Project Page', +}); +export const newProjectRouteRef = createRouteRef({ + path: '/gcp-projects/new', + title: 'GCP Project Page', +}); diff --git a/yarn.lock b/yarn.lock index 2ac7dffa6a..96dcb8bd09 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15055,18 +15055,6 @@ highlight.js@^10.4.1, highlight.js@~10.4.0: resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-10.4.1.tgz#d48fbcf4a9971c4361b3f95f302747afe19dbad0" integrity sha512-yR5lWvNz7c85OhVAEAeFhVCc/GV4C30Fjzc/rCP0aCWzc1UUOPUk55dK/qdwTZHBvMZo+eZ2jpk62ndX/xMFlg== -history@^4.9.0: - version "4.10.1" - resolved "https://registry.npmjs.org/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" - integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== - dependencies: - "@babel/runtime" "^7.1.2" - loose-envify "^1.2.0" - resolve-pathname "^3.0.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - value-equal "^1.0.1" - history@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/history/-/history-5.0.0.tgz#0cabbb6c4bbf835addb874f8259f6d25101efd08" @@ -15083,7 +15071,7 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -16289,11 +16277,6 @@ is-yarn-global@^0.3.0: resolved "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== -isarray@0.0.1: - version "0.0.1" - resolved "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" - integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= - isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -18081,7 +18064,7 @@ longest-streak@^2.0.0: resolved "https://registry.npmjs.org/longest-streak/-/longest-streak-2.0.4.tgz#b8599957da5b5dab64dee3fe316fa774597d90e4" integrity sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg== -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.0, loose-envify@^1.3.1, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -18734,14 +18717,6 @@ min-indent@^1.0.0: resolved "https://registry.npmjs.org/min-indent/-/min-indent-1.0.0.tgz#cfc45c37e9ec0d8f0a0ec3dd4ef7f7c3abe39256" integrity sha1-z8RcN+nsDY8KDsPdTvf3w6vjklY= -mini-create-react-context@^0.4.0: - version "0.4.0" - resolved "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.0.tgz#df60501c83151db69e28eac0ef08b4002efab040" - integrity sha512-b0TytUgFSbgFJGzJqXPKCFCBWigAjpjo+Fl7Vf7ZbKRDptszpppKxXH6DRXEABZ/gcEQczeb0iZ7JvL8e8jjCA== - dependencies: - "@babel/runtime" "^7.5.5" - tiny-warning "^1.0.3" - mini-css-extract-plugin@^0.9.0: version "0.9.0" resolved "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.9.0.tgz#47f2cf07aa165ab35733b1fc97d4c46c0564339e" @@ -20503,13 +20478,6 @@ path-to-regexp@0.1.7: resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= -path-to-regexp@^1.7.0: - version "1.8.0" - resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" - integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== - dependencies: - isarray "0.0.1" - path-type@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" @@ -21990,7 +21958,7 @@ react-inspector@^5.0.1: is-dom "^1.1.0" prop-types "^15.6.1" -react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0: +react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -22107,7 +22075,7 @@ react-resize-detector@^2.3.0: prop-types "^15.6.0" resize-observer-polyfill "^1.5.0" -react-router-dom@6.0.0-beta.0: +react-router-dom@6.0.0-beta.0, react-router-dom@^6.0.0-beta.0: version "6.0.0-beta.0" resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.0.0-beta.0.tgz#9dcc8555365f22f7fbd09f26b6b82543f3eb97d6" integrity sha512-36yNNGMT8RB9FRPL9nKJi6HKDkgOakU+o/2hHpSzR6e37gN70MpOU6QQlmif4oAWWBwjyGc3ZNOMFCsFuHUY5w== @@ -22115,35 +22083,6 @@ react-router-dom@6.0.0-beta.0: prop-types "^15.7.2" react-router "6.0.0-beta.0" -react-router-dom@^5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz#9e65a4d0c45e13289e66c7b17c7e175d0ea15662" - integrity sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA== - dependencies: - "@babel/runtime" "^7.1.2" - history "^4.9.0" - loose-envify "^1.3.1" - prop-types "^15.6.2" - react-router "5.2.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - -react-router@5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293" - integrity sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw== - dependencies: - "@babel/runtime" "^7.1.2" - history "^4.9.0" - hoist-non-react-statics "^3.1.0" - loose-envify "^1.3.1" - mini-create-react-context "^0.4.0" - path-to-regexp "^1.7.0" - prop-types "^15.6.2" - react-is "^16.6.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - react-router@6.0.0-beta.0, react-router@^6.0.0-beta.0: version "6.0.0-beta.0" resolved "https://registry.npmjs.org/react-router/-/react-router-6.0.0-beta.0.tgz#3e11f39b6ded4412c2fed9e4f989dd4c8156724d" @@ -22994,11 +22933,6 @@ resolve-from@^4.0.0: resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== -resolve-pathname@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd" - integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng== - resolve-url@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" @@ -25077,7 +25011,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.6: +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== @@ -25087,7 +25021,7 @@ tiny-merge-patch@^0.1.2: resolved "https://registry.npmjs.org/tiny-merge-patch/-/tiny-merge-patch-0.1.2.tgz#2e8ded19c56ea15dbd3ad4ed5db1c8e5ad544c3c" integrity sha1-Lo3tGcVuoV29OtTtXbHI5a1UTDw= -tiny-warning@^1.0.0, tiny-warning@^1.0.2, tiny-warning@^1.0.3: +tiny-warning@^1.0.2: version "1.0.3" resolved "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== @@ -26057,11 +25991,6 @@ validate.io-number@^1.0.3: resolved "https://registry.npmjs.org/validate.io-number/-/validate.io-number-1.0.3.tgz#f63ffeda248bf28a67a8d48e0e3b461a1665baf8" integrity sha1-9j/+2iSL8opnqNSODjtGGhZluvg= -value-equal@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" - integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== - vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" From fb265bb4fabca8365a6c2dcf52b256130f1a95e3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 19 Jan 2021 23:33:52 +0100 Subject: [PATCH 210/297] api-docs: port top-level component to composability API --- plugins/api-docs/src/index.ts | 2 +- plugins/api-docs/src/plugin.ts | 21 ++++++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/plugins/api-docs/src/index.ts b/plugins/api-docs/src/index.ts index f09aeb1038..e85c693864 100644 --- a/plugins/api-docs/src/index.ts +++ b/plugins/api-docs/src/index.ts @@ -15,4 +15,4 @@ */ export * from './components'; -export { plugin } from './plugin'; +export { plugin, ApiExplorerPage } from './plugin'; diff --git a/plugins/api-docs/src/plugin.ts b/plugins/api-docs/src/plugin.ts index 06db03f2e1..8faf5412fc 100644 --- a/plugins/api-docs/src/plugin.ts +++ b/plugins/api-docs/src/plugin.ts @@ -15,14 +15,21 @@ */ import { ApiEntity } from '@backstage/catalog-model'; -import { createApiFactory, createPlugin } from '@backstage/core'; -import { ApiExplorerPage } from './components/ApiExplorerPage/ApiExplorerPage'; +import { + createApiFactory, + createPlugin, + createRoutableExtension, +} from '@backstage/core'; +import { ApiExplorerPage as Page } from './components/ApiExplorerPage/ApiExplorerPage'; import { defaultDefinitionWidgets } from './components/ApiDefinitionCard'; import { rootRoute } from './routes'; import { apiDocsConfigRef } from './config'; export const plugin = createPlugin({ id: 'api-docs', + routes: { + root: rootRoute, + }, apis: [ createApiFactory({ api: apiDocsConfigRef, @@ -38,6 +45,14 @@ export const plugin = createPlugin({ }), ], register({ router }) { - router.addRoute(rootRoute, ApiExplorerPage); + router.addRoute(rootRoute, Page); }, }); + +export const ApiExplorerPage = plugin.provide( + createRoutableExtension({ + component: () => + import('./components/ApiExplorerPage').then(m => m.ApiExplorerPage), + mountPoint: rootRoute, + }), +); From d955622e81b05df47791b145f431014eaa42232e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 20 Jan 2021 00:46:52 +0100 Subject: [PATCH 211/297] api-docs: add basic dev setup with explore page --- plugins/api-docs/dev/example-api.yaml | 124 ++++++++++++++++++++++++++ plugins/api-docs/dev/index.tsx | 20 ++++- 2 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 plugins/api-docs/dev/example-api.yaml diff --git a/plugins/api-docs/dev/example-api.yaml b/plugins/api-docs/dev/example-api.yaml new file mode 100644 index 0000000000..a314b4e255 --- /dev/null +++ b/plugins/api-docs/dev/example-api.yaml @@ -0,0 +1,124 @@ +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: petstore + description: The petstore API + tags: + - store + - rest +spec: + type: openapi + lifecycle: experimental + owner: team-c + definition: | + openapi: "3.0.0" + info: + version: 1.0.0 + title: Swagger Petstore + license: + name: MIT + servers: + - url: http://petstore.swagger.io/v1 + paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: A paged array of pets + headers: + x-next: + description: A link to the next page of responses + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/Pets" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + summary: Create a pet + operationId: createPets + tags: + - pets + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Pets: + type: array + items: + $ref: "#/components/schemas/Pet" + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/plugins/api-docs/dev/index.tsx b/plugins/api-docs/dev/index.tsx index 812a5585d4..d7bc61073e 100644 --- a/plugins/api-docs/dev/index.tsx +++ b/plugins/api-docs/dev/index.tsx @@ -14,7 +14,23 @@ * limitations under the License. */ +import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { ApiExplorerPage, plugin } from '../src/plugin'; +import { catalogApiRef } from '@backstage/plugin-catalog'; +import petstoreApiEntity from './example-api.yaml'; -createDevApp().registerPlugin(plugin).render(); +createDevApp() + .registerApi({ + api: catalogApiRef, + deps: {}, + factory: () => + (({ + async getEntities() { + return { items: [petstoreApiEntity] }; + }, + } as unknown) as typeof catalogApiRef.T), + }) + .registerPlugin(plugin) + .addPage({ element: }) + .render(); From 67484136929cd53ac5038fd8fdec56ec3f669f6b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 1 Feb 2021 18:17:06 +0100 Subject: [PATCH 212/297] api-docs: update cards to grab entity from context + composability exports --- plugins/api-docs/dev/index.tsx | 6 +-- .../ApiDefinitionCard.test.tsx | 11 ++-- .../ApiDefinitionCard/ApiDefinitionCard.tsx | 25 +++++---- .../ApisCards/ConsumedApisCard.test.tsx | 14 +++-- .../components/ApisCards/ConsumedApisCard.tsx | 7 ++- .../ApisCards/ProvidedApisCard.test.tsx | 14 +++-- .../components/ApisCards/ProvidedApisCard.tsx | 7 ++- .../ConsumingComponentsCard.test.tsx | 14 +++-- .../ConsumingComponentsCard.tsx | 10 ++-- .../ProvidingComponentsCard.test.tsx | 14 +++-- .../ProvidingComponentsCard.tsx | 10 ++-- plugins/api-docs/src/index.ts | 6 ++- plugins/api-docs/src/plugin.test.ts | 4 +- plugins/api-docs/src/plugin.ts | 54 ++++++++++++++++++- 14 files changed, 148 insertions(+), 48 deletions(-) diff --git a/plugins/api-docs/dev/index.tsx b/plugins/api-docs/dev/index.tsx index d7bc61073e..4bf0215f32 100644 --- a/plugins/api-docs/dev/index.tsx +++ b/plugins/api-docs/dev/index.tsx @@ -16,8 +16,8 @@ import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { ApiExplorerPage, plugin } from '../src/plugin'; -import { catalogApiRef } from '@backstage/plugin-catalog'; +import { ApiExplorerPage, apiDocsPlugin } from '../src/plugin'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; import petstoreApiEntity from './example-api.yaml'; createDevApp() @@ -31,6 +31,6 @@ createDevApp() }, } as unknown) as typeof catalogApiRef.T), }) - .registerPlugin(plugin) + .registerPlugin(apiDocsPlugin) .addPage({ element: }) .render(); diff --git a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx index d59699ab9a..0d2648ab5f 100644 --- a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx +++ b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.test.tsx @@ -16,6 +16,7 @@ import { ApiEntity } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; @@ -54,7 +55,7 @@ paths: get: summary: List all artists responses: - "200": + "200": description: Success `; const apiEntity: ApiEntity = { @@ -81,7 +82,9 @@ paths: const { getByText } = await renderInTestApp( - + + + , ); @@ -110,7 +113,9 @@ paths: const { getByText } = await renderInTestApp( - + + + , ); diff --git a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx index 19370bfc18..0a4386e275 100644 --- a/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx +++ b/plugins/api-docs/src/components/ApiDefinitionCard/ApiDefinitionCard.tsx @@ -15,6 +15,7 @@ */ import { ApiEntity } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { CardTab, TabbedCard, useApi } from '@backstage/core'; import { Alert } from '@material-ui/lab'; import React from 'react'; @@ -22,29 +23,31 @@ import { apiDocsConfigRef } from '../../config'; import { PlainApiDefinitionWidget } from '../PlainApiDefinitionWidget'; type Props = { + /** @deprecated The entity is now grabbed from context instead */ apiEntity?: ApiEntity; }; -export const ApiDefinitionCard = ({ apiEntity }: Props) => { +export const ApiDefinitionCard = (_: Props) => { + const entity = useEntity().entity as ApiEntity; const config = useApi(apiDocsConfigRef); const { getApiDefinitionWidget } = config; - if (!apiEntity) { + if (!entity) { return Could not fetch the API; } - const definitionWidget = getApiDefinitionWidget(apiEntity); + const definitionWidget = getApiDefinitionWidget(entity); if (definitionWidget) { return ( - + - {definitionWidget.component(apiEntity.spec.definition)} + {definitionWidget.component(entity.spec.definition)} @@ -53,13 +56,13 @@ export const ApiDefinitionCard = ({ apiEntity }: Props) => { return ( + , ]} diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx index d406e160fc..7e9973c25f 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx @@ -16,7 +16,11 @@ import { Entity, RELATION_CONSUMES_API } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; @@ -63,7 +67,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); @@ -112,7 +118,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx index 0bd4919554..8a808a6488 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.tsx @@ -19,6 +19,7 @@ import { Entity, RELATION_CONSUMES_API, } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { EmptyState, InfoCard, Progress } from '@backstage/core'; import React, { PropsWithChildren } from 'react'; import { ApisTable } from './ApisTable'; @@ -37,11 +38,13 @@ const ApisCard = ({ }; type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; variant?: string; }; -export const ConsumedApisCard = ({ entity, variant = 'gridItem' }: Props) => { +export const ConsumedApisCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); const { entities, loading, error } = useRelatedEntities( entity, RELATION_CONSUMES_API, diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx index 7b6ce6e18a..2fc5763461 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx @@ -16,7 +16,11 @@ import { Entity, RELATION_PROVIDES_API } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; @@ -63,7 +67,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); @@ -112,7 +118,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx index 618f2dc1f6..ed6e893a03 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.tsx @@ -19,6 +19,7 @@ import { Entity, RELATION_PROVIDES_API, } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { EmptyState, InfoCard, Progress } from '@backstage/core'; import React, { PropsWithChildren } from 'react'; import { ApisTable } from './ApisTable'; @@ -37,11 +38,13 @@ const ApisCard = ({ }; type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; variant?: string; }; -export const ProvidedApisCard = ({ entity, variant = 'gridItem' }: Props) => { +export const ProvidedApisCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); const { entities, loading, error } = useRelatedEntities( entity, RELATION_PROVIDES_API, diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx index 99a8c0dc28..78080e8318 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx @@ -16,7 +16,11 @@ import { Entity, RELATION_API_CONSUMED_BY } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; @@ -62,7 +66,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); @@ -111,7 +117,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx index 0431367aa2..56d2fb977d 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.tsx @@ -19,6 +19,7 @@ import { Entity, RELATION_API_CONSUMED_BY, } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { EmptyState, InfoCard, Progress } from '@backstage/core'; import React, { PropsWithChildren } from 'react'; import { MissingConsumesApisEmptyState } from '../EmptyState'; @@ -37,14 +38,13 @@ const ComponentsCard = ({ }; type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; variant?: string; }; -export const ConsumingComponentsCard = ({ - entity, - variant = 'gridItem', -}: Props) => { +export const ConsumingComponentsCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); const { entities, loading, error } = useRelatedEntities( entity, RELATION_API_CONSUMED_BY, diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx index a3341ad8d0..5edd2efa14 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx @@ -16,7 +16,11 @@ import { Entity, RELATION_API_PROVIDED_BY } from '@backstage/catalog-model'; import { ApiProvider, ApiRegistry } from '@backstage/core'; -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { + CatalogApi, + catalogApiRef, + EntityProvider, +} from '@backstage/plugin-catalog-react'; import { renderInTestApp } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; @@ -62,7 +66,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); @@ -111,7 +117,9 @@ describe('', () => { const { getByText } = await renderInTestApp( - + + + , ); diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx index 9e405a3af3..959ff3feed 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.tsx @@ -19,6 +19,7 @@ import { Entity, RELATION_API_PROVIDED_BY, } from '@backstage/catalog-model'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { EmptyState, InfoCard, Progress } from '@backstage/core'; import React, { PropsWithChildren } from 'react'; import { MissingProvidesApisEmptyState } from '../EmptyState'; @@ -37,14 +38,13 @@ const ComponentsCard = ({ }; type Props = { - entity: Entity; + /** @deprecated The entity is now grabbed from context instead */ + entity?: Entity; variant?: string; }; -export const ProvidingComponentsCard = ({ - entity, - variant = 'gridItem', -}: Props) => { +export const ProvidingComponentsCard = ({ variant = 'gridItem' }: Props) => { + const { entity } = useEntity(); const { entities, loading, error } = useRelatedEntities( entity, RELATION_API_PROVIDED_BY, diff --git a/plugins/api-docs/src/index.ts b/plugins/api-docs/src/index.ts index e85c693864..dd231d6cb4 100644 --- a/plugins/api-docs/src/index.ts +++ b/plugins/api-docs/src/index.ts @@ -15,4 +15,8 @@ */ export * from './components'; -export { plugin, ApiExplorerPage } from './plugin'; +export { + apiDocsPlugin, + apiDocsPlugin as plugin, + ApiExplorerPage, +} from './plugin'; diff --git a/plugins/api-docs/src/plugin.test.ts b/plugins/api-docs/src/plugin.test.ts index 054e804382..eaafd36b3d 100644 --- a/plugins/api-docs/src/plugin.test.ts +++ b/plugins/api-docs/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { apiDocsPlugin } from './plugin'; describe('api-docs', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(apiDocsPlugin).toBeDefined(); }); }); diff --git a/plugins/api-docs/src/plugin.ts b/plugins/api-docs/src/plugin.ts index 8faf5412fc..429fd60095 100644 --- a/plugins/api-docs/src/plugin.ts +++ b/plugins/api-docs/src/plugin.ts @@ -19,13 +19,14 @@ import { createApiFactory, createPlugin, createRoutableExtension, + createComponentExtension, } from '@backstage/core'; import { ApiExplorerPage as Page } from './components/ApiExplorerPage/ApiExplorerPage'; import { defaultDefinitionWidgets } from './components/ApiDefinitionCard'; import { rootRoute } from './routes'; import { apiDocsConfigRef } from './config'; -export const plugin = createPlugin({ +export const apiDocsPlugin = createPlugin({ id: 'api-docs', routes: { root: rootRoute, @@ -49,10 +50,59 @@ export const plugin = createPlugin({ }, }); -export const ApiExplorerPage = plugin.provide( +export const ApiExplorerPage = apiDocsPlugin.provide( createRoutableExtension({ component: () => import('./components/ApiExplorerPage').then(m => m.ApiExplorerPage), mountPoint: rootRoute, }), ); + +export const EntityApiDefinitionCard = apiDocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/ApiDefinitionCard').then(m => m.ApiDefinitionCard), + }, + }), +); + +export const EntityConsumedApisCard = apiDocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/ApisCards').then(m => m.ConsumedApisCard), + }, + }), +); + +export const EntityConsumingComponentsCard = apiDocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/ComponentsCards').then( + m => m.ConsumingComponentsCard, + ), + }, + }), +); + +export const EntityProvidedApisCard = apiDocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/ApisCards').then(m => m.ProvidedApisCard), + }, + }), +); + +export const EntityProvidingComponentsCard = apiDocsPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/ComponentsCards').then( + m => m.ProvidingComponentsCard, + ), + }, + }), +); From ae7519f8218a4d41d23f1807e09218f7d5ddeb13 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 1 Feb 2021 18:38:38 +0100 Subject: [PATCH 213/297] gcp-projects: update plugin name --- plugins/gcp-projects/src/index.ts | 6 +++++- plugins/gcp-projects/src/plugin.ts | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/gcp-projects/src/index.ts b/plugins/gcp-projects/src/index.ts index 7c3f752b30..af3f0bc4d9 100644 --- a/plugins/gcp-projects/src/index.ts +++ b/plugins/gcp-projects/src/index.ts @@ -14,5 +14,9 @@ * limitations under the License. */ -export { plugin, GcpProjectsPage } from './plugin'; +export { + gcpProjectsPlugin, + gcpProjectsPlugin as plugin, + GcpProjectsPage, +} from './plugin'; export * from './api'; diff --git a/plugins/gcp-projects/src/plugin.ts b/plugins/gcp-projects/src/plugin.ts index b263365816..9126716d55 100644 --- a/plugins/gcp-projects/src/plugin.ts +++ b/plugins/gcp-projects/src/plugin.ts @@ -26,7 +26,7 @@ import { ProjectDetailsPage } from './components/ProjectDetailsPage'; import { ProjectListPage } from './components/ProjectListPage'; import { rootRouteRef, projectRouteRef, newProjectRouteRef } from './routes'; -export const plugin = createPlugin({ +export const gcpProjectsPlugin = createPlugin({ id: 'gcp-projects', routes: { root: rootRouteRef, @@ -47,7 +47,7 @@ export const plugin = createPlugin({ }, }); -export const GcpProjectsPage = plugin.provide( +export const GcpProjectsPage = gcpProjectsPlugin.provide( createRoutableExtension({ component: () => import('./components/GcpProjectsPage').then(m => m.GcpProjectsPage), From c5636e5afb299215fdcaac3a98ece25f3db00944 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 1 Feb 2021 18:45:53 +0100 Subject: [PATCH 214/297] user-settings: port to new composability API --- plugins/user-settings/dev/index.tsx | 14 +++++++++++--- plugins/user-settings/src/index.ts | 7 ++++++- plugins/user-settings/src/plugin.ts | 21 ++++++++++++++++++--- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/plugins/user-settings/dev/index.tsx b/plugins/user-settings/dev/index.tsx index 264d6f801f..3eaed00022 100644 --- a/plugins/user-settings/dev/index.tsx +++ b/plugins/user-settings/dev/index.tsx @@ -13,7 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +import React from 'react'; +import { createDevApp } from '@backstage/dev-utils'; +import { userSettingsPlugin, UserSettingsPage } from '../src/plugin'; + +createDevApp() + .registerPlugin(userSettingsPlugin) + .addPage({ + title: 'Settings', + element: , + }) + .render(); diff --git a/plugins/user-settings/src/index.ts b/plugins/user-settings/src/index.ts index 9b0ce4262c..b26c8b4b0b 100644 --- a/plugins/user-settings/src/index.ts +++ b/plugins/user-settings/src/index.ts @@ -13,5 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { plugin } from './plugin'; + +export { + userSettingsPlugin, + userSettingsPlugin as plugin, + UserSettingsPage, +} from './plugin'; export * from './components/'; diff --git a/plugins/user-settings/src/plugin.ts b/plugins/user-settings/src/plugin.ts index 2f896c8197..5353c12f10 100644 --- a/plugins/user-settings/src/plugin.ts +++ b/plugins/user-settings/src/plugin.ts @@ -13,13 +13,28 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createPlugin, createRouteRef } from '@backstage/core'; + +import { + createPlugin, + createRoutableExtension, + createRouteRef, +} from '@backstage/core'; export const settingsRouteRef = createRouteRef({ - path: '/settings', title: 'Settings', }); -export const plugin = createPlugin({ +export const userSettingsPlugin = createPlugin({ id: 'user-settings', + routes: { + settingsPage: settingsRouteRef, + }, }); + +export const UserSettingsPage = userSettingsPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/SettingsPage').then(m => m.SettingsPage), + mountPoint: settingsRouteRef, + }), +); From 5efc24a57c2a806183ccc04489c7407606e96c97 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 1 Feb 2021 18:50:17 +0100 Subject: [PATCH 215/297] welcome: port to new composability API --- plugins/welcome/src/index.ts | 2 +- plugins/welcome/src/plugin.test.ts | 4 ++-- plugins/welcome/src/plugin.ts | 20 +++++++++++++++----- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/plugins/welcome/src/index.ts b/plugins/welcome/src/index.ts index 3a0a0fe2d3..cccc6bacf3 100644 --- a/plugins/welcome/src/index.ts +++ b/plugins/welcome/src/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { welcomePlugin, welcomePlugin as plugin, WelcomePage } from './plugin'; diff --git a/plugins/welcome/src/plugin.test.ts b/plugins/welcome/src/plugin.test.ts index d60c73ec68..381ea80f38 100644 --- a/plugins/welcome/src/plugin.test.ts +++ b/plugins/welcome/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { welcomePlugin } from './plugin'; describe('welcome', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(welcomePlugin).toBeDefined(); }); }); diff --git a/plugins/welcome/src/plugin.ts b/plugins/welcome/src/plugin.ts index 754f708110..f950b93874 100644 --- a/plugins/welcome/src/plugin.ts +++ b/plugins/welcome/src/plugin.ts @@ -14,18 +14,28 @@ * limitations under the License. */ -import { createPlugin, createRouteRef } from '@backstage/core'; -import WelcomePage from './components/WelcomePage'; +import { + createPlugin, + createRoutableExtension, + createRouteRef, +} from '@backstage/core'; +import WelcomePageComponent from './components/WelcomePage'; export const rootRouteRef = createRouteRef({ - path: '/welcome', title: 'Welcome', }); -export const plugin = createPlugin({ +export const welcomePlugin = createPlugin({ id: 'welcome', register({ router, featureFlags }) { - router.addRoute(rootRouteRef, WelcomePage); + router.addRoute(rootRouteRef, WelcomePageComponent); featureFlags.register('enable-welcome-box'); }, }); + +export const WelcomePage = welcomePlugin.provide( + createRoutableExtension({ + component: () => import('./components/WelcomePage').then(m => m.default), + mountPoint: rootRouteRef, + }), +); From 35efaf5c0565a2e4453c4c679931b00e8b3a6126 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 1 Feb 2021 19:02:45 +0100 Subject: [PATCH 216/297] sonarqube: port to new composability API --- plugins/sonarqube/dev/index.tsx | 104 ++++++++---------- plugins/sonarqube/package.json | 1 + .../SonarQubeCard/SonarQubeCard.tsx | 9 +- plugins/sonarqube/src/index.ts | 6 +- plugins/sonarqube/src/plugin.test.ts | 4 +- plugins/sonarqube/src/plugin.ts | 12 +- 6 files changed, 72 insertions(+), 64 deletions(-) diff --git a/plugins/sonarqube/dev/index.tsx b/plugins/sonarqube/dev/index.tsx index 5e8aedd577..b99e552df9 100644 --- a/plugins/sonarqube/dev/index.tsx +++ b/plugins/sonarqube/dev/index.tsx @@ -22,14 +22,60 @@ import { Header, Page, } from '@backstage/core'; -import { createDevApp } from '@backstage/dev-utils'; +import { createDevApp, EntityGridItem } from '@backstage/dev-utils'; import { Grid } from '@material-ui/core'; import React from 'react'; -import { SonarQubeCard } from '../src'; +import { EntitySonarQubeCard, sonarQubePlugin } from '../src'; import { FindingSummary, SonarQubeApi, sonarQubeApiRef } from '../src/api'; import { SONARQUBE_PROJECT_KEY_ANNOTATION } from '../src/components/useProjectKey'; +const entity = (name?: string) => + ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + annotations: { + [SONARQUBE_PROJECT_KEY_ANNOTATION]: name, + }, + name: name, + }, + } as Entity); + createDevApp() + .registerPlugin(sonarQubePlugin) + .addPage({ + title: 'Cards', + element: ( + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + ), + }) .registerApi({ api: sonarQubeApiRef, deps: {}, @@ -114,58 +160,4 @@ createDevApp() }, } as SonarQubeApi), }) - .registerPlugin( - createPlugin({ - id: 'defectdojo-demo', - register({ router }) { - const entity = (name?: string) => - ({ - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - annotations: { - [SONARQUBE_PROJECT_KEY_ANNOTATION]: name, - }, - name: name, - }, - } as Entity); - - const ExamplePage = () => ( - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - ); - - router.addRoute( - createRouteRef({ path: '/', title: 'SonarQube' }), - ExamplePage, - ); - }, - }), - ) .render(); diff --git a/plugins/sonarqube/package.json b/plugins/sonarqube/package.json index b6701f9849..c286dd9dac 100644 --- a/plugins/sonarqube/package.json +++ b/plugins/sonarqube/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@backstage/catalog-model": "^0.7.0", + "@backstage/plugin-catalog-react": "^0.0.1", "@backstage/core": "^0.5.0", "@backstage/theme": "^0.2.2", "@material-ui/core": "^4.11.0", diff --git a/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx index c5e295a22b..5577048fa9 100644 --- a/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx +++ b/plugins/sonarqube/src/components/SonarQubeCard/SonarQubeCard.tsx @@ -22,6 +22,7 @@ import { Progress, useApi, } from '@backstage/core'; +import { useEntity } from '@backstage/plugin-catalog-react'; import { Chip, Grid } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import BugReport from '@material-ui/icons/BugReport'; @@ -69,10 +70,10 @@ const useStyles = makeStyles(theme => ({ }, })); -interface DuplicationRating { +type DuplicationRating = { greaterThan: number; rating: '1.0' | '2.0' | '3.0' | '4.0' | '5.0'; -} +}; const defaultDuplicationRatings: DuplicationRating[] = [ { greaterThan: 0, rating: '1.0' }, @@ -83,14 +84,14 @@ const defaultDuplicationRatings: DuplicationRating[] = [ ]; export const SonarQubeCard = ({ - entity, variant = 'gridItem', duplicationRatings = defaultDuplicationRatings, }: { - entity: Entity; + entity?: Entity; variant?: string; duplicationRatings?: DuplicationRating[]; }) => { + const { entity } = useEntity(); const sonarQubeApi = useApi(sonarQubeApiRef); const projectTitle = useProjectKey(entity); diff --git a/plugins/sonarqube/src/index.ts b/plugins/sonarqube/src/index.ts index f09aeb1038..8fae151929 100644 --- a/plugins/sonarqube/src/index.ts +++ b/plugins/sonarqube/src/index.ts @@ -15,4 +15,8 @@ */ export * from './components'; -export { plugin } from './plugin'; +export { + sonarQubePlugin, + sonarQubePlugin as plugin, + EntitySonarQubeCard, +} from './plugin'; diff --git a/plugins/sonarqube/src/plugin.test.ts b/plugins/sonarqube/src/plugin.test.ts index 32730b64c3..246f8b297e 100644 --- a/plugins/sonarqube/src/plugin.test.ts +++ b/plugins/sonarqube/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { sonarQubePlugin } from './plugin'; describe('sonarqube', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(sonarQubePlugin).toBeDefined(); }); }); diff --git a/plugins/sonarqube/src/plugin.ts b/plugins/sonarqube/src/plugin.ts index f8b8cafc5c..3f58b24abf 100644 --- a/plugins/sonarqube/src/plugin.ts +++ b/plugins/sonarqube/src/plugin.ts @@ -17,12 +17,13 @@ import { configApiRef, createApiFactory, + createComponentExtension, createPlugin, discoveryApiRef, } from '@backstage/core'; import { sonarQubeApiRef, SonarQubeClient } from './api'; -export const plugin = createPlugin({ +export const sonarQubePlugin = createPlugin({ id: 'sonarqube', apis: [ createApiFactory({ @@ -36,3 +37,12 @@ export const plugin = createPlugin({ }), ], }); + +export const EntitySonarQubeCard = sonarQubePlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/SonarQubeCard').then(m => m.SonarQubeCard), + }, + }), +); From 3d76c84dee701a75dc55d9d202ffa1b5cf17f5d4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 1 Feb 2021 19:04:26 +0100 Subject: [PATCH 217/297] gcp-projects,user-settings: update plugin test imports --- plugins/gcp-projects/dev/index.tsx | 4 ++-- plugins/gcp-projects/src/plugin.test.ts | 4 ++-- plugins/user-settings/src/plugin.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/gcp-projects/dev/index.tsx b/plugins/gcp-projects/dev/index.tsx index 812a5585d4..b87d4b7d47 100644 --- a/plugins/gcp-projects/dev/index.tsx +++ b/plugins/gcp-projects/dev/index.tsx @@ -15,6 +15,6 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { gcpProjectsPlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(gcpProjectsPlugin).render(); diff --git a/plugins/gcp-projects/src/plugin.test.ts b/plugins/gcp-projects/src/plugin.test.ts index 86e909995f..78eddd8603 100644 --- a/plugins/gcp-projects/src/plugin.test.ts +++ b/plugins/gcp-projects/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { gcpProjectsPlugin } from './plugin'; describe('gcp-projects', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(gcpProjectsPlugin).toBeDefined(); }); }); diff --git a/plugins/user-settings/src/plugin.test.ts b/plugins/user-settings/src/plugin.test.ts index 810a2cda12..479fd0ac61 100644 --- a/plugins/user-settings/src/plugin.test.ts +++ b/plugins/user-settings/src/plugin.test.ts @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { plugin } from './plugin'; +import { userSettingsPlugin } from './plugin'; describe('user-settings', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(userSettingsPlugin).toBeDefined(); }); }); From 8dfdec6139e5be4913b9589ae19f026f2e09c15d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 1 Feb 2021 19:09:52 +0100 Subject: [PATCH 218/297] add changesets --- .changeset/silent-readers-worry.md | 5 +++++ .changeset/stale-zebras-warn.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/silent-readers-worry.md create mode 100644 .changeset/stale-zebras-warn.md diff --git a/.changeset/silent-readers-worry.md b/.changeset/silent-readers-worry.md new file mode 100644 index 0000000000..866485ecc0 --- /dev/null +++ b/.changeset/silent-readers-worry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-sonarqube': patch +--- + +Migrate to new composability API, exporting the plugin as `sonarQubePlugin` and card as `EntitySonarQubeCard`. diff --git a/.changeset/stale-zebras-warn.md b/.changeset/stale-zebras-warn.md new file mode 100644 index 0000000000..33b53d3f75 --- /dev/null +++ b/.changeset/stale-zebras-warn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-welcome': patch +--- + +Migrated to new composability API, exporting the plugin as `welcomePlugin` and the page as `WelcomePage`. From 00e3c5a574ece24bc0d9a4f7a68d0a636672e0bd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 1 Feb 2021 19:15:30 +0100 Subject: [PATCH 219/297] api-docs: add missing entity card exports --- plugins/api-docs/src/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/api-docs/src/index.ts b/plugins/api-docs/src/index.ts index dd231d6cb4..e7484f77c3 100644 --- a/plugins/api-docs/src/index.ts +++ b/plugins/api-docs/src/index.ts @@ -19,4 +19,9 @@ export { apiDocsPlugin, apiDocsPlugin as plugin, ApiExplorerPage, + EntityApiDefinitionCard, + EntityConsumedApisCard, + EntityConsumingComponentsCard, + EntityProvidedApisCard, + EntityProvidingComponentsCard, } from './plugin'; From bc5082a0078dc1d9e64e91a1bca96b3349cdbcb1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 1 Feb 2021 19:15:43 +0100 Subject: [PATCH 220/297] add changesets --- .changeset/fresh-eels-compare.md | 5 +++++ .changeset/plenty-steaks-confess.md | 5 +++++ .changeset/quick-apes-shop.md | 5 +++++ 3 files changed, 15 insertions(+) create mode 100644 .changeset/fresh-eels-compare.md create mode 100644 .changeset/plenty-steaks-confess.md create mode 100644 .changeset/quick-apes-shop.md diff --git a/.changeset/fresh-eels-compare.md b/.changeset/fresh-eels-compare.md new file mode 100644 index 0000000000..5d17556f7f --- /dev/null +++ b/.changeset/fresh-eels-compare.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-user-settings': patch +--- + +Migrate to new composability API, exporting the plugin as `userSettingsPlugin` and the page as `UserSettingsPage`. diff --git a/.changeset/plenty-steaks-confess.md b/.changeset/plenty-steaks-confess.md new file mode 100644 index 0000000000..f9ffe0f539 --- /dev/null +++ b/.changeset/plenty-steaks-confess.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-gcp-projects': patch +--- + +Migrate to new composability API, exporting the plugin as `gcpProjectsPlugin` and page as `GcpProjectsPage`. diff --git a/.changeset/quick-apes-shop.md b/.changeset/quick-apes-shop.md new file mode 100644 index 0000000000..4c87b9253d --- /dev/null +++ b/.changeset/quick-apes-shop.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-api-docs': patch +--- + +Migrate to new composability API, exporting the plugin as `apiDocsPlugin`, index page as `ApiExplorerPage`, and entity page cards as `EntityApiDefinitionCard`, `EntityConsumedApisCard`, `EntityConsumingComponentsCard`, `EntityProvidedApisCard`, and `EntityProvidingComponentsCard`. From f8eefd6c8f33ad1e8452a52168df8abcf52a30dc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 1 Feb 2021 19:19:57 +0100 Subject: [PATCH 221/297] graphiql: finalize composability port --- plugins/graphiql/dev/index.tsx | 14 ++++++++++++-- plugins/graphiql/src/index.ts | 6 +++++- plugins/graphiql/src/plugin.test.ts | 4 ++-- plugins/graphiql/src/plugin.ts | 4 ++-- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/plugins/graphiql/dev/index.tsx b/plugins/graphiql/dev/index.tsx index b93995a5dc..918695e0e0 100644 --- a/plugins/graphiql/dev/index.tsx +++ b/plugins/graphiql/dev/index.tsx @@ -14,12 +14,18 @@ * limitations under the License. */ +import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; import { githubAuthApiRef, errorApiRef } from '@backstage/core'; -import { plugin, GraphQLEndpoints, graphQlBrowseApiRef } from '../src'; +import { + graphiqlPlugin, + GraphQLEndpoints, + graphQlBrowseApiRef, + GraphiQLPage, +} from '../src'; createDevApp() - .registerPlugin(plugin) + .registerPlugin(graphiqlPlugin) .registerApi({ api: graphQlBrowseApiRef, deps: { @@ -47,4 +53,8 @@ createDevApp() ]); }, }) + .addPage({ + title: 'GraphiQL', + element: , + }) .render(); diff --git a/plugins/graphiql/src/index.ts b/plugins/graphiql/src/index.ts index 4b1da14079..ed64779eba 100644 --- a/plugins/graphiql/src/index.ts +++ b/plugins/graphiql/src/index.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -export { plugin, GraphiQLPage } from './plugin'; +export { + graphiqlPlugin, + graphiqlPlugin as plugin, + GraphiQLPage, +} from './plugin'; export { GraphiQLPage as Router } from './components'; export * from './lib/api'; export * from './route-refs'; diff --git a/plugins/graphiql/src/plugin.test.ts b/plugins/graphiql/src/plugin.test.ts index 0841d3e071..3683c0be0a 100644 --- a/plugins/graphiql/src/plugin.test.ts +++ b/plugins/graphiql/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { graphiqlPlugin } from './plugin'; describe('graphiql', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(graphiqlPlugin).toBeDefined(); }); }); diff --git a/plugins/graphiql/src/plugin.ts b/plugins/graphiql/src/plugin.ts index 87f750b132..42969999d9 100644 --- a/plugins/graphiql/src/plugin.ts +++ b/plugins/graphiql/src/plugin.ts @@ -22,7 +22,7 @@ import { import { graphQlBrowseApiRef, GraphQLEndpoints } from './lib/api'; import { graphiQLRouteRef } from './route-refs'; -export const plugin = createPlugin({ +export const graphiqlPlugin = createPlugin({ id: 'graphiql', apis: [ // GitLab is used as an example endpoint, but most will want to plug in @@ -40,7 +40,7 @@ export const plugin = createPlugin({ ], }); -export const GraphiQLPage = plugin.provide( +export const GraphiQLPage = graphiqlPlugin.provide( createRoutableExtension({ component: () => import('./components').then(m => m.GraphiQLPage), mountPoint: graphiQLRouteRef, From b98928a38e0ca833594d18e7d8d3d176ed873fa4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 1 Feb 2021 19:20:41 +0100 Subject: [PATCH 222/297] sonarqube,welcome: update dev setups --- plugins/sonarqube/dev/index.tsx | 8 +------- plugins/welcome/dev/index.tsx | 4 ++-- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/plugins/sonarqube/dev/index.tsx b/plugins/sonarqube/dev/index.tsx index b99e552df9..2dd9236547 100644 --- a/plugins/sonarqube/dev/index.tsx +++ b/plugins/sonarqube/dev/index.tsx @@ -15,13 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; -import { - Content, - createPlugin, - createRouteRef, - Header, - Page, -} from '@backstage/core'; +import { Content, Header, Page } from '@backstage/core'; import { createDevApp, EntityGridItem } from '@backstage/dev-utils'; import { Grid } from '@material-ui/core'; import React from 'react'; diff --git a/plugins/welcome/dev/index.tsx b/plugins/welcome/dev/index.tsx index 812a5585d4..b237812f97 100644 --- a/plugins/welcome/dev/index.tsx +++ b/plugins/welcome/dev/index.tsx @@ -15,6 +15,6 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { welcomePlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(welcomePlugin).render(); From 87b189d00fef710d47c1c275447e90b3c740879c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 1 Feb 2021 19:21:33 +0100 Subject: [PATCH 223/297] changesets: add graphiql migration changeset --- .changeset/cyan-lions-float.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cyan-lions-float.md diff --git a/.changeset/cyan-lions-float.md b/.changeset/cyan-lions-float.md new file mode 100644 index 0000000000..f5fa064b71 --- /dev/null +++ b/.changeset/cyan-lions-float.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-graphiql': patch +--- + +Finalized composability API migration, now exporting the plugin as `graphiqlPlugin`. From 6c07b5c6e5c1008919e0b10897c05eb1ab716def Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 1 Feb 2021 23:46:51 +0100 Subject: [PATCH 224/297] dev-utils: add path option to addPage, switch to render element in route, and skip sidebar item title is not set --- packages/dev-utils/src/devApp/render.tsx | 37 +++++++++++++++--------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index f1a6d30b4c..5f86442b46 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -34,16 +34,16 @@ import { attachComponentData, } from '@backstage/core'; import SentimentDissatisfiedIcon from '@material-ui/icons/SentimentDissatisfied'; -import { Outlet } from 'react-router'; const GatheringRoute: (props: { path: string; - children: JSX.Element; -}) => JSX.Element = () => ; + element: JSX.Element; +}) => JSX.Element = ({ element }) => element; attachComponentData(GatheringRoute, 'core.gatherMountPoints', true); type RegisterPageOptions = { + path?: string; element: JSX.Element; title?: string; icon?: IconComponent; @@ -93,21 +93,30 @@ class DevAppBuilder { return this; } - addPage({ element, title, icon }: RegisterPageOptions): DevAppBuilder { - const path = `/page-${this.routes.length + 1}`; - this.sidebarItems.push( - , - ); + /** + * Adds a page component along with accompanying sidebar item. + * + * If no path is provided one will be generated. + * If no title is provider no sidebar item will be created. + */ + addPage(opts: RegisterPageOptions): DevAppBuilder { + const path = opts.path ?? `/page-${this.routes.length + 1}`; + if (opts.title) { + this.sidebarItems.push( + , + ); + } this.routes.push( - , + , ); return this; } + /** * Build a DevApp component using the resources registered so far */ From 5c300a25a72e5ca88bd9b6c598f1f18d09e2f195 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 1 Feb 2021 23:54:51 +0100 Subject: [PATCH 225/297] scaffolder: port to new composability API --- plugins/scaffolder/dev/index.tsx | 29 +++++++++++++++++++++++-- plugins/scaffolder/package.json | 1 + plugins/scaffolder/src/index.ts | 7 +++++- plugins/scaffolder/src/plugin.test.ts | 4 ++-- plugins/scaffolder/src/plugin.ts | 31 ++++++++++++++++++++++----- plugins/scaffolder/src/routes.ts | 3 +-- 6 files changed, 63 insertions(+), 12 deletions(-) diff --git a/plugins/scaffolder/dev/index.tsx b/plugins/scaffolder/dev/index.tsx index 812a5585d4..eff16ab0f5 100644 --- a/plugins/scaffolder/dev/index.tsx +++ b/plugins/scaffolder/dev/index.tsx @@ -14,7 +14,32 @@ * limitations under the License. */ +import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { discoveryApiRef } from '@backstage/core'; +import { CatalogClient } from '@backstage/catalog-client'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { TemplateIndexPage, TemplatePage } from '../src/plugin'; +import { ScaffolderApi, scaffolderApiRef } from '../src'; -createDevApp().registerPlugin(plugin).render(); +createDevApp() + .registerApi({ + api: catalogApiRef, + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new CatalogClient({ discoveryApi }), + }) + .registerApi({ + api: scaffolderApiRef, + deps: { discoveryApi: discoveryApiRef }, + factory: ({ discoveryApi }) => new ScaffolderApi({ discoveryApi }), + }) + .addPage({ + path: '/create', + title: 'Create', + element: , + }) + .addPage({ + path: '/create/:templateName', + element: , + }) + .render(); diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index d174991bb7..69021953a2 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -54,6 +54,7 @@ "@backstage/cli": "^0.5.0", "@backstage/dev-utils": "^0.1.8", "@backstage/test-utils": "^0.1.6", + "@backstage/catalog-client": "^0.3.5", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index f0fdf5b429..5f102e7853 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -14,6 +14,11 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { + scaffolderPlugin, + scaffolderPlugin as plugin, + TemplateIndexPage, + TemplatePage, +} from './plugin'; export { ScaffolderApi, scaffolderApiRef } from './api'; export { rootRoute, templateRoute } from './routes'; diff --git a/plugins/scaffolder/src/plugin.test.ts b/plugins/scaffolder/src/plugin.test.ts index 3b4c92168d..03dd9fe465 100644 --- a/plugins/scaffolder/src/plugin.test.ts +++ b/plugins/scaffolder/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { scaffolderPlugin } from './plugin'; describe('scaffolder', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(scaffolderPlugin).toBeDefined(); }); }); diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index 41f7a03249..ed20705185 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -18,13 +18,14 @@ import { createPlugin, createApiFactory, discoveryApiRef, + createRoutableExtension, } from '@backstage/core'; -import { ScaffolderPage } from './components/ScaffolderPage'; -import { TemplatePage } from './components/TemplatePage'; +import { ScaffolderPage as ScaffolderPageComponent } from './components/ScaffolderPage'; +import { TemplatePage as TemplatePageComponent } from './components/TemplatePage'; import { rootRoute, templateRoute } from './routes'; import { scaffolderApiRef, ScaffolderApi } from './api'; -export const plugin = createPlugin({ +export const scaffolderPlugin = createPlugin({ id: 'scaffolder', apis: [ createApiFactory({ @@ -34,7 +35,27 @@ export const plugin = createPlugin({ }), ], register({ router }) { - router.addRoute(rootRoute, ScaffolderPage); - router.addRoute(templateRoute, TemplatePage); + router.addRoute(rootRoute, ScaffolderPageComponent); + router.addRoute(templateRoute, TemplatePageComponent); + }, + routes: { + templateIndex: rootRoute, + template: templateRoute, }, }); + +export const TemplateIndexPage = scaffolderPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/ScaffolderPage').then(m => m.ScaffolderPage), + mountPoint: rootRoute, + }), +); + +export const TemplatePage = scaffolderPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/TemplatePage').then(m => m.TemplatePage), + mountPoint: templateRoute, + }), +); diff --git a/plugins/scaffolder/src/routes.ts b/plugins/scaffolder/src/routes.ts index 28c77f29a6..8efd1d3aaf 100644 --- a/plugins/scaffolder/src/routes.ts +++ b/plugins/scaffolder/src/routes.ts @@ -16,12 +16,11 @@ import { createRouteRef } from '@backstage/core'; export const rootRoute = createRouteRef({ - icon: () => null, path: '/create', title: 'Create new entity', }); + export const templateRoute = createRouteRef({ - icon: () => null, path: '/create/:templateName', title: 'Entity creation', }); From 7201498545e3dfdc41d6426ac4c03227414015d7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 1 Feb 2021 23:57:36 +0100 Subject: [PATCH 226/297] add changesets --- .changeset/chilled-toys-raise.md | 5 +++++ .changeset/moody-apricots-warn.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/chilled-toys-raise.md create mode 100644 .changeset/moody-apricots-warn.md diff --git a/.changeset/chilled-toys-raise.md b/.changeset/chilled-toys-raise.md new file mode 100644 index 0000000000..282d5492ec --- /dev/null +++ b/.changeset/chilled-toys-raise.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Migrated to new composability API, exporting the plugin as `scaffolderPlugin`. The template list page (`/create`) is exported as the `TemplateIndexPage` extension, and the templating page itself is exported as `TemplatePage`. diff --git a/.changeset/moody-apricots-warn.md b/.changeset/moody-apricots-warn.md new file mode 100644 index 0000000000..c4d9be0ba3 --- /dev/null +++ b/.changeset/moody-apricots-warn.md @@ -0,0 +1,5 @@ +--- +'@backstage/dev-utils': patch +--- + +Added `path` option to `addPage` that can be used to set a specific path for the page rather than a generated one. Also omit sidebar item altogether if `title` option is not set. From 9cda5593afcfec038a637e5a7da5a1a14204c202 Mon Sep 17 00:00:00 2001 From: Kaparwan Manoj Date: Tue, 2 Feb 2021 10:38:47 +1100 Subject: [PATCH 227/297] bitbucket own hosted v5.11.1 branchUrl fix and enbaled error tracing for mkdocs process stack trace --- packages/integration/src/bitbucket/core.ts | 5 +++-- packages/techdocs-common/src/stages/generate/helpers.ts | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/integration/src/bitbucket/core.ts b/packages/integration/src/bitbucket/core.ts index f5235d8189..e9a76d1ee0 100644 --- a/packages/integration/src/bitbucket/core.ts +++ b/packages/integration/src/bitbucket/core.ts @@ -32,10 +32,11 @@ export async function getBitbucketDefaultBranch( const isHosted = resource === 'bitbucket.org'; // Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp184 + // Changed branchUrl to support Atlassian Bitbucket v5.11.1 , which has different branchUrl format const branchUrl = isHosted ? `${config.apiBaseUrl}/repositories/${project}/${repoName}` - : `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/default-branch`; - + : `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/branches/default`; + const response = await fetch(branchUrl, getBitbucketRequestOptions(config)); if (!response.ok) { const message = `Failed to retrieve default branch from ${branchUrl}, ${response.status} ${response.statusText}`; diff --git a/packages/techdocs-common/src/stages/generate/helpers.ts b/packages/techdocs-common/src/stages/generate/helpers.ts index e396157b92..73be0e8ff0 100644 --- a/packages/techdocs-common/src/stages/generate/helpers.ts +++ b/packages/techdocs-common/src/stages/generate/helpers.ts @@ -133,6 +133,10 @@ export const runCommand = async ({ process.stderr.on('data', stream => { logStream.write(stream); + // added below change to make OS ( child process) exception to the backend logs + const bufferbase64 = Buffer.from(stream, 'base64'); + const textAscii = bufferbase64.toString('ascii'); + console.log(' Process Stderror trace: ', textAscii); }); process.on('error', error => { From 5b2dd00f55312f944538c72424ea32c17f42b308 Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Mon, 1 Feb 2021 21:51:03 -0500 Subject: [PATCH 228/297] Fix typos --- .../user-settings/src/components/FeatureFlags/EmptyFlags.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/user-settings/src/components/FeatureFlags/EmptyFlags.tsx b/plugins/user-settings/src/components/FeatureFlags/EmptyFlags.tsx index 5df4df4450..de6f4703e0 100644 --- a/plugins/user-settings/src/components/FeatureFlags/EmptyFlags.tsx +++ b/plugins/user-settings/src/components/FeatureFlags/EmptyFlags.tsx @@ -32,11 +32,11 @@ export const EmptyFlags = () => ( - An example how how to add a feature flags is highlighted below: + An example for how to add a feature flag is highlighted below: Date: Mon, 1 Feb 2021 21:51:32 -0500 Subject: [PATCH 229/297] Remove extraneous grid --- .../src/components/General/Profile.tsx | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/plugins/user-settings/src/components/General/Profile.tsx b/plugins/user-settings/src/components/General/Profile.tsx index b0231d95b6..c8034085bd 100644 --- a/plugins/user-settings/src/components/General/Profile.tsx +++ b/plugins/user-settings/src/components/General/Profile.tsx @@ -24,31 +24,27 @@ export const Profile = () => { const { profile, displayName } = useUserProfile(); return ( - - - - - - - - - - - - {displayName} - - - {profile.email} - - - - - - + + + + + + + + + + {displayName} + + + {profile.email} + - + + + + - + ); }; From c88aa5f04aa5b18cd60660cdd65db437abfddb15 Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Mon, 1 Feb 2021 21:52:06 -0500 Subject: [PATCH 230/297] Keep PinButton visible --- plugins/user-settings/src/components/General/General.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/plugins/user-settings/src/components/General/General.tsx b/plugins/user-settings/src/components/General/General.tsx index 2d30c00eec..b5054217e4 100644 --- a/plugins/user-settings/src/components/General/General.tsx +++ b/plugins/user-settings/src/components/General/General.tsx @@ -14,15 +14,13 @@ * limitations under the License. */ import { InfoCard } from '@backstage/core'; -import { Grid, List, useMediaQuery, useTheme } from '@material-ui/core'; +import { Grid, List } from '@material-ui/core'; import React from 'react'; import { PinButton } from './PinButton'; import { Profile } from './Profile'; import { ThemeToggle } from './ThemeToggle'; export const General = () => { - const theme = useTheme(); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); return ( @@ -32,7 +30,7 @@ export const General = () => { - {!fullScreen && } + From de98c32ed40d96c095f03f09867713e39cd3aef2 Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Mon, 1 Feb 2021 21:53:37 -0500 Subject: [PATCH 231/297] Add changeset --- .changeset/tidy-news-perform.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tidy-news-perform.md diff --git a/.changeset/tidy-news-perform.md b/.changeset/tidy-news-perform.md new file mode 100644 index 0000000000..055f051da7 --- /dev/null +++ b/.changeset/tidy-news-perform.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-user-settings': patch +--- + +Keep the Pin Sidebar setting visible on small screens. From 03144fb7b51b55c16f3ce48680fe2a6a09a90aa5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Feb 2021 04:47:52 +0000 Subject: [PATCH 232/297] chore(deps): bump jenkins from 0.28.0 to 0.28.1 Bumps [jenkins](https://github.com/silas/node-jenkins) from 0.28.0 to 0.28.1. - [Release notes](https://github.com/silas/node-jenkins/releases) - [Commits](https://github.com/silas/node-jenkins/compare/0.28.0...0.28.1) Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 2ac7dffa6a..783d2c793c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16438,9 +16438,9 @@ jake@^10.6.1: minimatch "^3.0.4" jenkins@^0.28.0: - version "0.28.0" - resolved "https://registry.npmjs.org/jenkins/-/jenkins-0.28.0.tgz#72d6fcc452145403b34f6d4ecbd877ee1ab77fca" - integrity sha512-EGzzZcyFwXBCPZZoDNvZTPmZOqaHALfAStNfCF37oh+Mc6G/e0MwIuQjx5kMEynTXR9bF5EwLiuMTiTf5kHk5g== + version "0.28.1" + resolved "https://registry.npmjs.org/jenkins/-/jenkins-0.28.1.tgz#f7951798ee5d2bb501a831979b6b5ecc1a922a64" + integrity sha512-gcC4QUrP4VzdqOMHoVzh36XlJprxJkI2HGLQSY7w84KoCTVNDcR/8O00tYyXp9vrZOx4wl5zCXLVKMgH2IoyJQ== dependencies: papi "^0.29.0" From b3058e29915d028cc35f63405daceabd1068356a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Feb 2021 04:55:58 +0000 Subject: [PATCH 233/297] chore(deps-dev): bump @types/express-serve-static-core Bumps [@types/express-serve-static-core](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/express-serve-static-core) from 4.17.9 to 4.17.18. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/express-serve-static-core) Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 2ac7dffa6a..625dad9208 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6654,9 +6654,9 @@ integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== "@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.5": - version "4.17.9" - resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.9.tgz#2d7b34dcfd25ec663c25c85d76608f8b249667f1" - integrity sha512-DG0BYg6yO+ePW+XoDENYz8zhNGC3jDDEpComMYn7WJc4mY1Us8Rw9ax2YhJXxpyk2SF47PQAoQ0YyVT1a0bEkA== + version "4.17.18" + resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.18.tgz#8371e260f40e0e1ca0c116a9afcd9426fa094c40" + integrity sha512-m4JTwx5RUBNZvky/JJ8swEJPKFd8si08pPF2PfizYjGZOKr/svUWPcoUmLow6MmPzhasphB7gSTINY67xn3JNA== dependencies: "@types/node" "*" "@types/qs" "*" From f573c572fbd67c60b7a0932dc586b9131a50acd8 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Tue, 2 Feb 2021 10:28:42 +0100 Subject: [PATCH 234/297] microsite/docs: Scroll nav sidebar to view for loaded page --- microsite/siteConfig.js | 1 + .../static/js/scroll-nav-to-view-in-docs.js | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 microsite/static/js/scroll-nav-to-view-in-docs.js diff --git a/microsite/siteConfig.js b/microsite/siteConfig.js index 8d0c4ea57c..51354343d8 100644 --- a/microsite/siteConfig.js +++ b/microsite/siteConfig.js @@ -83,6 +83,7 @@ const siteConfig = { 'https://unpkg.com/medium-zoom@1.0.6/dist/medium-zoom.min.js', '/js/medium-zoom.js', '/js/dismissable-banner.js', + '/js/scroll-nav-to-view-in-docs.js', ], // On page navigation for the current documentation page. diff --git a/microsite/static/js/scroll-nav-to-view-in-docs.js b/microsite/static/js/scroll-nav-to-view-in-docs.js new file mode 100644 index 0000000000..0e4da26674 --- /dev/null +++ b/microsite/static/js/scroll-nav-to-view-in-docs.js @@ -0,0 +1,23 @@ +// On backstage.io/docs pages, scroll the Nav sidebar to focus on +// the page being viewed. Helpful when the Nav is large enough that +// the selected page is hidden somewhere at bottom. +// Credits: https://github.com/facebook/docusaurus/issues/823#issuecomment-421152269 +document.addEventListener('DOMContentLoaded', () => { + // Find the active nav item in the sidebar + const item = document.getElementsByClassName('navListItemActive')[0]; + if (!item) { + return; + } + const bounding = item.getBoundingClientRect(); + if ( + bounding.top >= 0 && + bounding.bottom <= + (window.innerHeight || document.documentElement.clientHeight) + ) { + // Already visible. Do nothing. + } else { + // Not visible. Scroll sidebar. + item.scrollIntoView({ block: 'start', inline: 'nearest' }); + document.body.scrollTop = document.documentElement.scrollTop = 0; + } +}); From 246c9c6edc82fb6f370756cd8bf5d92be59ff960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 1 Feb 2021 19:06:30 +0100 Subject: [PATCH 235/297] Update docs/plugins/observability.md Co-authored-by: Adam Harvey --- .github/styles/vocab.txt | 2 ++ docs/plugins/observability.md | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index d545b380f5..2991a6babd 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -218,6 +218,7 @@ Spotify sqlite squidfunk src +stdout stefanalund subkey subtree @@ -258,6 +259,7 @@ Voi Wealthsimple Weaveworks Webpack +winston www WWW xyz diff --git a/docs/plugins/observability.md b/docs/plugins/observability.md index f52dd130bd..8b6668606f 100644 --- a/docs/plugins/observability.md +++ b/docs/plugins/observability.md @@ -5,7 +5,7 @@ title: Observability description: Adding Observability to Your Plugin --- -This article briefly describes the observability option that are available to a +This article briefly describes the observability options that are available to a Backstage integrator. ## Google Analytics @@ -19,3 +19,36 @@ app: ``` Replace the tracking ID with your own. + +For more information, learn about Google Analytics +[here](https://marketingplatform.google.com/about/analytics/). + +## Logging + +The backend supplies a central [winston](https://github.com/winstonjs/winston) +root logger that plugins are expected to use for their logging needs. In the +default production setup, it emits structured JSON logs on stdout, with a field +`"service": "backstage"` and also tagged on a per-plugin basis. Plugins that +want to more finely specify what part of their processes that emitted the log +message should add a `"component"` field to do so. + +An example log line could look as follows: + +```json +{ + "service": "backstage", + "type": "plugin", + "plugin": "catalog", + "component": "catalog-all-locations-refresh", + "level": "info", + "message": "Locations Refresh: Refreshing location bootstrap:bootstrap" +} +``` + +## Health Checks + +The example backend in the Backstage repository +[supplies](https://github.com/backstage/backstage/blob/bc18571b7a742863a770b2a54e785d6bbef7e184/packages/backend/src/index.ts#L99) +a very basic health check endpoint on the `/healthcheck` route. You may add such +a handler to your backend as well, and supply your own logic to it that fits +your particular health checking needs. From 20ca5f179f1431cd12d3ef84a99a4df9d8b096bf Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 2 Feb 2021 11:34:48 +0100 Subject: [PATCH 236/297] Update packages/dev-utils/src/devApp/render.tsx Co-authored-by: Adam Harvey --- packages/dev-utils/src/devApp/render.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index 5f86442b46..b4ec540f9c 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -97,7 +97,7 @@ class DevAppBuilder { * Adds a page component along with accompanying sidebar item. * * If no path is provided one will be generated. - * If no title is provider no sidebar item will be created. + * If no title is provided, no sidebar item will be created. */ addPage(opts: RegisterPageOptions): DevAppBuilder { const path = opts.path ?? `/page-${this.routes.length + 1}`; From 7717c46517ef01bd7aa35c9269df7123d826b323 Mon Sep 17 00:00:00 2001 From: Debajyoti Halder Date: Tue, 2 Feb 2021 16:42:37 +0530 Subject: [PATCH 237/297] Fix variable names of conflicting types --- .../src/lib/AuthConnector/DirectAuthConnector.ts | 14 +------------- packages/core/src/layout/HeaderTabs/HeaderTabs.tsx | 8 ++++---- .../src/github/GithubCredentialsProvider.test.ts | 12 ++++++------ .../src/components/BarChart/BarChartLabel.tsx | 4 ++-- .../src/components/BarChart/BarChartSteps.tsx | 4 ++-- .../components/ProductInsights/ProductInsights.tsx | 10 +++++----- .../MultistepJsonForm/MultistepJsonForm.tsx | 10 +++++----- .../src/components/TemplatePage/TemplatePage.tsx | 4 ++-- .../src/components/SearchResult/SearchResult.tsx | 5 ----- 9 files changed, 27 insertions(+), 44 deletions(-) diff --git a/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts index 71671edba6..e7764fcbaa 100644 --- a/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts +++ b/packages/core-api/src/lib/AuthConnector/DirectAuthConnector.ts @@ -13,12 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - AuthProvider, - ProfileInfo, - BackstageIdentity, - DiscoveryApi, -} from '../../apis/definitions'; +import { AuthProvider, DiscoveryApi } from '../../apis/definitions'; import { showLoginPopup } from '../loginPopup'; type Options = { @@ -26,13 +21,6 @@ type Options = { environment?: string; provider: AuthProvider & { id: string }; }; - -// export type DirectAuthResponse = { -// userId: string; -// profile: ProfileInfo; -// backstageIdentity: BackstageIdentity; -// }; - export class DirectAuthConnector { private readonly discoveryApi: DiscoveryApi; private readonly environment: string | undefined; diff --git a/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx b/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx index ef1f596f83..718a103f00 100644 --- a/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx +++ b/packages/core/src/layout/HeaderTabs/HeaderTabs.tsx @@ -18,7 +18,7 @@ // This is just a temporary solution to implementing tabs for now import React, { useState, useEffect } from 'react'; -import { makeStyles, Tabs, Tab } from '@material-ui/core'; +import { makeStyles, Tabs, Tab as TabUI } from '@material-ui/core'; const useStyles = makeStyles(theme => ({ tabsWrapper: { @@ -38,13 +38,13 @@ const useStyles = makeStyles(theme => ({ }, })); -export type objectTab = { +export type Tab = { id: string; label: string; }; type HeaderTabsProps = { - tabs: objectTab[]; + tabs: Tab[]; onChange?: (index: number) => void; selectedIndex?: number; }; @@ -81,7 +81,7 @@ export const HeaderTabs = ({ value={selectedTab} > {tabs.map((tab, index) => ( - { }); it('should return the default token if no app is configured', async () => { - const githubID = GithubCredentialsProvider.create({ + const githubProvider = GithubCredentialsProvider.create({ host: 'github.com', apps: [], token: 'fallback_token', }); await expect( - githubID.getCredentials({ + githubProvider.getCredentials({ url: 'https://github.com/404/foobar', }), ).resolves.toEqual(expect.objectContaining({ token: 'fallback_token' })); }); it('should return the configured token if listing installations throws', async () => { - const githubID = GithubCredentialsProvider.create({ + const githubProvider = GithubCredentialsProvider.create({ host: 'github.com', apps: [ { @@ -245,19 +245,19 @@ describe('GithubCredentialsProvider tests', () => { octokit.apps.listInstallations.mockRejectedValue({ status: 304 }); await expect( - githubID.getCredentials({ + githubProvider.getCredentials({ url: 'https://github.com/backstage', }), ).resolves.toEqual(expect.objectContaining({ token: 'hardcoded_token' })); }); it('should return undefined if no token or apps are configured', async () => { - const githubID = GithubCredentialsProvider.create({ + const githubProvider = GithubCredentialsProvider.create({ host: 'github.com', }); await expect( - githubID.getCredentials({ + githubProvider.getCredentials({ url: 'https://github.com/backstage', }), ).resolves.toEqual({ headers: undefined, token: undefined }); diff --git a/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx index 71bb08d563..cd882c6afd 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx @@ -18,7 +18,7 @@ import React, { PropsWithChildren } from 'react'; import { Box, Typography } from '@material-ui/core'; import { useBarChartLabelStyles } from '../../utils/styles'; -type BarChartLabelType = { +type BarChartLabelObject = { x: number; y: number; height: number; @@ -33,7 +33,7 @@ export const BarChartLabel = ({ width, details, children, -}: PropsWithChildren) => { +}: PropsWithChildren) => { const classes = useBarChartLabelStyles(); const translateX = width * -0.5; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx b/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx index 9576e74b42..ec573da68f 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { ButtonBase } from '@material-ui/core'; import { useBarChartStepperStyles as useStyles } from '../../utils/styles'; -export type BarChartStepsType = { +export type BarChartStepsObject = { steps: number; activeStep: number; onClick: (index: number) => void; @@ -28,7 +28,7 @@ export const BarChartSteps = ({ steps, activeStep, onClick, -}: BarChartStepsType) => { +}: BarChartStepsObject) => { const classes = useStyles(); const handleOnClick = (index: number) => ( event: React.MouseEvent, diff --git a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx index 381827e4e2..b13376d24a 100644 --- a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx +++ b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx @@ -85,26 +85,26 @@ export const ProductInsights = ({ async function getAllProductInsights( groupId: string, projectId: Maybe, - productsId: Product[], - lastCompleteBillingDateId: string, + productIds: Product[], + lastCompleteBillingDateString: string, ) { try { dispatchLoadingProducts(true); const responses = await Promise.allSettled( - productsId.map(product => + productIds.map(product => client.getProductInsights({ group: groupId, project: projectId, product: product.kind, intervals: intervalsOf( DEFAULT_DURATION, - lastCompleteBillingDateId, + lastCompleteBillingDateString, ), }), ), ).then(settledResponseOf); - const initialStatesNow = initialStatesOf(productsId, responses).sort( + const initialStatesNow = initialStatesOf(productIds, responses).sort( totalAggregationSort, ); setStates(initialStatesNow); diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index 0d66fbf92b..27c5b4f18a 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -19,7 +19,7 @@ import { Box, Button, Paper, - Step, + Step as StepUI, StepContent, StepLabel, Stepper, @@ -30,7 +30,7 @@ import { Theme as MuiTheme } from '@rjsf/material-ui'; import React, { useState } from 'react'; const Form = withTheme(MuiTheme); -type StepType = { +type Step = { schema: JSONSchema; label: string; } & Partial, 'schema'>>; @@ -39,7 +39,7 @@ type Props = { /** * Steps for the form, each contains label and form schema */ - steps: StepType[]; + steps: Step[]; formData: Record; onChange: (e: IChangeEvent) => void; onReset: () => void; @@ -67,7 +67,7 @@ export const MultistepJsonForm = ({ <> {steps.map(({ label, schema, ...formProps }) => ( - + {label}
-
+ ))}
{activeStep === steps.length && ( diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 9b089fae43..e385f9adc3 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -124,8 +124,8 @@ export const TemplatePage = () => { const handleCreate = async () => { try { - const Id = await scaffolderApi.scaffold(templateName, formState); - setJobId(Id); + const id = await scaffolderApi.scaffold(templateName, formState); + setJobId(id); setModalOpen(true); } catch (e) { errorApi.post(e); diff --git a/plugins/search/src/components/SearchResult/SearchResult.tsx b/plugins/search/src/components/SearchResult/SearchResult.tsx index 4b82679fcc..dc4a502515 100644 --- a/plugins/search/src/components/SearchResult/SearchResult.tsx +++ b/plugins/search/src/components/SearchResult/SearchResult.tsx @@ -59,11 +59,6 @@ type TableHeaderProps = { handleToggleFilters: () => void; }; -// type Filters = { -// selected: string; -// checked: Array; -// }; - // TODO: move out column to make the search result component more generic const columns: TableColumn[] = [ { From 2430ee7c2e632f6bcac7a8387d54d410b1e87583 Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Mon, 1 Feb 2021 22:15:26 -0500 Subject: [PATCH 238/297] feat(backend-common): support custom logger options --- .changeset/fluffy-nails-sort.md | 25 +++++++ .../backend-common/src/logging/formats.ts | 1 + packages/backend-common/src/logging/index.ts | 1 + .../src/logging/rootLogger.test.ts | 68 ++++++++++++++++++- .../backend-common/src/logging/rootLogger.ts | 49 +++++++++---- 5 files changed, 129 insertions(+), 15 deletions(-) create mode 100644 .changeset/fluffy-nails-sort.md diff --git a/.changeset/fluffy-nails-sort.md b/.changeset/fluffy-nails-sort.md new file mode 100644 index 0000000000..545698c47f --- /dev/null +++ b/.changeset/fluffy-nails-sort.md @@ -0,0 +1,25 @@ +--- +'@backstage/backend-common': patch +--- + +Updated the `rootLogger` in `@backstage/backend-common` to support custom logging options. This is useful when you want to make some changes without re-implementing the entire logger and calling `setRootLogger` or `logger.configure`. For example you can add additional `defaultMeta` tags to each log entry. The following changes are included: + +- Added `createRootLogger` which accepts winston `LoggerOptions`. These options allow overriding the default keys. +- Added an additional error format that can include stack traces. This can be enabled by setting a `LOG_STACKTRACE=true` environment variable. Any `Error` objects passed to `logger.error('message', err)` will include the full stack trace in a `stack` log entry key. + +Example Usage: + +```ts +// Create the logger +const logger = createRootLogger({ + defaultMeta: { appName: 'backstage', appEnv: 'prod' }, +}); + +// Add a custom logger transport +logger.add(new MyCustomTransport()); + +const config = await loadBackendConfig({ + argv: process.argv, + logger: getRootLogger(), // already set to new logger instance +}); +``` diff --git a/packages/backend-common/src/logging/formats.ts b/packages/backend-common/src/logging/formats.ts index 4f7949f115..fcd193f521 100644 --- a/packages/backend-common/src/logging/formats.ts +++ b/packages/backend-common/src/logging/formats.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import * as winston from 'winston'; import { TransformableInfo } from 'logform'; diff --git a/packages/backend-common/src/logging/index.ts b/packages/backend-common/src/logging/index.ts index 0ebf0371e9..ef2e96f6c0 100644 --- a/packages/backend-common/src/logging/index.ts +++ b/packages/backend-common/src/logging/index.ts @@ -14,5 +14,6 @@ * limitations under the License. */ +export * from './formats'; export * from './rootLogger'; export * from './voidLogger'; diff --git a/packages/backend-common/src/logging/rootLogger.test.ts b/packages/backend-common/src/logging/rootLogger.test.ts index 07ef857d50..5506d195a9 100644 --- a/packages/backend-common/src/logging/rootLogger.test.ts +++ b/packages/backend-common/src/logging/rootLogger.test.ts @@ -15,7 +15,7 @@ */ import * as winston from 'winston'; -import { getRootLogger, setRootLogger } from './rootLogger'; +import { createRootLogger, getRootLogger, setRootLogger } from './rootLogger'; describe('rootLogger', () => { it('can replace the default logger', () => { @@ -29,4 +29,70 @@ describe('rootLogger', () => { expect.stringContaining('testing'), ); }); + + describe('createRootLoger', () => { + it('creates a new logger', () => { + const oldLogger = getRootLogger(); + const newLogger = createRootLogger(); + + expect(oldLogger).not.toBe(newLogger); + }); + + it('replaces the existing root logger', () => { + const oldLogger = getRootLogger(); + createRootLogger(); + const newLogger = getRootLogger(); + expect(oldLogger).not.toBe(newLogger); + }); + + it('can append additional default metadata', () => { + const format = winston.format.json(); + const logger = createRootLogger({ + format, + defaultMeta: { + appName: 'backstage', + appEnv: 'prod', + containerId: 'abc', + }, + }); + jest.spyOn(format, 'transform'); + + logger.info('testing'); + + expect(format.transform).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'testing', + service: 'backstage', + appName: 'backstage', + appEnv: 'prod', + containerId: 'abc', + }), + {}, + ); + }); + + it('can add override existing transports', () => { + const transport = new winston.transports.Console({ level: 'debug' }); + const logger = createRootLogger({ transports: [transport] }); + expect(logger.transports.length).toBe(1); + expect(logger.transports[0]).toBe(transport); + }); + + it('can append an additional transport', () => { + const logger = createRootLogger(); + const transport = new winston.transports.Console({ level: 'debug' }); + logger.add(transport); + expect(logger.transports.length).toBe(2); + expect(logger.transports[1]).toBe(transport); + expect(logger.transports[1].level).toBe('debug'); + }); + + it('can override default format', () => { + const format = winston.format(() => false)(); + const logger = createRootLogger({ format }); + expect( + logger.format.transform({ message: 'hello', level: 'info' }), + ).toBeFalsy(); + }); + }); }); diff --git a/packages/backend-common/src/logging/rootLogger.ts b/packages/backend-common/src/logging/rootLogger.ts index 306ed23444..a8cf4721d6 100644 --- a/packages/backend-common/src/logging/rootLogger.ts +++ b/packages/backend-common/src/logging/rootLogger.ts @@ -13,23 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import { merge } from 'lodash'; import * as winston from 'winston'; +import { LoggerOptions } from 'winston'; import { coloredFormat } from './formats'; -let rootLogger: winston.Logger = winston.createLogger({ - level: process.env.LOG_LEVEL || 'info', - format: - process.env.NODE_ENV === 'production' - ? winston.format.json() - : coloredFormat, - defaultMeta: { service: 'backstage' }, - transports: [ - new winston.transports.Console({ - silent: - process.env.JEST_WORKER_ID !== undefined && !process.env.LOG_LEVEL, - }), - ], -}); +let rootLogger: winston.Logger; export function getRootLogger(): winston.Logger { return rootLogger; @@ -38,3 +28,34 @@ export function getRootLogger(): winston.Logger { export function setRootLogger(newLogger: winston.Logger) { rootLogger = newLogger; } + +export function createRootLogger( + options: winston.LoggerOptions = {}, + env = process.env, +): winston.Logger { + const logger = winston.createLogger( + merge( + { + level: env.LOG_LEVEL || 'info', + format: winston.format.combine( + env.NODE_ENV === 'production' ? winston.format.json() : coloredFormat, + ), + defaultMeta: { + service: 'backstage', + }, + transports: [ + new winston.transports.Console({ + silent: env.JEST_WORKER_ID !== undefined && !env.LOG_LEVEL, + }), + ], + }, + options, + ), + ); + + setRootLogger(logger); + + return logger; +} + +rootLogger = createRootLogger(); From 161e4f918cf91262591be7ce757429098257c5d9 Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Tue, 2 Feb 2021 06:11:40 -0500 Subject: [PATCH 239/297] update changeset --- .changeset/fluffy-nails-sort.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.changeset/fluffy-nails-sort.md b/.changeset/fluffy-nails-sort.md index 545698c47f..0aa87b532f 100644 --- a/.changeset/fluffy-nails-sort.md +++ b/.changeset/fluffy-nails-sort.md @@ -5,7 +5,6 @@ Updated the `rootLogger` in `@backstage/backend-common` to support custom logging options. This is useful when you want to make some changes without re-implementing the entire logger and calling `setRootLogger` or `logger.configure`. For example you can add additional `defaultMeta` tags to each log entry. The following changes are included: - Added `createRootLogger` which accepts winston `LoggerOptions`. These options allow overriding the default keys. -- Added an additional error format that can include stack traces. This can be enabled by setting a `LOG_STACKTRACE=true` environment variable. Any `Error` objects passed to `logger.error('message', err)` will include the full stack trace in a `stack` log entry key. Example Usage: From 786aa9ae3ac8dde2f2097fd36f1f18b96e37176f Mon Sep 17 00:00:00 2001 From: Manoj - Date: Tue, 2 Feb 2021 22:59:51 +1100 Subject: [PATCH 240/297] branchUrl backward compatibility if 404 with new format --- packages/integration/src/bitbucket/core.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/integration/src/bitbucket/core.ts b/packages/integration/src/bitbucket/core.ts index e9a76d1ee0..f22c727f63 100644 --- a/packages/integration/src/bitbucket/core.ts +++ b/packages/integration/src/bitbucket/core.ts @@ -32,12 +32,19 @@ export async function getBitbucketDefaultBranch( const isHosted = resource === 'bitbucket.org'; // Bitbucket Server https://docs.atlassian.com/bitbucket-server/rest/7.9.0/bitbucket-rest.html#idp184 - // Changed branchUrl to support Atlassian Bitbucket v5.11.1 , which has different branchUrl format const branchUrl = isHosted ? `${config.apiBaseUrl}/repositories/${project}/${repoName}` - : `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/branches/default`; + : `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/default-branch`; - const response = await fetch(branchUrl, getBitbucketRequestOptions(config)); + var response = await fetch(branchUrl, getBitbucketRequestOptions(config)); + + if (response.status === 404) { + // First try the new format, and then if it gets specifically a 404 it should try the old format + // (to support old Atlassian Bitbucket v5.11.1 format ) + branchUrl = `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/branches/default`; + response = await fetch(branchUrl, getBitbucketRequestOptions(config)); + } + if (!response.ok) { const message = `Failed to retrieve default branch from ${branchUrl}, ${response.status} ${response.statusText}`; throw new Error(message); From 07f86e88ffaf06d31a289a105bde52b8d225ab9c Mon Sep 17 00:00:00 2001 From: Manoj - Date: Tue, 2 Feb 2021 23:02:31 +1100 Subject: [PATCH 241/297] removed logging statements ( used for debug in local earlier) --- packages/techdocs-common/src/stages/generate/helpers.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/techdocs-common/src/stages/generate/helpers.ts b/packages/techdocs-common/src/stages/generate/helpers.ts index 73be0e8ff0..e396157b92 100644 --- a/packages/techdocs-common/src/stages/generate/helpers.ts +++ b/packages/techdocs-common/src/stages/generate/helpers.ts @@ -133,10 +133,6 @@ export const runCommand = async ({ process.stderr.on('data', stream => { logStream.write(stream); - // added below change to make OS ( child process) exception to the backend logs - const bufferbase64 = Buffer.from(stream, 'base64'); - const textAscii = bufferbase64.toString('ascii'); - console.log(' Process Stderror trace: ', textAscii); }); process.on('error', error => { From be8fc669fbdbf99866ca42abd93ee0b2b1b18aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 2 Feb 2021 13:33:31 +0100 Subject: [PATCH 242/297] backend-common: bump UrlReader integration test timeout --- packages/backend-common/src/reading/integration.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/backend-common/src/reading/integration.test.ts b/packages/backend-common/src/reading/integration.test.ts index da2e916210..acac059117 100644 --- a/packages/backend-common/src/reading/integration.test.ts +++ b/packages/backend-common/src/reading/integration.test.ts @@ -69,6 +69,8 @@ function withRetries(count: number, fn: () => Promise) { } describe('UrlReaders', () => { + jest.setTimeout(30_000); + it( 'should read data from azure', withRetries(3, async () => { From e44925723e264ae8e90c4bff15c46cdac1d95952 Mon Sep 17 00:00:00 2001 From: Parth Shandilya Date: Tue, 19 Jan 2021 23:46:48 +0100 Subject: [PATCH 243/297] TechDocs: Make requestUrl and storageUrl optional configs by using discovery APIs closes #3715 Co-authored-by: Himanshu Mishra --- .changeset/gentle-scissors-compare.md | 7 ++ docs/features/techdocs/configuration.md | 4 +- .../src/stages/publish/local.ts | 17 +++-- plugins/techdocs-backend/config.d.ts | 2 +- .../techdocs-backend/src/service/router.ts | 10 ++- plugins/techdocs/config.d.ts | 4 +- plugins/techdocs/dev/api.ts | 35 ++++++++-- plugins/techdocs/dev/index.tsx | 8 ++- plugins/techdocs/package.json | 1 + plugins/techdocs/src/api.test.ts | 15 ++-- plugins/techdocs/src/api.ts | 69 +++++++++++++++---- plugins/techdocs/src/plugin.ts | 16 +++-- .../techdocs/src/reader/components/Reader.tsx | 2 +- .../reader/components/TechDocsPage.test.tsx | 2 +- .../reader/transformers/addBaseUrl.test.ts | 2 +- .../src/reader/transformers/addBaseUrl.ts | 4 +- .../reader/transformers/onCssReady.test.ts | 5 +- .../src/reader/transformers/onCssReady.ts | 6 +- 18 files changed, 154 insertions(+), 55 deletions(-) create mode 100644 .changeset/gentle-scissors-compare.md diff --git a/.changeset/gentle-scissors-compare.md b/.changeset/gentle-scissors-compare.md new file mode 100644 index 0000000000..bb615987b2 --- /dev/null +++ b/.changeset/gentle-scissors-compare.md @@ -0,0 +1,7 @@ +--- +'@backstage/techdocs-common': patch +'@backstage/plugin-techdocs': patch +'@backstage/plugin-techdocs-backend': patch +--- + +`techdocs.requestUrl` and `techdocs.storageUrl` are now optional configs and the discovery API will be used to get the URL where techdocs plugin is hosted. diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index bb44050dcf..b9413227af 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -13,12 +13,12 @@ configuration options for TechDocs. # File: app-config.yaml techdocs: - # TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc. + # TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc. (Optional) requestUrl: http://localhost:7000/api/techdocs # Just another route in techdocs-backend where TechDocs requests the static files from. This URL uses an HTTP middleware - # to serve files from either a local directory or an External storage provider. + # to serve files from either a local directory or an External storage provider. (Optional) storageUrl: http://localhost:7000/api/techdocs/static/docs diff --git a/packages/techdocs-common/src/stages/publish/local.ts b/packages/techdocs-common/src/stages/publish/local.ts index d07472e17d..b8ccf7df8a 100644 --- a/packages/techdocs-common/src/stages/publish/local.ts +++ b/packages/techdocs-common/src/stages/publish/local.ts @@ -23,6 +23,7 @@ import { Entity, EntityName } from '@backstage/catalog-model'; import { resolvePackagePath, PluginEndpointDiscovery, + SingleHostDiscovery, } from '@backstage/backend-common'; import { Config } from '@backstage/config'; import { @@ -109,9 +110,9 @@ export class LocalPublish implements PublisherBase { fetchTechDocsMetadata(entityName: EntityName): Promise { return new Promise((resolve, reject) => { - this.discovery.getBaseUrl('techdocs').then(techdocsApiUrl => { + this.discovery.getBaseUrl('techdocs').then(async techdocsApiUrl => { const storageUrl = new URL( - new URL(this.config.getString('techdocs.storageUrl')).pathname, + new URL(await this.getStorageUrl()).pathname, techdocsApiUrl, ).toString(); @@ -141,12 +142,20 @@ export class LocalPublish implements PublisherBase { return express.static(staticDocsDir); } + async getStorageUrl() { + const discoveryApi = SingleHostDiscovery.fromConfig(this.config); + return ( + this.config.getOptionalString('techdocs.storageUrl') ?? + (await discoveryApi.getBaseUrl('techdocs')) + ); + } + async hasDocsBeenGenerated(entity: Entity): Promise { const namespace = entity.metadata.namespace ?? 'default'; return new Promise(resolve => { - this.discovery.getBaseUrl('techdocs').then(techdocsApiUrl => { + this.discovery.getBaseUrl('techdocs').then(async techdocsApiUrl => { const storageUrl = new URL( - new URL(this.config.getString('techdocs.storageUrl')).pathname, + new URL(await this.getStorageUrl()).pathname, techdocsApiUrl, ).toString(); diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index b51d86138a..1e4298688e 100644 --- a/plugins/techdocs-backend/config.d.ts +++ b/plugins/techdocs-backend/config.d.ts @@ -24,7 +24,7 @@ export interface Config { * attr: 'storageUrl' - accepts a string value * e.g. storageUrl: http://localhost:7000/api/techdocs/static/docs */ - storageUrl: string; + storageUrl?: string; /** * documentation building process depends on the builder attr * attr: 'builder' - accepts a string value diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index dc5e6a4911..7d232e9ea1 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -26,7 +26,10 @@ import { PublisherBase, getLocationForEntity, } from '@backstage/techdocs-common'; -import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { + PluginEndpointDiscovery, + SingleHostDiscovery, +} from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; import { getEntityNameFromUrlPath } from './helpers'; import { DocsBuilder } from '../DocsBuilder'; @@ -102,7 +105,10 @@ export async function createRouter({ router.get('/docs/:namespace/:kind/:name/*', async (req, res) => { const { kind, namespace, name } = req.params; - const storageUrl = config.getString('techdocs.storageUrl'); + const discoveryApi = SingleHostDiscovery.fromConfig(config); + const storageUrl = + config.getOptionalString('techdocs.storageUrl') ?? + `${await discoveryApi.getBaseUrl('techdocs')}/static/docs`; const catalogUrl = await discovery.getBaseUrl('catalog'); const triple = [kind, namespace, name].map(encodeURIComponent).join('/'); diff --git a/plugins/techdocs/config.d.ts b/plugins/techdocs/config.d.ts index 0af3ab4290..ef4e026911 100644 --- a/plugins/techdocs/config.d.ts +++ b/plugins/techdocs/config.d.ts @@ -22,12 +22,12 @@ export interface Config { * e.g. requestUrl: http://localhost:7000/api/techdocs * @visibility frontend */ - requestUrl: string; + requestUrl?: string; /** * attr: 'storageUrl' - accepts a string value * e.g. storageUrl: http://localhost:7000/api/techdocs/static/docs */ - storageUrl: string; + storageUrl?: string; /** * documentation building process depends on the builder attr * attr: 'builder' - accepts a string value diff --git a/plugins/techdocs/dev/api.ts b/plugins/techdocs/dev/api.ts index ae2b8c58ae..7d0679f6f6 100644 --- a/plugins/techdocs/dev/api.ts +++ b/plugins/techdocs/dev/api.ts @@ -13,20 +13,38 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { DiscoveryApi } from '@backstage/core'; +import { Config } from '@backstage/config'; import { EntityName } from '@backstage/catalog-model'; import { TechDocsStorage } from '../src/api'; export class TechDocsDevStorageApi implements TechDocsStorage { - public apiOrigin: string; + public configApi: Config; + public discoveryApi: DiscoveryApi; - constructor({ apiOrigin }: { apiOrigin: string }) { - this.apiOrigin = apiOrigin; + constructor({ + configApi, + discoveryApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + } + + async getApiOrigin() { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); } async getEntityDocs(entityId: EntityName, path: string) { const { name } = entityId; - const url = `${this.apiOrigin}/${name}/${path}`; + const apiOrigin = await this.getApiOrigin(); + const url = `${apiOrigin}/${name}/${path}`; const request = await fetch( `${url.endsWith('/') ? url : `${url}/`}index.html`, @@ -39,8 +57,13 @@ export class TechDocsDevStorageApi implements TechDocsStorage { return request.text(); } - getBaseUrl(oldBaseUrl: string, entityId: EntityName, path: string): string { + async getBaseUrl( + oldBaseUrl: string, + entityId: EntityName, + path: string, + ): Promise { const { name } = entityId; - return new URL(oldBaseUrl, `${this.apiOrigin}/${name}/${path}`).toString(); + const apiOrigin = await this.getApiOrigin(); + return new URL(oldBaseUrl, `${apiOrigin}/${name}/${path}`).toString(); } } diff --git a/plugins/techdocs/dev/index.tsx b/plugins/techdocs/dev/index.tsx index 12974ef8b7..42fa9a9ec4 100644 --- a/plugins/techdocs/dev/index.tsx +++ b/plugins/techdocs/dev/index.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { configApiRef, discoveryApiRef } from '@backstage/core'; import { createDevApp } from '@backstage/dev-utils'; import { plugin } from '../src/plugin'; import { TechDocsDevStorageApi } from './api'; @@ -22,10 +23,11 @@ import { techdocsStorageApiRef } from '../src'; createDevApp() .registerApi({ api: techdocsStorageApiRef, - deps: {}, - factory: () => + deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, + factory: ({ configApi, discoveryApi }) => new TechDocsDevStorageApi({ - apiOrigin: 'http://localhost:3000/api', + configApi, + discoveryApi, }), }) .registerPlugin(plugin) diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 90c8fd3840..20e294c661 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -31,6 +31,7 @@ "clean": "backstage-cli clean" }, "dependencies": { + "@backstage/config": "^0.1.2", "@backstage/catalog-model": "^0.7.0", "@backstage/core": "^0.5.0", "@backstage/plugin-catalog-react": "^0.0.1", diff --git a/plugins/techdocs/src/api.test.ts b/plugins/techdocs/src/api.test.ts index 132976e280..ebffa2469e 100644 --- a/plugins/techdocs/src/api.test.ts +++ b/plugins/techdocs/src/api.test.ts @@ -13,10 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { useApi, configApiRef, UrlPatternDiscovery } from '@backstage/core'; import { TechDocsStorageApi } from './api'; -const DOC_STORAGE_URL = 'https://example-storage.com'; - const mockEntity = { kind: 'Component', namespace: 'default', @@ -24,19 +23,23 @@ const mockEntity = { }; describe('TechDocsStorageApi', () => { + const mockBaseUrl = 'http://backstage:9191/api/techdocs'; + const configApi = useApi(configApiRef); + const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); + it('should return correct base url based on defined storage', () => { - const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL }); + const storageApi = new TechDocsStorageApi({ configApi, discoveryApi }); expect(storageApi.getBaseUrl('test.js', mockEntity, '')).toEqual( - `${DOC_STORAGE_URL}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test.js`, + `${mockBaseUrl}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test.js`, ); }); it('should return base url with correct entity structure', () => { - const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL }); + const storageApi = new TechDocsStorageApi({ configApi, discoveryApi }); expect(storageApi.getBaseUrl('test/', mockEntity, '')).toEqual( - `${DOC_STORAGE_URL}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test/`, + `${mockBaseUrl}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test/`, ); }); }); diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts index 3a950aeeb3..1ca0e40592 100644 --- a/plugins/techdocs/src/api.ts +++ b/plugins/techdocs/src/api.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { createApiRef } from '@backstage/core'; +import { createApiRef, DiscoveryApi } from '@backstage/core'; +import { Config } from '@backstage/config'; import { EntityName } from '@backstage/catalog-model'; import { TechDocsMetadata } from './types'; @@ -30,7 +31,11 @@ export const techdocsApiRef = createApiRef({ export interface TechDocsStorage { getEntityDocs(entityId: EntityName, path: string): Promise; - getBaseUrl(oldBaseUrl: string, entityId: EntityName, path: string): string; + getBaseUrl( + oldBaseUrl: string, + entityId: EntityName, + path: string, + ): Promise; } export interface TechDocs { @@ -44,10 +49,25 @@ export interface TechDocs { * @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API */ export class TechDocsApi implements TechDocs { - public apiOrigin: string; + public configApi: Config; + public discoveryApi: DiscoveryApi; - constructor({ apiOrigin }: { apiOrigin: string }) { - this.apiOrigin = apiOrigin; + constructor({ + configApi, + discoveryApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + } + + async getApiOrigin() { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); } /** @@ -62,7 +82,8 @@ export class TechDocsApi implements TechDocs { async getTechDocsMetadata(entityId: EntityName) { const { kind, namespace, name } = entityId; - const requestUrl = `${this.apiOrigin}/metadata/techdocs/${namespace}/${kind}/${name}`; + const apiOrigin = await this.getApiOrigin(); + const requestUrl = `${apiOrigin}/metadata/techdocs/${namespace}/${kind}/${name}`; const request = await fetch(`${requestUrl}`); const res = await request.json(); @@ -81,7 +102,8 @@ export class TechDocsApi implements TechDocs { async getEntityMetadata(entityId: EntityName) { const { kind, namespace, name } = entityId; - const requestUrl = `${this.apiOrigin}/metadata/entity/${namespace}/${kind}/${name}`; + const apiOrigin = await this.getApiOrigin(); + const requestUrl = `${apiOrigin}/metadata/entity/${namespace}/${kind}/${name}`; const request = await fetch(`${requestUrl}`); const res = await request.json(); @@ -96,10 +118,25 @@ export class TechDocsApi implements TechDocs { * @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API */ export class TechDocsStorageApi implements TechDocsStorage { - public apiOrigin: string; + public configApi: Config; + public discoveryApi: DiscoveryApi; - constructor({ apiOrigin }: { apiOrigin: string }) { - this.apiOrigin = apiOrigin; + constructor({ + configApi, + discoveryApi, + }: { + configApi: Config; + discoveryApi: DiscoveryApi; + }) { + this.configApi = configApi; + this.discoveryApi = discoveryApi; + } + + async getApiOrigin() { + return ( + this.configApi.getOptionalString('techdocs.requestUrl') ?? + (await this.discoveryApi.getBaseUrl('techdocs')) + ); } /** @@ -113,7 +150,8 @@ export class TechDocsStorageApi implements TechDocsStorage { async getEntityDocs(entityId: EntityName, path: string) { const { kind, namespace, name } = entityId; - const url = `${this.apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`; + const apiOrigin = await this.getApiOrigin(); + const url = `${apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`; const request = await fetch( `${url.endsWith('/') ? url : `${url}/`}index.html`, @@ -132,12 +170,17 @@ export class TechDocsStorageApi implements TechDocsStorage { return request.text(); } - getBaseUrl(oldBaseUrl: string, entityId: EntityName, path: string): string { + async getBaseUrl( + oldBaseUrl: string, + entityId: EntityName, + path: string, + ): Promise { const { kind, namespace, name } = entityId; + const apiOrigin = await this.getApiOrigin(); return new URL( oldBaseUrl, - `${this.apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`, + `${apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`, ).toString(); } } diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index 576ba0a292..982b749f2c 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -34,6 +34,7 @@ import { createRouteRef, createApiFactory, configApiRef, + discoveryApiRef, } from '@backstage/core'; import { techdocsStorageApiRef, @@ -57,24 +58,25 @@ export const rootCatalogDocsRouteRef = createRouteRef({ title: 'Docs', }); -// TODO: Use discovery API for frontend to get URL for techdocs-backend instead of requestUrl export const plugin = createPlugin({ id: 'techdocs', apis: [ createApiFactory({ api: techdocsStorageApiRef, - deps: { configApi: configApiRef }, - factory: ({ configApi }) => + deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, + factory: ({ configApi, discoveryApi }) => new TechDocsStorageApi({ - apiOrigin: configApi.getString('techdocs.requestUrl'), + configApi, + discoveryApi, }), }), createApiFactory({ api: techdocsApiRef, - deps: { configApi: configApiRef }, - factory: ({ configApi }) => + deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, + factory: ({ configApi, discoveryApi }) => new TechDocsApi({ - apiOrigin: configApi.getString('techdocs.requestUrl'), + configApi, + discoveryApi, }), }), ], diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index 1264e2b6eb..50990911a1 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -129,7 +129,7 @@ export const Reader = ({ entityId, onReady }: Props) => { }, }), onCssReady({ - docStorageUrl: techdocsStorageApi.apiOrigin, + docStorageUrl: techdocsStorageApi.getApiOrigin(), onLoading: (dom: Element) => { (dom as HTMLElement).style.setProperty('opacity', '0'); }, diff --git a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx index bc84f792a7..da383dd90c 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx @@ -56,7 +56,7 @@ describe('', () => { }; const techdocsStorageApi: Partial = { getEntityDocs: (): Promise => Promise.resolve('String'), - getBaseUrl: (): string => '', + getBaseUrl: (): Promise => Promise.resolve('String'), }; const apiRegistry = ApiRegistry.from([ diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts index 728c39826d..f915396fa3 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts @@ -21,7 +21,7 @@ import { TechDocsStorage } from '../../api'; const DOC_STORAGE_URL = 'https://example-host.storage.googleapis.com'; const techdocsStorageApi: TechDocsStorage = { - getBaseUrl: jest.fn(() => DOC_STORAGE_URL), + getBaseUrl: () => new Promise(resolve => resolve(DOC_STORAGE_URL)), getEntityDocs: () => new Promise(resolve => resolve('yes!')), }; diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts index 8d9d5a7294..2872ad36cc 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts @@ -35,12 +35,12 @@ export const addBaseUrl = ({ ): void => { Array.from(list) .filter(elem => !!elem.getAttribute(attributeName)) - .forEach((elem: T) => { + .forEach(async (elem: T) => { const elemAttribute = elem.getAttribute(attributeName); if (!elemAttribute) return; elem.setAttribute( attributeName, - techdocsStorageApi.getBaseUrl(elemAttribute, entityId, path), + await techdocsStorageApi.getBaseUrl(elemAttribute, entityId, path), ); }); }; diff --git a/plugins/techdocs/src/reader/transformers/onCssReady.test.ts b/plugins/techdocs/src/reader/transformers/onCssReady.test.ts index 67ae38c56b..1fbc59e213 100644 --- a/plugins/techdocs/src/reader/transformers/onCssReady.test.ts +++ b/plugins/techdocs/src/reader/transformers/onCssReady.test.ts @@ -22,8 +22,9 @@ import { } from '../../test-utils'; import { onCssReady } from '../transformers'; -const docStorageUrl: string = - 'https://techdocs-mock-sites.storage.googleapis.com'; +const docStorageUrl: Promise = Promise.resolve( + 'https://techdocs-mock-sites.storage.googleapis.com', +); const fixture = ` diff --git a/plugins/techdocs/src/reader/transformers/onCssReady.ts b/plugins/techdocs/src/reader/transformers/onCssReady.ts index dd50879459..50dbe52cbd 100644 --- a/plugins/techdocs/src/reader/transformers/onCssReady.ts +++ b/plugins/techdocs/src/reader/transformers/onCssReady.ts @@ -17,7 +17,7 @@ import type { Transformer } from './index'; type OnCssReadyOptions = { - docStorageUrl: string; + docStorageUrl: Promise; onLoading: (dom: Element) => void; onLoaded: (dom: Element) => void; }; @@ -30,7 +30,9 @@ export const onCssReady = ({ return dom => { const cssPages = Array.from( dom.querySelectorAll('head > link[rel="stylesheet"]'), - ).filter(elem => elem.getAttribute('href')?.startsWith(docStorageUrl)); + ).filter(async elem => + elem.getAttribute('href')?.startsWith(await docStorageUrl), + ); let count = cssPages.length; From 38469b66bf22d08af3a7a177563ef8fa208daab0 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Sun, 24 Jan 2021 13:01:11 +0100 Subject: [PATCH 244/297] techdocs: fix tests by reusing the instantiated discovery --- packages/techdocs-common/src/stages/publish/local.ts | 4 +--- plugins/techdocs-backend/src/service/router.ts | 8 ++------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/techdocs-common/src/stages/publish/local.ts b/packages/techdocs-common/src/stages/publish/local.ts index b8ccf7df8a..76489ce01a 100644 --- a/packages/techdocs-common/src/stages/publish/local.ts +++ b/packages/techdocs-common/src/stages/publish/local.ts @@ -23,7 +23,6 @@ import { Entity, EntityName } from '@backstage/catalog-model'; import { resolvePackagePath, PluginEndpointDiscovery, - SingleHostDiscovery, } from '@backstage/backend-common'; import { Config } from '@backstage/config'; import { @@ -143,10 +142,9 @@ export class LocalPublish implements PublisherBase { } async getStorageUrl() { - const discoveryApi = SingleHostDiscovery.fromConfig(this.config); return ( this.config.getOptionalString('techdocs.storageUrl') ?? - (await discoveryApi.getBaseUrl('techdocs')) + (await this.discovery.getBaseUrl('techdocs')) ); } diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 7d232e9ea1..fe7b52ab52 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -26,10 +26,7 @@ import { PublisherBase, getLocationForEntity, } from '@backstage/techdocs-common'; -import { - PluginEndpointDiscovery, - SingleHostDiscovery, -} from '@backstage/backend-common'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; import { getEntityNameFromUrlPath } from './helpers'; import { DocsBuilder } from '../DocsBuilder'; @@ -105,10 +102,9 @@ export async function createRouter({ router.get('/docs/:namespace/:kind/:name/*', async (req, res) => { const { kind, namespace, name } = req.params; - const discoveryApi = SingleHostDiscovery.fromConfig(config); const storageUrl = config.getOptionalString('techdocs.storageUrl') ?? - `${await discoveryApi.getBaseUrl('techdocs')}/static/docs`; + `${await discovery.getBaseUrl('techdocs')}/static/docs`; const catalogUrl = await discovery.getBaseUrl('catalog'); const triple = [kind, namespace, name].map(encodeURIComponent).join('/'); From 3927e79c291f289eb85ddbe3dc3cfb9b02716d1a Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Sun, 24 Jan 2021 14:08:16 +0100 Subject: [PATCH 245/297] techdocs: local publisher should directly access file system --- .../src/stages/publish/local.ts | 96 +++++++------------ 1 file changed, 36 insertions(+), 60 deletions(-) diff --git a/packages/techdocs-common/src/stages/publish/local.ts b/packages/techdocs-common/src/stages/publish/local.ts index 76489ce01a..e4d235609e 100644 --- a/packages/techdocs-common/src/stages/publish/local.ts +++ b/packages/techdocs-common/src/stages/publish/local.ts @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import fetch from 'cross-fetch'; import express from 'express'; import fs from 'fs-extra'; import path from 'path'; @@ -52,17 +51,13 @@ try { * called "static" at the root of techdocs-backend plugin. */ export class LocalPublish implements PublisherBase { - private readonly config: Config; - private readonly logger: Logger; - private readonly discovery: PluginEndpointDiscovery; - // TODO: Use a static fromConfig method to create a LocalPublish instance, similar to aws/gcs publishers. // Move the logic of setting staticDocsDir based on config over to fromConfig, // and set the value as a class parameter. constructor( - config: Config, - logger: Logger, - discovery: PluginEndpointDiscovery, + private readonly config: Config, + private readonly logger: Logger, + private readonly discovery: PluginEndpointDiscovery, ) { this.config = config; this.logger = logger; @@ -107,67 +102,48 @@ export class LocalPublish implements PublisherBase { }); } - fetchTechDocsMetadata(entityName: EntityName): Promise { - return new Promise((resolve, reject) => { - this.discovery.getBaseUrl('techdocs').then(async techdocsApiUrl => { - const storageUrl = new URL( - new URL(await this.getStorageUrl()).pathname, - techdocsApiUrl, - ).toString(); + async fetchTechDocsMetadata( + entityName: EntityName, + ): Promise { + const metadataPath = path.join( + staticDocsDir, + entityName.namespace, + entityName.kind, + entityName.name, + 'techdocs_metadata.json', + ); - const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`; - const metadataURL = `${storageUrl}/${entityRootDir}/techdocs_metadata.json`; - fetch(metadataURL) - .then(response => - response - .json() - .then(techdocsMetadata => resolve(techdocsMetadata)) - .catch(err => { - reject( - `Unable to parse metadata JSON for ${entityRootDir}. Error: ${err}`, - ); - }), - ) - .catch(err => { - reject( - `Unable to fetch metadata for ${entityRootDir}. Error ${err}`, - ); - }); - }); - }); + try { + return await fs.readJson(metadataPath); + } catch (err) { + this.logger.error( + `Unable to read techdocs_metadata.json at ${metadataPath}. Error: ${err}`, + ); + throw new Error(err.message); + } } docsRouter(): express.Handler { return express.static(staticDocsDir); } - async getStorageUrl() { - return ( - this.config.getOptionalString('techdocs.storageUrl') ?? - (await this.discovery.getBaseUrl('techdocs')) - ); - } - async hasDocsBeenGenerated(entity: Entity): Promise { const namespace = entity.metadata.namespace ?? 'default'; - return new Promise(resolve => { - this.discovery.getBaseUrl('techdocs').then(async techdocsApiUrl => { - const storageUrl = new URL( - new URL(await this.getStorageUrl()).pathname, - techdocsApiUrl, - ).toString(); - const entityRootDir = `${namespace}/${entity.kind}/${entity.metadata.name}`; - const indexHtmlUrl = `${storageUrl}/${entityRootDir}/index.html`; - // Check if the file exists - fs.access(indexHtmlUrl, fs.constants.F_OK, err => { - if (err) { - resolve(false); - } else { - resolve(true); - } - }); - }); - }); + const indexHtmlPath = path.join( + staticDocsDir, + namespace, + entity.kind, + entity.metadata.name, + 'index.html', + ); + + // Check if the file exists + try { + fs.access(indexHtmlPath, fs.constants.F_OK); + return true; + } catch (err) { + return false; + } } } From 0ed9d03c215f0c865cd0966a10cfa0ed26f71d58 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Sun, 24 Jan 2021 14:09:09 +0100 Subject: [PATCH 246/297] config: Remove techdocs.requestUrl and storageUrl from default app-config.yaml --- app-config.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index 763dd0c258..5240a050a8 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -74,8 +74,6 @@ organization: # Reference documentation http://backstage.io/docs/features/techdocs/configuration techdocs: - requestUrl: http://localhost:7000/api/techdocs - storageUrl: http://localhost:7000/api/techdocs/static/docs builder: 'local' # Alternatives - 'external' generators: techdocs: 'docker' # Alternatives - 'local' From 5d94132bf923b82f7d367af94bf8b827bc6ae5d3 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 28 Jan 2021 18:39:39 +0100 Subject: [PATCH 247/297] TechDocs: Suppress ts error of config not being used --- packages/techdocs-common/src/stages/publish/local.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/techdocs-common/src/stages/publish/local.ts b/packages/techdocs-common/src/stages/publish/local.ts index e4d235609e..b4b30c1bf1 100644 --- a/packages/techdocs-common/src/stages/publish/local.ts +++ b/packages/techdocs-common/src/stages/publish/local.ts @@ -55,6 +55,7 @@ export class LocalPublish implements PublisherBase { // Move the logic of setting staticDocsDir based on config over to fromConfig, // and set the value as a class parameter. constructor( + // @ts-ignore private readonly config: Config, private readonly logger: Logger, private readonly discovery: PluginEndpointDiscovery, From 29e269d9207a8e641afe06b5cfaa7c03bc661e2d Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 28 Jan 2021 18:51:18 +0100 Subject: [PATCH 248/297] TechDocs: Remove traces of requestUrl/storageUrl from docs --- docs/features/techdocs/configuration.md | 20 +++++++++++--------- docs/features/techdocs/getting-started.md | 21 +++------------------ 2 files changed, 14 insertions(+), 27 deletions(-) diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index b9413227af..20e436d8a4 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -13,15 +13,6 @@ configuration options for TechDocs. # File: app-config.yaml techdocs: - # TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc. (Optional) - - requestUrl: http://localhost:7000/api/techdocs - - # Just another route in techdocs-backend where TechDocs requests the static files from. This URL uses an HTTP middleware - # to serve files from either a local directory or an External storage provider. (Optional) - - storageUrl: http://localhost:7000/api/techdocs/static/docs - # generators.techdocs can have two values: 'docker' or 'local'. This is to determine how to run the generator - whether to # spin up the techdocs-container docker image or to run mkdocs locally (assuming all the dependencies are taken care of). # You want to change this to 'local' if you are running Backstage using your own custom Docker setup and want to avoid running @@ -101,4 +92,15 @@ techdocs: # https://docs.microsoft.com/en-us/azure/storage/common/storage-auth?toc=/azure/storage/blobs/toc.json accountKey: $env: TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY + + # (Optional and Legacy) TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc. + # You don't have to specify this anymore. + + requestUrl: http://localhost:7000/api/techdocs + + # (Optional and Legacy) Just another route in techdocs-backend where TechDocs requests the static files from. This URL uses an HTTP middleware + # to serve files from either a local directory or an External storage provider. + # You don't have to specify this anymore. + + storageUrl: http://localhost:7000/api/techdocs/static/docs ``` diff --git a/docs/features/techdocs/getting-started.md b/docs/features/techdocs/getting-started.md index 5147e17aaa..e0b323857d 100644 --- a/docs/features/techdocs/getting-started.md +++ b/docs/features/techdocs/getting-started.md @@ -150,26 +150,10 @@ app. Now let us tweak some configurations to suit your needs. **See [TechDocs Configuration Options](configuration.md) for complete configuration reference.** -### Setting TechDocs URLs - -```yaml -techdocs: - storageUrl: http://localhost:7000/api/techdocs/static/docs - requestUrl: http://localhost:7000/api/techdocs/ -``` - -`requestUrl` is used by TechDocs frontend plugin to discover `techdocs-backend` -endpoints, and the `storageUrl` is another endpoint in `techdocs-backend` which -acts as a middleware between TechDocs and the storage (where the static -generated docs site are stored). These default values should mostly work for -you. These options will soon be optional to set. - ### Should TechDocs Backend generate docs? ```yaml techdocs: - storageUrl: http://localhost:7000/api/techdocs/static/docs - requestUrl: http://localhost:7000/api/techdocs/ builder: 'local' ``` @@ -196,8 +180,6 @@ out Backstage for the first time. At a later time, review ```yaml techdocs: - storageUrl: http://localhost:7000/api/techdocs/static/docs - requestUrl: http://localhost:7000/api/techdocs/ builder: 'local' publisher: type: 'local' @@ -219,6 +201,9 @@ no config is provided. ```yaml techdocs: + builder: 'local' + publisher: + type: 'local' generators: techdocs: local ``` From ae893e99f9b9131c4ed3121a09f9e37a0f0d1c0f Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 28 Jan 2021 19:58:26 +0100 Subject: [PATCH 249/297] TechDocs: Update tests to reflect requestUrl change --- .../src/stages/publish/local.test.ts | 41 ++++--------------- 1 file changed, 9 insertions(+), 32 deletions(-) diff --git a/packages/techdocs-common/src/stages/publish/local.test.ts b/packages/techdocs-common/src/stages/publish/local.test.ts index efd56285ef..63adfad09a 100644 --- a/packages/techdocs-common/src/stages/publish/local.test.ts +++ b/packages/techdocs-common/src/stages/publish/local.test.ts @@ -24,23 +24,6 @@ import { import { ConfigReader } from '@backstage/config'; import { LocalPublish } from './local'; -jest.mock('fs-extra', () => { - const fsOriginal = jest.requireActual('fs-extra'); - return { - ...fsOriginal, - access: jest.fn().mockImplementation((path, checkType, callback) => { - if ( - path.includes('http://localhost:7000/static') && - checkType === fs.constants.F_OK - ) { - callback(); - } else { - callback(new Error()); - } - }), - }; -}); - const createMockEntity = (annotations = {}) => { return { apiVersion: 'version', @@ -59,40 +42,34 @@ const logger = getVoidLogger(); describe('local publisher', () => { it('should publish generated documentation dir', async () => { const testDiscovery: jest.Mocked = { - getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000'), + getBaseUrl: jest + .fn() + .mockResolvedValue('http://localhost:7000/api/techdocs'), getExternalBaseUrl: jest.fn(), }; - const mockConfig = new ConfigReader({ - techdocs: { - requestUrl: 'http://localhost:7000', - storageUrl: 'http://localhost:7000/static/docs', - }, - }); + const mockConfig = new ConfigReader({}); const publisher = new LocalPublish(mockConfig, logger, testDiscovery); const mockEntity = createMockEntity(); const tempDir = fs.mkdtempSync(`${__dirname}/test-component-folder-`); expect(tempDir).toBeTruthy(); - fs.closeSync(fs.openSync(path.join(tempDir, '/mock-file'), 'w')); + fs.closeSync(fs.openSync(path.join(tempDir, '/index.html'), 'w')); await publisher.publish({ entity: mockEntity, directory: tempDir }); - const publishDir = path.resolve( - __dirname, - `../../../../../plugins/techdocs-backend/static/docs/${mockEntity.metadata.name}`, - ); + fs.removeSync(tempDir); + const resultDir = path.resolve( __dirname, `../../../../../plugins/techdocs-backend/static/docs/default/${mockEntity.kind}/${mockEntity.metadata.name}`, ); expect(fs.existsSync(resultDir)).toBeTruthy(); - expect(fs.existsSync(path.join(resultDir, '/mock-file'))).toBeTruthy(); + expect(fs.existsSync(path.join(resultDir, '/index.html'))).toBeTruthy(); expect(await publisher.hasDocsBeenGenerated(mockEntity)).toBe(true); - fs.removeSync(publishDir); - fs.removeSync(tempDir); + fs.removeSync(resultDir); }); }); From 4ddf8d5e2763c70f8db23629dcd9b3d0fb5e189a Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 28 Jan 2021 20:17:58 +0100 Subject: [PATCH 250/297] TechDocs: Fix frontend tests due to async apiOrigin function --- plugins/techdocs/src/reader/components/TechDocsPage.test.tsx | 1 + plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx index da383dd90c..4b578bef38 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx @@ -57,6 +57,7 @@ describe('', () => { const techdocsStorageApi: Partial = { getEntityDocs: (): Promise => Promise.resolve('String'), getBaseUrl: (): Promise => Promise.resolve('String'), + getApiOrigin: (): Promise => Promise.resolve('String'), }; const apiRegistry = ApiRegistry.from([ diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts index f915396fa3..10c7191b63 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts @@ -21,7 +21,7 @@ import { TechDocsStorage } from '../../api'; const DOC_STORAGE_URL = 'https://example-host.storage.googleapis.com'; const techdocsStorageApi: TechDocsStorage = { - getBaseUrl: () => new Promise(resolve => resolve(DOC_STORAGE_URL)), + getBaseUrl: jest.fn(() => Promise.resolve(DOC_STORAGE_URL)), getEntityDocs: () => new Promise(resolve => resolve('yes!')), }; From 1df7721a68b8b1ef4fd675166bb061a3f3c08a91 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 28 Jan 2021 20:45:49 +0100 Subject: [PATCH 251/297] TechDocs: Fix test with invalid hook call error --- plugins/techdocs/src/api.test.ts | 21 +++++++++++++++------ plugins/techdocs/src/api.ts | 1 + 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/plugins/techdocs/src/api.test.ts b/plugins/techdocs/src/api.test.ts index ebffa2469e..734650d7c8 100644 --- a/plugins/techdocs/src/api.test.ts +++ b/plugins/techdocs/src/api.test.ts @@ -13,7 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useApi, configApiRef, UrlPatternDiscovery } from '@backstage/core'; +import { Config } from '@backstage/config'; +import { UrlPatternDiscovery } from '@backstage/core'; import { TechDocsStorageApi } from './api'; const mockEntity = { @@ -24,21 +25,29 @@ const mockEntity = { describe('TechDocsStorageApi', () => { const mockBaseUrl = 'http://backstage:9191/api/techdocs'; - const configApi = useApi(configApiRef); + const configApi = { + getOptionalString: () => 'http://backstage:9191/api/techdocs', + } as Partial; const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); - it('should return correct base url based on defined storage', () => { + it('should return correct base url based on defined storage', async () => { + // @ts-ignore Partial not assignable to Config. const storageApi = new TechDocsStorageApi({ configApi, discoveryApi }); - expect(storageApi.getBaseUrl('test.js', mockEntity, '')).toEqual( + await expect( + storageApi.getBaseUrl('test.js', mockEntity, ''), + ).resolves.toEqual( `${mockBaseUrl}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test.js`, ); }); - it('should return base url with correct entity structure', () => { + it('should return base url with correct entity structure', async () => { + // @ts-ignore Partial not assignable to Config. const storageApi = new TechDocsStorageApi({ configApi, discoveryApi }); - expect(storageApi.getBaseUrl('test/', mockEntity, '')).toEqual( + await expect( + storageApi.getBaseUrl('test/', mockEntity, ''), + ).resolves.toEqual( `${mockBaseUrl}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test/`, ); }); diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts index 1ca0e40592..a8b1f1e34f 100644 --- a/plugins/techdocs/src/api.ts +++ b/plugins/techdocs/src/api.ts @@ -178,6 +178,7 @@ export class TechDocsStorageApi implements TechDocsStorage { const { kind, namespace, name } = entityId; const apiOrigin = await this.getApiOrigin(); + return new URL( oldBaseUrl, `${apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`, From 436ca3f625bfd79615ab34e3166e6d0f5f9c322c Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 28 Jan 2021 20:59:43 +0100 Subject: [PATCH 252/297] create-app: Remove TechDocs requestUrl and storageUrl configs from app-config.yaml --- .changeset/curly-ghosts-laugh.md | 5 +++++ .../create-app/templates/default-app/app-config.yaml.hbs | 9 ++++----- 2 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 .changeset/curly-ghosts-laugh.md diff --git a/.changeset/curly-ghosts-laugh.md b/.changeset/curly-ghosts-laugh.md new file mode 100644 index 0000000000..8c55e93cfc --- /dev/null +++ b/.changeset/curly-ghosts-laugh.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Remove techdocs.requestUrl and techdocs.storageUrl from app-config.yaml diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index d253102f71..495b30dd6f 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -56,14 +56,13 @@ proxy: target: 'https://example.com' changeOrigin: true +# Reference documentation http://backstage.io/docs/features/techdocs/configuration techdocs: - requestUrl: http://localhost:7000/api/techdocs - storageUrl: http://localhost:7000/api/techdocs/static/docs - builder: 'local' + builder: 'local' # Alternatives - 'external' generators: - techdocs: 'docker' + techdocs: 'docker' # Alternatives - 'local' publisher: - type: 'local' + type: 'local' # Alternatives - 'googleGcs' or 'awsS3'. Read documentation for using alternatives. auth: # see https://backstage.io/docs/tutorials/quickstart-app-auth to know more about enabling auth providers From b37501a3d1ff20e6f79229b7ccc5a0cd510f9c63 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 2 Feb 2021 15:07:33 +0100 Subject: [PATCH 253/297] catalog: finalize port to new composability API + tweak dev-utils --- .changeset/hot-rules-shout.md | 5 +++ .changeset/selfish-kids-know.md | 5 +++ packages/dev-utils/src/devApp/render.tsx | 9 +++++- plugins/catalog-react/src/routes.ts | 1 + plugins/catalog/dev/index.tsx | 28 +++++++++++++++-- plugins/catalog/src/extensions.tsx | 40 ------------------------ plugins/catalog/src/index.ts | 8 +++-- plugins/catalog/src/plugin.test.ts | 4 +-- plugins/catalog/src/plugin.ts | 21 ++++++++++++- 9 files changed, 73 insertions(+), 48 deletions(-) create mode 100644 .changeset/hot-rules-shout.md create mode 100644 .changeset/selfish-kids-know.md delete mode 100644 plugins/catalog/src/extensions.tsx diff --git a/.changeset/hot-rules-shout.md b/.changeset/hot-rules-shout.md new file mode 100644 index 0000000000..c88f4b01dc --- /dev/null +++ b/.changeset/hot-rules-shout.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Add `children` option to `addPage`, which will be rendered as the children of the `Route`. diff --git a/.changeset/selfish-kids-know.md b/.changeset/selfish-kids-know.md new file mode 100644 index 0000000000..f434559135 --- /dev/null +++ b/.changeset/selfish-kids-know.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Finalize migration to new composability API, with the plugin instance now exported `catalogPlugin`. diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index b4ec540f9c..e1bdf4d0e9 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -38,6 +38,7 @@ import SentimentDissatisfiedIcon from '@material-ui/icons/SentimentDissatisfied' const GatheringRoute: (props: { path: string; element: JSX.Element; + children?: ReactNode; }) => JSX.Element = ({ element }) => element; attachComponentData(GatheringRoute, 'core.gatherMountPoints', true); @@ -45,6 +46,7 @@ attachComponentData(GatheringRoute, 'core.gatherMountPoints', true); type RegisterPageOptions = { path?: string; element: JSX.Element; + children?: JSX.Element; title?: string; icon?: IconComponent; }; @@ -112,7 +114,12 @@ class DevAppBuilder { ); } this.routes.push( - , + , ); return this; } diff --git a/plugins/catalog-react/src/routes.ts b/plugins/catalog-react/src/routes.ts index 2983b464ac..0fced32f59 100644 --- a/plugins/catalog-react/src/routes.ts +++ b/plugins/catalog-react/src/routes.ts @@ -19,6 +19,7 @@ import { createRouteRef } from '@backstage/core'; const NoIcon = () => null; +// TODO(Rugvip): Move these route refs back to the catalog plugin once we're all ported to using external routes export const rootRoute = createRouteRef({ icon: NoIcon, path: '', diff --git a/plugins/catalog/dev/index.tsx b/plugins/catalog/dev/index.tsx index 812a5585d4..34f23071b0 100644 --- a/plugins/catalog/dev/index.tsx +++ b/plugins/catalog/dev/index.tsx @@ -14,7 +14,31 @@ * limitations under the License. */ +import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { + catalogPlugin, + CatalogIndexPage, + CatalogEntityPage, + EntityLayout, +} from '../src'; -createDevApp().registerPlugin(plugin).render(); +createDevApp() + .registerPlugin(catalogPlugin) + .addPage({ + path: '/catalog', + title: 'Catalog', + element: , + }) + .addPage({ + path: '/catalog/:namespace/:kind/:name', + element: , + children: ( + + +

Overview

+
+
+ ), + }) + .render(); diff --git a/plugins/catalog/src/extensions.tsx b/plugins/catalog/src/extensions.tsx deleted file mode 100644 index 1eaa30b769..0000000000 --- a/plugins/catalog/src/extensions.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createRoutableExtension } from '@backstage/core'; -import { - catalogRouteRef, - entityRouteRef, -} from '@backstage/plugin-catalog-react'; -import { plugin } from './plugin'; - -export const CatalogIndexPage = plugin.provide( - createRoutableExtension({ - component: () => - import('./components/CatalogPage').then(m => m.CatalogPage), - mountPoint: catalogRouteRef, - }), -); - -export const CatalogEntityPage = plugin.provide( - createRoutableExtension({ - component: () => - import('./components/CatalogEntityPage/CatalogEntityPage').then( - m => m.CatalogEntityPage, - ), - mountPoint: entityRouteRef, - }), -); diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index 80367f80e4..3ef79680c4 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -19,5 +19,9 @@ export { EntityLayout } from './components/EntityLayout'; export { EntityPageLayout } from './components/EntityPageLayout'; export * from './components/EntitySwitch'; export { Router } from './components/Router'; -export * from './extensions'; -export { plugin } from './plugin'; +export { + catalogPlugin, + catalogPlugin as plugin, + CatalogIndexPage, + CatalogEntityPage, +} from './plugin'; diff --git a/plugins/catalog/src/plugin.test.ts b/plugins/catalog/src/plugin.test.ts index 427efc39e1..d2cf0e4bf5 100644 --- a/plugins/catalog/src/plugin.test.ts +++ b/plugins/catalog/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { catalogPlugin } from './plugin'; describe('catalog', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(catalogPlugin).toBeDefined(); }); }); diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index 69a1285ab4..c19925775d 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -19,6 +19,7 @@ import { createApiFactory, createPlugin, discoveryApiRef, + createRoutableExtension, } from '@backstage/core'; import { catalogApiRef, @@ -26,7 +27,7 @@ import { entityRouteRef, } from '@backstage/plugin-catalog-react'; -export const plugin = createPlugin({ +export const catalogPlugin = createPlugin({ id: 'catalog', apis: [ createApiFactory({ @@ -40,3 +41,21 @@ export const plugin = createPlugin({ catalogEntity: entityRouteRef, }, }); + +export const CatalogIndexPage = catalogPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/CatalogPage').then(m => m.CatalogPage), + mountPoint: catalogRouteRef, + }), +); + +export const CatalogEntityPage = catalogPlugin.provide( + createRoutableExtension({ + component: () => + import('./components/CatalogEntityPage/CatalogEntityPage').then( + m => m.CatalogEntityPage, + ), + mountPoint: entityRouteRef, + }), +); From 2f28f821a3738384e472ca6c146be7d518944a46 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 2 Feb 2021 15:24:34 +0100 Subject: [PATCH 254/297] backend-common: use separate integration test keys in CI --- .github/workflows/ci.yml | 4 ++++ .github/workflows/master-win.yml | 4 ++++ .github/workflows/master.yml | 4 ++++ .../src/reading/integration.test.ts | 15 +++++++++++---- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c35e2a88ff..aa2feebb3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,10 @@ jobs: env: CI: true NODE_OPTIONS: --max-old-space-size=4096 + INTEGRATION_TEST_GITHUB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITHUB_TOKEN }} + INTEGRATION_TEST_GITLAB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITLAB_TOKEN }} + INTEGRATION_TEST_BITBUCKET_TOKEN: ${{ secrets.INTEGRATION_TEST_BITBUCKET_TOKEN }} + INTEGRATION_TEST_AZURE_TOKEN: ${{ secrets.INTEGRATION_TEST_AZURE_TOKEN }} steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/master-win.yml b/.github/workflows/master-win.yml index e6a9bf158b..a9b5bfdf0e 100644 --- a/.github/workflows/master-win.yml +++ b/.github/workflows/master-win.yml @@ -16,6 +16,10 @@ jobs: env: CI: true NODE_OPTIONS: --max-old-space-size=4096 + INTEGRATION_TEST_GITHUB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITHUB_TOKEN }} + INTEGRATION_TEST_GITLAB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITLAB_TOKEN }} + INTEGRATION_TEST_BITBUCKET_TOKEN: ${{ secrets.INTEGRATION_TEST_BITBUCKET_TOKEN }} + INTEGRATION_TEST_AZURE_TOKEN: ${{ secrets.INTEGRATION_TEST_AZURE_TOKEN }} steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 0a615b7c82..425e1172a4 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -19,6 +19,10 @@ jobs: env: CI: true NODE_OPTIONS: --max-old-space-size=4096 + INTEGRATION_TEST_GITHUB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITHUB_TOKEN }} + INTEGRATION_TEST_GITLAB_TOKEN: ${{ secrets.INTEGRATION_TEST_GITLAB_TOKEN }} + INTEGRATION_TEST_BITBUCKET_TOKEN: ${{ secrets.INTEGRATION_TEST_BITBUCKET_TOKEN }} + INTEGRATION_TEST_AZURE_TOKEN: ${{ secrets.INTEGRATION_TEST_AZURE_TOKEN }} steps: - uses: actions/checkout@v2 diff --git a/packages/backend-common/src/reading/integration.test.ts b/packages/backend-common/src/reading/integration.test.ts index da2e916210..2145a4b0fd 100644 --- a/packages/backend-common/src/reading/integration.test.ts +++ b/packages/backend-common/src/reading/integration.test.ts @@ -26,27 +26,34 @@ const reader = UrlReaders.default({ github: [ { host: 'github.com', - token: `${86}af${617}d9c3c8bf958b37a${630691452765}bb0b0a`, + token: + process.env.INTEGRATION_TEST_GITHUB_TOKEN || + `${86}af${617}d9c3c8bf958b37a${630691452765}bb0b0a`, }, ], gitlab: [ { host: 'gitlab.com', - token: 'tveGtSHDBJM9ZRHZNRfm', + token: + process.env.INTEGRATION_TEST_GITLAB_TOKEN || 'tveGtSHDBJM9ZRHZNRfm', }, ], bitbucket: [ { host: 'bitbucket.org', username: 'backstage-verification', - appPassword: 'H79MAAhtbZwCafkVTrrQ', + appPassword: + process.env.INTEGRATION_TEST_BITBUCKET_TOKEN || + 'H79MAAhtbZwCafkVTrrQ', }, ], azure: [ { host: 'dev.azure.com', // lasts until 2022-01-28 - token: `myvyavvfojh6wvw4ose4bfywqttqx${5}z${5}zs${5}bdxauqaek3yinkazq`, + token: + process.env.INTEGRATION_TEST_AZURE_TOKEN || + `myvyavvfojh6wvw4ose4bfywqttqx${5}z${5}zs${5}bdxauqaek3yinkazq`, }, ], }, From 0c00a0c14c979c61a59ba002ead4f90fceacf852 Mon Sep 17 00:00:00 2001 From: Debajyoti Halder Date: Tue, 2 Feb 2021 23:07:42 +0530 Subject: [PATCH 255/297] cost-insights: Add better type names from patch --- .../AlertInsights/AlertInsights.tsx | 20 ++++++------- .../src/components/BarChart/BarChartLabel.tsx | 4 +-- .../src/components/BarChart/BarChartSteps.tsx | 4 +-- .../ProductInsights/ProductInsights.tsx | 28 +++++++------------ 4 files changed, 24 insertions(+), 32 deletions(-) diff --git a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx index 5204686118..7360c99cf7 100644 --- a/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx +++ b/plugins/cost-insights/src/components/AlertInsights/AlertInsights.tsx @@ -124,18 +124,18 @@ export const AlertInsights = ({ setSnackbarOpen(!!error); }, [error]); - function onSnooze(alertProp: Alert) { - setAlert(alertProp); + function onSnooze(alertToSnooze: Alert) { + setAlert(alertToSnooze); setStatus(AlertStatus.Snoozed); } - function onAccept(alertProp: Alert) { - setAlert(alertProp); + function onAccept(alertToAccept: Alert) { + setAlert(alertToAccept); setStatus(AlertStatus.Accepted); } - function onDismiss(alertProp: Alert) { - setAlert(alertProp); + function onDismiss(alertToDismiss: Alert) { + setAlert(alertToDismiss); setStatus(AlertStatus.Dismissed); } @@ -148,8 +148,8 @@ export const AlertInsights = ({ setStatus(null); } - function onDialogFormSubmit(inputData: any) { - setData(inputData); + function onDialogFormSubmit(formData: any) { + setData(formData); } function onSummaryButtonClick() { @@ -175,10 +175,10 @@ export const AlertInsights = ({
{isAlertInsightSectionDisplayed && ( - {active.map((alertItem, index) => ( + {active.map((activeAlert, index) => ( ) => { +}: PropsWithChildren) => { const classes = useBarChartLabelStyles(); const translateX = width * -0.5; diff --git a/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx b/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx index ec573da68f..8157e05444 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartSteps.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { ButtonBase } from '@material-ui/core'; import { useBarChartStepperStyles as useStyles } from '../../utils/styles'; -export type BarChartStepsObject = { +export type BarChartStepsProps = { steps: number; activeStep: number; onClick: (index: number) => void; @@ -28,7 +28,7 @@ export const BarChartSteps = ({ steps, activeStep, onClick, -}: BarChartStepsObject) => { +}: BarChartStepsProps) => { const classes = useStyles(); const handleOnClick = (index: number) => ( event: React.MouseEvent, diff --git a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx index b13376d24a..5702e67172 100644 --- a/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx +++ b/plugins/cost-insights/src/components/ProductInsights/ProductInsights.tsx @@ -82,32 +82,24 @@ export const ProductInsights = ({ ); useEffect(() => { - async function getAllProductInsights( - groupId: string, - projectId: Maybe, - productIds: Product[], - lastCompleteBillingDateString: string, - ) { + async function getAllProductInsights() { try { dispatchLoadingProducts(true); const responses = await Promise.allSettled( - productIds.map(product => + products.map(product => client.getProductInsights({ - group: groupId, - project: projectId, + group: group, + project: project, product: product.kind, - intervals: intervalsOf( - DEFAULT_DURATION, - lastCompleteBillingDateString, - ), + intervals: intervalsOf(DEFAULT_DURATION, lastCompleteBillingDate), }), ), ).then(settledResponseOf); - const initialStatesNow = initialStatesOf(productIds, responses).sort( + const updatedInitialStates = initialStatesOf(products, responses).sort( totalAggregationSort, ); - setStates(initialStatesNow); + setStates(updatedInitialStates); } catch (e) { setError(e); } finally { @@ -115,7 +107,7 @@ export const ProductInsights = ({ } } - getAllProductInsights(group, project, products, lastCompleteBillingDate); + getAllProductInsights(); }, [ client, group, @@ -128,8 +120,8 @@ export const ProductInsights = ({ useEffect( function handleOnLoaded() { if (onceRef.current) { - const currentProducts = initialStates.map(state => state.product); - onLoaded(currentProducts); + const initialProducts = initialStates.map(state => state.product); + onLoaded(initialProducts); } else { onceRef.current = true; } From a5628df4006b506883a04f4d2335764ae518aa77 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 2 Feb 2021 19:06:24 +0100 Subject: [PATCH 256/297] lighthouse: migrate to new composability API --- .changeset/fifty-dolls-doubt.md | 5 +++ plugins/lighthouse/dev/index.tsx | 4 +-- plugins/lighthouse/src/Router.tsx | 23 +++++------- .../components/AuditList/AuditListTable.tsx | 7 +--- .../src/components/AuditList/index.tsx | 3 +- .../src/components/AuditView/index.tsx | 10 ++---- plugins/lighthouse/src/index.ts | 15 ++++++-- plugins/lighthouse/src/plugin.test.ts | 4 +-- plugins/lighthouse/src/plugin.ts | 35 ++++++++++++++++++- 9 files changed, 68 insertions(+), 38 deletions(-) create mode 100644 .changeset/fifty-dolls-doubt.md diff --git a/.changeset/fifty-dolls-doubt.md b/.changeset/fifty-dolls-doubt.md new file mode 100644 index 0000000000..1abd7a16b9 --- /dev/null +++ b/.changeset/fifty-dolls-doubt.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-lighthouse': patch +--- + +Migrate to new composability API, exporting the plugin instance as `lighthousePlugin`, the top-level page as `LighthousePage`, the entity card as `EntityLastLighthouseAuditCard`, and the entity content as `EntityLighthouseContent`. diff --git a/plugins/lighthouse/dev/index.tsx b/plugins/lighthouse/dev/index.tsx index bf761965f1..1ea54e5871 100644 --- a/plugins/lighthouse/dev/index.tsx +++ b/plugins/lighthouse/dev/index.tsx @@ -15,11 +15,11 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { lighthousePlugin } from '../src/plugin'; import { lighthouseApiRef, LighthouseRestApi } from '../src'; createDevApp() - .registerPlugin(plugin) + .registerPlugin(lighthousePlugin) .registerApi({ api: lighthouseApiRef, deps: {}, diff --git a/plugins/lighthouse/src/Router.tsx b/plugins/lighthouse/src/Router.tsx index d264f3bc1b..0634a0aaa0 100644 --- a/plugins/lighthouse/src/Router.tsx +++ b/plugins/lighthouse/src/Router.tsx @@ -16,7 +16,6 @@ import React from 'react'; import { Route, Routes } from 'react-router-dom'; -import { createAuditRouteRef, rootRouteRef, viewAuditRouteRef } from './plugin'; import AuditList from './components/AuditList'; import AuditView, { AuditViewContent } from './components/AuditView'; import CreateAudit, { CreateAuditContent } from './components/CreateAudit'; @@ -25,32 +24,26 @@ import { LIGHTHOUSE_WEBSITE_URL_ANNOTATION } from '../constants'; import { AuditListForEntity } from './components/AuditList/AuditListForEntity'; import { MissingAnnotationEmptyState } from '@backstage/core'; -export const isPluginApplicableToEntity = (entity: Entity) => +export const isLighthouseAvailable = (entity: Entity) => Boolean(entity.metadata.annotations?.[LIGHTHOUSE_WEBSITE_URL_ANNOTATION]); export const Router = () => ( - } /> - } /> - } /> + } /> + } /> + } /> ); export const EmbeddedRouter = ({ entity }: { entity: Entity }) => - !isPluginApplicableToEntity(entity) ? ( + !isLighthouseAvailable(entity) ? ( ) : ( - } /> - } - /> - } - /> + } /> + } /> + } /> ); diff --git a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx index 0fb66a317b..8a59869266 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx @@ -25,7 +25,6 @@ import { } from '../../utils'; import { Link, generatePath } from 'react-router-dom'; import AuditStatusIcon from '../AuditStatusIcon'; -import { viewAuditRouteRef } from '../../plugin'; const columns: TableColumn[] = [ { @@ -99,11 +98,7 @@ export const AuditListTable = ({ items }: { items: Website[] }) => { return { websiteUrl: ( - + {website.url} ), diff --git a/plugins/lighthouse/src/components/AuditList/index.tsx b/plugins/lighthouse/src/components/AuditList/index.tsx index b022482da4..ce0fcdb46e 100644 --- a/plugins/lighthouse/src/components/AuditList/index.tsx +++ b/plugins/lighthouse/src/components/AuditList/index.tsx @@ -35,7 +35,6 @@ import { useQuery } from '../../utils'; import LighthouseSupportButton from '../SupportButton'; import LighthouseIntro, { LIGHTHOUSE_INTRO_LOCAL_STORAGE } from '../Intro'; import AuditListTable from './AuditListTable'; -import { createAuditRouteRef } from '../../plugin'; export const LIMIT = 10; @@ -110,7 +109,7 @@ const AuditList = () => { diff --git a/plugins/lighthouse/src/components/AuditView/index.tsx b/plugins/lighthouse/src/components/AuditView/index.tsx index 2a99339a84..2be284183c 100644 --- a/plugins/lighthouse/src/components/AuditView/index.tsx +++ b/plugins/lighthouse/src/components/AuditView/index.tsx @@ -47,7 +47,6 @@ import { lighthouseApiRef, Website, Audit } from '../../api'; import AuditStatusIcon from '../AuditStatusIcon'; import LighthouseSupportButton from '../SupportButton'; import { formatTime } from '../../utils'; -import { viewAuditRouteRef, createAuditRouteRef } from '../../plugin'; const useStyles = makeStyles({ contentGrid: { @@ -78,12 +77,7 @@ const AuditLinkList = ({ audits = [], selectedId }: AuditLinkListProps) => ( button component={Link} replace - to={resolvePath( - generatePath(viewAuditRouteRef.path, { - id: audit.id, - }), - '../../', - )} + to={resolvePath(generatePath('audit/:id', { id: audit.id }), '../../')} > @@ -163,7 +157,7 @@ export const AuditViewContent = () => { ); } - let createAuditButtonUrl = createAuditRouteRef.path; + let createAuditButtonUrl = 'create-audit'; if (value?.url) { createAuditButtonUrl += `?url=${encodeURIComponent(value.url)}`; } diff --git a/plugins/lighthouse/src/index.ts b/plugins/lighthouse/src/index.ts index 64fe2f8cc0..bf51bbbf35 100644 --- a/plugins/lighthouse/src/index.ts +++ b/plugins/lighthouse/src/index.ts @@ -14,7 +14,18 @@ * limitations under the License. */ -export { plugin } from './plugin'; -export { Router, isPluginApplicableToEntity, EmbeddedRouter } from './Router'; +export { + lighthousePlugin, + lighthousePlugin as plugin, + LighthousePage, + EntityLighthouseContent, + EntityLastLighthouseAuditCard, +} from './plugin'; +export { + Router, + isLighthouseAvailable as isPluginApplicableToEntity, + isLighthouseAvailable, + EmbeddedRouter, +} from './Router'; export * from './api'; export * from './components/Cards'; diff --git a/plugins/lighthouse/src/plugin.test.ts b/plugins/lighthouse/src/plugin.test.ts index 70b1844ec2..642f438e03 100644 --- a/plugins/lighthouse/src/plugin.test.ts +++ b/plugins/lighthouse/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { lighthousePlugin } from './plugin'; describe('lighthouse', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(lighthousePlugin).toBeDefined(); }); }); diff --git a/plugins/lighthouse/src/plugin.ts b/plugins/lighthouse/src/plugin.ts index e9055b4db7..c2a8521da7 100644 --- a/plugins/lighthouse/src/plugin.ts +++ b/plugins/lighthouse/src/plugin.ts @@ -19,6 +19,8 @@ import { createRouteRef, createApiFactory, configApiRef, + createRoutableExtension, + createComponentExtension, } from '@backstage/core'; import { lighthouseApiRef, LighthouseRestApi } from './api'; @@ -37,7 +39,11 @@ export const createAuditRouteRef = createRouteRef({ title: 'Create Lighthouse Audit', }); -export const plugin = createPlugin({ +export const entityContentRouteRef = createRouteRef({ + title: 'Lighthouse Entity Content', +}); + +export const lighthousePlugin = createPlugin({ id: 'lighthouse', apis: [ createApiFactory({ @@ -46,4 +52,31 @@ export const plugin = createPlugin({ factory: ({ configApi }) => LighthouseRestApi.fromConfig(configApi), }), ], + routes: { + root: createAuditRouteRef, + entityContent: entityContentRouteRef, + }, }); + +export const LighthousePage = lighthousePlugin.provide( + createRoutableExtension({ + component: () => import('./Router').then(m => m.Router), + mountPoint: rootRouteRef, + }), +); + +export const EntityLighthouseContent = lighthousePlugin.provide( + createRoutableExtension({ + component: () => import('./Router').then(m => m.EmbeddedRouter), + mountPoint: entityContentRouteRef, + }), +); + +export const EntityLastLighthouseAuditCard = lighthousePlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/Cards').then(m => m.LastLighthouseAuditCard), + }, + }), +); From b712841d61556a670efd3d7c73989ee1c8d5deda Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 2 Feb 2021 19:44:05 +0100 Subject: [PATCH 257/297] catalog-import: migrated to new composability API --- .changeset/fast-dots-camp.md | 5 +++++ plugins/catalog-import/dev/index.tsx | 4 ++-- plugins/catalog-import/src/index.ts | 6 +++++- plugins/catalog-import/src/plugin.test.ts | 4 ++-- plugins/catalog-import/src/plugin.ts | 13 ++++++++++++- 5 files changed, 26 insertions(+), 6 deletions(-) create mode 100644 .changeset/fast-dots-camp.md diff --git a/.changeset/fast-dots-camp.md b/.changeset/fast-dots-camp.md new file mode 100644 index 0000000000..7a1f510f47 --- /dev/null +++ b/.changeset/fast-dots-camp.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-import': patch +--- + +Migrated to new composability API, exporting the plugin instance as `catalogImportPlugin`, and the page as `CatalogImportPage`. diff --git a/plugins/catalog-import/dev/index.tsx b/plugins/catalog-import/dev/index.tsx index 812a5585d4..c9b46a7ed4 100644 --- a/plugins/catalog-import/dev/index.tsx +++ b/plugins/catalog-import/dev/index.tsx @@ -15,6 +15,6 @@ */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { catalogImportPlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(catalogImportPlugin).render(); diff --git a/plugins/catalog-import/src/index.ts b/plugins/catalog-import/src/index.ts index dd8ca2bbb4..7b4fc17121 100644 --- a/plugins/catalog-import/src/index.ts +++ b/plugins/catalog-import/src/index.ts @@ -14,6 +14,10 @@ * limitations under the License. */ -export { plugin } from './plugin'; +export { + catalogImportPlugin, + catalogImportPlugin as plugin, + CatalogImportPage, +} from './plugin'; export { Router } from './components/Router'; export * from './api'; diff --git a/plugins/catalog-import/src/plugin.test.ts b/plugins/catalog-import/src/plugin.test.ts index 2f5964946c..7ebe194b36 100644 --- a/plugins/catalog-import/src/plugin.test.ts +++ b/plugins/catalog-import/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { catalogImportPlugin } from './plugin'; describe('catalog-import', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(catalogImportPlugin).toBeDefined(); }); }); diff --git a/plugins/catalog-import/src/plugin.ts b/plugins/catalog-import/src/plugin.ts index 829be22045..42210d0ed1 100644 --- a/plugins/catalog-import/src/plugin.ts +++ b/plugins/catalog-import/src/plugin.ts @@ -21,6 +21,7 @@ import { discoveryApiRef, githubAuthApiRef, configApiRef, + createRoutableExtension, } from '@backstage/core'; import { catalogImportApiRef } from './api/CatalogImportApi'; import { CatalogImportClient } from './api/CatalogImportClient'; @@ -30,7 +31,7 @@ export const rootRouteRef = createRouteRef({ title: 'catalog-import', }); -export const plugin = createPlugin({ +export const catalogImportPlugin = createPlugin({ id: 'catalog-import', apis: [ createApiFactory({ @@ -44,4 +45,14 @@ export const plugin = createPlugin({ new CatalogImportClient({ discoveryApi, githubAuthApi, configApi }), }), ], + routes: { + importPage: rootRouteRef, + }, }); + +export const CatalogImportPage = catalogImportPlugin.provide( + createRoutableExtension({ + component: () => import('./components/Router').then(m => m.Router), + mountPoint: rootRouteRef, + }), +); From 0269f4fd9143a216d7bcaacc7b62f0022ec6e1d4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 2 Feb 2021 20:22:07 +0100 Subject: [PATCH 258/297] org: migrate to new composability API --- .changeset/metal-insects-compete.md | 5 +++++ plugins/org/dev/index.tsx | 4 ++-- plugins/org/src/index.ts | 9 +++++++- plugins/org/src/plugin.test.ts | 4 ++-- plugins/org/src/plugin.ts | 33 +++++++++++++++++++++++++++-- 5 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 .changeset/metal-insects-compete.md diff --git a/.changeset/metal-insects-compete.md b/.changeset/metal-insects-compete.md new file mode 100644 index 0000000000..ead779d6a9 --- /dev/null +++ b/.changeset/metal-insects-compete.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-org': patch +--- + +Migrate to new composability API, exporting the plugin instance as `orgPlugin`, and the entity cards as `EntityGroupProfileCard`, `EntityMembersListCard`, `EntityOwnershipCard`, and `EntityUserProfileCard`. diff --git a/plugins/org/dev/index.tsx b/plugins/org/dev/index.tsx index 264d6f801f..01951eaf03 100644 --- a/plugins/org/dev/index.tsx +++ b/plugins/org/dev/index.tsx @@ -14,6 +14,6 @@ * limitations under the License. */ import { createDevApp } from '@backstage/dev-utils'; -import { plugin } from '../src/plugin'; +import { orgPlugin } from '../src/plugin'; -createDevApp().registerPlugin(plugin).render(); +createDevApp().registerPlugin(orgPlugin).render(); diff --git a/plugins/org/src/index.ts b/plugins/org/src/index.ts index 77ad7f9266..36c5f94ee5 100644 --- a/plugins/org/src/index.ts +++ b/plugins/org/src/index.ts @@ -13,5 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { plugin } from './plugin'; +export { + orgPlugin, + orgPlugin as plugin, + EntityGroupProfileCard, + EntityMembersListCard, + EntityOwnershipCard, + EntityUserProfileCard, +} from './plugin'; export * from './components'; diff --git a/plugins/org/src/plugin.test.ts b/plugins/org/src/plugin.test.ts index d77cfd7ae8..e488422441 100644 --- a/plugins/org/src/plugin.test.ts +++ b/plugins/org/src/plugin.test.ts @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { plugin } from './plugin'; +import { orgPlugin } from './plugin'; describe('groups', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(orgPlugin).toBeDefined(); }); }); diff --git a/plugins/org/src/plugin.ts b/plugins/org/src/plugin.ts index 39c3502fb5..195e88ea41 100644 --- a/plugins/org/src/plugin.ts +++ b/plugins/org/src/plugin.ts @@ -13,8 +13,37 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createPlugin } from '@backstage/core'; +import { createComponentExtension, createPlugin } from '@backstage/core'; -export const plugin = createPlugin({ +export const orgPlugin = createPlugin({ id: 'org', }); + +export const EntityGroupProfileCard = orgPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components').then(m => m.GroupProfileCard), + }, + }), +); +export const EntityMembersListCard = orgPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components').then(m => m.MembersListCard), + }, + }), +); +export const EntityOwnershipCard = orgPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components').then(m => m.OwnershipCard), + }, + }), +); +export const EntityUserProfileCard = orgPlugin.provide( + createComponentExtension({ + component: { + lazy: () => import('./components').then(m => m.UserProfileCard), + }, + }), +); From a06f9974f563ca706d4d20dc8ac7ccdd0ad61930 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 2 Feb 2021 22:49:12 +0100 Subject: [PATCH 259/297] user-settings: restore route ref path to fix sidebar item --- plugins/user-settings/src/plugin.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/user-settings/src/plugin.ts b/plugins/user-settings/src/plugin.ts index 5353c12f10..36d2254c11 100644 --- a/plugins/user-settings/src/plugin.ts +++ b/plugins/user-settings/src/plugin.ts @@ -21,6 +21,7 @@ import { } from '@backstage/core'; export const settingsRouteRef = createRouteRef({ + path: '/settings', title: 'Settings', }); From 93a53ea093b809d1e8ac4d721e79ff41017fa198 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Tue, 2 Feb 2021 23:16:09 +0100 Subject: [PATCH 260/297] TechDocs: Use @deprecated for techdocs config urls --- plugins/techdocs-backend/config.d.ts | 15 ++++++++------- plugins/techdocs/config.d.ts | 24 +++++++++++++----------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index 1e4298688e..7ec1162d03 100644 --- a/plugins/techdocs-backend/config.d.ts +++ b/plugins/techdocs-backend/config.d.ts @@ -14,17 +14,12 @@ * limitations under the License. */ /** - * techdocs schema below is an abstract of what's used within techdocs-backend and for its visibility - * to view the complete techdoc schema please refer: plugins/techdocs/config.d.ts + * TechDocs schema below is an abstract of what's used within techdocs-backend and for its visibility + * to view the complete TechDocs schema please refer: plugins/techdocs/config.d.ts * */ export interface Config { /** Configuration options for the techdocs-backend plugin */ techdocs: { - /** - * attr: 'storageUrl' - accepts a string value - * e.g. storageUrl: http://localhost:7000/api/techdocs/static/docs - */ - storageUrl?: string; /** * documentation building process depends on the builder attr * attr: 'builder' - accepts a string value @@ -45,5 +40,11 @@ export interface Config { */ type: 'local' | 'googleGcs' | 'awsS3'; }; + /** + * attr: 'storageUrl' - accepts a string value + * e.g. storageUrl: http://localhost:7000/api/techdocs/static/docs + * @deprecated + */ + storageUrl?: string; }; } diff --git a/plugins/techdocs/config.d.ts b/plugins/techdocs/config.d.ts index ef4e026911..1104d75237 100644 --- a/plugins/techdocs/config.d.ts +++ b/plugins/techdocs/config.d.ts @@ -17,17 +17,6 @@ export interface Config { /** Configuration options for the techdocs plugin */ techdocs: { - /** - * attr: 'requestUrl' - accepts a string value - * e.g. requestUrl: http://localhost:7000/api/techdocs - * @visibility frontend - */ - requestUrl?: string; - /** - * attr: 'storageUrl' - accepts a string value - * e.g. storageUrl: http://localhost:7000/api/techdocs/static/docs - */ - storageUrl?: string; /** * documentation building process depends on the builder attr * attr: 'builder' - accepts a string value @@ -184,5 +173,18 @@ export interface Config { credentials?: string; }; }; + /** + * attr: 'requestUrl' - accepts a string value + * e.g. requestUrl: http://localhost:7000/api/techdocs + * @visibility frontend + * @deprecated + */ + requestUrl?: string; + /** + * attr: 'storageUrl' - accepts a string value + * e.g. storageUrl: http://localhost:7000/api/techdocs/static/docs + * @deprecated + */ + storageUrl?: string; }; } From 1ca98d7df87d956f2ce054b81bb7a069ac953bce Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Tue, 2 Feb 2021 23:28:39 +0100 Subject: [PATCH 261/297] TechDocs: Use mockFs for tests instead of real filesystem --- .../src/stages/publish/local.test.ts | 32 ++++++++----------- .../src/stages/publish/local.ts | 14 ++++---- 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/packages/techdocs-common/src/stages/publish/local.test.ts b/packages/techdocs-common/src/stages/publish/local.test.ts index 63adfad09a..cd1a17ab5f 100644 --- a/packages/techdocs-common/src/stages/publish/local.test.ts +++ b/packages/techdocs-common/src/stages/publish/local.test.ts @@ -13,15 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -/* eslint-disable no-restricted-syntax */ -import fs from 'fs-extra'; -import path from 'path'; import { getVoidLogger, PluginEndpointDiscovery, } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; +import mockFs from 'mock-fs'; +import * as os from 'os'; import { LocalPublish } from './local'; const createMockEntity = (annotations = {}) => { @@ -39,8 +37,17 @@ const createMockEntity = (annotations = {}) => { const logger = getVoidLogger(); +const tmpDir = + os.platform() === 'win32' ? 'C:\\tmp\\generatedDir' : '/tmp/generatedDir'; + describe('local publisher', () => { it('should publish generated documentation dir', async () => { + mockFs({ + [tmpDir]: { + 'index.html': '', + }, + }); + const testDiscovery: jest.Mocked = { getBaseUrl: jest .fn() @@ -52,24 +59,11 @@ describe('local publisher', () => { const publisher = new LocalPublish(mockConfig, logger, testDiscovery); const mockEntity = createMockEntity(); - const tempDir = fs.mkdtempSync(`${__dirname}/test-component-folder-`); - expect(tempDir).toBeTruthy(); - fs.closeSync(fs.openSync(path.join(tempDir, '/index.html'), 'w')); - await publisher.publish({ entity: mockEntity, directory: tempDir }); - - fs.removeSync(tempDir); - - const resultDir = path.resolve( - __dirname, - `../../../../../plugins/techdocs-backend/static/docs/default/${mockEntity.kind}/${mockEntity.metadata.name}`, - ); - - expect(fs.existsSync(resultDir)).toBeTruthy(); - expect(fs.existsSync(path.join(resultDir, '/index.html'))).toBeTruthy(); + await publisher.publish({ entity: mockEntity, directory: tmpDir }); expect(await publisher.hasDocsBeenGenerated(mockEntity)).toBe(true); - fs.removeSync(resultDir); + mockFs.restore(); }); }); diff --git a/packages/techdocs-common/src/stages/publish/local.ts b/packages/techdocs-common/src/stages/publish/local.ts index b4b30c1bf1..e349a2119a 100644 --- a/packages/techdocs-common/src/stages/publish/local.ts +++ b/packages/techdocs-common/src/stages/publish/local.ts @@ -13,17 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { + PluginEndpointDiscovery, + resolvePackagePath, +} from '@backstage/backend-common'; +import { Entity, EntityName } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; import express from 'express'; import fs from 'fs-extra'; -import path from 'path'; import os from 'os'; +import path from 'path'; import { Logger } from 'winston'; -import { Entity, EntityName } from '@backstage/catalog-model'; -import { - resolvePackagePath, - PluginEndpointDiscovery, -} from '@backstage/backend-common'; -import { Config } from '@backstage/config'; import { PublisherBase, PublishRequest, From d8386f780a6c60371c5e213584e14683482930f2 Mon Sep 17 00:00:00 2001 From: Manoj - Date: Wed, 3 Feb 2021 16:54:44 +1100 Subject: [PATCH 262/297] added condition handling the case - bitbucket.org --- packages/integration/src/bitbucket/core.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/integration/src/bitbucket/core.ts b/packages/integration/src/bitbucket/core.ts index f22c727f63..4359762676 100644 --- a/packages/integration/src/bitbucket/core.ts +++ b/packages/integration/src/bitbucket/core.ts @@ -38,7 +38,7 @@ export async function getBitbucketDefaultBranch( var response = await fetch(branchUrl, getBitbucketRequestOptions(config)); - if (response.status === 404) { + if (response.status === 404 && !isHosted) { // First try the new format, and then if it gets specifically a 404 it should try the old format // (to support old Atlassian Bitbucket v5.11.1 format ) branchUrl = `${config.apiBaseUrl}/projects/${project}/repos/${repoName}/branches/default`; From 2a3d7599e60870e292e949177773da40cd2bb1e9 Mon Sep 17 00:00:00 2001 From: Manoj - Date: Wed, 3 Feb 2021 18:59:25 +1100 Subject: [PATCH 263/297] added test cases to include both the endpoints --- .../integration/src/bitbucket/core.test.ts | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/integration/src/bitbucket/core.test.ts b/packages/integration/src/bitbucket/core.test.ts index 1287ab4d66..5bb06bcfa3 100644 --- a/packages/integration/src/bitbucket/core.test.ts +++ b/packages/integration/src/bitbucket/core.test.ts @@ -111,6 +111,7 @@ describe('bitbucket core', () => { describe('getBitbucketDownloadUrl', () => { it('add path param if a path is specified for Bitbucket Server', async () => { + const defaultBranchResponse = { displayId: 'main', }; @@ -125,6 +126,7 @@ describe('bitbucket core', () => { ), ), ); + const config: BitbucketIntegrationConfig = { host: 'bitbucket.mycompany.net', apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', @@ -249,6 +251,42 @@ describe('bitbucket core', () => { config, ); expect(defaultBranch).toEqual('main'); - }); + }); + + it('return default branch for Bitbucket Server for bitbucket version 5.11', async () => { + const defaultBranchResponse = { + displayId: 'main', + }; + worker.use( + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/default-branch', + (_, res, ctx) => + res( + ctx.status(404), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + rest.get( + 'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/branches/default', + (_, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(defaultBranchResponse), + ), + ), + ); + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0', + }; + const defaultBranch = await getBitbucketDefaultBranch( + 'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/README.md', + config, + ); + expect(defaultBranch).toEqual('main'); + }); + }); }); From 2e62aea6fbc190ca9f791030809d0891aadc5a0b Mon Sep 17 00:00:00 2001 From: Kaparwan Manoj Date: Wed, 3 Feb 2021 19:25:43 +1100 Subject: [PATCH 264/297] changeset created --- .changeset/wicked-boxes-taste.md | 5 ++ package.json | 2 +- yarn.lock | 96 +++++++++++++++++++++++++------- 3 files changed, 81 insertions(+), 22 deletions(-) create mode 100644 .changeset/wicked-boxes-taste.md diff --git a/.changeset/wicked-boxes-taste.md b/.changeset/wicked-boxes-taste.md new file mode 100644 index 0000000000..a875d1c50d --- /dev/null +++ b/.changeset/wicked-boxes-taste.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': major +--- + +#4322 bitbucket own hosted v5.11.1 branchUrl fix and enabled error tracing… #4347 diff --git a/package.json b/package.json index 758a8ba884..fd130fcbbd 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ }, "version": "1.0.0", "devDependencies": { - "@changesets/cli": "^2.11.0", + "@changesets/cli": "^2.14.0", "@octokit/openapi-types": "^2.2.0", "@spotify/eslint-config-oss": "^1.0.1", "@spotify/prettier-config": "^9.0.0", diff --git a/yarn.lock b/yarn.lock index 2ac7dffa6a..3626741854 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2655,21 +2655,22 @@ resolved "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-5.0.0.tgz#3ba791f37b90e7f6170d252b63aacfcae943c039" integrity sha512-WmKrB/575EJCzbeSJR3YQ5sET5FaizeljLRw1382qVUeGqzuWBgIS+AF5a0FO51uQTrDpoRgvuHC2IWVsgwkkA== -"@changesets/apply-release-plan@^4.0.0": - version "4.0.0" - resolved "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-4.0.0.tgz#e78efb56a4e459a8dab814ba43045f2ace0f27c9" - integrity sha512-MrcUd8wIlQ4S/PznzqJVsmnEpUGfPEkCGF54iqt8G05GEqi/zuxpoTfebcScpj5zeiDyxFIcA9RbeZ3pvJJxoA== +"@changesets/apply-release-plan@^4.2.0": + version "4.2.0" + resolved "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-4.2.0.tgz#f1005815f27c3238f66507e90c6ae14d8fc62b41" + integrity sha512-/vt6UwgQldhOw93Gb8llI5OuYGlJt2+U45AfcXsoxzl8gZzCmChGm3vUaQJYbmtL8TbL8OOVXHRIKJJidMNPKw== dependencies: - "@babel/runtime" "^7.4.4" - "@changesets/config" "^1.2.0" + "@babel/runtime" "^7.10.4" + "@changesets/config" "^1.5.0" "@changesets/get-version-range-type" "^0.3.2" "@changesets/git" "^1.0.5" - "@changesets/types" "^3.1.0" + "@changesets/types" "^3.3.0" "@manypkg/get-packages" "^1.0.1" + detect-indent "^6.0.0" fs-extra "^7.0.1" lodash.startcase "^4.4.0" outdent "^0.5.0" - prettier "^1.18.2" + prettier "^1.19.1" resolve-from "^5.0.0" semver "^5.4.1" @@ -2685,23 +2686,35 @@ "@manypkg/get-packages" "^1.0.1" semver "^5.4.1" -"@changesets/cli@^2.11.0": - version "2.12.0" - resolved "https://registry.npmjs.org/@changesets/cli/-/cli-2.12.0.tgz#26124b051e6ce6dcc5aa4595588c8cb2ce3e4363" - integrity sha512-dGdFkg75zsaEObsG8gwMLglS6sJVjXWwgVTAzEIjqIoWVnKwqZqccTb4gn0noq47uCwy7SqxiAJqGibIy9UOKw== +"@changesets/assemble-release-plan@^4.1.0": + version "4.1.0" + resolved "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-4.1.0.tgz#091e5e768dfe51835937e71d1ebaca1c9d6de55b" + integrity sha512-dMqe2L5Pn4UGTW89kOuuCuZD3pQFZj1Sxv92ZW4S98sXGsxcb2PdW+PeHbQ7tawkCYCOvzhXxAlN4OdF2DlDKQ== dependencies: "@babel/runtime" "^7.10.4" - "@changesets/apply-release-plan" "^4.0.0" - "@changesets/assemble-release-plan" "^4.0.0" - "@changesets/config" "^1.4.0" "@changesets/errors" "^0.1.4" - "@changesets/get-dependents-graph" "^1.1.3" + "@changesets/get-dependents-graph" "^1.2.0" + "@changesets/types" "^3.3.0" + "@manypkg/get-packages" "^1.0.1" + semver "^5.4.1" + +"@changesets/cli@^2.14.0": + version "2.14.0" + resolved "https://registry.npmjs.org/@changesets/cli/-/cli-2.14.0.tgz#b8d1d33d832c640ce0b95333bbd8d5ac5b9c9824" + integrity sha512-rbQMRDXl1cXOglnjUvYyrFLlYBbS0YZdfxZfW3ZbGLzLoS4n50+B9fLSE9oW20hQuL3zAnyLyacb9cwNhF2lig== + dependencies: + "@babel/runtime" "^7.10.4" + "@changesets/apply-release-plan" "^4.2.0" + "@changesets/assemble-release-plan" "^4.1.0" + "@changesets/config" "^1.5.0" + "@changesets/errors" "^0.1.4" + "@changesets/get-dependents-graph" "^1.2.0" "@changesets/get-release-plan" "^2.0.1" - "@changesets/git" "^1.0.6" + "@changesets/git" "^1.1.0" "@changesets/logger" "^0.0.5" "@changesets/pre" "^1.0.4" "@changesets/read" "^0.4.6" - "@changesets/types" "^3.2.0" + "@changesets/types" "^3.3.0" "@changesets/write" "^0.1.3" "@manypkg/get-packages" "^1.0.1" "@types/semver" "^6.0.0" @@ -2721,7 +2734,7 @@ term-size "^2.1.0" tty-table "^2.8.10" -"@changesets/config@^1.2.0", "@changesets/config@^1.4.0": +"@changesets/config@^1.2.0": version "1.4.0" resolved "https://registry.npmjs.org/@changesets/config/-/config-1.4.0.tgz#c157a4121f198b749f2bbc2e9015b6e976ece7d6" integrity sha512-eoTOcJ6py7jBDY8rUXwEGxR5UtvUX+p//0NhkVpPGcnvIeITHq+DOIsuWyGzWcb+1FaYkof3CCr32/komZTu4Q== @@ -2734,6 +2747,19 @@ fs-extra "^7.0.1" micromatch "^4.0.2" +"@changesets/config@^1.5.0": + version "1.5.0" + resolved "https://registry.npmjs.org/@changesets/config/-/config-1.5.0.tgz#6d58b01e45916318d3f39e9cde86a5d69dc58ed8" + integrity sha512-Bl9nLVYcwFCpd9jpzcOsExZk1NuTYX20D2YWHCdS1xll3W0yOdSUlWLUCCfugN1l3+yTR6iDW6q9o6vpCevWvA== + dependencies: + "@changesets/errors" "^0.1.4" + "@changesets/get-dependents-graph" "^1.2.0" + "@changesets/logger" "^0.0.5" + "@changesets/types" "^3.3.0" + "@manypkg/get-packages" "^1.0.1" + fs-extra "^7.0.1" + micromatch "^4.0.2" + "@changesets/errors@^0.1.4": version "0.1.4" resolved "https://registry.npmjs.org/@changesets/errors/-/errors-0.1.4.tgz#f79851746c43679a66b383fdff4c012f480f480d" @@ -2752,6 +2778,17 @@ fs-extra "^7.0.1" semver "^5.4.1" +"@changesets/get-dependents-graph@^1.2.0": + version "1.2.0" + resolved "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-1.2.0.tgz#6986927a5fec60bc6fcc76f966efae2ac30c05ed" + integrity sha512-96NInEKpEZH8KvmXyh42PynXVAdq3kQ9VjAeswHtJ3umUCeTF42b/KVXaov+5P1RNnaUVtRuEwzs4syGuowDTw== + dependencies: + "@changesets/types" "^3.3.0" + "@manypkg/get-packages" "^1.0.1" + chalk "^2.1.0" + fs-extra "^7.0.1" + semver "^5.4.1" + "@changesets/get-release-plan@^2.0.1": version "2.0.1" resolved "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-2.0.1.tgz#b95d8f1a3cc719ff4b42b9b9aae72458d8787c13" @@ -2770,7 +2807,7 @@ resolved "https://registry.npmjs.org/@changesets/get-version-range-type/-/get-version-range-type-0.3.2.tgz#8131a99035edd11aa7a44c341cbb05e668618c67" integrity sha512-SVqwYs5pULYjYT4op21F2pVbcrca4qA/bAA3FmFXKMN7Y+HcO8sbZUTx3TAy2VXulP2FACd1aC7f2nTuqSPbqg== -"@changesets/git@^1.0.5", "@changesets/git@^1.0.6": +"@changesets/git@^1.0.5": version "1.0.6" resolved "https://registry.npmjs.org/@changesets/git/-/git-1.0.6.tgz#057e627e5d3fcb74bf6c18d7284e130ba5a7632e" integrity sha512-e0M06XuME3W5lGhz+CO0vLc60u+hLk/pYjOx/6GXEWuQrwtGgeycFIfRgRt8qTs664o1oKtVHBbd7ItpoWgFfA== @@ -2782,6 +2819,18 @@ is-subdir "^1.1.1" spawndamnit "^2.0.0" +"@changesets/git@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@changesets/git/-/git-1.1.0.tgz#ecaa8058ac87b450c09da0e74b68e6854cd0742c" + integrity sha512-f/2rQynT+JiAL/V0V/GQdXhLkcb86ELg3UwH3fQO4wVdfUbE9NHIHq9ohJdH1Ymh0Lv48F/b38aWZ5v2sKiF3w== + dependencies: + "@babel/runtime" "^7.10.4" + "@changesets/errors" "^0.1.4" + "@changesets/types" "^3.1.1" + "@manypkg/get-packages" "^1.0.1" + is-subdir "^1.1.1" + spawndamnit "^2.0.0" + "@changesets/logger@^0.0.5": version "0.0.5" resolved "https://registry.npmjs.org/@changesets/logger/-/logger-0.0.5.tgz#68305dd5a643e336be16a2369cb17cdd8ed37d4c" @@ -2827,6 +2876,11 @@ resolved "https://registry.npmjs.org/@changesets/types/-/types-3.2.0.tgz#d8306d7219c3b19b6d860ddeb9d7374e2dd6b035" integrity sha512-rAmPtOyXpisEEE25CchKNUAf2ApyAeuZ/h78YDoqKZaCk5tUD0lgYZGPIRV9WTPoqNjJULIym37ogc6pkax5jg== +"@changesets/types@^3.3.0": + version "3.3.0" + resolved "https://registry.npmjs.org/@changesets/types/-/types-3.3.0.tgz#04cd8184b2d2da760667bd89bf9b930938dbd96e" + integrity sha512-rJamRo+OD/MQekImfIk07JZwYSB18iU6fYL8xOg0gfAiTh1a1+OlR1fPIxm55I7RsWw812is2YcPPwXdIewrhA== + "@changesets/write@^0.1.3": version "0.1.3" resolved "https://registry.npmjs.org/@changesets/write/-/write-0.1.3.tgz#00ae575af50274773d7493e77fb96838a08ad8ad" @@ -21292,7 +21346,7 @@ prepend-http@^2.0.0: resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= -prettier@^1.18.2: +prettier@^1.18.2, prettier@^1.19.1: version "1.19.1" resolved "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== From 5fa3bdb5537fa20f4c6948715eee11b923fc19e6 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Tue, 19 Jan 2021 17:04:06 +0100 Subject: [PATCH 265/297] Add support for links in ItemCard and fix height of cards with tags vs cards without tags --- .changeset/four-shrimps-flash.md | 7 ++++ .../src/layout/ItemCard/ItemCard.stories.tsx | 35 +++++++++++++++++-- .../core/src/layout/ItemCard/ItemCard.tsx | 26 +++++++++++--- .../src/reader/components/TechDocsHome.tsx | 17 ++++----- 4 files changed, 67 insertions(+), 18 deletions(-) create mode 100644 .changeset/four-shrimps-flash.md diff --git a/.changeset/four-shrimps-flash.md b/.changeset/four-shrimps-flash.md new file mode 100644 index 0000000000..fb155bafd7 --- /dev/null +++ b/.changeset/four-shrimps-flash.md @@ -0,0 +1,7 @@ +--- +'@backstage/core': patch +'@backstage/plugin-techdocs': patch +--- + +Add `href` in addition to `onClick` to `ItemCard`. Ensure that the height of a +`ItemCard` with and without tags is equal. diff --git a/packages/core/src/layout/ItemCard/ItemCard.stories.tsx b/packages/core/src/layout/ItemCard/ItemCard.stories.tsx index 75031a0765..6e534f6f5c 100644 --- a/packages/core/src/layout/ItemCard/ItemCard.stories.tsx +++ b/packages/core/src/layout/ItemCard/ItemCard.stories.tsx @@ -13,9 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { ItemCard } from '.'; import { Grid } from '@material-ui/core'; +import React from 'react'; +import { MemoryRouter } from 'react-router'; +import { ItemCard } from '.'; export default { title: 'Layout/Item Card', @@ -63,5 +64,35 @@ export const Tags = () => ( label="Button" /> + + + ); + +export const Link = () => ( + + + + + + + + + + +); diff --git a/packages/core/src/layout/ItemCard/ItemCard.tsx b/packages/core/src/layout/ItemCard/ItemCard.tsx index 991a05708f..3b0727b64f 100644 --- a/packages/core/src/layout/ItemCard/ItemCard.tsx +++ b/packages/core/src/layout/ItemCard/ItemCard.tsx @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { Button, Card, Chip, makeStyles, Typography } from '@material-ui/core'; import React from 'react'; -import { Button, Card, Chip, Typography, makeStyles } from '@material-ui/core'; +import { Link } from '../../components'; const useStyles = makeStyles(theme => ({ header: { @@ -30,6 +31,9 @@ const useStyles = makeStyles(theme => ({ overflow: 'hidden', textOverflow: 'ellipsis', }, + withTags: { + height: 'calc(175px - 32px - 8px)', + }, footer: { display: 'flex', flexDirection: 'row-reverse', @@ -43,7 +47,9 @@ type ItemCardProps = { type?: string; label: string; onClick?: () => void; + href?: string; }; + export const ItemCard = ({ description, tags, @@ -51,6 +57,7 @@ export const ItemCard = ({ type, label, onClick, + href, }: ItemCardProps) => { const classes = useStyles(); @@ -64,13 +71,22 @@ export const ItemCard = ({ {tags?.map((tag, i) => ( ))} - + 0 ? classes.withTags : '' + }`} + > {description}
- + {!href && ( + + )} + {href && {label}}
diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.tsx index 2d8b5c7dcc..f064c35517 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.tsx @@ -25,13 +25,12 @@ import { import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { Grid } from '@material-ui/core'; import React from 'react'; -import { generatePath, useNavigate } from 'react-router-dom'; +import { generatePath } from 'react-router-dom'; import { useAsync } from 'react-use'; import { rootDocsRouteRef } from '../../plugin'; export const TechDocsHome = () => { const catalogApi = useApi(catalogApiRef); - const navigate = useNavigate(); const { value, loading, error } = useAsync(async () => { const response = await catalogApi.getEntities(); @@ -80,15 +79,11 @@ export const TechDocsHome = () => { ? value.map((entity, index: number) => ( - navigate( - generatePath(rootDocsRouteRef.path, { - namespace: entity.metadata.namespace ?? 'default', - kind: entity.kind, - name: entity.metadata.name, - }), - ) - } + href={generatePath(rootDocsRouteRef.path, { + namespace: entity.metadata.namespace ?? 'default', + kind: entity.kind, + name: entity.metadata.name, + })} title={entity.metadata.name} label="Read Docs" description={entity.metadata.description} From 9cdd8f888896937878d26df01a6912c2f58ab265 Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Tue, 19 Jan 2021 18:22:38 +0100 Subject: [PATCH 266/297] Add ecosystem explorer --- packages/app/src/components/Root/Root.tsx | 2 + packages/app/src/plugins.ts | 1 + plugins/ecosystem/.eslintrc.js | 3 + plugins/ecosystem/README.md | 24 ++++++ plugins/ecosystem/dev/index.tsx | 58 +++++++++++++ plugins/ecosystem/docs/domains.png | Bin 0 -> 791113 bytes plugins/ecosystem/package.json | 50 +++++++++++ .../components/DomainCard/DomainCard.test.tsx | 49 +++++++++++ .../src/components/DomainCard/DomainCard.tsx | 38 +++++++++ .../src/components/DomainCard/index.ts | 16 ++++ .../DomainExplorer/DomainExplorer.tsx | 33 ++++++++ .../src/components/DomainExplorer/index.ts | 16 ++++ .../DomainExplorerPage/DomainExplorerPage.tsx | 78 ++++++++++++++++++ .../components/DomainExplorerPage/index.ts | 16 ++++ plugins/ecosystem/src/index.ts | 16 ++++ plugins/ecosystem/src/plugin.test.ts | 22 +++++ plugins/ecosystem/src/plugin.ts | 29 +++++++ plugins/ecosystem/src/setupTests.ts | 17 ++++ 18 files changed, 468 insertions(+) create mode 100644 plugins/ecosystem/.eslintrc.js create mode 100644 plugins/ecosystem/README.md create mode 100644 plugins/ecosystem/dev/index.tsx create mode 100644 plugins/ecosystem/docs/domains.png create mode 100644 plugins/ecosystem/package.json create mode 100644 plugins/ecosystem/src/components/DomainCard/DomainCard.test.tsx create mode 100644 plugins/ecosystem/src/components/DomainCard/DomainCard.tsx create mode 100644 plugins/ecosystem/src/components/DomainCard/index.ts create mode 100644 plugins/ecosystem/src/components/DomainExplorer/DomainExplorer.tsx create mode 100644 plugins/ecosystem/src/components/DomainExplorer/index.ts create mode 100644 plugins/ecosystem/src/components/DomainExplorerPage/DomainExplorerPage.tsx create mode 100644 plugins/ecosystem/src/components/DomainExplorerPage/index.ts create mode 100644 plugins/ecosystem/src/index.ts create mode 100644 plugins/ecosystem/src/plugin.test.ts create mode 100644 plugins/ecosystem/src/plugin.ts create mode 100644 plugins/ecosystem/src/setupTests.ts diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 52dd397418..29211dc42a 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -20,6 +20,7 @@ import HomeIcon from '@material-ui/icons/Home'; import ExtensionIcon from '@material-ui/icons/Extension'; import RuleIcon from '@material-ui/icons/AssignmentTurnedIn'; import MapIcon from '@material-ui/icons/MyLocation'; +import LayersIcon from '@material-ui/icons/Layers'; import LibraryBooks from '@material-ui/icons/LibraryBooks'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; import MoneyIcon from '@material-ui/icons/MonetizationOn'; @@ -86,6 +87,7 @@ const Root = ({ children }: PropsWithChildren<{}>) => ( {/* End global nav */} + +``` diff --git a/plugins/ecosystem/dev/index.tsx b/plugins/ecosystem/dev/index.tsx new file mode 100644 index 0000000000..30a071862b --- /dev/null +++ b/plugins/ecosystem/dev/index.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2021 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 { Entity } from '@backstage/catalog-model'; +import { createDevApp } from '@backstage/dev-utils'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog'; +import { plugin } from '../src/plugin'; + +createDevApp() + .registerPlugin(plugin) + .registerApi({ + api: catalogApiRef, + deps: {}, + factory: () => + ({ + async getEntities() { + const domainNames = [ + 'playback', + 'artists', + 'payments', + 'analytics', + 'songs', + 'devops', + ]; + + return { + items: domainNames.map( + (n, i) => + ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Domain', + metadata: { + name: n, + description: `Everything about ${n}`, + tags: i % 2 === 0 ? [n] : undefined, + }, + spec: { + owner: `${n}@example.com`, + }, + } as Entity), + ), + }; + }, + } as CatalogApi), + }) + .render(); diff --git a/plugins/ecosystem/docs/domains.png b/plugins/ecosystem/docs/domains.png new file mode 100644 index 0000000000000000000000000000000000000000..70f3be02d63719dc22a83a4e81b823dc85025eac GIT binary patch literal 791113 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

I!Hdk@EW`lT& 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 z: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 z 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

EB4$M1NfsdP!L? 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�gS(+HHVlSOYQbvLwMF*teiXu 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

vCbj;3EdaxfXCOVI`7O>OBELq+RP2tDG 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

- ); -}; diff --git a/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.test.tsx b/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.test.tsx index dc177265cd..2916fefbcf 100644 --- a/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.test.tsx +++ b/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.test.tsx @@ -17,8 +17,8 @@ import { wrapInTestApp } from '@backstage/test-utils'; import { render } from '@testing-library/react'; import * as React from 'react'; -import { RollbarTopItemsTable } from './RollbarTopItemsTable'; import { RollbarTopActiveItem } from '../../api/types'; +import { RollbarTopItemsTable } from './RollbarTopItemsTable'; const items: RollbarTopActiveItem[] = [ { diff --git a/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.tsx b/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.tsx index a04155a0c3..c22f813dd9 100644 --- a/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.tsx +++ b/plugins/rollbar/src/components/RollbarTopItemsTable/RollbarTopItemsTable.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import React from 'react'; import { Table, TableColumn } from '@backstage/core'; import { Box, Link, Typography } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; +import React from 'react'; import { RollbarFrameworkId, RollbarLevel, diff --git a/plugins/rollbar/src/components/Router.tsx b/plugins/rollbar/src/components/Router.tsx index f0756cb02a..6d793d7426 100644 --- a/plugins/rollbar/src/components/Router.tsx +++ b/plugins/rollbar/src/components/Router.tsx @@ -14,29 +14,36 @@ * limitations under the License. */ -import React from 'react'; -import { Routes, Route } from 'react-router'; import { Entity } from '@backstage/catalog-model'; import { MissingAnnotationEmptyState } from '@backstage/core'; -import { catalogRouteRef } from '../routes'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { Route, Routes } from 'react-router'; import { ROLLBAR_ANNOTATION } from '../constants'; +import { rootRouteRef } from '../plugin'; import { EntityPageRollbar } from './EntityPageRollbar/EntityPageRollbar'; export const isPluginApplicableToEntity = (entity: Entity) => Boolean(entity.metadata.annotations?.[ROLLBAR_ANNOTATION]); type Props = { + /** @deprecated The entity is now grabbed from context instead */ entity: Entity; }; -export const Router = ({ entity }: Props) => - !isPluginApplicableToEntity(entity) ? ( - - ) : ( +export const Router = (_props: Props) => { + const { entity } = useEntity(); + + if (!isPluginApplicableToEntity(entity)) { + ; + } + + return ( } /> ); +}; diff --git a/plugins/rollbar/src/components/TrendGraph/TrendGraph.test.tsx b/plugins/rollbar/src/components/TrendGraph/TrendGraph.test.tsx index ac1a328521..e944a53551 100644 --- a/plugins/rollbar/src/components/TrendGraph/TrendGraph.test.tsx +++ b/plugins/rollbar/src/components/TrendGraph/TrendGraph.test.tsx @@ -14,9 +14,9 @@ * limitations under the License. */ -import React from 'react'; -import { render } from '@testing-library/react'; import { wrapInTestApp } from '@backstage/test-utils'; +import { render } from '@testing-library/react'; +import React from 'react'; import { TrendGraph } from './TrendGraph'; describe('TrendGraph component', () => { diff --git a/plugins/rollbar/src/index.ts b/plugins/rollbar/src/index.ts index 2a31c91a13..52c0c35fd7 100644 --- a/plugins/rollbar/src/index.ts +++ b/plugins/rollbar/src/index.ts @@ -14,10 +14,12 @@ * limitations under the License. */ -export { plugin } from './plugin'; export * from './api'; -export * from './routes'; -export { Router } from './components/Router'; -export { RollbarProjectPage } from './components/RollbarProjectPage/RollbarProjectPage'; export { EntityPageRollbar } from './components/EntityPageRollbar/EntityPageRollbar'; +export { + isPluginApplicableToEntity, + isPluginApplicableToEntity as isRollbarAvailable, + Router, +} from './components/Router'; export { ROLLBAR_ANNOTATION } from './constants'; +export { rollbarPlugin as plugin, rollbarPlugin } from './plugin'; diff --git a/plugins/rollbar/src/plugin.test.ts b/plugins/rollbar/src/plugin.test.ts index 44c2168c78..7ef23b019a 100644 --- a/plugins/rollbar/src/plugin.test.ts +++ b/plugins/rollbar/src/plugin.test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { plugin } from './plugin'; +import { rollbarPlugin } from './plugin'; describe('rollbar', () => { it('should export plugin', () => { - expect(plugin).toBeDefined(); + expect(rollbarPlugin).toBeDefined(); }); }); diff --git a/plugins/rollbar/src/plugin.ts b/plugins/rollbar/src/plugin.ts index c3ca6173ff..216713b3fc 100644 --- a/plugins/rollbar/src/plugin.ts +++ b/plugins/rollbar/src/plugin.ts @@ -15,17 +15,21 @@ */ import { - createPlugin, createApiFactory, + createPlugin, + createRoutableExtension, + createRouteRef, discoveryApiRef, } from '@backstage/core'; -import { rootRouteRef, entityRouteRef } from './routes'; -import { RollbarHome } from './components/RollbarHome/RollbarHome'; -import { RollbarProjectPage } from './components/RollbarProjectPage/RollbarProjectPage'; import { rollbarApiRef } from './api/RollbarApi'; import { RollbarClient } from './api/RollbarClient'; -export const plugin = createPlugin({ +export const rootRouteRef = createRouteRef({ + path: '', + title: 'Rollbar', +}); + +export const rollbarPlugin = createPlugin({ id: 'rollbar', apis: [ createApiFactory({ @@ -34,8 +38,14 @@ export const plugin = createPlugin({ factory: ({ discoveryApi }) => new RollbarClient({ discoveryApi }), }), ], - register({ router }) { - router.addRoute(rootRouteRef, RollbarHome); - router.addRoute(entityRouteRef, RollbarProjectPage); + routes: { + entityContent: rootRouteRef, }, }); + +export const EntityRollbarContent = rollbarPlugin.provide( + createRoutableExtension({ + component: () => import('./components/Router').then(m => m.Router), + mountPoint: rootRouteRef, + }), +); diff --git a/plugins/rollbar/src/routes.ts b/plugins/rollbar/src/routes.ts deleted file mode 100644 index b514dff3c8..0000000000 --- a/plugins/rollbar/src/routes.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createRouteRef } from '@backstage/core'; - -const NoIcon = () => null; - -export const rootRouteRef = createRouteRef({ - icon: NoIcon, - path: '/rollbar', - title: 'Rollbar Home', -}); - -export const entityRouteRef = createRouteRef({ - path: '/rollbar/:optionalNamespaceAndName', - title: 'Rollbar', -}); - -export const catalogRouteRef = createRouteRef({ - icon: NoIcon, - path: '', - title: 'Rollbar', -}); From e934428a1c8723039167dc64c09ac1e8ba5b89ae Mon Sep 17 00:00:00 2001 From: Andrew Thauer <6507159+andrewthauer@users.noreply.github.com> Date: Wed, 3 Feb 2021 10:54:44 -0500 Subject: [PATCH 289/297] make rollbar props optional & export extension point --- .../src/components/EntityPageRollbar/EntityPageRollbar.tsx | 2 +- plugins/rollbar/src/components/Router.tsx | 2 +- plugins/rollbar/src/index.ts | 6 +++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/rollbar/src/components/EntityPageRollbar/EntityPageRollbar.tsx b/plugins/rollbar/src/components/EntityPageRollbar/EntityPageRollbar.tsx index b30874865f..07a39bd147 100644 --- a/plugins/rollbar/src/components/EntityPageRollbar/EntityPageRollbar.tsx +++ b/plugins/rollbar/src/components/EntityPageRollbar/EntityPageRollbar.tsx @@ -21,7 +21,7 @@ import { RollbarProject } from '../RollbarProject/RollbarProject'; type Props = { /** @deprecated The entity is now grabbed from context instead */ - entity: Entity; + entity?: Entity; }; export const EntityPageRollbar = (_props: Props) => { diff --git a/plugins/rollbar/src/components/Router.tsx b/plugins/rollbar/src/components/Router.tsx index 6d793d7426..7b95a4ec7c 100644 --- a/plugins/rollbar/src/components/Router.tsx +++ b/plugins/rollbar/src/components/Router.tsx @@ -28,7 +28,7 @@ export const isPluginApplicableToEntity = (entity: Entity) => type Props = { /** @deprecated The entity is now grabbed from context instead */ - entity: Entity; + entity?: Entity; }; export const Router = (_props: Props) => { diff --git a/plugins/rollbar/src/index.ts b/plugins/rollbar/src/index.ts index 52c0c35fd7..def2d99658 100644 --- a/plugins/rollbar/src/index.ts +++ b/plugins/rollbar/src/index.ts @@ -22,4 +22,8 @@ export { Router, } from './components/Router'; export { ROLLBAR_ANNOTATION } from './constants'; -export { rollbarPlugin as plugin, rollbarPlugin } from './plugin'; +export { + EntityRollbarContent, + rollbarPlugin as plugin, + rollbarPlugin, +} from './plugin'; From 0ec1cfe23dae7601f2703b52c8610fbd1452818d Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Wed, 3 Feb 2021 12:36:45 -0700 Subject: [PATCH 290/297] Fix incorrect dates from UTC abuse --- .../cost-insights/src/utils/change.test.ts | 6 +++--- plugins/cost-insights/src/utils/change.ts | 6 +----- plugins/cost-insights/src/utils/duration.ts | 19 ++++++------------- plugins/cost-insights/src/utils/formatters.ts | 7 ++----- plugins/cost-insights/src/utils/mockData.ts | 7 ++++--- 5 files changed, 16 insertions(+), 29 deletions(-) diff --git a/plugins/cost-insights/src/utils/change.test.ts b/plugins/cost-insights/src/utils/change.test.ts index 7cc03caa0b..9516dfe270 100644 --- a/plugins/cost-insights/src/utils/change.test.ts +++ b/plugins/cost-insights/src/utils/change.test.ts @@ -76,13 +76,13 @@ describe('getPreviousPeriodTotalCost', () => { change: changeOf(MockAggregatedDailyCosts), trendline: trendlineOf(MockAggregatedDailyCosts), }; - const exclusiveEndDate = '2020-09-30'; + const inclusiveEndDate = '2020-09-30'; expect( getPreviousPeriodTotalCost( mockGroupDailyCost.aggregation, Duration.P30D, - exclusiveEndDate, + inclusiveEndDate, ), - ).toEqual(100_000); + ).toEqual(96_600); }); }); diff --git a/plugins/cost-insights/src/utils/change.ts b/plugins/cost-insights/src/utils/change.ts index fc50439aad..92057a560e 100644 --- a/plugins/cost-insights/src/utils/change.ts +++ b/plugins/cost-insights/src/utils/change.ts @@ -22,7 +22,6 @@ import { GrowthType, MetricData, Duration, - DEFAULT_DATE_FORMAT, DateAggregation, } from '../types'; import dayjs, { OpUnitType } from 'dayjs'; @@ -73,10 +72,7 @@ export function getPreviousPeriodTotalCost( inclusiveEndDate: string, ): number { const dayjsDuration = dayjs.duration(duration); - const startDate = inclusiveStartDateOf( - duration, - dayjs(inclusiveEndDate).add(1, 'day').format(DEFAULT_DATE_FORMAT), - ); + const startDate = inclusiveStartDateOf(duration, inclusiveEndDate); // dayjs doesn't allow adding an ISO 8601 period to dates. const [amount, type]: [number, OpUnitType] = dayjsDuration.days() ? [dayjsDuration.days(), 'day'] diff --git a/plugins/cost-insights/src/utils/duration.ts b/plugins/cost-insights/src/utils/duration.ts index 79ea150fc5..27447537cc 100644 --- a/plugins/cost-insights/src/utils/duration.ts +++ b/plugins/cost-insights/src/utils/duration.ts @@ -24,23 +24,21 @@ export const DEFAULT_DURATION = Duration.P30D; * Derive the start date of a given period, assuming two repeating intervals. * * @param duration see comment on Duration enum - * @param exclusiveEndDate from CostInsightsApi.getLastCompleteBillingDate + 1 day + * @param inclusiveEndDate from CostInsightsApi.getLastCompleteBillingDate */ export function inclusiveStartDateOf( duration: Duration, - exclusiveEndDate: string, + inclusiveEndDate: string, ): string { switch (duration) { case Duration.P7D: case Duration.P30D: case Duration.P90D: - return moment(exclusiveEndDate) - .utc() + return moment(inclusiveEndDate) .subtract(moment.duration(duration).add(moment.duration(duration))) .format(DEFAULT_DATE_FORMAT); case Duration.P3M: - return moment(exclusiveEndDate) - .utc() + return moment(inclusiveEndDate) .startOf('quarter') .subtract(moment.duration(duration).add(moment.duration(duration))) .format(DEFAULT_DATE_FORMAT); @@ -57,13 +55,9 @@ export function exclusiveEndDateOf( case Duration.P7D: case Duration.P30D: case Duration.P90D: - return moment(inclusiveEndDate) - .utc() - .add(1, 'day') - .format(DEFAULT_DATE_FORMAT); + return moment(inclusiveEndDate).add(1, 'day').format(DEFAULT_DATE_FORMAT); case Duration.P3M: return moment(quarterEndDate(inclusiveEndDate)) - .utc() .add(1, 'day') .format(DEFAULT_DATE_FORMAT); default: @@ -76,7 +70,6 @@ export function inclusiveEndDateOf( inclusiveEndDate: string, ): string { return moment(exclusiveEndDateOf(duration, inclusiveEndDate)) - .utc() .subtract(1, 'day') .format(DEFAULT_DATE_FORMAT); } @@ -94,7 +87,7 @@ export function intervalsOf( } export function quarterEndDate(inclusiveEndDate: string): string { - const endDate = moment(inclusiveEndDate).utc(); + const endDate = moment(inclusiveEndDate); const endOfQuarter = endDate.endOf('quarter').format(DEFAULT_DATE_FORMAT); if (endOfQuarter === inclusiveEndDate) { return endDate.format(DEFAULT_DATE_FORMAT); diff --git a/plugins/cost-insights/src/utils/formatters.ts b/plugins/cost-insights/src/utils/formatters.ts index 182c567644..8f2b94f997 100644 --- a/plugins/cost-insights/src/utils/formatters.ts +++ b/plugins/cost-insights/src/utils/formatters.ts @@ -16,7 +16,7 @@ import moment from 'moment'; import pluralize from 'pluralize'; -import { Duration, DEFAULT_DATE_FORMAT } from '../types'; +import { Duration } from '../types'; import { inclusiveEndDateOf, inclusiveStartDateOf } from '../utils/duration'; export type Period = { @@ -92,11 +92,8 @@ export function formatPercent(n: number): string { } export function formatLastTwoLookaheadQuarters(inclusiveEndDate: string) { - const exclusiveEndDate = moment(inclusiveEndDate) - .add(1, 'day') - .format(DEFAULT_DATE_FORMAT); const start = moment( - inclusiveStartDateOf(Duration.P3M, exclusiveEndDate), + inclusiveStartDateOf(Duration.P3M, inclusiveEndDate), ).format('[Q]Q YYYY'); const end = moment(inclusiveEndDateOf(Duration.P3M, inclusiveEndDate)).format( '[Q]Q YYYY', diff --git a/plugins/cost-insights/src/utils/mockData.ts b/plugins/cost-insights/src/utils/mockData.ts index 14ff9aecd1..d4ac4cd2df 100644 --- a/plugins/cost-insights/src/utils/mockData.ts +++ b/plugins/cost-insights/src/utils/mockData.ts @@ -36,7 +36,7 @@ import { getDefaultState as getDefaultLoadingState, } from '../utils/loading'; import { findAlways } from '../utils/assert'; -import { inclusiveStartDateOf } from './duration'; +import { inclusiveEndDateOf, inclusiveStartDateOf } from './duration'; type mockAlertRenderer = (alert: T) => T; type mockEntityRenderer = (entity: T) => T; @@ -228,8 +228,9 @@ export function aggregationFor( baseline: number, ): DateAggregation[] { const { duration, endDate } = parseIntervals(intervals); + const inclusiveEndDate = inclusiveEndDateOf(duration, endDate); const days = dayjs(endDate).diff( - inclusiveStartDateOf(duration, endDate), + inclusiveStartDateOf(duration, inclusiveEndDate), 'day', ); @@ -244,7 +245,7 @@ export function aggregationFor( return [...Array(days).keys()].reduce( (values: DateAggregation[], i: number): DateAggregation[] => { const last = values.length ? values[values.length - 1].amount : baseline; - const date = dayjs(inclusiveStartDateOf(duration, endDate)) + const date = dayjs(inclusiveStartDateOf(duration, inclusiveEndDate)) .add(i, 'day') .format(DEFAULT_DATE_FORMAT); const amount = Math.max(0, last + nextDelta()); From 4c6a6dddddf1a5bd0768c2c1f30933e803e43ae9 Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Wed, 3 Feb 2021 13:30:57 -0700 Subject: [PATCH 291/297] changeset --- .changeset/cost-insights-wise-moons-crash.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cost-insights-wise-moons-crash.md diff --git a/.changeset/cost-insights-wise-moons-crash.md b/.changeset/cost-insights-wise-moons-crash.md new file mode 100644 index 0000000000..1277911059 --- /dev/null +++ b/.changeset/cost-insights-wise-moons-crash.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': patch +--- + +Fixed date calculations incorrectly converting to UTC in some cases. This should be a transparent change. From 11cb5ef94eb53c485893336a4690d39a185b2164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 3 Feb 2021 11:40:25 +0100 Subject: [PATCH 292/297] catalog-model: implement matchEntityWithRef for client side filtering --- .changeset/wise-lies-listen.md | 5 + packages/catalog-model/src/entity/index.ts | 1 + packages/catalog-model/src/entity/ref.test.ts | 323 +++++++++++++++++- packages/catalog-model/src/entity/ref.ts | 85 ++++- 4 files changed, 403 insertions(+), 11 deletions(-) create mode 100644 .changeset/wise-lies-listen.md diff --git a/.changeset/wise-lies-listen.md b/.changeset/wise-lies-listen.md new file mode 100644 index 0000000000..53f1edea02 --- /dev/null +++ b/.changeset/wise-lies-listen.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': patch +--- + +Implement matchEntityWithRef for client side filtering of entities by ref matching diff --git a/packages/catalog-model/src/entity/index.ts b/packages/catalog-model/src/entity/index.ts index 4afb8f5c96..e80e14f7a4 100644 --- a/packages/catalog-model/src/entity/index.ts +++ b/packages/catalog-model/src/entity/index.ts @@ -27,6 +27,7 @@ export type { } from './Entity'; export * from './policies'; export { + compareEntityToRef, getEntityName, parseEntityName, parseEntityRef, diff --git a/packages/catalog-model/src/entity/ref.test.ts b/packages/catalog-model/src/entity/ref.test.ts index bd7cc9477d..5ca511ee03 100644 --- a/packages/catalog-model/src/entity/ref.test.ts +++ b/packages/catalog-model/src/entity/ref.test.ts @@ -16,7 +16,12 @@ import { ENTITY_DEFAULT_NAMESPACE } from './constants'; import { Entity } from './Entity'; -import { parseEntityName, parseEntityRef, serializeEntityRef } from './ref'; +import { + compareEntityToRef, + parseEntityName, + parseEntityRef, + serializeEntityRef, +} from './ref'; describe('ref', () => { describe('parseEntityName', () => { @@ -381,4 +386,320 @@ describe('ref', () => { ).toEqual({ kind: 'a', namespace: 'b', name: 'c/x' }); }); }); + + describe('compareEntityToRef', () => { + const entityWithNamespace: Entity = { + apiVersion: 'a', + kind: 'K', + metadata: { + name: 'n', + namespace: 'ns', + }, + }; + const entityWithoutNamespace: Entity = { + apiVersion: 'a', + kind: 'K', + metadata: { + name: 'n', + }, + }; + + it('handles matching string refs', () => { + expect(compareEntityToRef(entityWithNamespace, 'K:ns/n')).toBe(true); + expect(compareEntityToRef(entityWithNamespace, 'k:nS/N')).toBe(true); + expect( + compareEntityToRef(entityWithNamespace, 'K:n', { + defaultNamespace: 'ns', + }), + ).toBe(true); + expect( + compareEntityToRef(entityWithNamespace, 'K:n', { + defaultNamespace: 'Ns', + }), + ).toBe(true); + expect( + compareEntityToRef(entityWithNamespace, 'ns/n', { defaultKind: 'K' }), + ).toBe(true); + expect( + compareEntityToRef(entityWithNamespace, 'n', { + defaultKind: 'K', + defaultNamespace: 'ns', + }), + ).toBe(true); + expect( + compareEntityToRef(entityWithNamespace, 'N', { + defaultKind: 'k', + defaultNamespace: 'nS', + }), + ).toBe(true); + + expect(compareEntityToRef(entityWithoutNamespace, 'K:default/n')).toBe( + true, + ); + expect(compareEntityToRef(entityWithoutNamespace, 'K:deFault/n')).toBe( + true, + ); + expect( + compareEntityToRef(entityWithoutNamespace, 'K:n', { + defaultNamespace: 'default', + }), + ).toBe(true); + expect( + compareEntityToRef(entityWithoutNamespace, 'K:n', { + defaultNamespace: 'deFault', + }), + ).toBe(true); + expect(compareEntityToRef(entityWithoutNamespace, 'K:default/n')).toBe( + true, + ); + expect(compareEntityToRef(entityWithoutNamespace, 'K:n')).toBe(true); + expect( + compareEntityToRef(entityWithoutNamespace, 'default/n', { + defaultKind: 'K', + }), + ).toBe(true); + expect( + compareEntityToRef(entityWithoutNamespace, 'n', { + defaultKind: 'K', + defaultNamespace: 'default', + }), + ).toBe(true); + expect( + compareEntityToRef(entityWithoutNamespace, 'n', { + defaultKind: 'K', + }), + ).toBe(true); + }); + + it('handles mismatching string refs', () => { + expect(compareEntityToRef(entityWithNamespace, 'X:ns/n')).toBe(false); + expect( + compareEntityToRef(entityWithoutNamespace, 'ns/n', { + defaultKind: 'X', + }), + ).toBe(false); + + expect(compareEntityToRef(entityWithNamespace, 'K:xx/n')).toBe(false); + expect( + compareEntityToRef(entityWithoutNamespace, 'K:n', { + defaultNamespace: 'xx', + }), + ).toBe(false); + + expect(compareEntityToRef(entityWithNamespace, 'K:ns/x')).toBe(false); + expect( + compareEntityToRef(entityWithoutNamespace, 'x', { + defaultKind: 'K', + defaultNamespace: 'ns', + }), + ).toBe(false); + }); + + it('handles matching compound refs', () => { + expect( + compareEntityToRef(entityWithNamespace, { + kind: 'K', + namespace: 'ns', + name: 'n', + }), + ).toBe(true); + expect( + compareEntityToRef(entityWithNamespace, { + kind: 'k', + namespace: 'Ns', + name: 'N', + }), + ).toBe(true); + expect( + compareEntityToRef( + entityWithNamespace, + { kind: 'K', name: 'n' }, + { + defaultNamespace: 'ns', + }, + ), + ).toBe(true); + expect( + compareEntityToRef( + entityWithNamespace, + { namespace: 'ns', name: 'n' }, + { defaultKind: 'K' }, + ), + ).toBe(true); + expect( + compareEntityToRef(entityWithNamespace, 'n', { + defaultKind: 'K', + defaultNamespace: 'ns', + }), + ).toBe(true); + expect( + compareEntityToRef(entityWithNamespace, 'N', { + defaultKind: 'k', + defaultNamespace: 'nS', + }), + ).toBe(true); + + expect( + compareEntityToRef(entityWithoutNamespace, { + kind: 'K', + namespace: 'default', + name: 'n', + }), + ).toBe(true); + expect( + compareEntityToRef(entityWithoutNamespace, { + kind: 'k', + namespace: 'deFault', + name: 'N', + }), + ).toBe(true); + expect( + compareEntityToRef( + entityWithoutNamespace, + { kind: 'K', name: 'n' }, + { + defaultNamespace: 'default', + }, + ), + ).toBe(true); + expect( + compareEntityToRef(entityWithoutNamespace, { kind: 'K', name: 'n' }), + ).toBe(true); + expect( + compareEntityToRef( + entityWithoutNamespace, + { namespace: 'default', name: 'n' }, + { + defaultKind: 'K', + }, + ), + ).toBe(true); + expect( + compareEntityToRef( + entityWithoutNamespace, + { name: 'n' }, + { + defaultKind: 'K', + defaultNamespace: 'default', + }, + ), + ).toBe(true); + expect( + compareEntityToRef( + entityWithoutNamespace, + { name: 'N' }, + { + defaultKind: 'k', + defaultNamespace: 'defAult', + }, + ), + ).toBe(true); + expect( + compareEntityToRef( + entityWithoutNamespace, + { name: 'n' }, + { + defaultKind: 'K', + }, + ), + ).toBe(true); + }); + + it('handles mismatching compound refs', () => { + expect( + compareEntityToRef(entityWithNamespace, { + kind: 'X', + namespace: 'ns', + name: 'n', + }), + ).toBe(false); + expect( + compareEntityToRef( + entityWithNamespace, + { + namespace: 'ns', + name: 'n', + }, + { defaultKind: 'X' }, + ), + ).toBe(false); + expect( + compareEntityToRef(entityWithoutNamespace, { + kind: 'X', + namespace: 'default', + name: 'n', + }), + ).toBe(false); + expect( + compareEntityToRef( + entityWithoutNamespace, + { + namespace: 'default', + name: 'n', + }, + { defaultKind: 'X' }, + ), + ).toBe(false); + + expect( + compareEntityToRef(entityWithNamespace, { + kind: 'K', + namespace: 'xx', + name: 'n', + }), + ).toBe(false); + expect( + compareEntityToRef( + entityWithNamespace, + { + kind: 'K', + name: 'n', + }, + { defaultNamespace: 'xx' }, + ), + ).toBe(false); + expect( + compareEntityToRef(entityWithoutNamespace, { + kind: 'K', + namespace: 'xx', + name: 'n', + }), + ).toBe(false); + expect( + compareEntityToRef( + entityWithoutNamespace, + { + kind: 'K', + name: 'n', + }, + { defaultNamespace: 'xx' }, + ), + ).toBe(false); + + expect( + compareEntityToRef(entityWithNamespace, { + kind: 'K', + namespace: 'ns', + name: 'x', + }), + ).toBe(false); + expect( + compareEntityToRef(entityWithoutNamespace, { + kind: 'K', + namespace: 'default', + name: 'x', + }), + ).toBe(false); + expect( + compareEntityToRef( + entityWithoutNamespace, + { + kind: 'K', + name: 'x', + }, + { defaultNamespace: 'default' }, + ), + ).toBe(false); + }); + }); }); diff --git a/packages/catalog-model/src/entity/ref.ts b/packages/catalog-model/src/entity/ref.ts index 9f6f175d8d..bf34962d01 100644 --- a/packages/catalog-model/src/entity/ref.ts +++ b/packages/catalog-model/src/entity/ref.ts @@ -18,6 +18,27 @@ import { EntityName, EntityRef } from '../types'; import { ENTITY_DEFAULT_NAMESPACE } from './constants'; import { Entity } from './Entity'; +function parseRefString( + ref: string, +): { + kind?: string; + namespace?: string; + name: string; +} { + const match = /^([^:/]+:)?([^:/]+\/)?([^:/]+)$/.exec(ref.trim()); + if (!match) { + throw new TypeError( + `Entity reference "${ref}" was not on the form [:][/]`, + ); + } + + return { + kind: match[1]?.slice(0, -1), + namespace: match[2]?.slice(0, -1), + name: match[3], + }; +} + /** * Extracts the kind, namespace and name that form the name triplet of the * given entity. @@ -121,17 +142,11 @@ export function parseEntityRef( } if (typeof ref === 'string') { - const match = /^([^:/]+:)?([^:/]+\/)?([^:/]+)$/.exec(ref.trim()); - if (!match) { - throw new Error( - `Entity reference "${ref}" was not on the form [:][/]`, - ); - } - + const parsed = parseRefString(ref); return { - kind: match[1]?.slice(0, -1) ?? context.defaultKind, - namespace: match[2]?.slice(0, -1) ?? context.defaultNamespace, - name: match[3], + kind: parsed.kind ?? context.defaultKind, + namespace: parsed.namespace ?? context.defaultNamespace, + name: parsed.name, }; } @@ -196,3 +211,53 @@ export function serializeEntityRef( return `${kind ? `${kind}:` : ''}${namespace ? `${namespace}/` : ''}${name}`; } + +/** + * Compares an entity to either a string reference or a compound reference. + * + * The comparison is case insensitive, and all of kind, namespace, and name + * must match (after applying the optional context to the ref). + * + * @param entity The entity to match + * @param ref A string or compound entity ref + * @param context An optional context of default kind and namespace, that apply + * to the ref if given + * @returns True if matching, false otherwise + */ +export function compareEntityToRef( + entity: Entity, + ref: EntityRef | EntityName, + context?: EntityRefContext, +): boolean { + const entityKind = entity.kind; + const entityNamespace = entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE; + const entityName = entity.metadata.name; + + let refKind: string | undefined; + let refNamespace: string | undefined; + let refName: string; + if (typeof ref === 'string') { + const parsed = parseRefString(ref); + refKind = parsed.kind || context?.defaultKind; + refNamespace = + parsed.namespace || context?.defaultNamespace || ENTITY_DEFAULT_NAMESPACE; + refName = parsed.name; + } else { + refKind = ref.kind || context?.defaultKind; + refNamespace = + ref.namespace || context?.defaultNamespace || ENTITY_DEFAULT_NAMESPACE; + refName = ref.name; + } + + if (!refKind || !refNamespace) { + throw new Error( + `Entity reference or context did not contain kind and namespace`, + ); + } + + return ( + entityKind.toLowerCase() === refKind.toLowerCase() && + entityNamespace.toLowerCase() === refNamespace.toLowerCase() && + entityName.toLowerCase() === refName.toLowerCase() + ); +} From 99076c25e17b9b230f5e13e1db769233dbd0c81b Mon Sep 17 00:00:00 2001 From: "Chongyang Adrian, Ke" Date: Thu, 4 Feb 2021 11:11:05 +0800 Subject: [PATCH 293/297] integration: fix gitlab spaces encoding --- packages/integration/src/gitlab/core.test.ts | 12 ++++++------ packages/integration/src/gitlab/core.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/integration/src/gitlab/core.test.ts b/packages/integration/src/gitlab/core.test.ts index 43fea72e0b..2b4713604c 100644 --- a/packages/integration/src/gitlab/core.test.ts +++ b/packages/integration/src/gitlab/core.test.ts @@ -49,23 +49,23 @@ describe('gitlab core', () => { { config: configWithNoToken, url: - 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path%20with%20spaces/to/file.yaml', result: - 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%20with%20spaces%2Fto%2Ffile.yaml/raw?ref=branch', }, { config: configWithToken, url: - 'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + 'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path%20with%20spaces/to/file.yaml', result: - 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%20with%20spaces%2Fto%2Ffile.yaml/raw?ref=branch', }, { config: configWithNoToken, url: - 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/to/file.yaml', // Repo not in subgroup + 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path%20with%20spaces/to/file.yaml', // Repo not in subgroup result: - 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%20with%20spaces%2Fto%2Ffile.yaml/raw?ref=branch', }, // Raw URLs { diff --git a/packages/integration/src/gitlab/core.ts b/packages/integration/src/gitlab/core.ts index c1d43c3a46..66b6733f56 100644 --- a/packages/integration/src/gitlab/core.ts +++ b/packages/integration/src/gitlab/core.ts @@ -109,7 +109,7 @@ export function buildProjectUrl(target: string, projectID: Number): URL { '/api/v4/projects', projectID, 'repository/files', - encodeURIComponent(filePath.join('/')), + encodeURIComponent(decodeURIComponent(filePath.join('/'))), 'raw', ].join('/'); url.search = `?ref=${branch}`; From c4abcdb609908faa0a4640ce5387d3da73bdfcb6 Mon Sep 17 00:00:00 2001 From: "Chongyang Adrian, Ke" Date: Thu, 4 Feb 2021 11:11:05 +0800 Subject: [PATCH 294/297] integration: fix gitlab spaces encoding --- .changeset/dry-kings-hear.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dry-kings-hear.md diff --git a/.changeset/dry-kings-hear.md b/.changeset/dry-kings-hear.md new file mode 100644 index 0000000000..c877785329 --- /dev/null +++ b/.changeset/dry-kings-hear.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Fix gitlab handling of paths with spaces From a2709a51ee0b1333465bb36b8154458c2370f829 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Feb 2021 05:03:04 +0000 Subject: [PATCH 295/297] chore(deps-dev): bump @types/zen-observable from 0.8.0 to 0.8.2 Bumps [@types/zen-observable](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/zen-observable) from 0.8.0 to 0.8.2. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/zen-observable) Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index b7502a9740..206d1f2705 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7740,9 +7740,9 @@ integrity sha512-MBSp62AjB1KrSOI3gX9GekddXU5YYQAVA93+aSl78biBqoSzxg876aQY2KJK5Gnfbpqq7O2cadVX5kPAtBqIXw== "@types/zen-observable@^0.8.0": - version "0.8.0" - resolved "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.0.tgz#8b63ab7f1aa5321248aad5ac890a485656dcea4d" - integrity sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg== + version "0.8.2" + resolved "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.2.tgz#808c9fa7e4517274ed555fa158f2de4b4f468e71" + integrity sha512-HrCIVMLjE1MOozVoD86622S7aunluLb2PJdPfb3nYiEtohm8mIB/vyv0Fd37AdeMFrTUQXEunw78YloMA3Qilg== "@typescript-eslint/eslint-plugin@^v4.14.0": version "4.14.0" From 19fe61c27a20e43fb82587c5f5176b9fd577372e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 4 Feb 2021 10:47:56 +0100 Subject: [PATCH 296/297] provide a changeset for the eslint update --- .changeset/dry-kings-hear.md | 2 +- .changeset/nervous-pens-destroy.md | 33 ++++++++++++++++++++++++++++++ .github/workflows/e2e.yml | 1 + 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 .changeset/nervous-pens-destroy.md diff --git a/.changeset/dry-kings-hear.md b/.changeset/dry-kings-hear.md index c877785329..06ac1ef877 100644 --- a/.changeset/dry-kings-hear.md +++ b/.changeset/dry-kings-hear.md @@ -2,4 +2,4 @@ '@backstage/integration': patch --- -Fix gitlab handling of paths with spaces +Fix GitLab handling of paths with spaces diff --git a/.changeset/nervous-pens-destroy.md b/.changeset/nervous-pens-destroy.md new file mode 100644 index 0000000000..da8ebc162d --- /dev/null +++ b/.changeset/nervous-pens-destroy.md @@ -0,0 +1,33 @@ +--- +'@backstage/cli': minor +--- + +We have updated the default `eslint` rules in the `@backstage/cli` package. + +```diff +-'@typescript-eslint/no-shadow': 'off', +-'@typescript-eslint/no-redeclare': 'off', ++'no-shadow': 'off', ++'no-redeclare': 'off', ++'@typescript-eslint/no-shadow': 'error', ++'@typescript-eslint/no-redeclare': 'error', +``` + +The rules are documented [here](https://eslint.org/docs/rules/no-shadow) and [here](https://eslint.org/docs/rules/no-redeclare). + +This involved a large number of small changes to the code base. When you compile your own code using the CLI, you may also be +affected. We consider these rules important, and the primary recommendation is to try to update your code according to the +documentation above. But those that prefer to not enable the rules, or need time to perform the updates, may update their +local `.eslintrc.js` file(s) in the repo root and/or in individual plugins as they see fit: + +```js +module.exports = { + // ... other declarations + rules: { + '@typescript-eslint/no-shadow': 'off', + '@typescript-eslint/no-redeclare': 'off', + }, +}; +``` + +Because of the nature of this change, we're unable to provide a grace period for the update :( diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 62be0a6cea..0fe1642394 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -3,6 +3,7 @@ name: E2E Test Linux on: pull_request: paths-ignore: + - '.changeset/**' - 'contrib/**' - 'docs/**' - 'microsite/**' From ea155ca8c9811315507a5dad2829dadab2ed15d5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 4 Feb 2021 12:35:43 +0100 Subject: [PATCH 297/297] yarn.lock: sync changeset versions --- yarn.lock | 67 ++++++------------------------------------------------- 1 file changed, 7 insertions(+), 60 deletions(-) diff --git a/yarn.lock b/yarn.lock index 206d1f2705..482ce1f737 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2674,19 +2674,7 @@ resolve-from "^5.0.0" semver "^5.4.1" -"@changesets/assemble-release-plan@^4.0.0": - version "4.0.0" - resolved "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-4.0.0.tgz#60c2392c0e2c99f24778ab3a5c8e8c80ddaaaa59" - integrity sha512-3Kv21FNvysTQvZs3fHr6aZeDibhZHtgI1++fMZplzVtwNVmpjow3zv9lcZmJP26LthbpVH3I8+nqlU7M43lfWA== - dependencies: - "@babel/runtime" "^7.10.4" - "@changesets/errors" "^0.1.4" - "@changesets/get-dependents-graph" "^1.1.3" - "@changesets/types" "^3.1.0" - "@manypkg/get-packages" "^1.0.1" - semver "^5.4.1" - -"@changesets/assemble-release-plan@^4.1.0": +"@changesets/assemble-release-plan@^4.0.0", "@changesets/assemble-release-plan@^4.1.0": version "4.1.0" resolved "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-4.1.0.tgz#091e5e768dfe51835937e71d1ebaca1c9d6de55b" integrity sha512-dMqe2L5Pn4UGTW89kOuuCuZD3pQFZj1Sxv92ZW4S98sXGsxcb2PdW+PeHbQ7tawkCYCOvzhXxAlN4OdF2DlDKQ== @@ -2734,20 +2722,7 @@ term-size "^2.1.0" tty-table "^2.8.10" -"@changesets/config@^1.2.0": - version "1.4.0" - resolved "https://registry.npmjs.org/@changesets/config/-/config-1.4.0.tgz#c157a4121f198b749f2bbc2e9015b6e976ece7d6" - integrity sha512-eoTOcJ6py7jBDY8rUXwEGxR5UtvUX+p//0NhkVpPGcnvIeITHq+DOIsuWyGzWcb+1FaYkof3CCr32/komZTu4Q== - dependencies: - "@changesets/errors" "^0.1.4" - "@changesets/get-dependents-graph" "^1.1.3" - "@changesets/logger" "^0.0.5" - "@changesets/types" "^3.2.0" - "@manypkg/get-packages" "^1.0.1" - fs-extra "^7.0.1" - micromatch "^4.0.2" - -"@changesets/config@^1.5.0": +"@changesets/config@^1.2.0", "@changesets/config@^1.5.0": version "1.5.0" resolved "https://registry.npmjs.org/@changesets/config/-/config-1.5.0.tgz#6d58b01e45916318d3f39e9cde86a5d69dc58ed8" integrity sha512-Bl9nLVYcwFCpd9jpzcOsExZk1NuTYX20D2YWHCdS1xll3W0yOdSUlWLUCCfugN1l3+yTR6iDW6q9o6vpCevWvA== @@ -2767,17 +2742,6 @@ dependencies: extendable-error "^0.1.5" -"@changesets/get-dependents-graph@^1.1.3": - version "1.1.3" - resolved "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-1.1.3.tgz#da959c43ce98f3a990a6b8d9c1f894bcc1b629c7" - integrity sha512-cTbySXwSv9yWp4Pp5R/b5Qv23wJgFaFCqUbsI3IJ2pyPl0vMaODAZS8NI1nNK2XSxGIg1tw+dWNSR4PlrKBSVQ== - dependencies: - "@changesets/types" "^3.0.0" - "@manypkg/get-packages" "^1.0.1" - chalk "^2.1.0" - fs-extra "^7.0.1" - semver "^5.4.1" - "@changesets/get-dependents-graph@^1.2.0": version "1.2.0" resolved "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-1.2.0.tgz#6986927a5fec60bc6fcc76f966efae2ac30c05ed" @@ -2807,19 +2771,7 @@ resolved "https://registry.npmjs.org/@changesets/get-version-range-type/-/get-version-range-type-0.3.2.tgz#8131a99035edd11aa7a44c341cbb05e668618c67" integrity sha512-SVqwYs5pULYjYT4op21F2pVbcrca4qA/bAA3FmFXKMN7Y+HcO8sbZUTx3TAy2VXulP2FACd1aC7f2nTuqSPbqg== -"@changesets/git@^1.0.5": - version "1.0.6" - resolved "https://registry.npmjs.org/@changesets/git/-/git-1.0.6.tgz#057e627e5d3fcb74bf6c18d7284e130ba5a7632e" - integrity sha512-e0M06XuME3W5lGhz+CO0vLc60u+hLk/pYjOx/6GXEWuQrwtGgeycFIfRgRt8qTs664o1oKtVHBbd7ItpoWgFfA== - dependencies: - "@babel/runtime" "^7.10.4" - "@changesets/errors" "^0.1.4" - "@changesets/types" "^3.1.1" - "@manypkg/get-packages" "^1.0.1" - is-subdir "^1.1.1" - spawndamnit "^2.0.0" - -"@changesets/git@^1.1.0": +"@changesets/git@^1.0.5", "@changesets/git@^1.1.0": version "1.1.0" resolved "https://registry.npmjs.org/@changesets/git/-/git-1.1.0.tgz#ecaa8058ac87b450c09da0e74b68e6854cd0742c" integrity sha512-f/2rQynT+JiAL/V0V/GQdXhLkcb86ELg3UwH3fQO4wVdfUbE9NHIHq9ohJdH1Ymh0Lv48F/b38aWZ5v2sKiF3w== @@ -2839,9 +2791,9 @@ chalk "^2.1.0" "@changesets/parse@^0.3.6": - version "0.3.6" - resolved "https://registry.npmjs.org/@changesets/parse/-/parse-0.3.6.tgz#8c2c8480fc07d2db2c37469d4a8df10906a989c6" - integrity sha512-0XPd/es9CfogI7XIqDr7I2mWzm++xX2s9GZsij3GajPYd7ouEsgJyNatPooxNtqj6ZepkiD6uqlqbeBUyj/A0Q== + version "0.3.7" + resolved "https://registry.npmjs.org/@changesets/parse/-/parse-0.3.7.tgz#1368136e2b83d5cff11b4d383a3032723530db99" + integrity sha512-8yqKulslq/7V2VRBsJqPgjnZMoehYqhJm5lEOXJPZ2rcuSdyj8+p/2vq2vRDBJT2m0rP+C9G8DujsGYQIFZezw== dependencies: "@changesets/types" "^3.0.0" js-yaml "^3.13.1" @@ -2871,12 +2823,7 @@ fs-extra "^7.0.1" p-filter "^2.1.0" -"@changesets/types@^3.0.0", "@changesets/types@^3.1.0", "@changesets/types@^3.1.1", "@changesets/types@^3.2.0": - version "3.2.0" - resolved "https://registry.npmjs.org/@changesets/types/-/types-3.2.0.tgz#d8306d7219c3b19b6d860ddeb9d7374e2dd6b035" - integrity sha512-rAmPtOyXpisEEE25CchKNUAf2ApyAeuZ/h78YDoqKZaCk5tUD0lgYZGPIRV9WTPoqNjJULIym37ogc6pkax5jg== - -"@changesets/types@^3.3.0": +"@changesets/types@^3.0.0", "@changesets/types@^3.1.0", "@changesets/types@^3.1.1", "@changesets/types@^3.3.0": version "3.3.0" resolved "https://registry.npmjs.org/@changesets/types/-/types-3.3.0.tgz#04cd8184b2d2da760667bd89bf9b930938dbd96e" integrity sha512-rJamRo+OD/MQekImfIk07JZwYSB18iU6fYL8xOg0gfAiTh1a1+OlR1fPIxm55I7RsWw812is2YcPPwXdIewrhA==