Merge branch 'master' into firehydrant-plugin
This commit is contained in:
@@ -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 />}
|
||||
+ />
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Added UI Schema support for array items for example, support EntityPicker within an array field
|
||||
@@ -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.
|
||||
@@ -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 />}
|
||||
+ />
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
//
|
||||
|
||||
+7
-7
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};*/
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+17
-9
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
+3
-3
@@ -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>,
|
||||
);
|
||||
|
||||
+1
-1
@@ -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[];
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user