Merge branch 'master' into mob/scaffolder-frontend

This commit is contained in:
Johan Haals
2021-02-15 13:19:06 +01:00
78 changed files with 1515 additions and 378 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
pass registered logger to requestLoggingHandler
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/integration': minor
---
Make `ScmIntegration.resolveUrl` mandatory.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Make the `TemplateCard` conform to what material-ui recommends in their examples. This fixes the extra padding around the buttons.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog': patch
---
Hide the kind of the owner if it's the default kind for the `ownedBy`
relationship (group).
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/backend-common': patch
---
Implement `UrlReader.search` for the other providers (Azure, Bitbucket, GitLab) as well.
The `UrlReader` subclasses now are implemented in terms of the respective `Integration` class.
+19
View File
@@ -0,0 +1,19 @@
---
'@backstage/core-api': patch
'@backstage/plugin-catalog': patch
---
Minor refactoring of BackstageApp.getSystemIcons to support custom registered
icons. Custom Icons can be added using:
```tsx
import AlarmIcon from '@material-ui/icons/Alarm';
import MyPersonIcon from './MyPerson';
const app = createApp({
icons: {
user: MyPersonIcon // override system icon
alert: AlarmIcon, // Custom icon
},
});
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Remove the "Move repository" menu entry from the catalog page, as it's just a placeholder.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/core-api': patch
'@backstage/core': patch
---
Export `createExternalRouteRef`, as well as give it an `id` for easier debugging, and fix parameter requirements when used with `useRouteRef`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-user-settings': patch
---
Use routed tabs to link to every settings page.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Tweak error message in lockfile parsing to include more information.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Fixed parsing of OIDC key timestamps when using SQLite.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Migrate about card to new composability API, exporting the entity cards as `EntityAboutCard`.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Fixed the `prepare` step for when using local templates that were added to the catalog using the `file:` target configuration.
No more `EPERM: operation not permitted` error messages.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core': patch
---
Export Select component
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Fix parsing of the path to default to empty string not undefined if git-url-parse throws something we don't expect. Fixes the error `The "path" argument must be of type string.` when preparing.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core': patch
---
Add Breadcrumbs component
+1
View File
@@ -119,6 +119,7 @@ Kumar
learnings
lerna
Lerna
lockfile
Luxon
magiclink
mailto
+9
View File
@@ -4,6 +4,15 @@ metadata:
name: backstage
description: |
Backstage is an open-source developer portal that puts the developer experience first.
links:
- title: Website
url: http://backstage.io
- title: Documentation
url: https://backstage.io/docs
- title: Storybook
url: https://backstage.io/storybook
- title: Discord Chat
url: https://discord.com/invite/EBHEGzX
annotations:
github.com/project-slug: backstage/backstage
backstage.io/techdocs-ref: url:https://github.com/backstage/backstage
@@ -205,7 +205,8 @@ described below.
In addition to these, you may add any number of other fields directly under
`metadata`, but be aware that general plugins and tools may not be able to
understand their semantics.
understand their semantics. See [Extending the model](extending-the-model.md)
for more information.
### `name` [required]
@@ -214,8 +215,8 @@ entity, and for machines and other components to reference the entity (e.g. in
URLs or from other entity specification files).
Names must be unique per kind, within a given namespace (if specified), at any
point in time. Names may be reused at a later time, after an entity is deleted
from the registry.
point in time. This uniqueness constraint is case insensitive. Names may be
reused at a later time, after an entity is deleted from the registry.
Names are required to follow a certain format. Entities that do not follow those
rules will not be accepted for registration in the catalog. The ruleset is
@@ -226,19 +227,7 @@ follows.
- Must consist of sequences of `[a-z0-9A-Z]` possibly separated by one of
`[-_.]`
Example: `visits-tracking-service`, `CircleciBuildsDump_avro_gcs`
In addition to this, names are passed through a normalization function and then
compared to the same normalized form of other entity names and made sure to not
collide. This rule of uniqueness exists to avoid situations where e.g. both
`my-component` and `MyComponent` are registered side by side, which leads to
confusion and risk. The normalization function is also configurable, but the
default behavior is as follows.
- Strip out all characters outside of the set `[a-zA-Z0-9]`
- Convert to lowercase
Example: `CircleciBuildsDs_avro_gcs` -> `circlecibuildsdsavrogcs`
Example: `visits-tracking-service`, `CircleciBuildsDumpV2_avro_gcs`
### `namespace` [optional]
@@ -248,7 +237,8 @@ the same format restrictions as `name` above.
This field is optional, and currently has no special semantics apart from
bounding the name uniqueness constraint if specified. It is reserved for future
use and may get broader semantic implication later. For now, it is recommended
to not specify a namespace unless you have specific need to do so.
to not specify a namespace unless you have specific need to do so. This means
the entity belongs to the `"default"` namespace.
Namespaces may also be part of the catalog, and are `v1` / `Namespace` entities,
i.e. not Backstage specific but the same as in Kubernetes.
@@ -278,7 +268,6 @@ most 253 characters in total. The name part must be sequences of `[a-zA-Z0-9]`
separated by any of `[-_.]`, at most 63 characters in total.
The `backstage.io/` prefix is reserved for use by Backstage core components.
Some keys such as `system` also have predefined semantics.
Values are strings that follow the same restrictions as `name` above.
+5
View File
@@ -9,6 +9,11 @@ The Backstage Glossary lists all the terms, abbreviations, and phrases used in
Backstage, together with their explanations. We encourage you to use the
terminology below for clarity and consistency when discussing Backstage.
### Authentication Glossary
This [page](./auth/glossary.md) directs to the terms and phrases related to
authentication and identity section of Backstage.
### Backstage User Profiles
There are three main user profiles for Backstage: the integrator, the
+5
View File
@@ -48,10 +48,15 @@ import { EntityPage } from './components/catalog/EntityPage';
import Root from './components/Root';
import { providers } from './identityProviders';
import * as plugins from './plugins';
import AlarmIcon from '@material-ui/icons/Alarm';
const app = createApp({
apis,
plugins: Object.values(plugins),
icons: {
// Custom icon example
alert: AlarmIcon,
},
components: {
SignInPage: props => {
return (
@@ -14,18 +14,22 @@
* limitations under the License.
*/
import * as os from 'os';
import { ConfigReader } from '@backstage/config';
import {
AzureIntegration,
readAzureIntegrationConfig,
} from '@backstage/integration';
import { msw } from '@backstage/test-utils';
import fs from 'fs-extra';
import mockFs from 'mock-fs';
import path from 'path';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { ConfigReader } from '@backstage/config';
import * as os from 'os';
import path from 'path';
import { NotModifiedError } from '../errors';
import { getVoidLogger } from '../logging';
import { AzureUrlReader } from './AzureUrlReader';
import { msw } from '@backstage/test-utils';
import { ReadTreeResponseFactory } from './tree';
import { NotModifiedError } from '../errors';
const logger = getVoidLogger();
@@ -36,6 +40,16 @@ const treeResponseFactory = ReadTreeResponseFactory.create({
const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
describe('AzureUrlReader', () => {
beforeEach(() => {
mockFs({
[tmpDir]: mockFs.directory(),
});
});
afterEach(() => {
mockFs.restore();
});
const worker = setupServer();
msw.setupDefaultHandlers(worker);
@@ -143,22 +157,18 @@ describe('AzureUrlReader', () => {
});
describe('readTree', () => {
beforeEach(() => {
mockFs({
[tmpDir]: mockFs.directory(),
});
});
afterEach(() => {
mockFs.restore();
});
const repoBuffer = fs.readFileSync(
path.resolve('src', 'reading', '__fixtures__', 'mock-main.zip'),
);
const processor = new AzureUrlReader(
{ host: 'dev.azure.com' },
new AzureIntegration(
readAzureIntegrationConfig(
new ConfigReader({
host: 'dev.azure.com',
}),
),
),
{ treeResponseFactory },
);
@@ -257,4 +267,79 @@ describe('AzureUrlReader', () => {
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
});
describe('search', () => {
const repoBuffer = fs.readFileSync(
path.resolve('src', 'reading', '__fixtures__', 'mock-main.zip'),
);
const processor = new AzureUrlReader(
new AzureIntegration(
readAzureIntegrationConfig(
new ConfigReader({
host: 'dev.azure.com',
}),
),
),
{ treeResponseFactory },
);
beforeEach(() => {
worker.use(
rest.get(
'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.body(repoBuffer),
),
),
rest.get(
// https://docs.microsoft.com/en-us/rest/api/azure/devops/git/commits/get%20commits?view=azure-devops-rest-6.0#on-a-branch
'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/commits',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
count: 2,
value: [
{
commitId: '123abc2',
comment: 'second commit',
},
{
commitId: '123abc1',
comment: 'first commit',
},
],
}),
),
),
);
});
it('works for the naive case', async () => {
const result = await processor.search(
'https://dev.azure.com/org-name/project-name/_git/repo-name?path=%2F**%2Findex.*&version=GBmaster',
);
expect(result.etag).toBe('123abc2');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://dev.azure.com/org-name/project-name/_git/repo-name?path=%2Fdocs%2Findex.md&version=GBmaster',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('throws NotModifiedError when same etag', async () => {
await expect(
processor.search(
'https://dev.azure.com/org-name/project-name/_git/repo-name?path=**/index.*&version=GBmaster',
{ etag: '123abc2' },
),
).rejects.toThrow(NotModifiedError);
});
});
});
@@ -15,39 +15,41 @@
*/
import {
AzureIntegrationConfig,
readAzureIntegrationConfigs,
getAzureFileFetchUrl,
getAzureDownloadUrl,
getAzureRequestOptions,
AzureIntegration,
getAzureCommitsUrl,
getAzureDownloadUrl,
getAzureFileFetchUrl,
getAzureRequestOptions,
ScmIntegrations,
} from '@backstage/integration';
import fetch from 'cross-fetch';
import parseGitUrl from 'git-url-parse';
import { Minimatch } from 'minimatch';
import { Readable } from 'stream';
import { NotFoundError, NotModifiedError } from '../errors';
import { ReadTreeResponseFactory } from './tree';
import { stripFirstDirectoryFromPath } from './tree/util';
import {
ReaderFactory,
ReadTreeOptions,
ReadTreeResponse,
SearchOptions,
SearchResponse,
UrlReader,
} from './types';
import { ReadTreeResponseFactory } from './tree';
export class AzureUrlReader implements UrlReader {
static factory: ReaderFactory = ({ config, treeResponseFactory }) => {
const configs = readAzureIntegrationConfigs(
config.getOptionalConfigArray('integrations.azure') ?? [],
);
return configs.map(options => {
const reader = new AzureUrlReader(options, { treeResponseFactory });
const predicate = (url: URL) => url.host === options.host;
const integrations = ScmIntegrations.fromConfig(config);
return integrations.azure.list().map(integration => {
const reader = new AzureUrlReader(integration, { treeResponseFactory });
const predicate = (url: URL) => url.host === integration.config.host;
return { reader, predicate };
});
};
constructor(
private readonly options: AzureIntegrationConfig,
private readonly integration: AzureIntegration,
private readonly deps: { treeResponseFactory: ReadTreeResponseFactory },
) {}
@@ -56,7 +58,10 @@ export class AzureUrlReader implements UrlReader {
let response: Response;
try {
response = await fetch(builtUrl, getAzureRequestOptions(this.options));
response = await fetch(
builtUrl,
getAzureRequestOptions(this.integration.config),
);
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
}
@@ -83,7 +88,7 @@ export class AzureUrlReader implements UrlReader {
const commitsAzureResponse = await fetch(
getAzureCommitsUrl(url),
getAzureRequestOptions(this.options),
getAzureRequestOptions(this.integration.config),
);
if (!commitsAzureResponse.ok) {
const message = `Failed to read tree from ${url}, ${commitsAzureResponse.status} ${commitsAzureResponse.statusText}`;
@@ -100,7 +105,9 @@ export class AzureUrlReader implements UrlReader {
const archiveAzureResponse = await fetch(
getAzureDownloadUrl(url),
getAzureRequestOptions(this.options, { Accept: 'application/zip' }),
getAzureRequestOptions(this.integration.config, {
Accept: 'application/zip',
}),
);
if (!archiveAzureResponse.ok) {
const message = `Failed to read tree from ${url}, ${archiveAzureResponse.status} ${archiveAzureResponse.statusText}`;
@@ -117,12 +124,38 @@ export class AzureUrlReader implements UrlReader {
});
}
async search(): Promise<SearchResponse> {
throw new Error('AzureUrlReader does not implement search');
async search(url: string, options?: SearchOptions): Promise<SearchResponse> {
const { filepath } = parseGitUrl(url);
const matcher = new Minimatch(filepath);
// TODO(freben): For now, read the entire repo and filter through that. In
// a future improvement, we could be smart and try to deduce that non-glob
// prefixes (like for filepaths such as some-prefix/**/a.yaml) can be used
// to get just that part of the repo.
const treeUrl = new URL(url);
treeUrl.searchParams.delete('path');
treeUrl.pathname = treeUrl.pathname.replace(/\/+$/, '');
const tree = await this.readTree(treeUrl.toString(), {
etag: options?.etag,
filter: path => matcher.match(stripFirstDirectoryFromPath(path)),
});
const files = await tree.files();
return {
etag: tree.etag,
files: files.map(file => ({
url: this.integration.resolveUrl({
url: `/${file.path}`,
base: url,
}),
content: file.content,
})),
};
}
toString() {
const { host, token } = this.options;
const { host, token } = this.integration.config;
return `azure{host=${host},authed=${Boolean(token)}}`;
}
}
@@ -15,11 +15,16 @@
*/
import { ConfigReader } from '@backstage/config';
import {
BitbucketIntegration,
readBitbucketIntegrationConfig,
} from '@backstage/integration';
import { msw } from '@backstage/test-utils';
import fs from 'fs-extra';
import mockFs from 'mock-fs';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import os from 'os';
import path from 'path';
import { NotModifiedError } from '../errors';
import { BitbucketUrlReader } from './BitbucketUrlReader';
@@ -30,19 +35,45 @@ const treeResponseFactory = ReadTreeResponseFactory.create({
});
const bitbucketProcessor = new BitbucketUrlReader(
{ host: 'bitbucket.org', apiBaseUrl: 'https://api.bitbucket.org/2.0' },
new BitbucketIntegration(
readBitbucketIntegrationConfig(
new ConfigReader({
host: 'bitbucket.org',
apiBaseUrl: 'https://api.bitbucket.org/2.0',
}),
),
),
{ treeResponseFactory },
);
const hostedBitbucketProcessor = new BitbucketUrlReader(
{
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
},
new BitbucketIntegration(
readBitbucketIntegrationConfig(
new ConfigReader({
host: 'bitbucket.mycompany.net',
apiBaseUrl: 'https://api.bitbucket.mycompany.net/rest/api/1.0',
}),
),
),
{ treeResponseFactory },
);
const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
describe('BitbucketUrlReader', () => {
beforeEach(() => {
mockFs({
[tmpDir]: mockFs.directory(),
});
});
afterEach(() => {
mockFs.restore();
});
const worker = setupServer();
msw.setupDefaultHandlers(worker);
describe('implementation', () => {
it('rejects unknown targets', async () => {
await expect(
@@ -54,19 +85,6 @@ describe('BitbucketUrlReader', () => {
});
describe('readTree', () => {
beforeEach(() => {
mockFs({
'/tmp': mockFs.directory(),
});
});
afterEach(() => {
mockFs.restore();
});
const worker = setupServer();
msw.setupDefaultHandlers(worker);
const repoBuffer = fs.readFileSync(
path.resolve(
'src',
@@ -247,12 +265,153 @@ describe('BitbucketUrlReader', () => {
expect(() => {
/* eslint-disable no-new */
new BitbucketUrlReader(
{
host: 'bitbucket.mycompany.net',
},
new BitbucketIntegration(
readBitbucketIntegrationConfig(
new ConfigReader({
host: 'bitbucket.mycompany.net',
}),
),
),
{ treeResponseFactory },
);
}).toThrowError('must configure an explicit apiBaseUrl');
});
});
describe('search hosted', () => {
const repoBuffer = fs.readFileSync(
path.resolve(
'src',
'reading',
'__fixtures__',
'bitbucket-repo-with-commit-hash.zip',
),
);
beforeEach(() => {
worker.use(
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
mainbranch: {
type: 'branch',
name: 'master',
},
}),
),
),
rest.get(
'https://bitbucket.org/backstage/mock/get/master.zip',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.set(
'content-disposition',
'attachment; filename=backstage-mock-12ab34cd56ef.zip',
),
ctx.body(repoBuffer),
),
),
rest.get(
'https://api.bitbucket.org/2.0/repositories/backstage/mock/commits/master',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ hash: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
);
});
it('works for the naive case', async () => {
const result = await bitbucketProcessor.search(
'https://bitbucket.org/backstage/mock/src/master/**/index.*',
);
expect(result.etag).toBe('12ab34cd56ef');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.org/backstage/mock/src/master/docs/index.md',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('throws NotModifiedError when same etag', async () => {
await expect(
bitbucketProcessor.search(
'https://bitbucket.org/backstage/mock/src/master/**/index.*',
{ etag: '12ab34cd56ef' },
),
).rejects.toThrow(NotModifiedError);
});
});
describe('search private', () => {
const privateBitbucketRepoBuffer = fs.readFileSync(
path.resolve(
'src',
'reading',
'__fixtures__',
'bitbucket-server-repo.zip',
),
);
beforeEach(() => {
worker.use(
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/archive?format=zip&prefix=mock&path=docs',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.set(
'content-disposition',
'attachment; filename=backstage-mock.zip',
),
ctx.body(privateBitbucketRepoBuffer),
),
),
rest.get(
'https://api.bitbucket.mycompany.net/rest/api/1.0/projects/backstage/repos/mock/commits',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
values: [{ id: '12ab34cd56ef78gh90ij12kl34mn56op78qr90st' }],
}),
),
),
);
});
it('works for the naive case', async () => {
const result = await hostedBitbucketProcessor.search(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/**/index.*?at=master',
);
expect(result.etag).toBe('12ab34cd56ef');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/docs/index.md?at=master',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('throws NotModifiedError when same etag', async () => {
await expect(
hostedBitbucketProcessor.search(
'https://bitbucket.mycompany.net/projects/backstage/repos/mock/browse/**/index.*?at=master',
{ etag: '12ab34cd56ef' },
),
).rejects.toThrow(NotModifiedError);
});
});
});
@@ -15,22 +15,25 @@
*/
import {
BitbucketIntegrationConfig,
BitbucketIntegration,
getBitbucketDefaultBranch,
getBitbucketDownloadUrl,
getBitbucketFileFetchUrl,
getBitbucketRequestOptions,
readBitbucketIntegrationConfigs,
ScmIntegrations,
} from '@backstage/integration';
import fetch from 'cross-fetch';
import parseGitUrl from 'git-url-parse';
import { Minimatch } from 'minimatch';
import { Readable } from 'stream';
import { NotFoundError, NotModifiedError } from '../errors';
import { ReadTreeResponseFactory } from './tree';
import { stripFirstDirectoryFromPath } from './tree/util';
import {
ReaderFactory,
ReadTreeOptions,
ReadTreeResponse,
SearchOptions,
SearchResponse,
UrlReader,
} from './types';
@@ -40,45 +43,43 @@ import {
* the one exposed by Bitbucket Cloud itself.
*/
export class BitbucketUrlReader implements UrlReader {
private readonly config: BitbucketIntegrationConfig;
private readonly treeResponseFactory: ReadTreeResponseFactory;
static factory: ReaderFactory = ({ config, treeResponseFactory }) => {
const configs = readBitbucketIntegrationConfigs(
config.getOptionalConfigArray('integrations.bitbucket') ?? [],
);
return configs.map(provider => {
const reader = new BitbucketUrlReader(provider, { treeResponseFactory });
const predicate = (url: URL) => url.host === provider.host;
const integrations = ScmIntegrations.fromConfig(config);
return integrations.bitbucket.list().map(integration => {
const reader = new BitbucketUrlReader(integration, {
treeResponseFactory,
});
const predicate = (url: URL) => url.host === integration.config.host;
return { reader, predicate };
});
};
constructor(
config: BitbucketIntegrationConfig,
deps: { treeResponseFactory: ReadTreeResponseFactory },
private readonly integration: BitbucketIntegration,
private readonly deps: { treeResponseFactory: ReadTreeResponseFactory },
) {
const { host, apiBaseUrl, token, username, appPassword } = config;
const {
host,
apiBaseUrl,
token,
username,
appPassword,
} = integration.config;
if (!apiBaseUrl) {
throw new Error(
`Bitbucket integration for '${host}' must configure an explicit apiBaseUrl`,
);
}
if (!token && username && !appPassword) {
} else if (!token && username && !appPassword) {
throw new Error(
`Bitbucket integration for '${host}' has configured a username but is missing a required appPassword.`,
);
}
this.config = config;
this.treeResponseFactory = deps.treeResponseFactory;
}
async read(url: string): Promise<Buffer> {
const bitbucketUrl = getBitbucketFileFetchUrl(url, this.config);
const options = getBitbucketRequestOptions(this.config);
const bitbucketUrl = getBitbucketFileFetchUrl(url, this.integration.config);
const options = getBitbucketRequestOptions(this.integration.config);
let response: Response;
try {
@@ -109,10 +110,13 @@ export class BitbucketUrlReader implements UrlReader {
throw new NotModifiedError();
}
const downloadUrl = await getBitbucketDownloadUrl(url, this.config);
const downloadUrl = await getBitbucketDownloadUrl(
url,
this.integration.config,
);
const archiveBitbucketResponse = await fetch(
downloadUrl,
getBitbucketRequestOptions(this.config),
getBitbucketRequestOptions(this.integration.config),
);
if (!archiveBitbucketResponse.ok) {
const message = `Failed to read tree from ${url}, ${archiveBitbucketResponse.status} ${archiveBitbucketResponse.statusText}`;
@@ -122,7 +126,7 @@ export class BitbucketUrlReader implements UrlReader {
throw new Error(message);
}
return await this.treeResponseFactory.fromZipArchive({
return await this.deps.treeResponseFactory.fromZipArchive({
stream: (archiveBitbucketResponse.body as unknown) as Readable,
subpath: filepath,
etag: lastCommitShortHash,
@@ -130,12 +134,36 @@ export class BitbucketUrlReader implements UrlReader {
});
}
async search(): Promise<SearchResponse> {
throw new Error('BitbucketUrlReader does not implement search');
async search(url: string, options?: SearchOptions): Promise<SearchResponse> {
const { filepath } = parseGitUrl(url);
const matcher = new Minimatch(filepath);
// TODO(freben): For now, read the entire repo and filter through that. In
// a future improvement, we could be smart and try to deduce that non-glob
// prefixes (like for filepaths such as some-prefix/**/a.yaml) can be used
// to get just that part of the repo.
const treeUrl = url.replace(filepath, '').replace(/\/+$/, '');
const tree = await this.readTree(treeUrl, {
etag: options?.etag,
filter: path => matcher.match(stripFirstDirectoryFromPath(path)),
});
const files = await tree.files();
return {
etag: tree.etag,
files: files.map(file => ({
url: this.integration.resolveUrl({
url: `/${file.path}`,
base: url,
}),
content: file.content,
})),
};
}
toString() {
const { host, token, username, appPassword } = this.config;
const { host, token, username, appPassword } = this.integration.config;
let authed = Boolean(token);
if (!authed) {
authed = Boolean(username && appPassword);
@@ -148,18 +176,18 @@ export class BitbucketUrlReader implements UrlReader {
let branch = ref;
if (!branch) {
branch = await getBitbucketDefaultBranch(url, this.config);
branch = await getBitbucketDefaultBranch(url, this.integration.config);
}
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`;
? `${this.integration.config.apiBaseUrl}/repositories/${project}/${repoName}/commits/${branch}`
: `${this.integration.config.apiBaseUrl}/projects/${project}/repos/${repoName}/commits`;
const commitsResponse = await fetch(
commitsApiUrl,
getBitbucketRequestOptions(this.config),
getBitbucketRequestOptions(this.integration.config),
);
if (!commitsResponse.ok) {
const message = `Failed to retrieve commits from ${commitsApiUrl}, ${commitsResponse.status} ${commitsResponse.statusText}`;
@@ -15,12 +15,17 @@
*/
import { ConfigReader } from '@backstage/config';
import { GithubCredentialsProvider } from '@backstage/integration';
import {
GithubCredentialsProvider,
GitHubIntegration,
readGitHubIntegrationConfig,
} from '@backstage/integration';
import { msw } from '@backstage/test-utils';
import fs from 'fs-extra';
import mockFs from 'mock-fs';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import os from 'os';
import path from 'path';
import { NotFoundError, NotModifiedError } from '../errors';
import {
@@ -41,26 +46,45 @@ const mockCredentialsProvider = ({
} as unknown) as GithubCredentialsProvider;
const githubProcessor = new GithubUrlReader(
{
host: 'github.com',
apiBaseUrl: 'https://api.github.com',
},
new GitHubIntegration(
readGitHubIntegrationConfig(
new ConfigReader({
host: 'github.com',
apiBaseUrl: 'https://api.github.com',
}),
),
),
{ treeResponseFactory, credentialsProvider: mockCredentialsProvider },
);
const gheProcessor = new GithubUrlReader(
{
host: 'ghe.github.com',
apiBaseUrl: 'https://ghe.github.com/api/v3',
},
new GitHubIntegration(
readGitHubIntegrationConfig(
new ConfigReader({
host: 'ghe.github.com',
apiBaseUrl: 'https://ghe.github.com/api/v3',
}),
),
),
{ treeResponseFactory, credentialsProvider: mockCredentialsProvider },
);
const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
describe('GithubUrlReader', () => {
const worker = setupServer();
msw.setupDefaultHandlers(worker);
beforeEach(() => {
mockFs({
[tmpDir]: mockFs.directory(),
});
});
afterEach(() => {
mockFs.restore();
});
beforeEach(() => {
jest.clearAllMocks();
});
@@ -94,7 +118,7 @@ describe('GithubUrlReader', () => {
worker.use(
rest.get(
'https://api.github.com/repos/backstage/mock/tree/contents/?ref=main',
'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/?ref=main',
(req, res, ctx) => {
expect(req.headers.get('authorization')).toBe(
mockHeaders.Authorization,
@@ -111,7 +135,7 @@ describe('GithubUrlReader', () => {
),
);
await githubProcessor.read(
await gheProcessor.read(
'https://github.com/backstage/mock/tree/blob/main',
);
});
@@ -122,16 +146,6 @@ describe('GithubUrlReader', () => {
*/
describe('readTree', () => {
beforeEach(() => {
mockFs({
'/tmp': mockFs.directory(),
});
});
afterEach(() => {
mockFs.restore();
});
const repoBuffer = fs.readFileSync(
path.resolve(
'src',
@@ -397,9 +411,13 @@ describe('GithubUrlReader', () => {
expect(() => {
/* eslint-disable no-new */
new GithubUrlReader(
{
host: 'ghe.mycompany.net',
},
new GitHubIntegration(
readGitHubIntegrationConfig(
new ConfigReader({
host: 'ghe.mycompany.net',
}),
),
),
{
treeResponseFactory,
credentialsProvider: mockCredentialsProvider,
@@ -414,14 +432,6 @@ describe('GithubUrlReader', () => {
*/
describe('search', () => {
beforeEach(() => {
mockFs({ '/tmp': mockFs.directory() });
});
afterEach(() => {
mockFs.restore();
});
const repoBuffer = fs.readFileSync(
path.resolve(
'src',
@@ -17,8 +17,8 @@
import {
getGitHubFileFetchUrl,
GithubCredentialsProvider,
GitHubIntegrationConfig,
readGitHubIntegrationConfigs,
GitHubIntegration,
ScmIntegrations,
} from '@backstage/integration';
import { RestEndpointMethodTypes } from '@octokit/rest';
import fetch from 'cross-fetch';
@@ -48,36 +48,36 @@ export type GhBlobResponse = RestEndpointMethodTypes['git']['getBlob']['response
*/
export class GithubUrlReader implements UrlReader {
static factory: ReaderFactory = ({ config, treeResponseFactory }) => {
const configs = readGitHubIntegrationConfigs(
config.getOptionalConfigArray('integrations.github') ?? [],
);
return configs.map(provider => {
const credentialsProvider = GithubCredentialsProvider.create(provider);
const reader = new GithubUrlReader(provider, {
const integrations = ScmIntegrations.fromConfig(config);
return integrations.github.list().map(integration => {
const credentialsProvider = GithubCredentialsProvider.create(
integration.config,
);
const reader = new GithubUrlReader(integration, {
treeResponseFactory,
credentialsProvider,
});
const predicate = (url: URL) => url.host === provider.host;
const predicate = (url: URL) => url.host === integration.config.host;
return { reader, predicate };
});
};
constructor(
private readonly config: GitHubIntegrationConfig,
private readonly integration: GitHubIntegration,
private readonly deps: {
treeResponseFactory: ReadTreeResponseFactory;
credentialsProvider: GithubCredentialsProvider;
},
) {
if (!config.apiBaseUrl && !config.rawBaseUrl) {
if (!integration.config.apiBaseUrl && !integration.config.rawBaseUrl) {
throw new Error(
`GitHub integration for '${config.host}' must configure an explicit apiBaseUrl and rawBaseUrl`,
`GitHub integration '${integration.title}' must configure an explicit apiBaseUrl or rawBaseUrl`,
);
}
}
async read(url: string): Promise<Buffer> {
const ghUrl = getGitHubFileFetchUrl(url, this.config);
const ghUrl = getGitHubFileFetchUrl(url, this.integration.config);
const { headers } = await this.deps.credentialsProvider.getCredentials({
url,
});
@@ -155,7 +155,7 @@ export class GithubUrlReader implements UrlReader {
}
toString() {
const { host, token } = this.config;
const { host, token } = this.integration.config;
return `github{host=${host},authed=${Boolean(token)}}`;
}
@@ -258,7 +258,7 @@ export class GithubUrlReader implements UrlReader {
});
const repo: GhRepoResponse = await this.fetchJson(
`${this.config.apiBaseUrl}/repos/${full_name}`,
`${this.integration.config.apiBaseUrl}/repos/${full_name}`,
{ headers },
);
@@ -20,11 +20,16 @@ import fs from 'fs-extra';
import mockFs from 'mock-fs';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import os from 'os';
import path from 'path';
import { getVoidLogger } from '../logging';
import { GitlabUrlReader } from './GitlabUrlReader';
import { ReadTreeResponseFactory } from './tree';
import { NotModifiedError, NotFoundError } from '../errors';
import {
GitLabIntegration,
readGitLabIntegrationConfig,
} from '@backstage/integration';
const logger = getVoidLogger();
@@ -33,24 +38,44 @@ const treeResponseFactory = ReadTreeResponseFactory.create({
});
const gitlabProcessor = new GitlabUrlReader(
{
host: 'gitlab.com',
apiBaseUrl: 'https://gitlab.com/api/v4',
baseUrl: 'https://gitlab.com',
},
new GitLabIntegration(
readGitLabIntegrationConfig(
new ConfigReader({
host: 'gitlab.com',
apiBaseUrl: 'https://gitlab.com/api/v4',
baseUrl: 'https://gitlab.com',
}),
),
),
{ treeResponseFactory },
);
const hostedGitlabProcessor = new GitlabUrlReader(
{
host: 'gitlab.mycompany.com',
apiBaseUrl: 'https://gitlab.mycompany.com/api/v4',
baseUrl: 'https://gitlab.mycompany.com',
},
new GitLabIntegration(
readGitLabIntegrationConfig(
new ConfigReader({
host: 'gitlab.mycompany.com',
apiBaseUrl: 'https://gitlab.mycompany.com/api/v4',
baseUrl: 'https://gitlab.mycompany.com',
}),
),
),
{ treeResponseFactory },
);
const tmpDir = os.platform() === 'win32' ? 'C:\\tmp' : '/tmp';
describe('GitlabUrlReader', () => {
beforeEach(() => {
mockFs({
[tmpDir]: mockFs.directory(),
});
});
afterEach(() => {
mockFs.restore();
});
const worker = setupServer();
msw.setupDefaultHandlers(worker);
@@ -156,16 +181,6 @@ describe('GitlabUrlReader', () => {
});
describe('readTree', () => {
beforeEach(() => {
mockFs({
'/tmp': mockFs.directory(),
});
});
afterEach(() => {
mockFs.restore();
});
const archiveBuffer = fs.readFileSync(
path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.zip'),
);
@@ -382,4 +397,80 @@ describe('GitlabUrlReader', () => {
await expect(fnGithub).rejects.toThrow(NotFoundError);
});
});
describe('search', () => {
const archiveBuffer = fs.readFileSync(
path.resolve('src', 'reading', '__fixtures__', 'gitlab-archive.zip'),
);
const projectGitlabApiResponse = {
id: 11111111,
default_branch: 'main',
};
const branchGitlabApiResponse = {
commit: {
id: 'sha123abc',
},
};
beforeEach(() => {
worker.use(
rest.get(
'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/archive.zip?sha=main',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/zip'),
ctx.set(
'content-disposition',
'attachment; filename="mock-main-sha123abc.zip"',
),
ctx.body(archiveBuffer),
),
),
rest.get(
'https://gitlab.com/api/v4/projects/backstage%2Fmock',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(projectGitlabApiResponse),
),
),
rest.get(
'https://gitlab.com/api/v4/projects/backstage%2Fmock/repository/branches/main',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(branchGitlabApiResponse),
),
),
);
});
it('works for the naive case', async () => {
const result = await gitlabProcessor.search(
'https://gitlab.com/backstage/mock/tree/main/**/index.*',
);
expect(result.etag).toBe('sha123abc');
expect(result.files.length).toBe(1);
expect(result.files[0].url).toBe(
'https://gitlab.com/backstage/mock/tree/main/docs/index.md',
);
await expect(result.files[0].content()).resolves.toEqual(
Buffer.from('# Test\n'),
);
});
it('throws NotModifiedError when same etag', async () => {
await expect(
gitlabProcessor.search(
'https://gitlab.com/backstage/mock/tree/main/**/index.*',
{ etag: 'sha123abc' },
),
).rejects.toThrow(NotModifiedError);
});
});
});
@@ -17,49 +17,51 @@
import {
getGitLabFileFetchUrl,
getGitLabRequestOptions,
GitLabIntegrationConfig,
readGitLabIntegrationConfigs,
GitLabIntegration,
ScmIntegrations,
} from '@backstage/integration';
import fetch from 'cross-fetch';
import parseGitUrl from 'git-url-parse';
import { Minimatch } from 'minimatch';
import { Readable } from 'stream';
import { NotFoundError, NotModifiedError } from '../errors';
import { ReadTreeResponseFactory } from './tree';
import { stripFirstDirectoryFromPath } from './tree/util';
import {
ReaderFactory,
ReadTreeOptions,
ReadTreeResponse,
SearchOptions,
SearchResponse,
UrlReader,
} from './types';
export class GitlabUrlReader implements UrlReader {
private readonly treeResponseFactory: ReadTreeResponseFactory;
static factory: ReaderFactory = ({ config, treeResponseFactory }) => {
const configs = readGitLabIntegrationConfigs(
config.getOptionalConfigArray('integrations.gitlab') ?? [],
);
return configs.map(provider => {
const reader = new GitlabUrlReader(provider, { treeResponseFactory });
const predicate = (url: URL) => url.host === provider.host;
const integrations = ScmIntegrations.fromConfig(config);
return integrations.gitlab.list().map(integration => {
const reader = new GitlabUrlReader(integration, {
treeResponseFactory,
});
const predicate = (url: URL) => url.host === integration.config.host;
return { reader, predicate };
});
};
constructor(
private readonly config: GitLabIntegrationConfig,
deps: { treeResponseFactory: ReadTreeResponseFactory },
) {
this.treeResponseFactory = deps.treeResponseFactory;
}
private readonly integration: GitLabIntegration,
private readonly deps: { treeResponseFactory: ReadTreeResponseFactory },
) {}
async read(url: string): Promise<Buffer> {
const builtUrl = await getGitLabFileFetchUrl(url, this.config);
const builtUrl = await getGitLabFileFetchUrl(url, this.integration.config);
let response: Response;
try {
response = await fetch(builtUrl, getGitLabRequestOptions(this.config));
response = await fetch(
builtUrl,
getGitLabRequestOptions(this.integration.config),
);
} catch (e) {
throw new Error(`Unable to read ${url}, ${e}`);
}
@@ -86,9 +88,11 @@ export class GitlabUrlReader implements UrlReader {
// https://docs.gitlab.com/ee/api/README.html#namespaced-path-encoding
const projectGitlabResponse = await fetch(
new URL(
`${this.config.apiBaseUrl}/projects/${encodeURIComponent(full_name)}`,
`${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent(
full_name,
)}`,
).toString(),
getGitLabRequestOptions(this.config),
getGitLabRequestOptions(this.integration.config),
);
if (!projectGitlabResponse.ok) {
const msg = `Failed to read tree from ${url}, ${projectGitlabResponse.status} ${projectGitlabResponse.statusText}`;
@@ -106,11 +110,11 @@ export class GitlabUrlReader implements UrlReader {
// the provided sha.
const branchGitlabResponse = await fetch(
new URL(
`${this.config.apiBaseUrl}/projects/${encodeURIComponent(
`${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent(
full_name,
)}/repository/branches/${branch}`,
).toString(),
getGitLabRequestOptions(this.config),
getGitLabRequestOptions(this.integration.config),
);
if (!branchGitlabResponse.ok) {
const message = `Failed to read tree (branch) from ${url}, ${branchGitlabResponse.status} ${branchGitlabResponse.statusText}`;
@@ -128,10 +132,10 @@ export class GitlabUrlReader implements UrlReader {
// https://docs.gitlab.com/ee/api/repositories.html#get-file-archive
const archiveGitLabResponse = await fetch(
`${this.config.apiBaseUrl}/projects/${encodeURIComponent(
`${this.integration.config.apiBaseUrl}/projects/${encodeURIComponent(
full_name,
)}/repository/archive.zip?sha=${branch}`,
getGitLabRequestOptions(this.config),
getGitLabRequestOptions(this.integration.config),
);
if (!archiveGitLabResponse.ok) {
const message = `Failed to read tree (archive) from ${url}, ${archiveGitLabResponse.status} ${archiveGitLabResponse.statusText}`;
@@ -141,7 +145,7 @@ export class GitlabUrlReader implements UrlReader {
throw new Error(message);
}
return await this.treeResponseFactory.fromZipArchive({
return await this.deps.treeResponseFactory.fromZipArchive({
stream: (archiveGitLabResponse.body as unknown) as Readable,
subpath: filepath,
etag: commitSha,
@@ -149,12 +153,33 @@ export class GitlabUrlReader implements UrlReader {
});
}
async search(): Promise<SearchResponse> {
throw new Error('GitlabUrlReader does not implement search');
async search(url: string, options?: SearchOptions): Promise<SearchResponse> {
const { filepath } = parseGitUrl(url);
const matcher = new Minimatch(filepath);
// TODO(freben): For now, read the entire repo and filter through that. In
// a future improvement, we could be smart and try to deduce that non-glob
// prefixes (like for filepaths such as some-prefix/**/a.yaml) can be used
// to get just that part of the repo.
const treeUrl = url.replace(filepath, '').replace(/\/+$/, '');
const tree = await this.readTree(treeUrl, {
etag: options?.etag,
filter: path => matcher.match(stripFirstDirectoryFromPath(path)),
});
const files = await tree.files();
return {
etag: tree.etag,
files: files.map(file => ({
url: this.integration.resolveUrl({ url: `/${file.path}`, base: url }),
content: file.content,
})),
};
}
toString() {
const { host, token } = this.config;
const { host, token } = this.integration.config;
return `gitlab{host=${host},authed=${Boolean(token)}}`;
}
}
@@ -25,15 +25,12 @@ import {
ReadTreeResponseDirOptions,
ReadTreeResponseFile,
} from '../types';
import { stripFirstDirectoryFromPath } from './util';
// Tar types for `Parse` is not a proper constructor, but it should be
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.
@@ -84,7 +81,7 @@ export class TarArchiveResponse implements ReadTreeResponse {
// 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, '');
const relativePath = stripFirstDirectoryFromPath(entry.path);
if (this.subPath) {
if (!relativePath.startsWith(this.subPath)) {
@@ -161,7 +158,7 @@ export class TarArchiveResponse implements ReadTreeResponse {
filter: path => {
// 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, '');
const relativePath = stripFirstDirectoryFromPath(path);
if (this.subPath && !relativePath.startsWith(this.subPath)) {
return false;
}
@@ -24,11 +24,7 @@ import {
ReadTreeResponseDirOptions,
ReadTreeResponseFile,
} 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 = /^[^\/]+\//;
import { stripFirstDirectoryFromPath } from './util';
/**
* Wraps a zip archive stream into a tree response reader.
@@ -65,18 +61,13 @@ export class ZipArchiveResponse implements ReadTreeResponse {
this.read = true;
}
// 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);
const strippedPath = stripFirstDirectoryFromPath(entry.path);
if (this.subPath) {
if (!strippedPath.startsWith(this.subPath)) {
@@ -104,7 +95,7 @@ export class ZipArchiveResponse implements ReadTreeResponse {
if (this.shouldBeIncluded(entry)) {
files.push({
path: this.getInnerPath(this.stripTopDirectory(entry.path)),
path: this.getInnerPath(stripFirstDirectoryFromPath(entry.path)),
content: () => entry.buffer(),
});
} else {
@@ -153,7 +144,7 @@ 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.getInnerPath(
this.stripTopDirectory(entry.path),
stripFirstDirectoryFromPath(entry.path),
);
const dirname = platformPath.dirname(entryPath);
if (dirname) {
@@ -0,0 +1,25 @@
/*
* 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.
*/
// 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 = /^[^\/]+\//;
// Removes the first segment of a forward-slash-separated path
export function stripFirstDirectoryFromPath(path: string): string {
return path.replace(directoryNameRegex, '');
}
@@ -161,7 +161,7 @@ export class ServiceBuilderImpl implements ServiceBuilder {
app.use(cors(corsOptions));
}
app.use(compression());
app.use(requestLoggingHandler());
app.use(requestLoggingHandler(logger));
for (const [root, route] of this.routers) {
app.use(root, route);
}
@@ -25,6 +25,9 @@ metadata:
- url: https://example.com/web
title: Website
icon: web
- url: https://example.com/alert
title: Alerts
icon: alert
spec:
type: service
lifecycle: experimental
+1 -1
View File
@@ -153,7 +153,7 @@ export class Lockfile {
const acceptedVersion = versions.find(v => semver.satisfies(v, range));
if (!acceptedVersion) {
throw new Error(
`No existing version was accepted for range ${range}, searching through ${versions}`,
`No existing version was accepted for range ${range}, searching through ${versions}, for package ${name}`,
);
}
+4 -4
View File
@@ -48,7 +48,7 @@ import {
routeElementDiscoverer,
traverseElementTree,
} from '../extensions/traversal';
import { IconComponent, SystemIconKey, SystemIcons } from '../icons';
import { IconComponent, IconComponentMap, IconKey } from '../icons';
import { BackstagePlugin } from '../plugin';
import { RouteRef } from '../routing';
import {
@@ -95,7 +95,7 @@ export function generateBoundRoutes(
type FullAppOptions = {
apis: Iterable<AnyApiFactory>;
icons: SystemIcons;
icons: IconComponentMap;
plugins: BackstagePlugin<any, any>[];
components: AppComponents;
themes: AppTheme[];
@@ -144,7 +144,7 @@ export class PrivateAppImpl implements BackstageApp {
private configApi?: ConfigApi;
private readonly apis: Iterable<AnyApiFactory>;
private readonly icons: SystemIcons;
private readonly icons: IconComponentMap;
private readonly plugins: BackstagePlugin<any, any>[];
private readonly components: AppComponents;
private readonly themes: AppTheme[];
@@ -169,7 +169,7 @@ export class PrivateAppImpl implements BackstageApp {
return this.plugins;
}
getSystemIcon(key: SystemIconKey): IconComponent {
getSystemIcon(key: IconKey): IconComponent {
return this.icons[key];
}
+4 -4
View File
@@ -15,7 +15,7 @@
*/
import { ComponentType } from 'react';
import { IconComponent, SystemIconKey, SystemIcons } from '../icons';
import { IconComponent, IconComponentMap, IconKey } from '../icons';
import { BackstagePlugin, AnyExternalRoutes } from '../plugin/types';
import { RouteRef } from '../routing';
import { AnyApiFactory } from '../apis';
@@ -94,7 +94,7 @@ export type AppOptions = {
/**
* Supply icons to override the default ones.
*/
icons?: Partial<SystemIcons>;
icons?: IconComponentMap;
/**
* A list of all plugins to include in the app.
@@ -169,9 +169,9 @@ export type BackstageApp = {
getPlugins(): BackstagePlugin<any, any>[];
/**
* Get a common icon for this app.
* Get a common or custom icon for this app.
*/
getSystemIcon(key: SystemIconKey): IconComponent;
getSystemIcon(key: IconKey): IconComponent;
/**
* Provider component that should wrap the Router created with getRouter()
+9 -3
View File
@@ -15,15 +15,19 @@
*/
import { SvgIconProps } from '@material-ui/core';
import MuiDashboardIcon from '@material-ui/icons/Dashboard';
import MuiHelpIcon from '@material-ui/icons/Help';
import PeopleIcon from '@material-ui/icons/People';
import PersonIcon from '@material-ui/icons/Person';
import React from 'react';
import { useApp } from '../app/AppContext';
import { IconComponent, SystemIconKey, SystemIcons } from './types';
import { IconComponent, SystemIconKey, IconComponentMap } from './types';
export const defaultSystemIcons: SystemIcons = {
export const defaultSystemIcons: IconComponentMap = {
user: PersonIcon,
group: PeopleIcon,
dashboard: MuiDashboardIcon,
help: MuiHelpIcon,
};
const overridableSystemIcon = (key: SystemIconKey): IconComponent => {
@@ -35,5 +39,7 @@ const overridableSystemIcon = (key: SystemIconKey): IconComponent => {
return Component;
};
export const UserIcon = overridableSystemIcon('user');
export const DashboardIcon = overridableSystemIcon('dashboard');
export const GroupIcon = overridableSystemIcon('group');
export const HelpIcon = overridableSystemIcon('help');
export const UserIcon = overridableSystemIcon('user');
+4 -2
View File
@@ -17,6 +17,8 @@
import { ComponentType } from 'react';
import { SvgIconProps } from '@material-ui/core';
export type SystemIconKey = 'user' | 'group' | 'dashboard' | 'help';
export type IconComponent = ComponentType<SvgIconProps>;
export type SystemIconKey = 'user' | 'group';
export type SystemIcons = { [key in SystemIconKey]: IconComponent };
export type IconKey = SystemIconKey | string;
export type IconComponentMap = { [key in IconKey]: IconComponent };
+1
View File
@@ -31,6 +31,7 @@ export * from './MarkdownContent';
export * from './OAuthRequestDialog';
export * from './Progress';
export * from './ProgressBars';
export * from './Select';
export * from './SimpleStepper';
export * from './Status';
export * from './StructuredMetadataTable';
@@ -0,0 +1,137 @@
/*
* 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 { Box, List, ListItem, Popover, Typography } from '@material-ui/core';
import ExpandLessIcon from '@material-ui/icons/ExpandLess';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import React, { Fragment } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { Breadcrumbs } from '.';
import { Header, Page } from '..';
import { Link } from '../../components/Link';
export default {
title: 'Layout/Breadcrumbs',
component: Breadcrumbs,
};
export const InHeader = () => (
<MemoryRouter>
<h2>Standard breadcrumbs</h2>
<p>
Underlined pages are links. This should show a hierarchical relationship.
</p>
<Page themeId="other">
<Header title="Current Page" type="General Page" typeLink="/" />
</Page>
</MemoryRouter>
);
export const OutsideOfHeader = () => {
const [anchorEl, setAnchorEl] = React.useState<HTMLAnchorElement | null>(
null,
);
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const open = Boolean(anchorEl);
return (
<MemoryRouter>
<p>
It might be the case that you want to keep your breadcrumbs outside of
the header. In that case, they should be positioned above the title of
the page.
</p>
<h2>Standard breadcrumbs</h2>
<p>
Underlined pages are links. This should show a hierarchical
relationship.
</p>
<Breadcrumbs color="primaryText" />
<Breadcrumbs color="primaryText">
<Link to="/">General Page</Link>
<Link to="/">Second Page</Link>
<Typography>Current page</Typography>
</Breadcrumbs>
<h2>Hidden breadcrumbs</h2>
<p>
Use this when you have more than three breadcrumbs. When user clicks on
ellipses, expand the breadcrumbs out.
</p>
<Breadcrumbs color="primaryText">
<Link to="/">General Page</Link>
<Link to="/">Second Page</Link>
<Link to="/">Third Page</Link>
<Link to="/">Fourth Page</Link>
<Typography>Current page</Typography>
</Breadcrumbs>
<h2>Layered breadcrumbs</h2>
<p>
Use this when you want to show alternative breadcrumbs on the same
hierarchical level.
</p>
<Fragment>
<Breadcrumbs color="primaryText">
<Link to="/">General Page</Link>
<Link to="/" onClick={handleClick}>
<Box display="flex" alignItems="center">
<span>Second Page</span>
{open ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</Box>
</Link>
<Typography>Current page</Typography>
</Breadcrumbs>
<Popover
open={open}
onClose={handleClose}
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
>
<List>
<ListItem button style={{ textDecoration: 'underline' }}>
Parallel second page
</ListItem>
<ListItem button style={{ textDecoration: 'underline' }}>
Another parallel second page
</ListItem>
<ListItem button style={{ textDecoration: 'underline' }}>
Yet another, parallel second page
</ListItem>
</List>
</Popover>
</Fragment>
</MemoryRouter>
);
};
@@ -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 { renderInTestApp } from '@backstage/test-utils';
import { Typography } from '@material-ui/core';
import { fireEvent } from '@testing-library/react';
import React from 'react';
import { Link } from '../..';
import { Breadcrumbs } from './Breadcrumbs';
describe('<Breadcrumbs/>', () => {
it('should render', async () => {
const rendered = await renderInTestApp(
<Breadcrumbs>
<Link to="/">General Page</Link>
<Typography>Current Page</Typography>
</Breadcrumbs>,
);
expect(rendered.getByLabelText('breadcrumb')).toBeVisible();
expect(rendered.getByText('General Page')).toBeVisible();
expect(rendered.getByText('Current Page')).toBeVisible();
});
it('should render hidden breadcrumbs', async () => {
const rendered = await renderInTestApp(
<Breadcrumbs>
<Link to="/">General Page</Link>
<Link to="/">Second Page</Link>
<Link to="/">Third Page</Link>
<Link to="/">Fourth Page</Link>
<Typography>Current page</Typography>
</Breadcrumbs>,
);
expect(rendered.getByText('...')).toBeVisible();
expect(rendered.queryByText('Third Page')).not.toBeInTheDocument();
expect(rendered.queryByText('Fourth Page')).not.toBeInTheDocument();
fireEvent.click(rendered.getByText('...'));
expect(rendered.getByText('Third Page')).toBeVisible();
expect(rendered.getByText('Fourth Page')).toBeVisible();
});
});
@@ -0,0 +1,99 @@
/*
* 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 {
Box,
Breadcrumbs as MaterialBreadcrumbs,
List,
ListItem,
Popover,
Typography,
withStyles,
} from '@material-ui/core';
import React, { ComponentProps, Fragment } from 'react';
type Props = ComponentProps<typeof MaterialBreadcrumbs>;
const ClickableText = withStyles({
root: {
textDecoration: 'underline',
cursor: 'pointer',
},
})(Typography);
const StyledBox = withStyles({
root: {
textDecoration: 'underline',
color: 'inherit',
},
})(Box);
export const Breadcrumbs = ({ children, ...props }: Props) => {
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(
null,
);
const childrenArray = React.Children.toArray(children);
const [firstPage, secondPage, ...expandablePages] = childrenArray;
const currentPage = expandablePages.length
? expandablePages.pop()
: childrenArray[childrenArray.length - 1];
const hasHiddenBreadcrumbs = childrenArray.length > 3;
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const open = Boolean(anchorEl);
return (
<Fragment>
<MaterialBreadcrumbs aria-label="breadcrumb" {...props}>
{childrenArray.length > 1 && <StyledBox clone>{firstPage}</StyledBox>}
{childrenArray.length > 2 && <StyledBox clone>{secondPage}</StyledBox>}
{hasHiddenBreadcrumbs && (
<ClickableText onClick={handleClick}>...</ClickableText>
)}
<Box style={{ fontStyle: 'italic' }}>{currentPage}</Box>
</MaterialBreadcrumbs>
<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
>
<List>
{expandablePages.map(pageLink => (
<ListItem button>
<StyledBox clone>{pageLink}</StyledBox>
</ListItem>
))}
</List>
</Popover>
</Fragment>
);
};
@@ -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 { Breadcrumbs } from './Breadcrumbs';
+6 -17
View File
@@ -16,15 +16,10 @@
import React, { ReactNode, CSSProperties, PropsWithChildren } from 'react';
import { Helmet } from 'react-helmet';
import {
Link,
Typography,
Tooltip,
makeStyles,
Breadcrumbs,
} from '@material-ui/core';
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
import { Typography, Tooltip, makeStyles } from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
import { Breadcrumbs } from '..';
import { Link } from '../../components';
const useStyles = makeStyles<BackstageTheme>(theme => ({
header: {
@@ -136,15 +131,9 @@ const TypeFragment = ({
}
return (
<Breadcrumbs
aria-label="breadcrumb"
separator={<ChevronRightIcon fontSize="small" />}
className={classes.breadcrumb}
>
<Link href={typeLink} color="inherit">
<Typography className={classes.breadcrumbType}> {type}</Typography>
</Link>
<Typography className={classes.breadcrumbTitle}>{pageTitle}</Typography>
<Breadcrumbs className={classes.breadcrumb}>
<Link to={typeLink}>{type}</Link>
<Typography>{pageTitle}</Typography>
</Breadcrumbs>
);
};
+1
View File
@@ -28,3 +28,4 @@ export * from './Page';
export * from './Sidebar';
export * from './SignInPage';
export * from './TabbedCard';
export * from './Breadcrumbs';
+5 -4
View File
@@ -19,6 +19,7 @@ import { AzureIntegration } from './azure/AzureIntegration';
import { BitbucketIntegration } from './bitbucket/BitbucketIntegration';
import { GitHubIntegration } from './github/GitHubIntegration';
import { GitLabIntegration } from './gitlab/GitLabIntegration';
import { defaultScmResolveUrl } from './helpers';
import {
ScmIntegration,
ScmIntegrationRegistry,
@@ -83,11 +84,11 @@ export class ScmIntegrations implements ScmIntegrationRegistry {
}
resolveUrl(options: { url: string; base: string }): string {
const resolve = this.byUrl(options.base)?.resolveUrl;
if (!resolve) {
return new URL(options.url, options.base).toString();
const integration = this.byUrl(options.base);
if (!integration) {
return defaultScmResolveUrl(options);
}
return resolve(options);
return integration.resolveUrl(options);
}
}
@@ -58,6 +58,16 @@ describe('AzureIntegration', () => {
'https://dev.azure.com/organization/project/_git/repository?path=%2Fa.yaml',
);
expect(
integration.resolveUrl({
url: '/a.yaml',
base:
'https://dev.azure.com/organization/project/_git/repository?path=%2Ffolder%2Fcatalog-info.yaml',
}),
).toBe(
'https://dev.azure.com/organization/project/_git/repository?path=%2Fa.yaml',
);
expect(
integration.resolveUrl({
url: './a.yaml',
+2 -1
View File
@@ -14,14 +14,15 @@
* limitations under the License.
*/
export { AzureIntegration } from './AzureIntegration';
export {
readAzureIntegrationConfig,
readAzureIntegrationConfigs,
} from './config';
export type { AzureIntegrationConfig } from './config';
export {
getAzureCommitsUrl,
getAzureDownloadUrl,
getAzureFileFetchUrl,
getAzureRequestOptions,
getAzureCommitsUrl,
} from './core';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { basicIntegrations } from '../helpers';
import { basicIntegrations, defaultScmResolveUrl } from '../helpers';
import { ScmIntegration, ScmIntegrationsFactory } from '../types';
import {
BitbucketIntegrationConfig,
@@ -47,4 +47,8 @@ export class BitbucketIntegration implements ScmIntegration {
get config(): BitbucketIntegrationConfig {
return this.integrationConfig;
}
resolveUrl(options: { url: string; base: string }): string {
return defaultScmResolveUrl(options);
}
}
@@ -14,6 +14,7 @@
* limitations under the License.
*/
export { BitbucketIntegration } from './BitbucketIntegration';
export {
readBitbucketIntegrationConfig,
readBitbucketIntegrationConfigs,
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { basicIntegrations } from '../helpers';
import { basicIntegrations, defaultScmResolveUrl } from '../helpers';
import { ScmIntegration, ScmIntegrationsFactory } from '../types';
import {
GitHubIntegrationConfig,
@@ -45,4 +45,8 @@ export class GitHubIntegration implements ScmIntegration {
get config(): GitHubIntegrationConfig {
return this.integrationConfig;
}
resolveUrl(options: { url: string; base: string }): string {
return defaultScmResolveUrl(options);
}
}
+1
View File
@@ -21,3 +21,4 @@ export {
export type { GitHubIntegrationConfig } from './config';
export { getGitHubFileFetchUrl, getGitHubRequestOptions } from './core';
export { GithubCredentialsProvider } from './GithubCredentialsProvider';
export { GitHubIntegration } from './GitHubIntegration';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { basicIntegrations } from '../helpers';
import { basicIntegrations, defaultScmResolveUrl } from '../helpers';
import { ScmIntegration, ScmIntegrationsFactory } from '../types';
import {
GitLabIntegrationConfig,
@@ -45,4 +45,8 @@ export class GitLabIntegration implements ScmIntegration {
get config(): GitLabIntegrationConfig {
return this.integrationConfig;
}
resolveUrl(options: { url: string; base: string }): string {
return defaultScmResolveUrl(options);
}
}
+1
View File
@@ -20,3 +20,4 @@ export {
} from './config';
export type { GitLabIntegrationConfig } from './config';
export { getGitLabFileFetchUrl, getGitLabRequestOptions } from './core';
export { GitLabIntegration } from './GitLabIntegration';
+67 -1
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { isValidHost } from './helpers';
import { defaultScmResolveUrl, isValidHost } from './helpers';
describe('isValidHost', () => {
it.each([
@@ -51,3 +51,69 @@ describe('isValidHost', () => {
expect(isValidHost(str)).toBe(expected);
});
});
describe('defaultScmResolveUrl', () => {
it('works for relative paths and retains query params', () => {
expect(
defaultScmResolveUrl({
url: './b.yaml',
base:
'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml',
}),
).toBe(
'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/b.yaml',
);
expect(
defaultScmResolveUrl({
url: './b.yaml',
base:
'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml?at=master',
}),
).toBe(
'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/b.yaml?at=master',
);
expect(
defaultScmResolveUrl({
url: 'b.yaml',
base:
'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml',
}),
).toBe(
'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/b.yaml',
);
});
it('works for absolute paths and retains query params', () => {
expect(
defaultScmResolveUrl({
url: '/other/b.yaml',
base:
'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml',
}),
).toBe(
'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/other/b.yaml',
);
expect(
defaultScmResolveUrl({
url: '/other/b.yaml',
base:
'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml?at=master',
}),
).toBe(
'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/other/b.yaml?at=master',
);
});
it('works for full urls and throws away query params', () => {
expect(
defaultScmResolveUrl({
url: 'https://b.com/b.yaml',
base:
'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/folder/a.yaml?at=master',
}),
).toBe('https://b.com/b.yaml');
});
});
+41
View File
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import parseGitUrl from 'git-url-parse';
import { ScmIntegration, ScmIntegrationsGroup } from './types';
/** Checks whether the given argument is a valid URL hostname */
@@ -51,3 +52,43 @@ export function basicIntegrations<T extends ScmIntegration>(
},
};
}
/**
* Default implementation of ScmIntegration.resolveUrl, that only works with
* URL pathname based providers.
*/
export function defaultScmResolveUrl(options: {
url: string;
base: string;
}): string {
const { url, base } = options;
// If it is a fully qualified URL - then return it verbatim
try {
// eslint-disable-next-line no-new
new URL(url);
return url;
} catch {
// ignore intentionally
}
let updated: URL;
if (url.startsWith('/')) {
// If it is an absolute path, move relative to the repo root
const { filepath } = parseGitUrl(base);
updated = new URL(base);
const repoRootPath = updated.pathname
.substring(0, updated.pathname.length - filepath.length)
.replace(/\/+$/, '');
updated.pathname = `${repoRootPath}${url}`;
} else {
// For relative URLs, just let the default URL constructor handle the
// resolving. Note that this essentially will treat the last segment of the
// base as a file - NOT a folder - unless the url ends in a slash.
updated = new URL(url, base);
}
updated.search = new URL(base).search;
return updated.toString();
}
+1
View File
@@ -18,5 +18,6 @@ export * from './azure';
export * from './bitbucket';
export * from './github';
export * from './gitlab';
export { defaultScmResolveUrl } from './helpers';
export { ScmIntegrations } from './ScmIntegrations';
export type { ScmIntegration, ScmIntegrationRegistry } from './types';
+20 -7
View File
@@ -36,16 +36,21 @@ export interface ScmIntegration {
title: string;
/**
* Works like the two-argument form of the URL constructor, resolving an
* absolute or relative URL in relation to a base URL.
* Resolves an absolute or relative URL in relation to a base URL.
*
* If this method is not implemented, the URL constructor is used instead for
* URLs that match this integration.
* This method is adapted for use within SCM systems, so relative URLs are
* within the context of the root of the hierarchy pointed to by the base
* URL.
*
* For example, if the base URL is `<repo root url>/folder/a.yaml`, i.e.
* within the file tree of a certain repo, an absolute path of `/b.yaml` does
* not resolve to `https://hostname/b.yaml` but rather to
* `<repo root url>/b.yaml` inside the file tree of that same repo.
*
* @param options.url The (absolute or relative) URL or path to resolve
* @param options.base The base URL onto which this resolution happens
*/
resolveUrl?(options: { url: string; base: string }): string;
resolveUrl(options: { url: string; base: string }): string;
}
/**
@@ -83,8 +88,16 @@ export interface ScmIntegrationRegistry
gitlab: ScmIntegrationsGroup<GitLabIntegration>;
/**
* Works like the two-argument form of the URL constructor, resolving an
* absolute or relative URL in relation to a base URL.
* Resolves an absolute or relative URL in relation to a base URL.
*
* This method is adapted for use within SCM systems, so relative URLs are
* within the context of the root of the hierarchy pointed to by the base
* URL.
*
* For example, if the base URL is `<repo root url>/folder/a.yaml`, i.e.
* within the file tree of a certain repo, an absolute path of `/b.yaml` does
* not resolve to `https://hostname/b.yaml` but rather to
* `<repo root url>/b.yaml` inside the file tree of that same repo.
*
* @param options.url The (absolute or relative) URL or path to resolve
* @param options.base The base URL onto which this resolution happens
@@ -39,7 +39,7 @@ type Options = {
const parseDate = (date: string | Date) => {
const parsedDate =
typeof date === 'string'
? DateTime.fromSQL(date, { locale: 'UTC' })
? DateTime.fromSQL(date, { zone: 'UTC' })
: DateTime.fromJSDate(date);
if (!parsedDate.isValid) {
@@ -14,8 +14,9 @@
* limitations under the License.
*/
import React from 'react';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { render } from '@testing-library/react';
import React from 'react';
import { AboutCard } from './AboutCard';
describe('<AboutCard /> GitHub', () => {
@@ -36,7 +37,11 @@ describe('<AboutCard /> GitHub', () => {
lifecycle: 'production',
},
};
const { getByText } = render(<AboutCard entity={entity} />);
const { getByText } = render(
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>,
);
expect(getByText('service')).toBeInTheDocument();
expect(getByText('View Source').closest('a')).toHaveAttribute(
'href',
@@ -67,7 +72,11 @@ describe('<AboutCard /> GitLab', () => {
lifecycle: 'production',
},
};
const { getByText } = render(<AboutCard entity={entity} />);
const { getByText } = render(
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>,
);
expect(getByText('service')).toBeInTheDocument();
expect(getByText('View Source').closest('a')).toHaveAttribute(
'href',
@@ -98,7 +107,11 @@ describe('<AboutCard /> BitBucket', () => {
lifecycle: 'production',
},
};
const { getByText } = render(<AboutCard entity={entity} />);
const { getByText } = render(
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>,
);
expect(getByText('service')).toBeInTheDocument();
expect(getByText('View Source').closest('a')).toHaveAttribute(
'href',
@@ -19,6 +19,8 @@ import {
ENTITY_DEFAULT_NAMESPACE,
RELATION_PROVIDES_API,
} from '@backstage/catalog-model';
import { HeaderIconLinkRow } from '@backstage/core';
import { useEntity } from '@backstage/plugin-catalog-react';
import {
Card,
CardContent,
@@ -27,14 +29,13 @@ import {
IconButton,
makeStyles,
} from '@material-ui/core';
import ExtensionIcon from '@material-ui/icons/Extension';
import DocsIcon from '@material-ui/icons/Description';
import EditIcon from '@material-ui/icons/Edit';
import ExtensionIcon from '@material-ui/icons/Extension';
import GitHubIcon from '@material-ui/icons/GitHub';
import React from 'react';
import { findLocationForEntityMeta } from '../../data/utils';
import { createEditLink, determineUrlType } from '../createEditLink';
import { HeaderIconLinkRow } from '@backstage/core';
import { AboutContent } from './AboutContent';
const useStyles = makeStyles({
@@ -76,12 +77,14 @@ function getCodeLinkInfo(entity: Entity): CodeLinkInfo {
}
type AboutCardProps = {
entity: Entity;
/** @deprecated The entity is now grabbed from context instead */
entity?: Entity;
variant?: string;
};
export function AboutCard({ entity, variant }: AboutCardProps) {
export function AboutCard({ variant }: AboutCardProps) {
const classes = useStyles();
const { entity } = useEntity();
const codeLink = getCodeLinkInfo(entity);
// TODO: Also support RELATION_CONSUMES_API here
const hasApis = entity.relations?.some(r => r.type === RELATION_PROVIDES_API);
@@ -66,7 +66,7 @@ export const AboutContent = ({ entity }: Props) => {
</Typography>
</AboutField>
<AboutField label="Owner" gridSizes={{ xs: 12, sm: 6, lg: 4 }}>
<EntityRefLinks entityRefs={ownedByRelations} />
<EntityRefLinks entityRefs={ownedByRelations} defaultKind="group" />
</AboutField>
{isSystem && (
<AboutField
@@ -25,7 +25,6 @@ import {
import { makeStyles } from '@material-ui/core/styles';
import Cancel from '@material-ui/icons/Cancel';
import MoreVert from '@material-ui/icons/MoreVert';
import SwapHoriz from '@material-ui/icons/SwapHoriz';
import React, { useState } from 'react';
// TODO(freben): It should probably instead be the case that Header sets the theme text color to white inside itself unconditionally instead
@@ -82,12 +81,6 @@ export const EntityContextMenu = ({ onUnregisterEntity }: Props) => {
</ListItemIcon>
<Typography variant="inherit">Unregister entity</Typography>
</MenuItem>
<MenuItem>
<ListItemIcon>
<SwapHoriz fontSize="small" />
</ListItemIcon>
<Typography variant="inherit">Move repository</Typography>
</MenuItem>
</MenuList>
</Popover>
</>
@@ -15,7 +15,7 @@
*/
import { Entity } from '@backstage/catalog-model';
import { IconComponent, InfoCard, useApp } from '@backstage/core';
import { IconComponent, IconKey, InfoCard, useApp } from '@backstage/core';
import { useEntity } from '@backstage/plugin-catalog-react';
import LanguageIcon from '@material-ui/icons/Language';
import React from 'react';
@@ -33,9 +33,8 @@ export const EntityLinksCard = ({ cols = undefined }: Props) => {
const { entity } = useEntity();
const app = useApp();
// TODO: Refactor App.icons & App.getSystemIcon to support custom icons
const iconResolver = (key: string | undefined): IconComponent => {
return app.getSystemIcon(key as any) ?? LanguageIcon;
const iconResolver = (key: IconKey | undefined): IconComponent => {
return app.getSystemIcon(key ?? '') ?? LanguageIcon;
};
const links = entity?.metadata?.links;
+1
View File
@@ -24,5 +24,6 @@ export {
catalogPlugin as plugin,
CatalogIndexPage,
CatalogEntityPage,
EntityAboutCard,
EntityLinksCard,
} from './plugin';
+8
View File
@@ -71,6 +71,14 @@ export const CatalogEntityPage = catalogPlugin.provide(
}),
);
export const EntityAboutCard = catalogPlugin.provide(
createComponentExtension({
component: {
lazy: () => import('./components/AboutCard').then(m => m.AboutCard),
},
}),
);
export const EntityLinksCard = catalogPlugin.provide(
createComponentExtension({
component: {
@@ -43,7 +43,7 @@ export class BitbucketPreparer implements PreparerBase {
const targetPath = path.join(workspacePath, 'template');
const fullPathToTemplate = path.resolve(
checkoutPath,
parsedGitUrl.filepath,
parsedGitUrl.filepath ?? '',
);
const git = Git.fromAuth({ logger, ...this.getAuth() });
@@ -28,7 +28,7 @@ describe('File preparer', () => {
const preparer = new FilePreparer();
const root = os.platform() === 'win32' ? 'C:\\' : '/';
const workspacePath = path.join(root, 'tmp');
const checkoutPath = path.resolve(workspacePath, 'checkout');
const targetPath = path.resolve(workspacePath, 'template');
await preparer.prepare({
url: `file:///${root}path/to/template`,
@@ -37,12 +37,12 @@ describe('File preparer', () => {
});
expect(fs.copy).toHaveBeenCalledWith(
path.join(root, 'path', 'to', 'template'),
checkoutPath,
targetPath,
{
recursive: true,
},
);
expect(fs.ensureDir).toHaveBeenCalledWith(checkoutPath);
expect(fs.ensureDir).toHaveBeenCalledWith(targetPath);
await expect(
preparer.prepare({
@@ -25,12 +25,12 @@ export class FilePreparer implements PreparerBase {
throw new InputError(`Wrong location protocol, should be 'file', ${url}`);
}
const checkoutDir = path.join(workspacePath, 'checkout');
await fs.ensureDir(checkoutDir);
const targetDir = path.join(workspacePath, 'template');
await fs.ensureDir(targetDir);
const templatePath = fileURLToPath(url);
await fs.copy(templatePath, checkoutDir, {
await fs.copy(templatePath, targetDir, {
recursive: true,
});
}
@@ -33,7 +33,7 @@ export class GithubPreparer implements PreparerBase {
const targetPath = path.join(workspacePath, 'template');
const fullPathToTemplate = path.resolve(
checkoutPath,
parsedGitUrl.filepath,
parsedGitUrl.filepath ?? '',
);
const git = this.config.token
@@ -33,7 +33,7 @@ export class GitlabPreparer implements PreparerBase {
const targetPath = path.join(workspacePath, 'template');
const fullPathToTemplate = path.resolve(
checkoutPath,
parsedGitUrl.filepath,
parsedGitUrl.filepath ?? '',
);
parsedGitUrl.git_suffix = true;
@@ -17,7 +17,7 @@
import { Config } from '@backstage/config';
import Docker from 'dockerode';
import express from 'express';
import { resolve as resolvePath } from 'path';
import { resolve as resolvePath, dirname } from 'path';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import {
@@ -171,7 +171,7 @@ export async function createRouter(
const preparer = new FilePreparer();
const path = resolvePath(
templateEntityLocation,
dirname(templateEntityLocation),
template.spec.path || '.',
);
@@ -17,6 +17,8 @@ import { Button, useRouteRef } from '@backstage/core';
import { BackstageTheme, pageTheme } from '@backstage/theme';
import {
Card,
CardActions,
CardContent,
Chip,
makeStyles,
Typography,
@@ -33,18 +35,11 @@ const useStyles = makeStyles(theme => ({
props.backgroundImage,
backgroundPosition: 0,
},
content: {
padding: theme.spacing(2),
},
description: {
height: 175,
overflow: 'hidden',
textOverflow: 'ellipsis',
},
footer: {
display: 'flex',
flexDirection: 'row-reverse',
},
}));
export type TemplateCardProps = {
@@ -75,19 +70,19 @@ export const TemplateCard = ({
<Typography variant="subtitle2">{type}</Typography>
<Typography variant="h6">{title}</Typography>
</div>
<div className={classes.content}>
<CardContent>
{tags?.map(tag => (
<Chip label={tag} key={tag} />
))}
<Typography variant="body2" paragraph className={classes.description}>
{description}
</Typography>
<div className={classes.footer}>
<Button color="primary" to={templateLink({ templateName: name })}>
Choose
</Button>
</div>
</div>
</CardContent>
<CardActions>
<Button color="primary" to={templateLink({ templateName: name })}>
Choose
</Button>
</CardActions>
</Card>
);
};
@@ -98,7 +98,7 @@ describe('TemplatePage', () => {
</ApiProvider>,
);
expect(rendered.queryByText('Create a new component')).toBeInTheDocument();
expect(rendered.queryByText('Create a New Component')).toBeInTheDocument();
expect(rendered.queryByText('React SSR Template')).toBeInTheDocument();
// await act(async () => await mutate('templates/test'));
});
@@ -115,7 +115,7 @@ describe('TemplatePage', () => {
</ApiProvider>,
);
expect(rendered.queryByText('Create a new component')).toBeInTheDocument();
expect(rendered.queryByText('Create a New Component')).toBeInTheDocument();
expect(rendered.queryByTestId('loading-progress')).toBeInTheDocument();
// Need to cleanup the promise or will timeout
act(() => {
@@ -140,7 +140,7 @@ describe('TemplatePage', () => {
);
expect(
rendered.queryByText('Create a new component'),
rendered.queryByText('Create a New Component'),
).not.toBeInTheDocument();
expect(rendered.queryByText('This is root')).toBeInTheDocument();
});
@@ -117,10 +117,10 @@ export const TemplatePage = () => {
return (
<Page themeId="home">
<Header
pageTitleOverride="Create a new component"
pageTitleOverride="Create a New Component"
title={
<>
Create a new component <Lifecycle alpha shorthand />
Create a New Component <Lifecycle alpha shorthand />
</>
}
subtitle="Create new software components using standard templates"
@@ -14,39 +14,35 @@
* limitations under the License.
*/
import React, { useState } from 'react';
import { Content, Header, HeaderTabs, Page } from '@backstage/core';
import { General } from './General';
import { Header, Page, TabbedLayout } from '@backstage/core';
import React from 'react';
import { AuthProviders } from './AuthProviders';
import { FeatureFlags } from './FeatureFlags';
import { General } from './General';
type Props = {
providerSettings?: JSX.Element;
};
export const SettingsPage = ({ providerSettings }: Props) => {
const [activeTab, setActiveTab] = useState<number>(0);
const onTabChange = (index: number) => {
setActiveTab(index);
};
const tabs = [
{ id: 'general', label: 'General' },
{ id: 'auth-providers', label: 'Authentication Providers' },
{ id: 'feature-flags', label: 'Feature Flags' },
];
const content = [
<General />,
<AuthProviders providerSettings={providerSettings} />,
<FeatureFlags />,
];
return (
<Page themeId="home">
<Header title="Settings" />
<HeaderTabs tabs={tabs} onChange={onTabChange} />
<Content>{content[activeTab]}</Content>
<TabbedLayout>
<TabbedLayout.Route path="general" title="General">
<General />
</TabbedLayout.Route>
<TabbedLayout.Route
path="auth-providers"
title="Authentication Providers"
>
<AuthProviders providerSettings={providerSettings} />
</TabbedLayout.Route>
<TabbedLayout.Route path="feature-flags" title="Feature Flags">
<FeatureFlags />
</TabbedLayout.Route>
</TabbedLayout>
</Page>
);
};
+157 -37
View File
@@ -4623,16 +4623,16 @@
integrity sha512-In1q0tIiqTYKAGe3KOHDcFDdZRFISyQeSeipeTHGfki23ebHRZcjxvqj5SSdBkw65D4VpSREMi0s9i5iJiMcTw==
"@storybook/addon-actions@^6.1.11":
version "6.1.11"
resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.1.11.tgz#73e91cc95c45ea477cfd4f3603f6b95f5829eab6"
integrity sha512-J44XLx2G732OG7Az79Cpk5UlI5SyXHeQqdykwT/4IEQXSBXAYWSTIJJjpJdcjR/D+zpklab1QDSiWxCrKbe81A==
version "6.1.17"
resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.1.17.tgz#9d32336284738cefa69b99acafa4b132d5533600"
integrity sha512-4hyAvmjnI4C1ZQ7/t21jKKXE0jO1zAk310BkYin0NJf77Qi0tUE1DNOwirJY/xzRih36wWi1V79c/ZOJNsLv9Q==
dependencies:
"@storybook/addons" "6.1.11"
"@storybook/api" "6.1.11"
"@storybook/client-api" "6.1.11"
"@storybook/components" "6.1.11"
"@storybook/core-events" "6.1.11"
"@storybook/theming" "6.1.11"
"@storybook/addons" "6.1.17"
"@storybook/api" "6.1.17"
"@storybook/client-api" "6.1.17"
"@storybook/components" "6.1.17"
"@storybook/core-events" "6.1.17"
"@storybook/theming" "6.1.17"
core-js "^3.0.1"
fast-deep-equal "^3.1.1"
global "^4.3.2"
@@ -4698,7 +4698,7 @@
global "^4.3.2"
regenerator-runtime "^0.13.7"
"@storybook/addons@6.1.15", "@storybook/addons@^6.1.11":
"@storybook/addons@6.1.15":
version "6.1.15"
resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.1.15.tgz#09eb8d962f58bd20b4ac2f83b515831c83226352"
integrity sha512-ENyHapLFOG93VaoQXPX8O3IWjLRyVBox9C9P20LMruKX/SfXAXx20qsoAWKKPGssopyOin17aoQX9pj+lFmCZQ==
@@ -4713,6 +4713,21 @@
global "^4.3.2"
regenerator-runtime "^0.13.7"
"@storybook/addons@6.1.17", "@storybook/addons@^6.1.11":
version "6.1.17"
resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.1.17.tgz#ab0666446acb9fc19c94d7204dc9aafdefb6c7c2"
integrity sha512-3upDPJPzUkls2V3Fozzg+JOcv138bF90pbdRe9YSNu37QvRIL+iQODY7oFygMl+kqjG2F1FGw5EvxAV1mnlwCw==
dependencies:
"@storybook/api" "6.1.17"
"@storybook/channels" "6.1.17"
"@storybook/client-logger" "6.1.17"
"@storybook/core-events" "6.1.17"
"@storybook/router" "6.1.17"
"@storybook/theming" "6.1.17"
core-js "^3.0.1"
global "^4.3.2"
regenerator-runtime "^0.13.7"
"@storybook/api@6.1.11":
version "6.1.11"
resolved "https://registry.npmjs.org/@storybook/api/-/api-6.1.11.tgz#1e0b798203df823ac21184386258cf8b5f17f440"
@@ -4763,18 +4778,30 @@
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/channel-postmessage@6.1.11":
version "6.1.11"
resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.1.11.tgz#62c1079f04870dd27925bd538a2020e7380daa2e"
integrity sha512-voW4Z2SUacDOxwN2q1NEBL//8OpgvL2C5CeoG1VQyEllKM8Vg9t1Nxo2FFTJBzv5LeEX7VIJKeBoB25DYvKyng==
"@storybook/api@6.1.17":
version "6.1.17"
resolved "https://registry.npmjs.org/@storybook/api/-/api-6.1.17.tgz#50393ce9b718063b67680212df895eceacc0c11d"
integrity sha512-sthcfuk2EQ3F5R620PBqpI4Pno3g7KQm6YPZA0DXB+LD/z61xH9ToE1gTLF4nzlE6HwghwkXOZyRwDowRdG+7A==
dependencies:
"@storybook/channels" "6.1.11"
"@storybook/client-logger" "6.1.11"
"@storybook/core-events" "6.1.11"
"@reach/router" "^1.3.3"
"@storybook/channels" "6.1.17"
"@storybook/client-logger" "6.1.17"
"@storybook/core-events" "6.1.17"
"@storybook/csf" "0.0.1"
"@storybook/router" "6.1.17"
"@storybook/semver" "^7.3.2"
"@storybook/theming" "6.1.17"
"@types/reach__router" "^1.3.7"
core-js "^3.0.1"
fast-deep-equal "^3.1.1"
global "^4.3.2"
qs "^6.6.0"
lodash "^4.17.15"
memoizerific "^1.11.3"
regenerator-runtime "^0.13.7"
store2 "^2.7.1"
telejson "^5.0.2"
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/channel-postmessage@6.1.15":
version "6.1.15"
@@ -4789,6 +4816,19 @@
qs "^6.6.0"
telejson "^5.0.2"
"@storybook/channel-postmessage@6.1.17":
version "6.1.17"
resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.1.17.tgz#309ce67c94637ec13319d4ce360a8f3742ddbaf4"
integrity sha512-2nVqxq4oZdSITqhFOnkh1rmDMjCwHuobnK5Fp3l7ftCkbmiZHMheKK9Tz4Rb803dhXvcGYs0zRS8NjKyxlOLsA==
dependencies:
"@storybook/channels" "6.1.17"
"@storybook/client-logger" "6.1.17"
"@storybook/core-events" "6.1.17"
core-js "^3.0.1"
global "^4.3.2"
qs "^6.6.0"
telejson "^5.0.2"
"@storybook/channels@6.1.11":
version "6.1.11"
resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.1.11.tgz#a93a83746ad78dd40e1c056029f6d93b17bb66bc"
@@ -4807,27 +4847,12 @@
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/client-api@6.1.11":
version "6.1.11"
resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.1.11.tgz#d25aac484ca84a1acb01d450e756a62408f00c1a"
integrity sha512-DodJQzGCR+PYs26klvbquTjfBgkw5nvCZd3jpgWQtOrYaY/cMY1LLkVkKqrm2ENW8f7vf7tiw78RtxaXy7xeIQ==
"@storybook/channels@6.1.17":
version "6.1.17"
resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.1.17.tgz#2cc89a6b9727d19c24b15fa3cb15569b469db864"
integrity sha512-MUdj0eKr/AbxevHTSXX7AsgxAz6e5O4ZxoYX5G8ggoqSXrWzws6zRFmUmmTdjpIvVmP2M1Kh4SYFAKcS/AGw9w==
dependencies:
"@storybook/addons" "6.1.11"
"@storybook/channel-postmessage" "6.1.11"
"@storybook/channels" "6.1.11"
"@storybook/client-logger" "6.1.11"
"@storybook/core-events" "6.1.11"
"@storybook/csf" "0.0.1"
"@types/qs" "^6.9.0"
"@types/webpack-env" "^1.15.3"
core-js "^3.0.1"
global "^4.3.2"
lodash "^4.17.15"
memoizerific "^1.11.3"
qs "^6.6.0"
regenerator-runtime "^0.13.7"
stable "^0.1.8"
store2 "^2.7.1"
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
@@ -4855,6 +4880,30 @@
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/client-api@6.1.17":
version "6.1.17"
resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.1.17.tgz#3ced22f08a47af70ccf8929111bc44b79e9e8ec0"
integrity sha512-Loz/wdh0axgq0PS19tx0tGEFEkFWlYc6YauJGHjygYa1xX7mJ54hDoaTolySCXN1HtfZn08D847yjGSN2oIqVg==
dependencies:
"@storybook/addons" "6.1.17"
"@storybook/channel-postmessage" "6.1.17"
"@storybook/channels" "6.1.17"
"@storybook/client-logger" "6.1.17"
"@storybook/core-events" "6.1.17"
"@storybook/csf" "0.0.1"
"@types/qs" "^6.9.0"
"@types/webpack-env" "^1.15.3"
core-js "^3.0.1"
global "^4.3.2"
lodash "^4.17.15"
memoizerific "^1.11.3"
qs "^6.6.0"
regenerator-runtime "^0.13.7"
stable "^0.1.8"
store2 "^2.7.1"
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/client-logger@6.1.11":
version "6.1.11"
resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.1.11.tgz#5dd092e4293e5f58f7e89ddbc6eb2511b7d60954"
@@ -4871,6 +4920,14 @@
core-js "^3.0.1"
global "^4.3.2"
"@storybook/client-logger@6.1.17":
version "6.1.17"
resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.1.17.tgz#0d89aaf824457f19bf9aa585bbcada57595e7d01"
integrity sha512-oqExrxhmws0ihB47sjdynZHd3OpUP4KWkx4udG+74lYIvBH+EZmQ9xF+UofeY3j5p1I9k8ugEcVKy0sqh1yR3w==
dependencies:
core-js "^3.0.1"
global "^4.3.2"
"@storybook/components@6.1.11":
version "6.1.11"
resolved "https://registry.npmjs.org/@storybook/components/-/components-6.1.11.tgz#edd5db7fe43f47b5a7ab515840795a89d931512e"
@@ -4923,6 +4980,32 @@
react-textarea-autosize "^8.1.1"
ts-dedent "^2.0.0"
"@storybook/components@6.1.17":
version "6.1.17"
resolved "https://registry.npmjs.org/@storybook/components/-/components-6.1.17.tgz#f92d36e370ec6039d8c7cee9ef13dda866eed3da"
integrity sha512-rIEll0UTxEKmG4IsSS5K+6DjRLVtX8J+9cg79GSAC7N1ZHUR1UQmjjJaehJa5q/NQ5H8C39acxpT4Py/BcsL2g==
dependencies:
"@popperjs/core" "^2.5.4"
"@storybook/client-logger" "6.1.17"
"@storybook/csf" "0.0.1"
"@storybook/theming" "6.1.17"
"@types/overlayscrollbars" "^1.9.0"
"@types/react-color" "^3.0.1"
"@types/react-syntax-highlighter" "11.0.4"
core-js "^3.0.1"
fast-deep-equal "^3.1.1"
global "^4.3.2"
lodash "^4.17.15"
markdown-to-jsx "^6.11.4"
memoizerific "^1.11.3"
overlayscrollbars "^1.10.2"
polished "^3.4.4"
react-color "^2.17.0"
react-popper-tooltip "^3.1.1"
react-syntax-highlighter "^13.5.0"
react-textarea-autosize "^8.1.1"
ts-dedent "^2.0.0"
"@storybook/core-events@6.1.11":
version "6.1.11"
resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.1.11.tgz#d50e8ec90490f9a7180a8c8a83afb6dcfe47ed66"
@@ -4937,6 +5020,13 @@
dependencies:
core-js "^3.0.1"
"@storybook/core-events@6.1.17":
version "6.1.17"
resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.1.17.tgz#697ed916fcb2a411bc9f8bdbfacd0eb9d394eb58"
integrity sha512-xBI7kmyROcqhYNmFv4QBjD77CzV+k/0F051YFS5WicEI4qDWPPvzaShhm96ZrGobUX3+di4pC11gqdsrFeNCEg==
dependencies:
core-js "^3.0.1"
"@storybook/core@6.1.15":
version "6.1.15"
resolved "https://registry.npmjs.org/@storybook/core/-/core-6.1.15.tgz#7ff8c314d3857497bf2e26c69a1fa93ef37301aa"
@@ -5113,6 +5203,18 @@
memoizerific "^1.11.3"
qs "^6.6.0"
"@storybook/router@6.1.17":
version "6.1.17"
resolved "https://registry.npmjs.org/@storybook/router/-/router-6.1.17.tgz#96746878c50c6c97c7de5a1b23a9503c5d648775"
integrity sha512-wLqSOB5yLXgNyDGy008RUvjVRtVMq7lhmMRicSIxgJpkakPrMRN8n/nK7pxgQc/xDTphnS0u1nT01i97WszhCg==
dependencies:
"@reach/router" "^1.3.3"
"@types/reach__router" "^1.3.7"
core-js "^3.0.1"
global "^4.3.2"
memoizerific "^1.11.3"
qs "^6.6.0"
"@storybook/semver@^7.3.2":
version "7.3.2"
resolved "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz#f3b9c44a1c9a0b933c04e66d0048fcf2fa10dac0"
@@ -5174,6 +5276,24 @@
resolve-from "^5.0.0"
ts-dedent "^2.0.0"
"@storybook/theming@6.1.17":
version "6.1.17"
resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.1.17.tgz#99cc120a230c30458d833b40c806b9b4dff7b34a"
integrity sha512-LpRuY2aIh2td+qZi7E8cp2oM88LudNMmTsBT6N2/Id69u/a9qQd2cYCA9k9fAsg7rjor+wR/N695jk3SGtoFTw==
dependencies:
"@emotion/core" "^10.1.1"
"@emotion/is-prop-valid" "^0.8.6"
"@emotion/styled" "^10.0.23"
"@storybook/client-logger" "6.1.17"
core-js "^3.0.1"
deep-object-diff "^1.1.0"
emotion-theming "^10.0.19"
global "^4.3.2"
memoizerific "^1.11.3"
polished "^3.4.4"
resolve-from "^5.0.0"
ts-dedent "^2.0.0"
"@storybook/ui@6.1.15":
version "6.1.15"
resolved "https://registry.npmjs.org/@storybook/ui/-/ui-6.1.15.tgz#a0f6c49fcf81cf172cd2de4c8dba2be1296891f6"