Merge branch 'master' into firehydrant-plugin

This commit is contained in:
Christine Yi
2021-08-10 15:26:11 -04:00
65 changed files with 1996 additions and 637 deletions
+18
View File
@@ -0,0 +1,18 @@
---
'@backstage/plugin-techdocs': patch
---
Expose a new composable `TechDocsIndexPage` and a `DefaultTechDocsHome` with support for starring docs and filtering on owned, starred, owner, and tags.
You can migrate to the new UI view by making the following changes in your `App.tsx`:
```diff
- <Route path="/docs" element={<TechdocsPage />} />
+ <Route path="/docs" element={<TechDocsIndexPage />}>
+ <DefaultTechDocsHome />
+ </Route>
+ <Route
+ path="/docs/:namespace/:kind/:name/*"
+ element={<TechDocsReaderPage />}
+ />
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Added UI Schema support for array items for example, support EntityPicker within an array field
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Switch `EventSource` implementation with header support from a Node.js API-based one to an XHR-based one.
+18
View File
@@ -0,0 +1,18 @@
---
'@backstage/create-app': patch
---
Use new composable `TechDocsIndexPage` and `DefaultTechDocsHome`
Make the following changes to your `App.tsx` to migrate existing apps:
```diff
- <Route path="/docs" element={<TechdocsPage />} />
+ <Route path="/docs" element={<TechDocsIndexPage />}>
+ <DefaultTechDocsHome />
+ </Route>
+ <Route
+ path="/docs/:namespace/:kind/:name/*"
+ element={<TechDocsReaderPage />}
+ />
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend-module-elasticsearch': patch
---
Fix to allow optionally reading `auth` parameter for custom hosted ElasticSearch instances. Also remove `bearer` auth config since it's currently unsupported.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog': patch
'@backstage/plugin-catalog-react': patch
---
Move and rename `FavoriteEntity` component to `catalog-react`
@@ -0,0 +1,19 @@
---
'@backstage/techdocs-common': patch
---
Stale TechDocs content (files that had previously been published but which have
since been removed) is now removed from storage at publish-time. This is now
supported by the following publishers:
- Google GCS
- AWS S3
- Azure Blob Storage
You may need to apply a greater level of permissions (e.g. the ability to
delete objects in your storage provider) to any credentials/accounts used by
the TechDocs CLI or TechDocs backend in order for this change to take effect.
For more details, see [#6132][issue-ref].
[issue-ref]: https://github.com/backstage/backstage/issues/6132
+3 -16
View File
@@ -130,12 +130,9 @@ Other ElasticSearch instances can be connected to by using standard
ElasticSearch authentication methods and exposed URL, provided that the cluster
supports that. The configuration options needed are the URL to the node and
authentication information. Authentication can be handled by either providing
username/password or an API key or a bearer token. In case both
username/password combination and one of the tokens are provided, token takes
precedence. For more information how to create an API key, see
[Elastic documentation on API keys](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-api-key.html),
and how to create a bearer token see
[Elastic documentation on tokens.](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-service-token.html)
username/password or an API key. For more information how to create an API key,
see
[Elastic documentation on API keys](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-api-key.html).
#### Configuration examples
@@ -150,16 +147,6 @@ search:
password: changeme
```
##### With bearer token
```yaml
search:
elasticsearch:
node: http://localhost:9200
auth:
bearer: token
```
##### With API key
```yaml
@@ -57,6 +57,30 @@ if the original location delegates to another location. A common case is, that a
location is registered as `bootstrap:bootstrap` which means that it is part of
the `app-config.yaml` of a Backstage installation.
### backstage.io/orphan
This annotation is either absent, or present with the exact _string_ value
`"true"`. It should never be added manually. Instead, the catalog itself injects
the annotation as part of its processing loops, on entities that are found to
have no registered locations or config locations that keep them "active" /
"alive".
For example, suppose that the user first registers a location URL pointing to a
`Location` kind entity, which in turn refers to two `Component` kind entities in
two other files nearby. The end result is that the catalog contains those three
entities. Now suppose that the user edits the original `Location` entity to only
refer to the first of the `Component` kind entities. This will intentionally
_not_ lead to the other `Component` entity to be removed from the catalog (for
safety reasons). Instead, it gains this orphan marker annotation, to make it
clear that user action is required to completely remove it, if desired.
```yaml
# Example:
metadata:
annotations:
backstage.io/orphan: 'true'
```
### backstage.io/techdocs-ref
```yaml
@@ -61,7 +61,7 @@ If you do not prefer (3a) and optionally like to use a service account, you can
follow these steps.
Create a new Service Account and a key associated with it. In roles of the
service account, use "Storage Admin".
service account, use "Storage Object Admin".
If you want to create a custom role, make sure to include both `get` and
`create` permissions for both "Objects" and "Buckets". See
@@ -143,6 +143,8 @@ permissions to:
- `s3:ListBucket` to retrieve bucket metadata
- `s3:PutObject` to upload files to the bucket
- `s3:DeleteObject` and `s3:DeleteObjectVersion` to delete stale content during
re-publishing
To _read_ TechDocs from the S3 bucket the IAM policy needs to have at a minimum
permissions to:
@@ -345,6 +347,10 @@ techdocs:
accountKey: ${TECHDOCS_AZURE_BLOB_STORAGE_ACCOUNT_KEY}
```
In either case, the account or credentials used to access your container and all
TechDocs objects underneath it should have the `Storage Blog Data Owner` role
applied, in order to read, write, and delete objects as needed.
**4. That's it!**
Your Backstage app is now ready to use Azure Blob Storage for TechDocs, to store
+12 -2
View File
@@ -52,7 +52,11 @@ import {
} from '@backstage/plugin-scaffolder';
import { SearchPage } from '@backstage/plugin-search';
import { TechRadarPage } from '@backstage/plugin-tech-radar';
import { TechdocsPage } from '@backstage/plugin-techdocs';
import {
DefaultTechDocsHome,
TechDocsIndexPage,
TechDocsReaderPage,
} from '@backstage/plugin-techdocs';
import { UserSettingsPage } from '@backstage/plugin-user-settings';
import AlarmIcon from '@material-ui/icons/Alarm';
import React from 'react';
@@ -116,7 +120,13 @@ const routes = (
{entityPage}
</Route>
<Route path="/catalog-import" element={<CatalogImportPage />} />
<Route path="/docs" element={<TechdocsPage />} />
<Route path="/docs" element={<TechDocsIndexPage />}>
<DefaultTechDocsHome />
</Route>
<Route
path="/docs/:namespace/:kind/:name/*"
element={<TechDocsReaderPage />}
/>
<Route path="/create" element={<ScaffolderPage />}>
<ScaffolderFieldExtensions>
<EntityPickerFieldExtension />
+1 -1
View File
@@ -52,7 +52,7 @@ export async function serveBundle(options: ServeOptions) {
},
static: {
publicPath: config.output?.publicPath as string,
directory: paths.targetPublic,
directory: paths.targetPublic ?? '/',
},
historyApiFallback: {
// Paths with dots should still use the history fallback.
@@ -36,6 +36,12 @@ const useStyles = makeStyles(theme => ({
selected: {
color: theme.palette.text.primary,
},
tabRoot: {
'&:hover': {
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
},
},
}));
export type Tab = {
@@ -89,7 +95,7 @@ export const HeaderTabs = ({
key={tab.id}
value={index}
className={styles.defaultTab}
classes={{ selected: styles.selected }}
classes={{ selected: styles.selected, root: styles.tabRoot }}
/>
))}
</Tabs>
@@ -98,6 +98,7 @@ const TabbedCard = ({
<ErrorBoundary {...errProps}>
{title && <BoldHeader title={title} />}
<Tabs
selectionFollowsFocus
classes={tabsClasses}
value={value || selectedIndex}
onChange={handleChange}
@@ -119,6 +120,11 @@ const useCardTabStyles = makeStyles(theme => ({
margin: theme.spacing(0, 2, 0, 0),
padding: theme.spacing(0.5, 0, 0.5, 0),
textTransform: 'none',
'&:hover': {
opacity: 1,
backgroundColor: 'transparent',
color: theme.palette.text.primary,
},
},
selected: {
fontWeight: 'bold',
@@ -13,7 +13,11 @@ import {
import { ScaffolderPage, scaffolderPlugin } from '@backstage/plugin-scaffolder';
import { SearchPage } from '@backstage/plugin-search';
import { TechRadarPage } from '@backstage/plugin-tech-radar';
import { TechdocsPage } from '@backstage/plugin-techdocs';
import {
DefaultTechDocsHome,
TechDocsIndexPage,
TechDocsReaderPage,
} from '@backstage/plugin-techdocs';
import { UserSettingsPage } from '@backstage/plugin-user-settings';
import { apis } from './apis';
import { entityPage } from './components/catalog/EntityPage';
@@ -50,7 +54,13 @@ const routes = (
>
{entityPage}
</Route>
<Route path="/docs" element={<TechdocsPage />} />
<Route path="/docs" element={<TechDocsIndexPage />}>
<DefaultTechDocsHome />
</Route>
<Route
path="/docs/:namespace/:kind/:name/*"
element={<TechDocsReaderPage />}
/>
<Route path="/create" element={<ScaffolderPage />} />
<Route path="/api-docs" element={<ApiExplorerPage />} />
<Route
@@ -102,6 +102,36 @@ class BlockBlobClientFailUpload extends BlockBlobClient {
}
}
class ContainerClientIterator {
private containerName: string;
constructor(containerName) {
this.containerName = containerName;
}
async next() {
if (
this.containerName === 'delete_stale_files_success' ||
this.containerName === 'delete_stale_files_error'
) {
return {
value: {
segment: {
blobItems: [{ name: `stale_file.png` }],
},
},
};
}
return {
value: {
segment: {
blobItems: [],
},
},
};
}
}
export class ContainerClient {
private readonly containerName;
@@ -125,6 +155,20 @@ export class ContainerClient {
getBlockBlobClient(blobName: string) {
return new BlockBlobClient(blobName);
}
listBlobsFlat() {
return {
byPage: () => {
return new ContainerClientIterator(this.containerName);
},
};
}
deleteBlob() {
if (this.containerName === 'delete_stale_files_error') {
throw new Error('Message');
}
}
}
class ContainerClientFailGetProperties extends ContainerClient {
@@ -77,6 +77,10 @@ class GCSFile {
});
return readable;
}
delete() {
return Promise.resolve();
}
}
class Bucket {
@@ -105,8 +109,28 @@ class Bucket {
}
file(destinationFilePath: string) {
if (this.bucketName === 'delete_stale_files_error') {
throw Error('Message');
}
return new GCSFile(destinationFilePath);
}
getFilesStream() {
const readable = new Readable();
readable._read = () => {};
process.nextTick(() => {
if (
this.bucketName === 'delete_stale_files_success' ||
this.bucketName === 'delete_stale_files_error'
) {
readable.emit('data', { name: 'stale-file.png' });
}
readable.emit('end');
});
return readable;
}
}
export class Storage {
@@ -104,6 +104,33 @@ export class S3 {
}),
};
}
listObjectsV2({ Bucket }) {
return {
promise: () => {
if (
Bucket === 'delete_stale_files_success' ||
Bucket === 'delete_stale_files_error'
) {
return Promise.resolve({
Contents: [{ Key: 'stale_file.png' }],
});
}
return Promise.resolve({});
},
};
}
deleteObject({ Bucket }) {
return {
promise: () => {
if (Bucket === 'delete_stale_files_error') {
throw new Error('Message');
}
return Promise.resolve();
},
};
}
}
export default {
@@ -63,11 +63,10 @@ const getEntityRootDir = (entity: Entity) => {
};
const logger = getVoidLogger();
const loggerInfoSpy = jest.spyOn(logger, 'info');
const loggerErrorSpy = jest.spyOn(logger, 'error');
let publisher: PublisherBase;
beforeEach(() => {
mockFs.restore();
const createPublisherMock = (bucketName: string) => {
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7000',
@@ -78,13 +77,22 @@ beforeEach(() => {
accessKeyId: 'accessKeyId',
secretAccessKey: 'secretAccessKey',
},
bucketName: 'bucketName',
bucketName,
},
},
},
});
publisher = AwsS3Publish.fromConfig(mockConfig, logger);
return AwsS3Publish.fromConfig(mockConfig, logger);
};
let publisher: PublisherBase;
beforeEach(() => {
loggerInfoSpy.mockClear();
loggerErrorSpy.mockClear();
mockFs.restore();
publisher = createPublisherMock('bucketName');
});
describe('AwsS3Publish', () => {
@@ -96,24 +104,7 @@ describe('AwsS3Publish', () => {
});
it('should reject incorrect config', async () => {
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7000',
publisher: {
type: 'awsS3',
awsS3: {
credentials: {
accessKeyId: 'accessKeyId',
secretAccessKey: 'secretAccessKey',
},
// this bucket name will throw an error
bucketName: 'errorBucket',
},
},
},
});
const errorPublisher = AwsS3Publish.fromConfig(mockConfig, logger);
const errorPublisher = createPublisherMock('errorBucket');
expect(await errorPublisher.getReadiness()).toEqual({
isAvailable: false,
@@ -188,8 +179,28 @@ describe('AwsS3Publish', () => {
await expect(fails).rejects.toMatchObject({
message: expect.stringContaining(wrongPathToGeneratedDirectory),
});
});
mockFs.restore();
it('should delete stale files after upload', async () => {
const entity = createMockEntity();
const directory = getEntityRootDir(entity);
const bucketName = 'delete_stale_files_success';
publisher = createPublisherMock(bucketName);
await publisher.publish({ entity, directory });
expect(loggerInfoSpy).toHaveBeenLastCalledWith(
`Successfully deleted stale files for Entity ${entity.metadata.name}. Total number of files: 1`,
);
});
it('should log error when the stale files deletion fails', async () => {
const entity = createMockEntity();
const directory = getEntityRootDir(entity);
const bucketName = 'delete_stale_files_error';
publisher = createPublisherMock(bucketName);
await publisher.publish({ entity, directory });
expect(loggerErrorSpy).toHaveBeenLastCalledWith(
'Unable to delete file(s) from AWS S3. Error: Message',
);
});
});
@@ -16,7 +16,7 @@
import { Entity, EntityName } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import aws, { Credentials } from 'aws-sdk';
import { ListObjectsV2Output, ManagedUpload } from 'aws-sdk/clients/s3';
import { ListObjectsV2Output } from 'aws-sdk/clients/s3';
import { CredentialsOptions } from 'aws-sdk/lib/credentials';
import express from 'express';
import fs from 'fs-extra';
@@ -26,8 +26,11 @@ import path from 'path';
import { Readable } from 'stream';
import { Logger } from 'winston';
import {
bulkStorageOperation,
getCloudPathForLocalPath,
getFileTreeRecursively,
getHeadersForFileExtension,
getStaleFiles,
lowerCaseEntityTripletInStoragePath,
} from './helpers';
import {
@@ -175,54 +178,84 @@ export class AwsS3Publish implements PublisherBase {
* Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html
*/
async publish({ entity, directory }: PublishRequest): Promise<void> {
// First, try to retrieve a list of all individual files currently existing
let existingFiles: string[] = [];
try {
// Note: S3 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 remoteFolder = getCloudPathForLocalPath(entity);
existingFiles = await this.getAllObjectsFromBucket({
prefix: remoteFolder,
});
} catch (e) {
this.logger.error(
`Unable to list files for Entity ${entity.metadata.name}: ${e.message}`,
);
}
const limiter = createLimiter(10);
const uploadPromises: Array<Promise<ManagedUpload.SendData>> = [];
for (const filePath of allFilesToUpload) {
// 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 = path.relative(directory, filePath);
// Then, merge new files into the same folder
let absoluteFilesToUpload;
try {
// 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']
absoluteFilesToUpload = await getFileTreeRecursively(directory);
// Convert destination file path to a POSIX path for uploading.
// S3 expects / as path separator and relativeFilePath will contain \\ on Windows.
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html
const relativeFilePathPosix = relativeFilePath
.split(path.sep)
.join(path.posix.sep);
// The / delimiter is intentional since it represents the cloud storage and not the local file system.
const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
const destination = `${entityRootDir}/${relativeFilePathPosix}`; // S3 Bucket file relative path
// Rate limit the concurrent execution of file uploads to batches of 10 (per publish)
const uploadFile = limiter(() => {
const fileStream = fs.createReadStream(filePath);
await bulkStorageOperation(
async absoluteFilePath => {
const relativeFilePath = path.relative(directory, absoluteFilePath);
const fileStream = fs.createReadStream(absoluteFilePath);
const params = {
Bucket: this.bucketName,
Key: destination,
Key: getCloudPathForLocalPath(entity, relativeFilePath),
Body: fileStream,
};
return this.storageClient.upload(params).promise();
});
uploadPromises.push(uploadFile);
}
await Promise.all(uploadPromises);
this.logger.info(
`Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`,
},
absoluteFilesToUpload,
{ concurrencyLimit: 10 },
);
this.logger.info(
`Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${absoluteFilesToUpload.length}`,
);
return;
} catch (e) {
const errorMessage = `Unable to upload file(s) to AWS S3. ${e}`;
this.logger.error(errorMessage);
throw new Error(errorMessage);
}
// Last, try to remove the files that were *only* present previously
try {
const relativeFilesToUpload = absoluteFilesToUpload.map(
absoluteFilePath =>
getCloudPathForLocalPath(
entity,
path.relative(directory, absoluteFilePath),
),
);
const staleFiles = getStaleFiles(relativeFilesToUpload, existingFiles);
await bulkStorageOperation(
async relativeFilePath => {
return await this.storageClient
.deleteObject({
Bucket: this.bucketName,
Key: relativeFilePath,
})
.promise();
},
staleFiles,
{ concurrencyLimit: 10 },
);
this.logger.info(
`Successfully deleted stale files for Entity ${entity.metadata.name}. Total number of files: ${staleFiles.length}`,
);
} catch (error) {
const errorMessage = `Unable to delete file(s) from AWS S3. ${error}`;
this.logger.error(errorMessage);
}
}
async fetchTechDocsMetadata(
@@ -365,17 +398,19 @@ export class AwsS3Publish implements PublisherBase {
/**
* Returns a list of all object keys from the configured bucket.
*/
protected async getAllObjectsFromBucket(): Promise<string[]> {
protected async getAllObjectsFromBucket(
{ prefix } = { prefix: '' },
): Promise<string[]> {
const objects: string[] = [];
let nextContinuation: string | undefined;
let allObjects: ListObjectsV2Output;
// Iterate through every file in the root of the publisher.
do {
allObjects = await this.storageClient
.listObjectsV2({
Bucket: this.bucketName,
ContinuationToken: nextContinuation,
...(prefix ? { Prefix: prefix } : {}),
})
.promise();
objects.push(
@@ -63,12 +63,18 @@ const getEntityRootDir = (entity: Entity) => {
};
const logger = getVoidLogger();
jest.spyOn(logger, 'info').mockReturnValue(logger);
jest.spyOn(logger, 'error').mockReturnValue(logger);
const loggerInfoSpy = jest.spyOn(logger, 'info');
const loggerErrorSpy = jest.spyOn(logger, 'error');
let publisher: PublisherBase;
beforeEach(async () => {
mockFs.restore();
const createPublisherMock = ({
accountName = 'accountName',
containerName = 'containerName',
}:
| {
accountName?: string;
containerName?: string;
}
| undefined = {}) => {
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7000',
@@ -76,16 +82,24 @@ beforeEach(async () => {
type: 'azureBlobStorage',
azureBlobStorage: {
credentials: {
accountName: 'accountName',
accountName,
accountKey: 'accountKey',
},
containerName: 'containerName',
containerName,
},
},
},
});
publisher = AzureBlobStoragePublish.fromConfig(mockConfig, logger);
return AzureBlobStoragePublish.fromConfig(mockConfig, logger);
};
let publisher: PublisherBase;
beforeEach(async () => {
loggerInfoSpy.mockClear();
loggerErrorSpy.mockClear();
mockFs.restore();
publisher = createPublisherMock();
});
describe('publishing with valid credentials', () => {
@@ -97,32 +111,15 @@ describe('publishing with valid credentials', () => {
});
it('should reject incorrect config', async () => {
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7000',
publisher: {
type: 'azureBlobStorage',
azureBlobStorage: {
credentials: {
accountName: 'accountName',
accountKey: 'accountKey',
},
containerName: 'bad_container',
},
},
},
const errorPublisher = createPublisherMock({
containerName: 'bad_container',
});
const errorPublisher = await AzureBlobStoragePublish.fromConfig(
mockConfig,
logger,
);
expect(await errorPublisher.getReadiness()).toEqual({
isAvailable: false,
});
expect(logger.error).toHaveBeenCalledWith(
expect(loggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Could not retrieve metadata about the Azure Blob Storage container bad_container.`,
),
@@ -146,6 +143,10 @@ describe('publishing with valid credentials', () => {
});
});
afterEach(() => {
mockFs.restore();
});
it('should publish a directory', async () => {
const entity = createMockEntity();
const entityRootDir = getEntityRootDir(entity);
@@ -156,7 +157,6 @@ describe('publishing with valid credentials', () => {
directory: entityRootDir,
}),
).toBeUndefined();
mockFs.restore();
});
it('should fail to publish a directory', async () => {
@@ -175,37 +175,19 @@ describe('publishing with valid credentials', () => {
directory: wrongPathToGeneratedDirectory,
});
await expect(fails).rejects.toMatchObject({
message: expect.stringContaining(
`Unable to upload file(s) to Azure Blob Storage. Error: Failed to read template directory: ENOENT, no such file or directory`,
),
});
await expect(fails).rejects.toMatchObject({
message: expect.stringContaining(wrongPathToGeneratedDirectory),
});
mockFs.restore();
await expect(fails).rejects.toThrow(
/Unable to upload file\(s\) to Azure. Error: Failed to read template directory: ENOENT, no such file or directory/,
);
await expect(fails).rejects.toThrow(
new RegExp(wrongPathToGeneratedDirectory),
);
});
it('reports an error when bad account credentials', async () => {
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7000',
publisher: {
type: 'azureBlobStorage',
azureBlobStorage: {
credentials: {
accountName: 'failupload',
accountKey: 'accountKey',
},
containerName: 'containerName',
},
},
},
publisher = createPublisherMock({
accountName: 'failupload',
});
publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger);
const entity = createMockEntity();
const entityRootDir = getEntityRootDir(entity);
@@ -225,20 +207,38 @@ describe('publishing with valid credentials', () => {
error = e;
}
expect(error.message).toContain(
`Unable to upload file(s) to Azure Blob Storage.`,
);
expect(error.message).toContain(`Unable to upload file(s) to Azure`);
expect(logger.error).toHaveBeenCalledWith(
expect(loggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Unable to upload file(s) to Azure Blob Storage. Error: Upload failed for ${path.join(
`Unable to upload file(s) to Azure. Error: Upload failed for ${path.join(
entityRootDir,
'index.html',
)} with status code 500`,
),
);
});
mockFs.restore();
it('should delete stale files after upload', async () => {
const entity = createMockEntity();
const directory = getEntityRootDir(entity);
const containerName = 'delete_stale_files_success';
publisher = createPublisherMock({ containerName });
await publisher.publish({ entity, directory });
expect(loggerInfoSpy).toHaveBeenLastCalledWith(
`Successfully deleted stale files for Entity ${entity.metadata.name}. Total number of files: 1`,
);
});
it('should log error when the stale files deletion fails', async () => {
const entity = createMockEntity();
const directory = getEntityRootDir(entity);
const containerName = 'delete_stale_files_error';
publisher = createPublisherMock({ containerName });
await publisher.publish({ entity, directory });
expect(loggerErrorSpy).toHaveBeenLastCalledWith(
'Unable to delete file(s) from Azure. Error: Message',
);
});
});
@@ -16,6 +16,7 @@
import { DefaultAzureCredential } from '@azure/identity';
import {
BlobServiceClient,
ContainerClient,
StorageSharedKeyCredential,
} from '@azure/storage-blob';
import { Entity, EntityName } from '@backstage/catalog-model';
@@ -26,8 +27,11 @@ import limiterFactory from 'p-limit';
import { default as path, default as platformPath } from 'path';
import { Logger } from 'winston';
import {
bulkStorageOperation,
getCloudPathForLocalPath,
getFileTreeRecursively,
getHeadersForFileExtension,
getStaleFiles,
lowerCaseEntityTripletInStoragePath,
} from './helpers';
import {
@@ -133,75 +137,101 @@ export class AzureBlobStoragePublish implements PublisherBase {
* Directory structure used in the container is - entityNamespace/entityKind/entityName/index.html
*/
async publish({ entity, directory }: PublishRequest): Promise<void> {
// First, try to retrieve a list of all individual files currently existing
const remoteFolder = getCloudPathForLocalPath(entity);
let existingFiles: string[] = [];
try {
// 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);
existingFiles = await this.getAllBlobsFromContainer({
prefix: remoteFolder,
maxPageSize: BATCH_CONCURRENCY,
});
} catch (e) {
this.logger.error(
`Unable to list files for Entity ${entity.metadata.name}: ${e.message}`,
);
}
// 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);
// Then, merge new files into the same folder
let absoluteFilesToUpload;
let container: ContainerClient;
try {
// 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']
absoluteFilesToUpload = await getFileTreeRecursively(directory);
const promises = allFilesToUpload.map(sourceFilePath => {
// 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 = path.normalize(
path.relative(directory, sourceFilePath),
);
// Convert destination file path to a POSIX path for uploading.
// Azure Blob Storage expects / as path separator and relativeFilePath will contain \\ on Windows.
// https://docs.microsoft.com/en-us/rest/api/storageservices/naming-and-referencing-containers--blobs--and-metadata#blob-names
const relativeFilePathPosix = relativeFilePath
.split(path.sep)
.join(path.posix.sep);
// The / delimiter is intentional since it represents the cloud storage and not the local file system.
const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
const destination = `${entityRootDir}/${relativeFilePathPosix}`; // Azure Blob Storage Container file relative path
return limiter(async () => {
const response = await this.storageClient
.getContainerClient(this.containerName)
.getBlockBlobClient(destination)
.uploadFile(sourceFilePath);
container = this.storageClient.getContainerClient(this.containerName);
const failedOperations: Error[] = [];
await bulkStorageOperation(
async absoluteFilePath => {
const relativeFilePath = path.normalize(
path.relative(directory, absoluteFilePath),
);
const response = await container
.getBlockBlobClient(
getCloudPathForLocalPath(entity, relativeFilePath),
)
.uploadFile(absoluteFilePath);
if (response._response.status >= 400) {
return {
...response,
error: new Error(
`Upload failed for ${sourceFilePath} with status code ${response._response.status}`,
failedOperations.push(
new Error(
`Upload failed for ${absoluteFilePath} with status code ${response._response.status}`,
),
};
);
}
return {
...response,
error: undefined,
};
});
});
const responses = await Promise.all(promises);
return response;
},
absoluteFilesToUpload,
{ concurrencyLimit: BATCH_CONCURRENCY },
);
const failed = responses.filter(r => r.error);
if (failed.length === 0) {
this.logger.info(
`Successfully uploaded the ${responses.length} generated file(s) for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`,
);
} else {
if (failedOperations.length > 0) {
throw new Error(
failed
.map(r => r.error?.message)
failedOperations
.map(r => r.message)
.filter(Boolean)
.join(' '),
);
}
this.logger.info(
`Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${absoluteFilesToUpload.length}`,
);
} catch (e) {
const errorMessage = `Unable to upload file(s) to Azure Blob Storage. ${e}`;
const errorMessage = `Unable to upload file(s) to Azure. ${e}`;
this.logger.error(errorMessage);
throw new Error(errorMessage);
}
// Last, try to remove the files that were *only* present previously
try {
const relativeFilesToUpload = absoluteFilesToUpload.map(
absoluteFilePath =>
getCloudPathForLocalPath(
entity,
path.relative(directory, absoluteFilePath),
),
);
const staleFiles = getStaleFiles(relativeFilesToUpload, existingFiles);
await bulkStorageOperation(
async relativeFilePath => {
return await container.deleteBlob(relativeFilePath);
},
staleFiles,
{ concurrencyLimit: BATCH_CONCURRENCY },
);
this.logger.info(
`Successfully deleted stale files for Entity ${entity.metadata.name}. Total number of files: ${staleFiles.length}`,
);
} catch (error) {
const errorMessage = `Unable to delete file(s) from Azure. ${error}`;
this.logger.error(errorMessage);
}
}
private download(containerName: string, blobPath: string): Promise<Buffer> {
@@ -350,4 +380,30 @@ export class AzureBlobStoragePublish implements PublisherBase {
await Promise.all(promises);
}
protected async getAllBlobsFromContainer({
prefix,
maxPageSize,
}: {
prefix: string;
maxPageSize: number;
}): Promise<string[]> {
const blobs: string[] = [];
const container = this.storageClient.getContainerClient(this.containerName);
let iterator = container.listBlobsFlat({ prefix }).byPage({ maxPageSize });
let response = (await iterator.next()).value;
do {
for (const blob of response?.segment?.blobItems ?? []) {
blobs.push(blob.name);
}
iterator = container
.listBlobsFlat({ prefix })
.byPage({ continuationToken: response.continuationToken, maxPageSize });
response = (await iterator.next()).value;
} while (response && response.continuationToken);
return blobs;
}
}
@@ -63,12 +63,10 @@ const getEntityRootDir = (entity: Entity) => {
};
const logger = getVoidLogger();
jest.spyOn(logger, 'info').mockReturnValue(logger);
const loggerInfoSpy = jest.spyOn(logger, 'info').mockReturnValue(logger);
const loggerErrorSpy = jest.spyOn(logger, 'error').mockReturnValue(logger);
let publisher: PublisherBase;
beforeEach(async () => {
mockFs.restore();
const createPublisherMock = (bucketName: string) => {
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7000',
@@ -76,13 +74,21 @@ beforeEach(async () => {
type: 'googleGcs',
googleGcs: {
credentials: '{}',
bucketName: 'bucketName',
bucketName,
},
},
},
});
return GoogleGCSPublish.fromConfig(mockConfig, logger);
};
publisher = await GoogleGCSPublish.fromConfig(mockConfig, logger);
let publisher: PublisherBase;
beforeEach(async () => {
loggerInfoSpy.mockClear();
loggerErrorSpy.mockClear();
mockFs.restore();
publisher = createPublisherMock('bucketName');
});
describe('GoogleGCSPublish', () => {
@@ -94,20 +100,7 @@ describe('GoogleGCSPublish', () => {
});
it('should reject incorrect config', async () => {
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7000',
publisher: {
type: 'googleGcs',
googleGcs: {
credentials: '{}',
bucketName: 'errorBucket',
},
},
},
});
const errorPublisher = GoogleGCSPublish.fromConfig(mockConfig, logger);
const errorPublisher = createPublisherMock('errorBucket');
expect(await errorPublisher.getReadiness()).toEqual({
isAvailable: false,
@@ -145,7 +138,6 @@ describe('GoogleGCSPublish', () => {
directory: entityRootDir,
}),
).toBeUndefined();
mockFs.restore();
});
it('should fail to publish a directory', async () => {
@@ -181,8 +173,28 @@ describe('GoogleGCSPublish', () => {
await expect(fails).rejects.toMatchObject({
message: expect.stringContaining(wrongPathToGeneratedDirectory),
});
});
mockFs.restore();
it('should delete stale files after upload', async () => {
const entity = createMockEntity();
const directory = getEntityRootDir(entity);
const bucketName = 'delete_stale_files_success';
publisher = createPublisherMock(bucketName);
await publisher.publish({ entity, directory });
expect(loggerInfoSpy).toHaveBeenLastCalledWith(
`Successfully deleted stale files for Entity ${entity.metadata.name}. Total number of files: 1`,
);
});
it('should log error when the stale files deletion fails', async () => {
const entity = createMockEntity();
const directory = getEntityRootDir(entity);
const bucketName = 'delete_stale_files_error';
publisher = createPublisherMock(bucketName);
await publisher.publish({ entity, directory });
expect(loggerErrorSpy).toHaveBeenLastCalledWith(
'Unable to delete file(s) from Google Cloud Storage. Error: Message',
);
});
});
@@ -15,18 +15,19 @@
*/
import { Entity, EntityName } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import {
FileExistsResponse,
Storage,
UploadResponse,
} from '@google-cloud/storage';
import { File, FileExistsResponse, Storage } from '@google-cloud/storage';
import express from 'express';
import JSON5 from 'json5';
import createLimiter from 'p-limit';
import path from 'path';
import { Readable } from 'stream';
import { Logger } from 'winston';
import { getFileTreeRecursively, getHeadersForFileExtension } from './helpers';
import {
bulkStorageOperation,
getCloudPathForLocalPath,
getFileTreeRecursively,
getHeadersForFileExtension,
getStaleFiles,
} from './helpers';
import { MigrateWriteStream } from './migrations';
import {
PublisherBase,
@@ -114,49 +115,73 @@ export class GoogleGCSPublish implements PublisherBase {
* Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html
*/
async publish({ entity, directory }: PublishRequest): Promise<void> {
const bucket = this.storageClient.bucket(this.bucketName);
// First, try to retrieve a list of all individual files currently existing
let existingFiles: string[] = [];
try {
// Note: GCS 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 remoteFolder = getCloudPathForLocalPath(entity);
existingFiles = await this.getFilesForFolder(remoteFolder);
} catch (e) {
this.logger.error(
`Unable to list files for Entity ${entity.metadata.name}: ${e.message}`,
);
}
const limiter = createLimiter(10);
const uploadPromises: Array<Promise<UploadResponse>> = [];
allFilesToUpload.forEach(sourceFilePath => {
// 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 = path.relative(directory, sourceFilePath);
// Then, merge new files into the same folder
let absoluteFilesToUpload;
try {
// 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']
absoluteFilesToUpload = await getFileTreeRecursively(directory);
// Convert destination file path to a POSIX path for uploading.
// GCS expects / as path separator and relativeFilePath will contain \\ on Windows.
// https://cloud.google.com/storage/docs/gsutil/addlhelp/HowSubdirectoriesWork
const relativeFilePathPosix = relativeFilePath
.split(path.sep)
.join(path.posix.sep);
// The / delimiter is intentional since it represents the cloud storage and not the local file system.
const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
const destination = `${entityRootDir}/${relativeFilePathPosix}`; // GCS Bucket file relative path
// Rate limit the concurrent execution of file uploads to batches of 10 (per publish)
const uploadFile = limiter(() =>
this.storageClient.bucket(this.bucketName).upload(sourceFilePath, {
destination,
}),
);
uploadPromises.push(uploadFile);
});
await Promise.all(uploadPromises);
await bulkStorageOperation(
async absoluteFilePath => {
const relativeFilePath = path.relative(directory, absoluteFilePath);
return await bucket.upload(absoluteFilePath, {
destination: getCloudPathForLocalPath(entity, relativeFilePath),
});
},
absoluteFilesToUpload,
{ concurrencyLimit: 10 },
);
this.logger.info(
`Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`,
`Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${absoluteFilesToUpload.length}`,
);
} catch (e) {
const errorMessage = `Unable to upload file(s) to Google Cloud Storage. ${e}`;
this.logger.error(errorMessage);
throw new Error(errorMessage);
}
// Last, try to remove the files that were *only* present previously
try {
const relativeFilesToUpload = absoluteFilesToUpload.map(
absoluteFilePath =>
getCloudPathForLocalPath(
entity,
path.relative(directory, absoluteFilePath),
),
);
const staleFiles = getStaleFiles(relativeFilesToUpload, existingFiles);
await bulkStorageOperation(
async relativeFilePath => {
return await bucket.file(relativeFilePath).delete();
},
staleFiles,
{ concurrencyLimit: 10 },
);
this.logger.info(
`Successfully deleted stale files for Entity ${entity.metadata.name}. Total number of files: ${staleFiles.length}`,
);
} catch (error) {
const errorMessage = `Unable to delete file(s) from Google Cloud Storage. ${error}`;
this.logger.error(errorMessage);
}
}
fetchTechDocsMetadata(entityName: EntityName): Promise<TechDocsMetadata> {
@@ -255,4 +280,29 @@ export class GoogleGCSPublish implements PublisherBase {
});
});
}
private getFilesForFolder(folder: string): Promise<string[]> {
const fileMetadataStream: Readable = this.storageClient
.bucket(this.bucketName)
.getFilesStream({ prefix: folder });
return new Promise((resolve, reject) => {
const files: string[] = [];
fileMetadataStream.on('error', error => {
// push file to file array
reject(error);
});
fileMetadataStream.on('data', (file: File) => {
// push file to file array
files.push(file.name);
});
fileMetadataStream.on('end', () => {
// resolve promise
resolve(files);
});
});
}
}
@@ -16,9 +16,13 @@
import mockFs from 'mock-fs';
import * as os from 'os';
import * as path from 'path';
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import {
getStaleFiles,
getFileTreeRecursively,
getCloudPathForLocalPath,
getHeadersForFileExtension,
bulkStorageOperation,
lowerCaseEntityTripletInStoragePath,
} from './helpers';
@@ -99,3 +103,118 @@ describe('lowerCaseEntityTripletInStoragePath', () => {
).toThrowError(error);
});
});
describe('getStaleFiles', () => {
const defaultFiles = [
'default/Component/backstage/index.html',
'default/Component/backstage/techdocs_metadata.json',
'default/Component/backstage/assests/javascripts/bundle.7f4f3c92.min.js',
'default/Component/backstage/assets/stylesheets/main.fe0cca5b.min.css',
];
it('should return empty array if there is no stale file', () => {
const oldFiles = [...defaultFiles];
const newFiles = [...defaultFiles];
const staleFiles = getStaleFiles(newFiles, oldFiles);
expect(staleFiles).toHaveLength(0);
});
it('should return all stale files when they exists', () => {
const oldFiles = [...defaultFiles, 'stale_file.png'];
const newFiles = [...defaultFiles];
const staleFiles = getStaleFiles(newFiles, oldFiles);
expect(staleFiles).toHaveLength(1);
expect(staleFiles).toEqual(expect.arrayContaining(['stale_file.png']));
});
});
describe('getCloudPathForLocalPath', () => {
const entity: Entity = {
apiVersion: 'version',
metadata: { namespace: 'default', name: 'backstage' },
kind: 'Component',
};
it('should compose a remote bucket path including entity information', () => {
const remoteBucket = getCloudPathForLocalPath(entity);
expect(remoteBucket).toBe(
`${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}/`,
);
});
it('should compose a remote filename including entity information', () => {
const localPath = 'index.html';
const remoteBucket = getCloudPathForLocalPath(entity, localPath);
expect(remoteBucket).toBe(
`${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}/${localPath}`,
);
});
it('should use the default namespace when it is undefined', () => {
const localPath = 'index.html';
const {
kind,
metadata: { name },
} = entity;
const remoteBucket = getCloudPathForLocalPath(
{ kind, metadata: { name } } as Entity,
localPath,
);
expect(remoteBucket).toBe(
`${ENTITY_DEFAULT_NAMESPACE}/${entity.kind}/${entity.metadata.name}/${localPath}`,
);
});
it('should throw error when entity is invalid', () => {
expect(() => getCloudPathForLocalPath({} as Entity)).toThrow();
});
});
describe('bulkStorageOperation', () => {
const length = 26;
const args = Array.from({ length });
const createConcurrentRequestCounter = (
callback: (count: number) => void,
) => {
let count = 0;
return () =>
new Promise(resolve => {
callback(++count);
setTimeout(() => {
count--;
resolve(null);
}, 100);
});
};
it('should take care of rate limit by default', async () => {
const operation = createConcurrentRequestCounter((count: number) => {
expect(count <= 25).toBeTruthy();
});
await bulkStorageOperation(operation, args);
});
it('should accept the number of concurrency limit', async () => {
const concurrencyLimit = 10;
const operation = createConcurrentRequestCounter((count: number) => {
expect(count <= concurrencyLimit).toBeTruthy();
});
await bulkStorageOperation(operation, args, { concurrencyLimit });
});
it('should wait for all promises be resolved', async () => {
const callback = jest.fn();
const operation = createConcurrentRequestCounter(callback);
await bulkStorageOperation(operation, args);
expect(callback).toHaveBeenCalledTimes(length);
});
it('should call operation with the correct argument', async () => {
const files = ['file1.txt', 'file2.txt'];
const fn = jest.fn();
await bulkStorageOperation(fn, files);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenNthCalledWith(1, files[0]);
expect(fn).toHaveBeenNthCalledWith(2, files[1]);
});
});
@@ -13,7 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import mime from 'mime-types';
import path from 'path';
import createLimiter from 'p-limit';
import recursiveReadDir from 'recursive-readdir';
/**
@@ -111,3 +114,42 @@ export const lowerCaseEntityTripletInStoragePath = (
const lowerName = name.toLowerCase();
return [lowerNamespace, lowerKind, lowerName, ...parts].join('/');
};
// Only returns the files that existed previously and are not present anymore.
export const getStaleFiles = (
newFiles: string[],
oldFiles: string[],
): string[] => {
const staleFiles = new Set(oldFiles);
newFiles.forEach(newFile => {
staleFiles.delete(newFile);
});
return Array.from(staleFiles);
};
// Compose actual filename on remote bucket including entity information
export const getCloudPathForLocalPath = (
entity: Entity,
localPath = '',
): string => {
// Convert destination file path to a POSIX path for uploading.
// GCS expects / as path separator and relativeFilePath will contain \\ on Windows.
// https://cloud.google.com/storage/docs/gsutil/addlhelp/HowSubdirectoriesWork
const relativeFilePathPosix = localPath.split(path.sep).join(path.posix.sep);
// The / delimiter is intentional since it represents the cloud storage and not the local file system.
const entityRootDir = `${
entity.metadata?.namespace ?? ENTITY_DEFAULT_NAMESPACE
}/${entity.kind}/${entity.metadata.name}`;
return `${entityRootDir}/${relativeFilePathPosix}`; // GCS Bucket file relative path
};
// Perform rate limited generic operations by passing a function and a list of arguments
export const bulkStorageOperation = async <T>(
operation: (arg: T) => Promise<unknown>,
args: T[],
{ concurrencyLimit } = { concurrencyLimit: 25 },
) => {
const limiter = createLimiter(concurrencyLimit);
await Promise.all(args.map(arg => limiter(operation, arg)));
};
@@ -29,18 +29,14 @@ export const readState = (stateString: string): OAuthState => {
) {
throw Error(`Invalid state passed via request`);
}
return {
nonce: state.nonce,
env: state.env,
};
return state as OAuthState;
};
export const encodeState = (state: OAuthState): string => {
const searchParams = new URLSearchParams();
searchParams.append('nonce', state.nonce);
searchParams.append('env', state.env);
const stateString = new URLSearchParams(state).toString();
return Buffer.from(searchParams.toString(), 'utf-8').toString('hex');
return Buffer.from(stateString, 'utf-8').toString('hex');
};
export const verifyNonce = (req: express.Request, providerId: string) => {
+26 -2
View File
@@ -10,9 +10,11 @@ import { AsyncState } from 'react-use/lib/useAsync';
import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client';
import { CatalogApi } from '@backstage/catalog-client';
import { ComponentEntity } from '@backstage/catalog-model';
import { ComponentProps } from 'react';
import { Context } from 'react';
import { Entity } from '@backstage/catalog-model';
import { EntityName } from '@backstage/catalog-model';
import { IconButton } from '@material-ui/core';
import { LinkProps } from '@backstage/core-components';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
@@ -116,7 +118,10 @@ export const EntityContext: Context<EntityLoadingStatus>;
//
// @public (undocumented)
export type EntityFilter = {
getCatalogFilters?: () => Record<string, string | string[]>;
getCatalogFilters?: () => Record<
string,
string | symbol | (string | symbol)[]
>;
filterEntity?: (entity: Entity) => boolean;
toQueryValue?: () => string | string[];
};
@@ -618,6 +623,25 @@ export class EntityTypeFilter implements EntityFilter {
// @public (undocumented)
export const EntityTypePicker: () => JSX.Element | null;
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "FavoriteEntity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export const FavoriteEntity: (props: Props_2) => JSX.Element;
// Warning: (ae-missing-release-tag) "favoriteEntityIcon" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const favoriteEntityIcon: (isStarred: boolean) => JSX.Element;
// Warning: (ae-missing-release-tag) "favoriteEntityTooltip" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const favoriteEntityTooltip: (
isStarred: boolean,
) => 'Remove from favorites' | 'Add to favorites';
// Warning: (ae-missing-release-tag) "formatEntityRefTitle" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -677,7 +701,7 @@ export const MockEntityListContextProvider: ({
// @public (undocumented)
export function reduceCatalogFilters(
filters: EntityFilter[],
): Record<string, string | string[]>;
): Record<string, string | symbol | (string | symbol)[]>;
// Warning: (ae-missing-release-tag) "reduceEntityFilters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -15,7 +15,7 @@
*/
import React, { ComponentProps } from 'react';
import { useStarredEntities } from '@backstage/plugin-catalog-react';
import { useStarredEntities } from '../../hooks/useStarredEntities';
import { IconButton, Tooltip, withStyles } from '@material-ui/core';
import StarBorder from '@material-ui/icons/StarBorder';
import Star from '@material-ui/icons/Star';
@@ -29,17 +29,17 @@ const YellowStar = withStyles({
},
})(Star);
export const favouriteEntityTooltip = (isStarred: boolean) =>
export const favoriteEntityTooltip = (isStarred: boolean) =>
isStarred ? 'Remove from favorites' : 'Add to favorites';
export const favouriteEntityIcon = (isStarred: boolean) =>
export const favoriteEntityIcon = (isStarred: boolean) =>
isStarred ? <YellowStar /> : <StarBorder />;
/**
* IconButton for showing if a current entity is starred and adding/removing it from the favourite entities
* IconButton for showing if a current entity is starred and adding/removing it from the favorite entities
* @param props MaterialUI IconButton props extended by required `entity` prop
*/
export const FavouriteEntity = (props: Props) => {
export const FavoriteEntity = (props: Props) => {
const { toggleStarredEntity, isStarredEntity } = useStarredEntities();
const isStarred = isStarredEntity(props.entity);
return (
@@ -48,8 +48,8 @@ export const FavouriteEntity = (props: Props) => {
{...props}
onClick={() => toggleStarredEntity(props.entity)}
>
<Tooltip title={favouriteEntityTooltip(isStarred)}>
{favouriteEntityIcon(isStarred)}
<Tooltip title={favoriteEntityTooltip(isStarred)}>
{favoriteEntityIcon(isStarred)}
</Tooltip>
</IconButton>
);
@@ -0,0 +1,21 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 {
favoriteEntityTooltip,
favoriteEntityIcon,
FavoriteEntity,
} from './FavoriteEntity';
@@ -22,4 +22,5 @@ export * from './EntitySearchBar';
export * from './EntityTable';
export * from './EntityTagPicker';
export * from './EntityTypePicker';
export * from './FavoriteEntity';
export * from './UserListPicker';
+4 -1
View File
@@ -23,7 +23,10 @@ export type EntityFilter = {
* { field: 'kind', values: ['component'] }
* { field: 'metadata.name', values: ['component-1', 'component-2'] }
*/
getCatalogFilters?: () => Record<string, string | string[]>;
getCatalogFilters?: () => Record<
string,
string | symbol | (string | symbol)[]
>;
/**
* Filter entities on the frontend after a catalog-backend request. This function will be called
+2 -2
View File
@@ -19,13 +19,13 @@ import { EntityFilter } from '../types';
export function reduceCatalogFilters(
filters: EntityFilter[],
): Record<string, string | string[]> {
): Record<string, string | symbol | (string | symbol)[]> {
return filters.reduce((compoundFilter, filter) => {
return {
...compoundFilter,
...(filter.getCatalogFilters ? filter.getCatalogFilters() : {}),
};
}, {} as Record<string, string | string[]>);
}, {} as Record<string, string | symbol | (string | symbol)[]>);
}
export function reduceEntityFilters(
@@ -15,6 +15,8 @@
*/
import { RELATION_OWNED_BY, RELATION_PART_OF } from '@backstage/catalog-model';
import {
favoriteEntityIcon,
favoriteEntityTooltip,
formatEntityRefTitle,
getEntityMetadataEditUrl,
getEntityMetadataViewUrl,
@@ -26,10 +28,6 @@ import Edit from '@material-ui/icons/Edit';
import OpenInNew from '@material-ui/icons/OpenInNew';
import { capitalize } from 'lodash';
import React from 'react';
import {
favouriteEntityIcon,
favouriteEntityTooltip,
} from '../FavouriteEntity/FavouriteEntity';
import * as columnFactories from './columns';
import { EntityRow } from './types';
import {
@@ -105,8 +103,8 @@ export const CatalogTable = ({ columns, actions }: CatalogTableProps) => {
const isStarred = isStarredEntity(entity);
return {
cellStyle: { paddingLeft: '1em' },
icon: () => favouriteEntityIcon(isStarred),
tooltip: favouriteEntityTooltip(isStarred),
icon: () => favoriteEntityIcon(isStarred),
tooltip: favoriteEntityTooltip(isStarred),
onClick: () => toggleStarredEntity(entity),
};
},
@@ -35,6 +35,7 @@ import {
import {
EntityContext,
EntityRefLinks,
FavoriteEntity,
getEntityRelations,
useEntityCompoundName,
} from '@backstage/plugin-catalog-react';
@@ -43,7 +44,6 @@ import { Alert } from '@material-ui/lab';
import React, { useContext, useState } from 'react';
import { useNavigate } from 'react-router';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
type SubRoute = {
@@ -79,7 +79,7 @@ const EntityLayoutTitle = ({
>
{title}
</Box>
{entity && <FavouriteEntity entity={entity} />}
{entity && <FavoriteEntity entity={entity} />}
</Box>
);
};
@@ -21,6 +21,7 @@ import {
import {
EntityContext,
EntityRefLinks,
FavoriteEntity,
getEntityRelations,
useEntityCompoundName,
} from '@backstage/plugin-catalog-react';
@@ -28,7 +29,6 @@ import { Box } from '@material-ui/core';
import React, { useContext, useState } from 'react';
import { useNavigate } from 'react-router';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
import { Tabbed } from './Tabbed';
@@ -54,7 +54,7 @@ const EntityPageTitle = ({
}) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
{entity && <FavouriteEntity entity={entity} />}
{entity && <FavoriteEntity entity={entity} />}
</Box>
);
@@ -346,4 +346,68 @@ describe('transformSchemaToProps', () => {
uiSchema: expectedUiSchema,
});
});
it('transforms schema with array items', () => {
const inputSchema = {
type: 'object',
properties: {
person: {
type: 'array',
items: {
type: 'object',
properties: {
name: {
type: 'string',
},
address: {
type: 'string',
'ui:widget': 'textarea',
},
},
},
},
accountNumber: {
type: 'number',
},
},
};
const expectedSchema = {
type: 'object',
properties: {
person: {
type: 'array',
items: {
type: 'object',
properties: {
name: {
type: 'string',
},
address: {
type: 'string',
},
},
},
},
accountNumber: {
type: 'number',
},
},
};
const expectedUiSchema = {
accountNumber: {},
person: {
items: {
name: {},
address: {
'ui:widget': 'textarea',
},
},
},
};
expect(transformSchemaToProps(inputSchema)).toEqual({
schema: expectedSchema,
uiSchema: expectedUiSchema,
});
});
});
@@ -26,7 +26,7 @@ function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) {
return;
}
const { properties, anyOf, oneOf, allOf, dependencies } = schema;
const { properties, items, anyOf, oneOf, allOf, dependencies } = schema;
for (const propName in schema) {
if (!schema.hasOwnProperty(propName)) {
@@ -55,6 +55,12 @@ function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) {
}
}
if (isObject(items)) {
const innerUiSchema = {};
uiSchema.items = innerUiSchema;
extractUiSchema(items, innerUiSchema);
}
if (Array.isArray(anyOf)) {
for (const schemaNode of anyOf) {
if (!isObject(schemaNode)) {
+27 -22
View File
@@ -75,30 +75,35 @@ export interface Config {
* Authentication credentials for ElasticSearch
* If both ApiKey/Bearer token and username+password is provided, tokens take precedence
*/
auth?: {
username?: string;
auth?:
| {
username: string;
/**
* @visibility secret
*/
password?: string;
/**
* @visibility secret
*/
password: string;
}
| {
/**
* Base64 Encoded API key to be used to connect to the cluster.
* See: https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-api-key.html
*
* @visibility secret
*/
apiKey: string;
};
/* TODO(kuangp): unsupported until @elastic/elasticsearch@7.14 is released
| {
/**
* Base64 Encoded API key to be used to connect to the cluster.
* See: https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-api-key.html
*
* @visibility secret
*/
apiKey?: string;
/**
* Bearer authentication token to connect to the cluster.
* See: https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-service-token.html
*
* @visibility secret
*/
bearer?: string;
};
/**
* Bearer authentication token to connect to the cluster.
* See: https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-create-service-token.html
*
* @visibility secret
*
bearer: string;
};*/
};
};
}
@@ -35,13 +35,6 @@ export type ConcreteElasticSearchQuery = {
elasticSearchQuery: Object;
};
type ElasticConfigAuth = {
username: string;
password: string;
apiKey: string;
bearer: string;
};
type ElasticSearchQueryTranslator = (
query: SearchQuery,
) => ConcreteElasticSearchQuery;
@@ -105,11 +98,15 @@ export class ElasticSearchSearchEngine implements SearchEngine {
if (config.getOptionalString('provider') === 'elastic') {
logger.info('Initializing Elastic.co ElasticSearch search engine.');
const authConfig = config.getConfig('auth');
return new Client({
cloud: {
id: config.getString('cloudId'),
},
auth: config.get<ElasticConfigAuth>('auth'),
auth: {
username: authConfig.getString('username'),
password: authConfig.getString('password'),
},
});
}
if (config.getOptionalString('provider') === 'aws') {
@@ -122,9 +119,20 @@ export class ElasticSearchSearchEngine implements SearchEngine {
});
}
logger.info('Initializing ElasticSearch search engine.');
const authConfig = config.getOptionalConfig('auth');
const auth =
authConfig &&
(authConfig.has('apiKey')
? {
apiKey: authConfig.getString('apiKey'),
}
: {
username: authConfig.getString('username'),
password: authConfig.getString('password'),
});
return new Client({
node: config.getString('node'),
auth: config.get<ElasticConfigAuth>('auth'),
auth,
});
}
+1 -1
View File
@@ -86,7 +86,7 @@ export const AddShortcut = ({ onClose, anchorEl, api }: Props) => {
<Popover
open={open}
anchorEl={anchorEl}
onExit={handleClose}
TransitionProps={{ onExit: handleClose }}
onClose={onClose}
anchorOrigin={{
vertical: 'top',
+122 -3
View File
@@ -5,6 +5,7 @@
```ts
/// <reference types="react" />
import { Action } from '@material-table/core';
import { ApiRef } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { Config } from '@backstage/config';
@@ -14,7 +15,65 @@ import { Entity } from '@backstage/catalog-model';
import { EntityName } from '@backstage/catalog-model';
import { IdentityApi } from '@backstage/core-plugin-api';
import { LocationSpec } from '@backstage/catalog-model';
import { default as React_2 } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
import { TableColumn } from '@backstage/core-components';
import { TableProps } from '@backstage/core-components';
import { UserListFilterKind } from '@backstage/plugin-catalog-react';
// Warning: (ae-missing-release-tag) "createCopyDocsUrlAction" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
function createCopyDocsUrlAction(copyToClipboard: Function): (
row: DocsTableRow,
) => {
icon: () => JSX.Element;
tooltip: string;
onClick: () => any;
};
// Warning: (ae-missing-release-tag) "createNameColumn" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
function createNameColumn(): TableColumn<DocsTableRow>;
// Warning: (ae-missing-release-tag) "createOwnerColumn" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
function createOwnerColumn(): TableColumn<DocsTableRow>;
// Warning: (ae-missing-release-tag) "createStarEntityAction" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
function createStarEntityAction(
isStarredEntity: Function,
toggleStarredEntity: Function,
): ({ entity }: DocsTableRow) => {
cellStyle: {
paddingLeft: string;
};
icon: () => JSX.Element;
tooltip: string;
onClick: () => any;
};
// Warning: (ae-missing-release-tag) "createTypeColumn" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
function createTypeColumn(): TableColumn<DocsTableRow>;
// Warning: (ae-missing-release-tag) "DefaultTechDocsHome" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const DefaultTechDocsHome: ({
initialFilter,
columns,
actions,
}: {
initialFilter?: UserListFilterKind | undefined;
columns?: TableColumn<DocsTableRow>[] | undefined;
actions?: TableProps<DocsTableRow>['actions'];
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "DocsCardGrid" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -42,16 +101,58 @@ export const DocsResultListItem: ({
export const DocsTable: ({
entities,
title,
loading,
columns,
actions,
}: {
entities: Entity[] | undefined;
title?: string | undefined;
loading?: boolean | undefined;
columns?: TableColumn<DocsTableRow>[] | undefined;
actions?:
| (
| Action<DocsTableRow>
| {
action: (rowData: DocsTableRow) => Action<DocsTableRow>;
position: string;
}
| ((rowData: DocsTableRow) => Action<DocsTableRow>)
)[]
| undefined;
}) => JSX.Element | null;
// Warning: (ae-missing-release-tag) "DocsTableRow" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type DocsTableRow = {
entity: Entity;
resolved: {
docsUrl: string;
ownedByRelationsTitle: string;
ownedByRelations: EntityName[];
};
};
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "EmbeddedDocsRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const EmbeddedDocsRouter: (_props: Props) => JSX.Element;
export const EmbeddedDocsRouter: (_props: Props_2) => JSX.Element;
// Warning: (ae-missing-release-tag) "EntityListDocsTable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const EntityListDocsTable: {
({
columns,
actions,
}: {
columns?: TableColumn<DocsTableRow>[] | undefined;
actions?: TableProps<DocsTableRow>['actions'];
}): JSX.Element;
columns: typeof columnFactories;
actions: typeof actionFactories;
};
// Warning: (ae-missing-release-tag) "EntityTechdocsContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -69,7 +170,7 @@ export type PanelType = 'DocsCardGrid' | 'DocsTable';
// Warning: (ae-missing-release-tag) "Reader" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const Reader: ({ entityId, onReady }: Props_2) => JSX.Element;
export const Reader: ({ entityId, onReady }: Props_3) => JSX.Element;
// Warning: (ae-missing-release-tag) "Router" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -143,11 +244,27 @@ export const TechDocsCustomHome: ({
tabsConfig: TabsConfig;
}) => JSX.Element;
// Warning: (ae-missing-release-tag) "TechDocsIndexPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const TechDocsIndexPage: () => JSX.Element;
// Warning: (ae-missing-release-tag) "TechdocsPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const TechdocsPage: () => JSX.Element;
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "TechDocsPageWrapper" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const TechDocsPageWrapper: ({ children }: Props) => JSX.Element;
// Warning: (ae-missing-release-tag) "TechDocsPicker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const TechDocsPicker: () => null;
// Warning: (ae-missing-release-tag) "techdocsPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -255,7 +372,9 @@ export class TechDocsStorageClient implements TechDocsStorageApi {
// Warnings were encountered during analysis:
//
// src/plugin.d.ts:18:5 - (ae-forgotten-export) The symbol "TabsConfig" needs to be exported by the entry point index.d.ts
// src/home/components/EntityListDocsTable.d.ts:11:5 - (ae-forgotten-export) The symbol "columnFactories" needs to be exported by the entry point index.d.ts
// src/home/components/EntityListDocsTable.d.ts:12:5 - (ae-forgotten-export) The symbol "actionFactories" needs to be exported by the entry point index.d.ts
// src/plugin.d.ts:24:5 - (ae-forgotten-export) The symbol "TabsConfig" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)
```
+4 -3
View File
@@ -38,14 +38,17 @@
"@backstage/errors": "^0.1.1",
"@backstage/integration": "^0.5.9",
"@backstage/integration-react": "^0.1.6",
"@backstage/plugin-catalog": "^0.6.10",
"@backstage/plugin-catalog-react": "^0.4.1",
"@backstage/theme": "^0.2.9",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@material-ui/styles": "^4.10.0",
"@types/react": "*",
"dompurify": "^2.2.9",
"eventsource": "^1.1.0",
"event-source-polyfill": "^1.0.25",
"lodash": "^4.17.21",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-lazylog": "^4.5.2",
@@ -65,10 +68,8 @@
"@testing-library/react-hooks": "^3.4.2",
"@testing-library/user-event": "^13.1.8",
"@types/dompurify": "^2.2.2",
"@types/eventsource": "^1.1.5",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"@types/react": "*",
"canvas": "^2.6.1",
"cross-fetch": "^3.0.6",
"msw": "^0.29.0"
+7 -4
View File
@@ -23,8 +23,8 @@ import {
rootDocsRouteRef,
rootCatalogDocsRouteRef,
} from './routes';
import { TechDocsHome } from './home/components/TechDocsHome';
import { TechDocsPage } from './reader/components/TechDocsPage';
import { TechDocsIndexPage } from './home/components/TechDocsIndexPage';
import { TechDocsPage as TechDocsReaderPage } from './reader/components/TechDocsPage';
import { EntityPageDocs } from './EntityPageDocs';
import { MissingAnnotationEmptyState } from '@backstage/core-components';
@@ -33,8 +33,11 @@ const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
export const Router = () => {
return (
<Routes>
<Route path={`/${rootRouteRef.path}`} element={<TechDocsHome />} />
<Route path={`/${rootDocsRouteRef.path}`} element={<TechDocsPage />} />
<Route path={`/${rootRouteRef.path}`} element={<TechDocsIndexPage />} />
<Route
path={`/${rootDocsRouteRef.path}`}
element={<TechDocsReaderPage />}
/>
</Routes>
);
};
+13 -12
View File
@@ -18,13 +18,14 @@ import { Config } from '@backstage/config';
import { UrlPatternDiscovery } from '@backstage/core-app-api';
import { IdentityApi } from '@backstage/core-plugin-api';
import { NotFoundError } from '@backstage/errors';
import EventSource from 'eventsource';
import { EventSourcePolyfill } from 'event-source-polyfill';
import { TechDocsStorageClient } from './client';
const MockedEventSource: jest.MockedClass<typeof EventSource> =
EventSource as any;
const MockedEventSource = EventSourcePolyfill as jest.MockedClass<
typeof EventSourcePolyfill
>;
jest.mock('eventsource');
jest.mock('event-source-polyfill');
const mockEntity = {
kind: 'Component',
@@ -82,7 +83,7 @@ describe('TechDocsStorageClient', () => {
MockedEventSource.prototype.addEventListener.mockImplementation(
(type, fn) => {
if (type === 'finish') {
if (type === 'finish' && typeof fn === 'function') {
fn({ data: '{"updated": false}' } as any);
}
},
@@ -106,7 +107,7 @@ describe('TechDocsStorageClient', () => {
MockedEventSource.prototype.addEventListener.mockImplementation(
(type, fn) => {
if (type === 'finish') {
if (type === 'finish' && typeof fn === 'function') {
fn({ data: '{"updated": false}' } as any);
}
},
@@ -132,7 +133,7 @@ describe('TechDocsStorageClient', () => {
MockedEventSource.prototype.addEventListener.mockImplementation(
(type, fn) => {
if (type === 'finish') {
if (type === 'finish' && typeof fn === 'function') {
fn({ data: '{"updated": false}' } as any);
}
},
@@ -153,7 +154,7 @@ describe('TechDocsStorageClient', () => {
MockedEventSource.prototype.addEventListener.mockImplementation(
(type, fn) => {
if (type === 'finish') {
if (type === 'finish' && typeof fn === 'function') {
fn({ data: '{"updated": true}' } as any);
}
},
@@ -174,11 +175,11 @@ describe('TechDocsStorageClient', () => {
MockedEventSource.prototype.addEventListener.mockImplementation(
(type, fn) => {
if (type === 'log') {
if (type === 'log' && typeof fn === 'function') {
fn({ data: '"A log message"' } as any);
}
if (type === 'finish') {
if (type === 'finish' && typeof fn === 'function') {
fn({ data: '{"updated": false}' } as any);
}
},
@@ -210,7 +211,7 @@ describe('TechDocsStorageClient', () => {
const instance = MockedEventSource.mock
.instances[0] as jest.Mocked<EventSource>;
instance.onerror({
instance.onerror?.({
status: 404,
message: 'Some not found warning',
} as any);
@@ -236,7 +237,7 @@ describe('TechDocsStorageClient', () => {
const instance = MockedEventSource.mock
.instances[0] as jest.Mocked<EventSource>;
instance.onerror({
instance.onerror?.({
type: 'error',
data: 'Some other error',
} as any);
+3 -2
View File
@@ -18,7 +18,7 @@ import { EntityName } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
import { NotFoundError, ResponseError } from '@backstage/errors';
import EventSource from 'eventsource';
import { EventSourcePolyfill } from 'event-source-polyfill';
import { SyncResult, TechDocsApi, TechDocsStorageApi } from './api';
import { TechDocsEntityMetadata, TechDocsMetadata } from './types';
@@ -214,7 +214,8 @@ export class TechDocsStorageClient implements TechDocsStorageApi {
const token = await this.identityApi.getIdToken();
return new Promise((resolve, reject) => {
const source = new EventSource(url, {
// Polyfill is used to add support for custom headers and auth
const source = new EventSourcePolyfill(url, {
withCredentials: true,
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
+27
View File
@@ -0,0 +1,27 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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.
*/
declare module 'event-source-polyfill' {
export class EventSourcePolyfill extends EventSource {
constructor(
url: string,
options?: {
withCredentials?: boolean;
headers?: HeadersInit;
},
);
}
}
@@ -0,0 +1,84 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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, catalogApiRef } from '@backstage/plugin-catalog-react';
import { MockStorageApi, renderInTestApp } from '@backstage/test-utils';
import { screen } from '@testing-library/react';
import React from 'react';
import { DefaultTechDocsHome } from './DefaultTechDocsHome';
import {
ApiProvider,
ApiRegistry,
ConfigReader,
} from '@backstage/core-app-api';
import {
ConfigApi,
configApiRef,
storageApiRef,
} from '@backstage/core-plugin-api';
jest.mock('@backstage/plugin-catalog-react', () => {
const actual = jest.requireActual('@backstage/plugin-catalog-react');
return {
...actual,
useOwnUser: () => 'test-user',
};
});
const mockCatalogApi = {
getEntityByName: () => Promise.resolve(),
getEntities: async () => ({
items: [
{
apiVersion: 'version',
kind: 'User',
metadata: {
name: 'owned',
namespace: 'default',
},
},
],
}),
} as Partial<CatalogApi>;
describe('TechDocs Home', () => {
const configApi: ConfigApi = new ConfigReader({
organization: {
name: 'My Company',
},
});
const apiRegistry = ApiRegistry.from([
[catalogApiRef, mockCatalogApi],
[configApiRef, configApi],
[storageApiRef, MockStorageApi.create()],
]);
it('should render a TechDocs home page', async () => {
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<DefaultTechDocsHome />
</ApiProvider>,
);
// Header
expect(await screen.findByText('Documentation')).toBeInTheDocument();
expect(
await screen.findByText(/Documentation available in My Company/i),
).toBeInTheDocument();
});
});
@@ -0,0 +1,75 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 {
Content,
ContentHeader,
SupportButton,
TableColumn,
TableProps,
} from '@backstage/core-components';
import {
EntityListContainer,
FilterContainer,
FilteredEntityLayout,
} from '@backstage/plugin-catalog';
import {
EntityListProvider,
EntityOwnerPicker,
EntityTagPicker,
UserListFilterKind,
UserListPicker,
} from '@backstage/plugin-catalog-react';
import { EntityListDocsTable } from './EntityListDocsTable';
import { TechDocsPageWrapper } from './TechDocsPageWrapper';
import { TechDocsPicker } from './TechDocsPicker';
import { DocsTableRow } from './types';
export const DefaultTechDocsHome = ({
initialFilter = 'all',
columns,
actions,
}: {
initialFilter?: UserListFilterKind;
columns?: TableColumn<DocsTableRow>[];
actions?: TableProps<DocsTableRow>['actions'];
}) => {
return (
<TechDocsPageWrapper>
<Content>
<ContentHeader title="">
<SupportButton>
Discover documentation in your ecosystem.
</SupportButton>
</ContentHeader>
<EntityListProvider>
<FilteredEntityLayout>
<FilterContainer>
<TechDocsPicker />
<UserListPicker initialFilter={initialFilter} />
<EntityOwnerPicker />
<EntityTagPicker />
</FilterContainer>
<EntityListContainer>
<EntityListDocsTable actions={actions} columns={columns} />
</EntityListContainer>
</FilteredEntityLayout>
</EntityListProvider>
</Content>
</TechDocsPageWrapper>
);
};
@@ -18,91 +18,83 @@ import React from 'react';
import { useCopyToClipboard } from 'react-use';
import { generatePath } from 'react-router-dom';
import { IconButton, Tooltip } from '@material-ui/core';
import ShareIcon from '@material-ui/icons/Share';
import { Entity } from '@backstage/catalog-model';
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
import {
formatEntityRefTitle,
getEntityRelations,
} from '@backstage/plugin-catalog-react';
import { rootDocsRouteRef } from '../../routes';
import {
Table,
EmptyState,
Button,
SubvalueCell,
Link,
EmptyState,
Table,
TableColumn,
TableProps,
} from '@backstage/core-components';
import * as actionFactories from './actions';
import * as columnFactories from './columns';
import { DocsTableRow } from './types';
export const DocsTable = ({
entities,
title,
loading,
columns,
actions,
}: {
entities: Entity[] | undefined;
title?: string | undefined;
loading?: boolean | undefined;
columns?: TableColumn<DocsTableRow>[];
actions?: TableProps<DocsTableRow>['actions'];
}) => {
const [, copyToClipboard] = useCopyToClipboard();
if (!entities) return null;
const documents = entities.map(entity => {
const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY);
return {
name: entity.metadata.name,
description: entity.metadata.description,
owner: entity?.spec?.owner,
type: entity?.spec?.type,
docsUrl: generatePath(rootDocsRouteRef.path, {
namespace: entity.metadata.namespace ?? 'default',
kind: entity.kind,
name: entity.metadata.name,
}),
entity,
resolved: {
docsUrl: generatePath(rootDocsRouteRef.path, {
namespace: entity.metadata.namespace ?? 'default',
kind: entity.kind,
name: entity.metadata.name,
}),
ownedByRelations,
ownedByRelationsTitle: ownedByRelations
.map(r => formatEntityRefTitle(r, { defaultKind: 'group' }))
.join(', '),
},
};
});
const columns = [
{
title: 'Document',
field: 'name',
highlight: true,
render: (row: any): React.ReactNode => (
<SubvalueCell
value={<Link to={row.docsUrl}>{row.name}</Link>}
subvalue={row.description}
/>
),
},
{
title: 'Owner',
field: 'owner',
},
{
title: 'Type',
field: 'type',
},
{
title: 'Actions',
width: '10%',
render: (row: any) => (
<Tooltip title="Click to copy documentation link to clipboard">
<IconButton
onClick={() =>
copyToClipboard(`${window.location.href}/${row.docsUrl}`)
}
>
<ShareIcon />
</IconButton>
</Tooltip>
),
},
const defaultColumns: TableColumn<DocsTableRow>[] = [
columnFactories.createNameColumn(),
columnFactories.createOwnerColumn(),
columnFactories.createTypeColumn(),
];
const defaultActions: TableProps<DocsTableRow>['actions'] = [
actionFactories.createCopyDocsUrlAction(copyToClipboard),
];
return (
<>
{documents && documents.length > 0 ? (
<Table
{loading || (documents && documents.length > 0) ? (
<Table<DocsTableRow>
isLoading={loading}
options={{
paging: true,
pageSize: 20,
search: true,
actionsColumnIndex: -1,
}}
data={documents}
columns={columns}
columns={columns || defaultColumns}
actions={actions || defaultActions}
title={
title
? `${title} (${documents.length})`
@@ -0,0 +1,79 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { useCopyToClipboard } from 'react-use';
import { capitalize } from 'lodash';
import {
CodeSnippet,
TableColumn,
TableProps,
WarningPanel,
} from '@backstage/core-components';
import {
useEntityListProvider,
useStarredEntities,
} from '@backstage/plugin-catalog-react';
import { DocsTable } from './DocsTable';
import * as actionFactories from './actions';
import * as columnFactories from './columns';
import { DocsTableRow } from './types';
export const EntityListDocsTable = ({
columns,
actions,
}: {
columns?: TableColumn<DocsTableRow>[];
actions?: TableProps<DocsTableRow>['actions'];
}) => {
const { loading, error, entities, filters } = useEntityListProvider();
const { isStarredEntity, toggleStarredEntity } = useStarredEntities();
const [, copyToClipboard] = useCopyToClipboard();
const title = capitalize(filters.user?.value ?? 'all');
const defaultActions = [
actionFactories.createCopyDocsUrlAction(copyToClipboard),
actionFactories.createStarEntityAction(
isStarredEntity,
toggleStarredEntity,
),
];
if (error) {
return (
<WarningPanel
severity="error"
title="Could not load available documentation."
>
<CodeSnippet language="text" text={error.toString()} />
</WarningPanel>
);
}
return (
<DocsTable
title={title}
entities={entities}
loading={loading}
actions={actions || defaultActions}
columns={columns}
/>
);
};
EntityListDocsTable.columns = columnFactories;
EntityListDocsTable.actions = actionFactories;
@@ -18,7 +18,7 @@ import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import { screen } from '@testing-library/react';
import React from 'react';
import { TechDocsHome } from './TechDocsHome';
import { LegacyTechDocsHome } from './LegacyTechDocsHome';
import {
ApiProvider,
@@ -51,7 +51,7 @@ const mockCatalogApi = {
}),
} as Partial<CatalogApi>;
describe('TechDocs Home', () => {
describe('Legacy TechDocs Home', () => {
const configApi: ConfigApi = new ConfigReader({
organization: {
name: 'My Company',
@@ -66,7 +66,7 @@ describe('TechDocs Home', () => {
it('should render a TechDocs home page', async () => {
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<TechDocsHome />
<LegacyTechDocsHome />
</ApiProvider>,
);
@@ -17,7 +17,7 @@
import React from 'react';
import { PanelType, TechDocsCustomHome } from './TechDocsCustomHome';
export const TechDocsHome = () => {
export const LegacyTechDocsHome = () => {
const tabsConfig = [
{
label: 'Overview',
@@ -28,20 +28,19 @@ import {
import { Entity } from '@backstage/catalog-model';
import { DocsTable } from './DocsTable';
import { DocsCardGrid } from './DocsCardGrid';
import { TechDocsPageWrapper } from './TechDocsPageWrapper';
import {
CodeSnippet,
Content,
Header,
HeaderTabs,
Page,
Progress,
WarningPanel,
SupportButton,
ContentHeader,
} from '@backstage/core-components';
import { ConfigApi, configApiRef, useApi } from '@backstage/core-plugin-api';
import { useApi } from '@backstage/core-plugin-api';
const panels = {
DocsTable: DocsTable,
@@ -122,7 +121,6 @@ export const TechDocsCustomHome = ({
}) => {
const [selectedTab, setSelectedTab] = useState<number>(0);
const catalogApi: CatalogApi = useApi(catalogApiRef);
const configApi: ConfigApi = useApi(configApiRef);
const {
value: entities,
@@ -147,27 +145,21 @@ export const TechDocsCustomHome = ({
});
});
const generatedSubtitle = `Documentation available in ${
configApi.getOptionalString('organization.name') ?? 'Backstage'
}`;
const currentTabConfig = tabsConfig[selectedTab];
if (loading) {
return (
<Page themeId="documentation">
<Header title="Documentation" subtitle={generatedSubtitle} />
<TechDocsPageWrapper>
<Content>
<Progress />
</Content>
</Page>
</TechDocsPageWrapper>
);
}
if (error) {
return (
<Page themeId="documentation">
<Header title="Documentation" subtitle={generatedSubtitle} />
<TechDocsPageWrapper>
<Content>
<WarningPanel
severity="error"
@@ -176,13 +168,12 @@ export const TechDocsCustomHome = ({
<CodeSnippet language="text" text={error.toString()} />
</WarningPanel>
</Content>
</Page>
</TechDocsPageWrapper>
);
}
return (
<Page themeId="documentation">
<Header title="Documentation" subtitle={generatedSubtitle} />
<TechDocsPageWrapper>
<HeaderTabs
selectedIndex={selectedTab}
onChange={index => setSelectedTab(index)}
@@ -201,6 +192,6 @@ export const TechDocsCustomHome = ({
/>
))}
</Content>
</Page>
</TechDocsPageWrapper>
);
};
@@ -0,0 +1,44 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { renderInTestApp } from '@backstage/test-utils';
import { useOutlet } from 'react-router';
import { TechDocsIndexPage } from './TechDocsIndexPage';
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useOutlet: jest.fn().mockReturnValue('Route Children'),
}));
jest.mock('./LegacyTechDocsHome', () => ({
LegacyTechDocsHome: jest.fn().mockReturnValue('LegacyTechDocsHomeMock'),
}));
describe('TechDocsIndexPage', () => {
it('renders provided router element', async () => {
const { getByText } = await renderInTestApp(<TechDocsIndexPage />);
expect(getByText('Route Children')).toBeInTheDocument();
});
it('renders legacy TechDocs home when no router children are provided', async () => {
(useOutlet as jest.Mock).mockReturnValueOnce(null);
const { getByText } = await renderInTestApp(<TechDocsIndexPage />);
expect(getByText('LegacyTechDocsHomeMock')).toBeInTheDocument();
});
});
@@ -0,0 +1,25 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { useOutlet } from 'react-router';
import { LegacyTechDocsHome } from './LegacyTechDocsHome';
export const TechDocsIndexPage = () => {
const outlet = useOutlet();
return outlet || <LegacyTechDocsHome />;
};
@@ -0,0 +1,41 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { PageWithHeader } from '@backstage/core-components';
import { useApi, configApiRef } from '@backstage/core-plugin-api';
type Props = {
children?: React.ReactNode;
};
export const TechDocsPageWrapper = ({ children }: Props) => {
const configApi = useApi(configApiRef);
const generatedSubtitle = `Documentation available in ${
configApi.getOptionalString('organization.name') ?? 'Backstage'
}`;
return (
<PageWithHeader
title="Documentation"
subtitle={generatedSubtitle}
themeId="documentation"
>
{children}
</PageWithHeader>
);
};
@@ -0,0 +1,47 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { useEffect } from 'react';
import {
CATALOG_FILTER_EXISTS,
DefaultEntityFilters,
EntityFilter,
useEntityListProvider,
} from '@backstage/plugin-catalog-react';
class TechDocsFilter implements EntityFilter {
getCatalogFilters(): Record<string, string | symbol | (string | symbol)[]> {
return {
'metadata.annotations.backstage.io/techdocs-ref': CATALOG_FILTER_EXISTS,
};
}
}
type CustomFilters = DefaultEntityFilters & {
techdocs?: TechDocsFilter;
};
export const TechDocsPicker = () => {
const { updateFilters } = useEntityListProvider<CustomFilters>();
useEffect(() => {
updateFilters({
techdocs: new TechDocsFilter(),
});
}, [updateFilters]);
return null;
};
@@ -0,0 +1,54 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 ShareIcon from '@material-ui/icons/Share';
import {
favoriteEntityIcon,
favoriteEntityTooltip,
} from '@backstage/plugin-catalog-react';
import { DocsTableRow } from './types';
export function createCopyDocsUrlAction(copyToClipboard: Function) {
return (row: DocsTableRow) => {
return {
icon: () => <ShareIcon fontSize="small" />,
tooltip: 'Click to copy documentation link to clipboard',
onClick: () =>
copyToClipboard(
`${window.location.origin}${window.location.pathname.replace(
/\/?$/,
'/',
)}${row.resolved.docsUrl}`,
),
};
};
}
export function createStarEntityAction(
isStarredEntity: Function,
toggleStarredEntity: Function,
) {
return ({ entity }: DocsTableRow) => {
const isStarred = isStarredEntity(entity);
return {
cellStyle: { paddingLeft: '1em' },
icon: () => favoriteEntityIcon(isStarred),
tooltip: favoriteEntityTooltip(isStarred),
onClick: () => toggleStarredEntity(entity),
};
};
}
@@ -0,0 +1,56 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { Link, SubvalueCell, TableColumn } from '@backstage/core-components';
import { EntityRefLinks } from '@backstage/plugin-catalog-react';
import { DocsTableRow } from './types';
export function createNameColumn(): TableColumn<DocsTableRow> {
return {
title: 'Document',
field: 'entity.metadata.name',
highlight: true,
render: (row: DocsTableRow) => (
<SubvalueCell
value={
<Link to={row.resolved.docsUrl}>{row.entity.metadata.name}</Link>
}
subvalue={row.entity.metadata.description}
/>
),
};
}
export function createOwnerColumn(): TableColumn<DocsTableRow> {
return {
title: 'Owner',
field: 'resolved.ownedByRelationsTitle',
render: ({ resolved }) => (
<EntityRefLinks
entityRefs={resolved.ownedByRelations}
defaultKind="group"
/>
),
};
}
export function createTypeColumn(): TableColumn<DocsTableRow> {
return {
title: 'Type',
field: 'entity.spec.type',
};
}
@@ -0,0 +1,22 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { EntityListDocsTable } from './EntityListDocsTable';
export { DefaultTechDocsHome } from './DefaultTechDocsHome';
export { TechDocsPageWrapper } from './TechDocsPageWrapper';
export { TechDocsPicker } from './TechDocsPicker';
export type { PanelType } from './TechDocsCustomHome';
export type { DocsTableRow } from './types';
@@ -0,0 +1,26 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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';
export type DocsTableRow = {
entity: Entity;
resolved: {
docsUrl: string;
ownedByRelationsTitle: string;
ownedByRelations: EntityName[];
};
};
+8 -1
View File
@@ -18,13 +18,20 @@ export * from './api';
export { techdocsApiRef, techdocsStorageApiRef } from './api';
export type { TechDocsApi, TechDocsStorageApi } from './api';
export { TechDocsClient, TechDocsStorageClient } from './client';
export type { PanelType } from './home/components/TechDocsCustomHome';
export type { DocsTableRow, PanelType } from './home/components';
export {
EntityListDocsTable,
DefaultTechDocsHome,
TechDocsPageWrapper,
TechDocsPicker,
} from './home/components';
export * from './components/DocsResultListItem';
export {
DocsCardGrid,
DocsTable,
EntityTechdocsContent,
TechDocsCustomHome,
TechDocsIndexPage,
TechdocsPage,
techdocsPlugin as plugin,
techdocsPlugin,
+10
View File
@@ -113,6 +113,16 @@ export const TechDocsCustomHome = techdocsPlugin.provide(
}),
);
export const TechDocsIndexPage = techdocsPlugin.provide(
createRoutableExtension({
component: () =>
import('./home/components/TechDocsIndexPage').then(
m => m.TechDocsIndexPage,
),
mountPoint: rootRouteRef,
}),
);
export const TechDocsReaderPage = techdocsPlugin.provide(
createRoutableExtension({
component: () =>
+200 -217
View File
@@ -5385,18 +5385,18 @@
integrity sha512-VYOdo8P7lIScAkl02nB9KpUAuOYMManryBIBuKJkAw5D3aVtLobfmdIKvdV6MqEmGMEQPbn7w/UpnjJYhUH+IA==
"@storybook/addon-a11y@^6.3.4":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-6.3.6.tgz#6d9dbdaa067a8f6ad15cef64f2103568457d2aae"
integrity sha512-IYVDFEGgKORdm7NPZPqltOvu29R9LaZwGBvfzbS3GUc3I9XLbT/cxkZN68Zzmjn2GtP/X1v4uLqp57OsPb4Cdg==
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-6.3.7.tgz#a802455f2d932eda07314e3d44a96c94bbd22b3d"
integrity sha512-Z5Lhxm8r5CkPW9FYf6zmAk9c7IhUeUQZxKZeEWGZdOvcjQ32rtg4IYvO2SHgWNrEKBdxxFm3pMiyK3wylQLfsQ==
dependencies:
"@storybook/addons" "6.3.6"
"@storybook/api" "6.3.6"
"@storybook/channels" "6.3.6"
"@storybook/client-api" "6.3.6"
"@storybook/client-logger" "6.3.6"
"@storybook/components" "6.3.6"
"@storybook/core-events" "6.3.6"
"@storybook/theming" "6.3.6"
"@storybook/addons" "6.3.7"
"@storybook/api" "6.3.7"
"@storybook/channels" "6.3.7"
"@storybook/client-api" "6.3.7"
"@storybook/client-logger" "6.3.7"
"@storybook/components" "6.3.7"
"@storybook/core-events" "6.3.7"
"@storybook/theming" "6.3.7"
axe-core "^4.2.0"
core-js "^3.8.2"
global "^4.4.0"
@@ -5407,16 +5407,16 @@
util-deprecate "^1.0.2"
"@storybook/addon-actions@^6.1.11":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.3.6.tgz#691d61d6aca9c4b3edba50c531cbe4d4139ed451"
integrity sha512-1MBqCbFiupGEDyIXqFkzF4iR8AduuB7qSNduqtsFauvIkrG5bnlbg5JC7WjnixkCaaWlufgbpasEHioXO9EXGw==
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.3.7.tgz#b25434972bef351aceb3f7ec6fd66e210f256aac"
integrity sha512-CEAmztbVt47Gw1o6Iw0VP20tuvISCEKk9CS/rCjHtb4ubby6+j/bkp3pkEUQIbyLdHiLWFMz0ZJdyA/U6T6jCw==
dependencies:
"@storybook/addons" "6.3.6"
"@storybook/api" "6.3.6"
"@storybook/client-api" "6.3.6"
"@storybook/components" "6.3.6"
"@storybook/core-events" "6.3.6"
"@storybook/theming" "6.3.6"
"@storybook/addons" "6.3.7"
"@storybook/api" "6.3.7"
"@storybook/client-api" "6.3.7"
"@storybook/components" "6.3.7"
"@storybook/core-events" "6.3.7"
"@storybook/theming" "6.3.7"
core-js "^3.8.2"
fast-deep-equal "^3.1.3"
global "^4.4.0"
@@ -5430,15 +5430,15 @@
uuid-browser "^3.1.0"
"@storybook/addon-links@^6.1.11":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-6.3.6.tgz#dc410d5b4a0d222b6b8d0ef03da7a4c16919c092"
integrity sha512-PaeAJTjwtPlhrLZlaSQ1YIFA8V0C1yI0dc351lPbTiE7fJ7DwTE03K6xIF/jEdTo+xzhi2PM1Fgvi/SsSecI8w==
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-6.3.7.tgz#f273abba6d056794a4aa920b2fa9639136e6747f"
integrity sha512-/8Gq18o1DejP3Om0ZOJRkMzW5FoHqoAmR0pFx4DipmNu5lYy7V3krLw4jW4qja1MuQkZ53MGh08FJOoAc2RZvQ==
dependencies:
"@storybook/addons" "6.3.6"
"@storybook/client-logger" "6.3.6"
"@storybook/core-events" "6.3.6"
"@storybook/addons" "6.3.7"
"@storybook/client-logger" "6.3.7"
"@storybook/core-events" "6.3.7"
"@storybook/csf" "0.0.1"
"@storybook/router" "6.3.6"
"@storybook/router" "6.3.7"
"@types/qs" "^6.9.5"
core-js "^3.8.2"
global "^4.4.0"
@@ -5448,17 +5448,17 @@
ts-dedent "^2.0.0"
"@storybook/addon-storysource@^6.1.11":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/addon-storysource/-/addon-storysource-6.3.6.tgz#3ecacd39690675ffcba0d40ca235090fc2bf5574"
integrity sha512-BNNuy/6Vb7NzeCDbt+bCL1dC/XQOalzKHW6FplO0pR0eMSi6EmJJnU9V+5rFAUtG5zxGeCx2h7TrrFecouM2BA==
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/addon-storysource/-/addon-storysource-6.3.7.tgz#7818f2402e19453747274d2c1e8e0a99f24fc2ab"
integrity sha512-cb1F47aD/c5TQDMmYwuKz608YeLWYSYQXGtCdijzjznYVSMJ4eAFnd38AiF2Ax1EM79BEMqzqH1SrveibAaQlA==
dependencies:
"@storybook/addons" "6.3.6"
"@storybook/api" "6.3.6"
"@storybook/client-logger" "6.3.6"
"@storybook/components" "6.3.6"
"@storybook/router" "6.3.6"
"@storybook/source-loader" "6.3.6"
"@storybook/theming" "6.3.6"
"@storybook/addons" "6.3.7"
"@storybook/api" "6.3.7"
"@storybook/client-logger" "6.3.7"
"@storybook/components" "6.3.7"
"@storybook/router" "6.3.7"
"@storybook/source-loader" "6.3.7"
"@storybook/theming" "6.3.7"
core-js "^3.8.2"
estraverse "^5.2.0"
loader-utils "^2.0.0"
@@ -5467,34 +5467,34 @@
react-syntax-highlighter "^13.5.3"
regenerator-runtime "^0.13.7"
"@storybook/addons@6.3.6", "@storybook/addons@^6.1.11":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.3.6.tgz#330fd722bdae8abefeb029583e89e51e62c20b60"
integrity sha512-tVV0vqaEEN9Md4bgScwfrnZYkN8iKZarpkIOFheLev+PHjSp8lgWMK5SNWDlbBYqfQfzrz9xbs+F07bMjfx9jQ==
"@storybook/addons@6.3.7", "@storybook/addons@^6.1.11":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.3.7.tgz#7c6b8d11b65f67b1884f6140437fe996dc39537a"
integrity sha512-9stVjTcc52bqqh7YQex/LpSjJ4e2Czm4/ZYDjIiNy0p4OZEx+yLhL5mZzMWh2NQd6vv+pHASBSxf2IeaR5511A==
dependencies:
"@storybook/api" "6.3.6"
"@storybook/channels" "6.3.6"
"@storybook/client-logger" "6.3.6"
"@storybook/core-events" "6.3.6"
"@storybook/router" "6.3.6"
"@storybook/theming" "6.3.6"
"@storybook/api" "6.3.7"
"@storybook/channels" "6.3.7"
"@storybook/client-logger" "6.3.7"
"@storybook/core-events" "6.3.7"
"@storybook/router" "6.3.7"
"@storybook/theming" "6.3.7"
core-js "^3.8.2"
global "^4.4.0"
regenerator-runtime "^0.13.7"
"@storybook/api@6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/api/-/api-6.3.6.tgz#b110688ae0a970c9443d47b87616a09456f3708e"
integrity sha512-F5VuR1FrEwD51OO/EDDAZXNfF5XmJedYHJLwwCB4az2ZMrzG45TxGRmiEohrSTO6wAHGkAvjlEoX5jWOCqQ4pw==
"@storybook/api@6.3.7":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/api/-/api-6.3.7.tgz#88b8a51422cd0739c91bde0b1d65fb6d8a8485d0"
integrity sha512-57al8mxmE9agXZGo8syRQ8UhvGnDC9zkuwkBPXchESYYVkm3Mc54RTvdAOYDiy85VS4JxiGOywHayCaRwgUddQ==
dependencies:
"@reach/router" "^1.3.4"
"@storybook/channels" "6.3.6"
"@storybook/client-logger" "6.3.6"
"@storybook/core-events" "6.3.6"
"@storybook/channels" "6.3.7"
"@storybook/client-logger" "6.3.7"
"@storybook/core-events" "6.3.7"
"@storybook/csf" "0.0.1"
"@storybook/router" "6.3.6"
"@storybook/router" "6.3.7"
"@storybook/semver" "^7.3.2"
"@storybook/theming" "6.3.6"
"@storybook/theming" "6.3.7"
"@types/reach__router" "^1.3.7"
core-js "^3.8.2"
fast-deep-equal "^3.1.3"
@@ -5508,10 +5508,10 @@
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/builder-webpack4@6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/builder-webpack4/-/builder-webpack4-6.3.6.tgz#fe444abfc178e005ea077e2bcfd6ae7509522908"
integrity sha512-LhTPQQowS2t6BRnyfusWZLbhjjf54/HiQyovJTTDnqrCiO6QoCMbVnp79LeO1aSkpQCKoeqOZ7TzH87fCytnZA==
"@storybook/builder-webpack4@6.3.7":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/builder-webpack4/-/builder-webpack4-6.3.7.tgz#1cc1a1184043be3f6ef840d0b43ba91a803105e2"
integrity sha512-M5envblMzAUrNqP1+ouKiL8iSIW/90+kBRU2QeWlZoZl1ib+fiFoKk06cgbaC70Bx1lU8nOnI/VBvB5pLhXLaw==
dependencies:
"@babel/core" "^7.12.10"
"@babel/plugin-proposal-class-properties" "^7.12.1"
@@ -5534,20 +5534,20 @@
"@babel/preset-env" "^7.12.11"
"@babel/preset-react" "^7.12.10"
"@babel/preset-typescript" "^7.12.7"
"@storybook/addons" "6.3.6"
"@storybook/api" "6.3.6"
"@storybook/channel-postmessage" "6.3.6"
"@storybook/channels" "6.3.6"
"@storybook/client-api" "6.3.6"
"@storybook/client-logger" "6.3.6"
"@storybook/components" "6.3.6"
"@storybook/core-common" "6.3.6"
"@storybook/core-events" "6.3.6"
"@storybook/node-logger" "6.3.6"
"@storybook/router" "6.3.6"
"@storybook/addons" "6.3.7"
"@storybook/api" "6.3.7"
"@storybook/channel-postmessage" "6.3.7"
"@storybook/channels" "6.3.7"
"@storybook/client-api" "6.3.7"
"@storybook/client-logger" "6.3.7"
"@storybook/components" "6.3.7"
"@storybook/core-common" "6.3.7"
"@storybook/core-events" "6.3.7"
"@storybook/node-logger" "6.3.7"
"@storybook/router" "6.3.7"
"@storybook/semver" "^7.3.2"
"@storybook/theming" "6.3.6"
"@storybook/ui" "6.3.6"
"@storybook/theming" "6.3.7"
"@storybook/ui" "6.3.7"
"@types/node" "^14.0.10"
"@types/webpack" "^4.41.26"
autoprefixer "^9.8.6"
@@ -5584,38 +5584,38 @@
webpack-hot-middleware "^2.25.0"
webpack-virtual-modules "^0.2.2"
"@storybook/channel-postmessage@6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.3.6.tgz#f29c3678161462428e78c9cfed2da11ffca4acb0"
integrity sha512-GK7hXnaa+1pxEeMpREDzAZ3+2+k1KN1lbrZf+V7Kc1JZv1/Ji/vxk8AgxwiuzPAMx5J0yh/FduPscIPZ87Pibw==
"@storybook/channel-postmessage@6.3.7":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/channel-postmessage/-/channel-postmessage-6.3.7.tgz#bd4edf84a29aa2cd4a22d26115c60194d289a840"
integrity sha512-Cmw8HRkeSF1yUFLfEIUIkUICyCXX8x5Ol/5QPbiW9HPE2hbZtYROCcg4bmWqdq59N0Tp9FQNSn+9ZygPgqQtNw==
dependencies:
"@storybook/channels" "6.3.6"
"@storybook/client-logger" "6.3.6"
"@storybook/core-events" "6.3.6"
"@storybook/channels" "6.3.7"
"@storybook/client-logger" "6.3.7"
"@storybook/core-events" "6.3.7"
core-js "^3.8.2"
global "^4.4.0"
qs "^6.10.0"
telejson "^5.3.2"
"@storybook/channels@6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.3.6.tgz#a258764ed78fd836ff90489ae74ac055312bf056"
integrity sha512-gCIQVr+dS/tg3AyCxIvkOXMVAs08BCIHXsaa2+XzmacnJBSP+CEHtI6IZ8WEv7tzZuXOiKLVg+wugeIh4j2I4g==
"@storybook/channels@6.3.7":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.3.7.tgz#85ed5925522b802d959810f78d37aacde7fea66e"
integrity sha512-aErXO+SRO8MPp2wOkT2n9d0fby+8yM35tq1tI633B4eQsM74EykbXPv7EamrYPqp1AI4BdiloyEpr0hmr2zlvg==
dependencies:
core-js "^3.8.2"
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/client-api@6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.3.6.tgz#4826ce366ae109f608da6ade24b29efeb9b7f7dd"
integrity sha512-Q/bWuH691L6k7xkiKtBmZo8C+ijgmQ+vc2Fz8pzIRZuMV8ROL74qhrS4BMKV4LhiYm4f8todtWfaQPBjawZMIA==
"@storybook/client-api@6.3.7":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.3.7.tgz#cb1dca05467d777bd09aadbbdd1dd22ca537ce14"
integrity sha512-8wOH19cMIwIIYhZy5O5Wl8JT1QOL5kNuamp9GPmg5ff4DtnG+/uUslskRvsnKyjPvl+WbIlZtBVWBiawVdd/yQ==
dependencies:
"@storybook/addons" "6.3.6"
"@storybook/channel-postmessage" "6.3.6"
"@storybook/channels" "6.3.6"
"@storybook/client-logger" "6.3.6"
"@storybook/core-events" "6.3.6"
"@storybook/addons" "6.3.7"
"@storybook/channel-postmessage" "6.3.7"
"@storybook/channels" "6.3.7"
"@storybook/client-logger" "6.3.7"
"@storybook/core-events" "6.3.7"
"@storybook/csf" "0.0.1"
"@types/qs" "^6.9.5"
"@types/webpack-env" "^1.16.0"
@@ -5630,23 +5630,23 @@
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/client-logger@6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.3.6.tgz#020ba518ab8286194ce103a6ff91767042e296c0"
integrity sha512-qpXQ52ylxPm7l3+WAteV42NmqWA+L1FaJhMOvm2gwl3PxRd2cNXn2BwEhw++eA6qmJH/7mfOKXG+K+QQwOTpRA==
"@storybook/client-logger@6.3.7":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.3.7.tgz#ff17b7494e7e9e23089b0d5c5364c371c726bdd1"
integrity sha512-BQRErHE3nIEuUJN/3S3dO1LzxAknOgrFeZLd4UVcH/fvjtS1F4EkhcbH+jNyUWvcWGv66PZYN0oFPEn/g3Savg==
dependencies:
core-js "^3.8.2"
global "^4.4.0"
"@storybook/components@6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/components/-/components-6.3.6.tgz#bc2fa1dbe59f42b5b2aeb9f84424072835d4ce8b"
integrity sha512-aZkmtAY8b+LFXG6dVp6cTS6zGJuxkHRHcesRSWRQPxtgitaz1G58clRHxbKPRokfjPHNgYA3snogyeqxSA7YNQ==
"@storybook/components@6.3.7":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/components/-/components-6.3.7.tgz#42b1ca6d24e388e02eab82aa9ed3365db2266ecc"
integrity sha512-O7LIg9Z18G0AJqXX7Shcj0uHqwXlSA5UkHgaz9A7mqqqJNl6m6FwwTWcxR1acUfYVNkO+czgpqZHNrOF6rky1A==
dependencies:
"@popperjs/core" "^2.6.0"
"@storybook/client-logger" "6.3.6"
"@storybook/client-logger" "6.3.7"
"@storybook/csf" "0.0.1"
"@storybook/theming" "6.3.6"
"@storybook/theming" "6.3.7"
"@types/color-convert" "^2.0.0"
"@types/overlayscrollbars" "^1.12.0"
"@types/react-syntax-highlighter" "11.0.5"
@@ -5668,18 +5668,18 @@
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/core-client@6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/core-client/-/core-client-6.3.6.tgz#7def721aa15d4faaff574780d30b92055db7261c"
integrity sha512-Bq86flEdXdMNbdHrGMNQ6OT1tcBQU8ym56d+nG46Ctjf5GN+Dl+rPtRWuu7cIZs10KgqJH+86DXp+tvpQIDidg==
"@storybook/core-client@6.3.7":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/core-client/-/core-client-6.3.7.tgz#cfb75952e0e1d32f2aca92bca2786334ab589c40"
integrity sha512-M/4A65yV+Y4lsCQXX4BtQO/i3BcMPrU5FkDG8qJd3dkcx2fUlFvGWqQPkcTZE+MPVvMEGl/AsEZSADzah9+dAg==
dependencies:
"@storybook/addons" "6.3.6"
"@storybook/channel-postmessage" "6.3.6"
"@storybook/client-api" "6.3.6"
"@storybook/client-logger" "6.3.6"
"@storybook/core-events" "6.3.6"
"@storybook/addons" "6.3.7"
"@storybook/channel-postmessage" "6.3.7"
"@storybook/client-api" "6.3.7"
"@storybook/client-logger" "6.3.7"
"@storybook/core-events" "6.3.7"
"@storybook/csf" "0.0.1"
"@storybook/ui" "6.3.6"
"@storybook/ui" "6.3.7"
airbnb-js-shims "^2.2.1"
ansi-to-html "^0.6.11"
core-js "^3.8.2"
@@ -5691,10 +5691,10 @@
unfetch "^4.2.0"
util-deprecate "^1.0.2"
"@storybook/core-common@6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/core-common/-/core-common-6.3.6.tgz#da8eed703b609968e15177446f0f1609d1d6d0d0"
integrity sha512-nHolFOmTPymI50j180bCtcf1UJZ2eOnYaECRtHvVrCUod5KFF7wh2EHrgWoKqrKrsn84UOY/LkX2C2WkbYtWRg==
"@storybook/core-common@6.3.7":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/core-common/-/core-common-6.3.7.tgz#9eedf3ff16aff870950e3372ab71ef846fa3ac52"
integrity sha512-exLoqRPPsAefwyjbsQBLNFrlPCcv69Q/pclqmIm7FqAPR7f3CKP1rqsHY0PnemizTL/+cLX5S7mY90gI6wpNug==
dependencies:
"@babel/core" "^7.12.10"
"@babel/plugin-proposal-class-properties" "^7.12.1"
@@ -5717,7 +5717,7 @@
"@babel/preset-react" "^7.12.10"
"@babel/preset-typescript" "^7.12.7"
"@babel/register" "^7.12.1"
"@storybook/node-logger" "6.3.6"
"@storybook/node-logger" "6.3.7"
"@storybook/semver" "^7.3.2"
"@types/glob-base" "^0.3.0"
"@types/micromatch" "^4.0.1"
@@ -5745,24 +5745,24 @@
util-deprecate "^1.0.2"
webpack "4"
"@storybook/core-events@6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.3.6.tgz#c4a09e2c703170995604d63e46e45adc3c9cd759"
integrity sha512-Ut1dz96bJ939oSn5t1ckPXd3WcFejK96Sb3+R/z23vEHUWGBFtygGyw8r/SX/WNDVzGmQU8c+mzJJTZwCBJz8A==
"@storybook/core-events@6.3.7":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.3.7.tgz#c5bc7cae7dc295de73b6b9f671ecbe582582e9bd"
integrity sha512-l5Hlhe+C/dqxjobemZ6DWBhTOhQoFF3F1Y4kjFGE7pGZl/mas4M72I5I/FUcYCmbk2fbLfZX8hzKkUqS1hdyLA==
dependencies:
core-js "^3.8.2"
"@storybook/core-server@6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/core-server/-/core-server-6.3.6.tgz#43c1415573c3b72ec6b9ae48d68e1bb446722f7c"
integrity sha512-47ZcfxYn7t891oAMG98iH1BQIgQT9Yk/2BBNVCWY43Ong+ME1xJ6j4C/jkRUOseP7URlfLUQsUYKAYJNVijDvg==
"@storybook/core-server@6.3.7":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/core-server/-/core-server-6.3.7.tgz#6f29ad720aafe4a97247b5e306eac4174d0931f2"
integrity sha512-m5OPD/rmZA7KFewkXzXD46/i1ngUoFO4LWOiAY/wR6RQGjYXGMhSa5UYFF6MNwSbiGS5YieHkR5crB1HP47AhQ==
dependencies:
"@storybook/builder-webpack4" "6.3.6"
"@storybook/core-client" "6.3.6"
"@storybook/core-common" "6.3.6"
"@storybook/csf-tools" "6.3.6"
"@storybook/manager-webpack4" "6.3.6"
"@storybook/node-logger" "6.3.6"
"@storybook/builder-webpack4" "6.3.7"
"@storybook/core-client" "6.3.7"
"@storybook/core-common" "6.3.7"
"@storybook/csf-tools" "6.3.7"
"@storybook/manager-webpack4" "6.3.7"
"@storybook/node-logger" "6.3.7"
"@storybook/semver" "^7.3.2"
"@types/node" "^14.0.10"
"@types/node-fetch" "^2.5.7"
@@ -5791,18 +5791,18 @@
util-deprecate "^1.0.2"
webpack "4"
"@storybook/core@6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/core/-/core-6.3.6.tgz#604419d346433103675901b3736bfa1ed9bc534f"
integrity sha512-y71VvVEbqCpG28fDBnfNg3RnUPnicwFYq9yuoFVRF0LYcJCy5cYhkIfW3JG8mN2m0P+LzH80mt2Rj6xlSXrkdQ==
"@storybook/core@6.3.7":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/core/-/core-6.3.7.tgz#482228a270abc3e23fed10c7bc4df674da22ca19"
integrity sha512-YTVLPXqgyBg7TALNxQ+cd+GtCm/NFjxr/qQ1mss1T9GCMR0IjE0d0trgOVHHLAO8jCVlK8DeuqZCCgZFTXulRw==
dependencies:
"@storybook/core-client" "6.3.6"
"@storybook/core-server" "6.3.6"
"@storybook/core-client" "6.3.7"
"@storybook/core-server" "6.3.7"
"@storybook/csf-tools@6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/csf-tools/-/csf-tools-6.3.6.tgz#603d9e832f946998b75ff8368fe862375d6cb52c"
integrity sha512-MQevelkEUVNCSjKMXLNc/G8q/BB5babPnSeI0IcJq4k+kLUSHtviimLNpPowMgGJBPx/y9VihH8N7vdJUWVj9w==
"@storybook/csf-tools@6.3.7":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/csf-tools/-/csf-tools-6.3.7.tgz#505514d211f8698c47ddb15662442098b4b00156"
integrity sha512-A7yGsrYwh+vwVpmG8dHpEimX021RbZd9VzoREcILH56u8atssdh/rseljyWlRes3Sr4QgtLvDB7ggoJ+XDZH7w==
dependencies:
"@babel/generator" "^7.12.11"
"@babel/parser" "^7.12.11"
@@ -5826,20 +5826,20 @@
dependencies:
lodash "^4.17.15"
"@storybook/manager-webpack4@6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/manager-webpack4/-/manager-webpack4-6.3.6.tgz#a5334aa7ae1e048bca8f4daf868925d7054fb715"
integrity sha512-qh/jV4b6mFRpRFfhk1JSyO2gKRz8PLPvDt2AD52/bTAtNRzypKoiWqyZNR2CJ9hgNQtDrk2CO3eKPrcdKYGizQ==
"@storybook/manager-webpack4@6.3.7":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/manager-webpack4/-/manager-webpack4-6.3.7.tgz#9ca604dea38d3c47eb38bf485ca6107861280aa8"
integrity sha512-cwUdO3oklEtx6y+ZOl2zHvflICK85emiXBQGgRcCsnwWQRBZOMh+tCgOSZj4jmISVpT52RtT9woG4jKe15KBig==
dependencies:
"@babel/core" "^7.12.10"
"@babel/plugin-transform-template-literals" "^7.12.1"
"@babel/preset-react" "^7.12.10"
"@storybook/addons" "6.3.6"
"@storybook/core-client" "6.3.6"
"@storybook/core-common" "6.3.6"
"@storybook/node-logger" "6.3.6"
"@storybook/theming" "6.3.6"
"@storybook/ui" "6.3.6"
"@storybook/addons" "6.3.7"
"@storybook/core-client" "6.3.7"
"@storybook/core-common" "6.3.7"
"@storybook/node-logger" "6.3.7"
"@storybook/theming" "6.3.7"
"@storybook/ui" "6.3.7"
"@types/node" "^14.0.10"
"@types/webpack" "^4.41.26"
babel-loader "^8.2.2"
@@ -5869,10 +5869,10 @@
webpack-dev-middleware "^3.7.3"
webpack-virtual-modules "^0.2.2"
"@storybook/node-logger@6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-6.3.6.tgz#10356608593440a8e3acf2aababef40333a3401b"
integrity sha512-XMDkMN7nVRojjiezrURlkI57+nz3OoH4UBV6qJZICKclxtdKAy0wwOlUSYEUq+axcJ4nvdfzPPoDfGoj37SW7A==
"@storybook/node-logger@6.3.7":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-6.3.7.tgz#492469ea4749de8d984af144976961589a1ac382"
integrity sha512-YXHCblruRe6HcNefDOpuXJoaybHnnSryIVP9Z+gDv6OgLAMkyxccTIaQL9dbc/eI4ywgzAz4kD8t1RfVwXNVXw==
dependencies:
"@types/npmlog" "^4.1.2"
chalk "^4.1.0"
@@ -5894,17 +5894,17 @@
tslib "^2.0.0"
"@storybook/react@^6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/react/-/react-6.3.6.tgz#593bc0743ad22ed5e6e072e6157c20c704864fc3"
integrity sha512-2c30XTe7WzKnvgHBGOp1dzBVlhcbc3oEX0SIeVE9ZYkLvRPF+J1jG948a26iqOCOgRAW13Bele37mG1gbl4tiw==
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/react/-/react-6.3.7.tgz#b15259aeb4cdeef99cc7f09d21db42e3ecd7a01a"
integrity sha512-4S0iCQIzgi6PdAtV2sYw4uL57yIwbMInNFSux9CxPnVdlxOxCJ+U8IgTxD4tjkTvR4boYSEvEle46ns/bwg5iQ==
dependencies:
"@babel/preset-flow" "^7.12.1"
"@babel/preset-react" "^7.12.10"
"@pmmmwh/react-refresh-webpack-plugin" "^0.4.3"
"@storybook/addons" "6.3.6"
"@storybook/core" "6.3.6"
"@storybook/core-common" "6.3.6"
"@storybook/node-logger" "6.3.6"
"@storybook/addons" "6.3.7"
"@storybook/core" "6.3.7"
"@storybook/core-common" "6.3.7"
"@storybook/node-logger" "6.3.7"
"@storybook/react-docgen-typescript-plugin" "1.0.2-canary.253f8c1.0"
"@storybook/semver" "^7.3.2"
"@types/webpack-env" "^1.16.0"
@@ -5922,13 +5922,13 @@
ts-dedent "^2.0.0"
webpack "4"
"@storybook/router@6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/router/-/router-6.3.6.tgz#cea20d64bae17397dc9e1689a656b80a98674c34"
integrity sha512-fQ1n7cm7lPFav7I+fStQciSVMlNdU+yLY6Fue252rpV5Q68bMTjwKpjO9P2/Y3CCj4QD3dPqwEkn4s0qUn5tNA==
"@storybook/router@6.3.7":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/router/-/router-6.3.7.tgz#1714a99a58a7b9f08b6fcfe2b678dad6ca896736"
integrity sha512-6tthN8op7H0NoYoE1SkQFJKC42pC4tGaoPn7kEql8XGeUJnxPtVFjrnywlTrRnKuxZKIvbilQBAwDml97XH23Q==
dependencies:
"@reach/router" "^1.3.4"
"@storybook/client-logger" "6.3.6"
"@storybook/client-logger" "6.3.7"
"@types/reach__router" "^1.3.7"
core-js "^3.8.2"
fast-deep-equal "^3.1.3"
@@ -5946,13 +5946,13 @@
core-js "^3.6.5"
find-up "^4.1.0"
"@storybook/source-loader@6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/source-loader/-/source-loader-6.3.6.tgz#2d3d01919baad7a40f67d1150c74e41dea5f1d4c"
integrity sha512-om3iS3a+D287FzBrbXB/IXB6Z5Ql2yc4dFKTy6FPe5v4N3U0p5puWOKUYWWbTX1JbcpRj0IXXo7952G68tcC1g==
"@storybook/source-loader@6.3.7":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/source-loader/-/source-loader-6.3.7.tgz#cc348305df3c2d8d716c0bab7830c9f537b859ff"
integrity sha512-0xQTq90jwx7W7MJn/idEBCGPOyxi/3No5j+5YdfJsSGJRuMFH3jt6pGgdeZ4XA01cmmIX6bZ+fB9al6yAzs91w==
dependencies:
"@storybook/addons" "6.3.6"
"@storybook/client-logger" "6.3.6"
"@storybook/addons" "6.3.7"
"@storybook/client-logger" "6.3.7"
"@storybook/csf" "0.0.1"
core-js "^3.8.2"
estraverse "^5.2.0"
@@ -5962,15 +5962,15 @@
prettier "~2.2.1"
regenerator-runtime "^0.13.7"
"@storybook/theming@6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.3.6.tgz#75624f6d4e01530b87afca3eab9996a16c0370ab"
integrity sha512-mPrQrMUREajNEWxzgR8t0YIZsI9avPv25VNA08fANnwVsc887p4OL5eCTL2dFIlD34YDzAwiyRKYoLj2vDW4nw==
"@storybook/theming@6.3.7":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.3.7.tgz#6daf9a21b26ed607f3c28a82acd90c0248e76d8b"
integrity sha512-GXBdw18JJd5jLLcRonAZWvGGdwEXByxF1IFNDSOYCcpHWsMgTk4XoLjceu9MpXET04pVX51LbVPLCvMdggoohg==
dependencies:
"@emotion/core" "^10.1.1"
"@emotion/is-prop-valid" "^0.8.6"
"@emotion/styled" "^10.0.27"
"@storybook/client-logger" "6.3.6"
"@storybook/client-logger" "6.3.7"
core-js "^3.8.2"
deep-object-diff "^1.1.0"
emotion-theming "^10.0.27"
@@ -5980,21 +5980,21 @@
resolve-from "^5.0.0"
ts-dedent "^2.0.0"
"@storybook/ui@6.3.6":
version "6.3.6"
resolved "https://registry.npmjs.org/@storybook/ui/-/ui-6.3.6.tgz#a9ed8265e34bb8ef9f0dd08f40170b3dcf8a8931"
integrity sha512-S9FjISUiAmbBR7d6ubUEcELQdffDfRxerloxkXs5Ou7n8fEPqpgQB01Hw5MLRUwTEpxPzHn+xtIGYritAGxt/Q==
"@storybook/ui@6.3.7":
version "6.3.7"
resolved "https://registry.npmjs.org/@storybook/ui/-/ui-6.3.7.tgz#d0caea50640670da3189bbbb67c43da30c90455a"
integrity sha512-PBeRO8qtwAbtHvxUgNtz/ChUR6qnN+R37dMaIs3Y96jbks1fS2K9Mt7W5s1HnUbWbg2KsZMv9D4VYPBasY+Isw==
dependencies:
"@emotion/core" "^10.1.1"
"@storybook/addons" "6.3.6"
"@storybook/api" "6.3.6"
"@storybook/channels" "6.3.6"
"@storybook/client-logger" "6.3.6"
"@storybook/components" "6.3.6"
"@storybook/core-events" "6.3.6"
"@storybook/router" "6.3.6"
"@storybook/addons" "6.3.7"
"@storybook/api" "6.3.7"
"@storybook/channels" "6.3.7"
"@storybook/client-logger" "6.3.7"
"@storybook/components" "6.3.7"
"@storybook/core-events" "6.3.7"
"@storybook/router" "6.3.7"
"@storybook/semver" "^7.3.2"
"@storybook/theming" "6.3.6"
"@storybook/theming" "6.3.7"
"@types/markdown-to-jsx" "^6.11.3"
copy-to-clipboard "^3.3.1"
core-js "^3.8.2"
@@ -6574,11 +6574,6 @@
resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83"
integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==
"@types/eventsource@^1.1.5":
version "1.1.5"
resolved "https://registry.npmjs.org/@types/eventsource/-/eventsource-1.1.5.tgz#408e9b45efb176c8bea672ab58c81e7ab00d24bc"
integrity sha512-BA9q9uC2PAMkUS7DunHTxWZZaVpeNzDG8lkBxcKwzKJClfDQ4Z59/Csx7HSH/SIqFN2JWh0tAKAM6k/wRR0OZg==
"@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.18", "@types/express-serve-static-core@^4.17.21":
version "4.17.24"
resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.24.tgz#ea41f93bf7e0d59cd5a76665068ed6aab6815c07"
@@ -6831,27 +6826,17 @@
recast "^0.20.3"
"@types/json-schema-merge-allof@^0.6.0":
version "0.6.0"
resolved "https://registry.npmjs.org/@types/json-schema-merge-allof/-/json-schema-merge-allof-0.6.0.tgz#0f587d8a3bcb41a55ef2e91d3ba96430c9bc1813"
integrity sha512-v6iCEk4Sxy1twlCTtrRxMqSHX0vuLJ7Ql4hiIUZRMOswxtlUeybIfYaZIj7pX747RBczG2YtBm4Fn6sqKzeY2Q==
version "0.6.1"
resolved "https://registry.npmjs.org/@types/json-schema-merge-allof/-/json-schema-merge-allof-0.6.1.tgz#c476c2aeed04d8a14882ff872de9ddeca5608e0b"
integrity sha512-tBVtkCCbA1oF8vQ2cp2yuGLp0T2f0AZ2dAic64ZftoWQnKqrTYY/+PuiqPKX1XaxoR43ll/EkYcHnJbdbHUS2g==
dependencies:
"@types/json-schema" "*"
"@types/json-schema@*", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.6", "@types/json-schema@^7.0.7":
version "7.0.7"
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad"
integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==
"@types/json-schema@^7.0.4":
"@types/json-schema@*", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.6", "@types/json-schema@^7.0.7", "@types/json-schema@^7.0.8":
version "7.0.9"
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d"
integrity sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==
"@types/json-schema@^7.0.8":
version "7.0.8"
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.8.tgz#edf1bf1dbf4e04413ca8e5b17b3b7d7d54b59818"
integrity sha512-YSBPTLTVm2e2OoQIDYx8HaeWJ5tTToLH67kXR7zYNGupXMEHa2++G8k+DczX2cFVgalypqtyZIcU19AFcmOpmg==
"@types/json-stable-stringify@^1.0.32":
version "1.0.32"
resolved "https://registry.npmjs.org/@types/json-stable-stringify/-/json-stable-stringify-1.0.32.tgz#121f6917c4389db3923640b2e68de5fa64dda88e"
@@ -13219,6 +13204,11 @@ event-emitter@^0.3.5:
d "1"
es5-ext "~0.10.14"
event-source-polyfill@^1.0.25:
version "1.0.25"
resolved "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.25.tgz#d8bb7f99cb6f8119c2baf086d9f6ee0514b6d9c8"
integrity sha512-hQxu6sN1Eq4JjoI7ITdQeGGUN193A2ra83qC0Ltm9I2UJVAten3OFVN6k5RX4YWeCS0BoC8xg/5czOCIHVosQg==
event-stream@=3.3.4:
version "3.3.4"
resolved "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz#4ab4c9a0f5a54db9338b4c34d86bfce8f4b35571"
@@ -13279,13 +13269,6 @@ eventsource@^1.0.5:
dependencies:
original "^1.0.0"
eventsource@^1.1.0:
version "1.1.0"
resolved "https://registry.npmjs.org/eventsource/-/eventsource-1.1.0.tgz#00e8ca7c92109e94b0ddf32dac677d841028cfaf"
integrity sha512-VSJjT5oCNrFvCS6igjzPAt5hBzQ2qPBFIbJ03zLI9SE0mxwZpMw6BfJrbFHm1a141AavMEB8JHmBhWAd66PfCg==
dependencies:
original "^1.0.0"
evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3:
version "1.0.3"
resolved "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02"