Merge branch 'backstage:master' into master

This commit is contained in:
Joe Patterson
2022-09-09 10:31:27 +10:00
committed by GitHub
57 changed files with 634 additions and 159 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-node': minor
---
Added refresh function to the `EntityProviderConnection` to be able to schedule refreshes from entity providers.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Support showing counts in option labels of the `EntityTagPicker`. You can enable this by adding the `showCounts` property
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/catalog-client': minor
---
Adding `validateEntity` method that calls `/validate-entity` endpoint.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': minor
---
Added the `refresh` function to the Connection of the entity providers.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/app-defaults': patch
'@backstage/test-utils': patch
---
Add missing peer dependencies
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Updated `versions:bump` command to be compatible with Yarn 3.
@@ -0,0 +1,92 @@
---
# prettier-ignore
title: FYI 📣 The Plugin Analytics API
author: Eric Peterson, Spotify
authorURL: https://github.com/iamEAP
authorImageURL: https://avatars.githubusercontent.com/u/3496491?v=4
---
**TL;DR** If you didn't know, now you know: the Backstage plugin analytics API is here to help you understand how developers in your organization are using Backstage.
![The Plugin Analytics API](assets/22-09-08/analytics-api-fyi.png)
<!--truncate-->
## What is the plugin analytics API?
The plugin analytics API is a [utility api](https://backstage.io/docs/api/utility-apis) available by default in every Backstage instance, intended to bridge the gap between the needs of Backstage integrators and plugin developers. While Backstage integrators want visibility into the plugins theyve installed, they lack the power to instrument those plugins. And although plugin developers have the power to instrument plugins, they cant do so without a single, vendor-agnostic way to track events. Enter: the plugin analytics API.
While “analytics” as a concept can be broad, the goal of the API is narrowly focused: empower those deploying Backstage to understand usage of their instance. The plugin analytics API isnt designed to solve for observability use-cases like tracing, logging, performance monitoring, error metrics, or alerting. Rather, the API is designed to capture and quantify real user interactions, which can form the basis for metrics like daily active users, top plugins, and more.
## Start collecting data
Backstage core (and a few other plugins) are already instrumented with [key events](https://backstage.io/docs/plugins/analytics#key-events) that are ready for you to start collecting and analyzing.
The simplest way to get started is to use one of the [supported analytics tools](https://backstage.io/docs/plugins/analytics#supported-analytics-tools) and install its provided API implementation like you would any other utility API. For example:
```tsx
// packages/app/src/apis.ts
import {
analyticsApiRef,
configApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { GoogleAnalytics } from '@backstage/plugin-analytics-module-ga';
export const apis: AnyApiFactory[] = [
// Instantiate and register the GA Analytics API Implementation.
createApiFactory({
api: analyticsApiRef,
deps: { configApi: configApiRef, identityApi: identityApiRef },
factory: ({ configApi, identityApi }) =>
GoogleAnalytics.fromConfig(configApi, {
identityApi,
}),
}),
];
```
If your chosen analytics tool doesnt have an integration yet, you can write a custom integration by [following these instructions](https://backstage.io/docs/plugins/analytics#writing-integrations). (And if youre integrating with a publicly available analytics service, as opposed to a custom in-house system, why not [consider contributing it back to the community](https://backstage.io/docs/plugins/create-a-plugin)?)
## Instrument plugins
While some key events are already instrumented, there may be important actions in open source plugins that are un-instrumented, not to mention in your custom, InnerSource plugins. Luckily, the plugin analytics API can be leveraged by open source and InnerSource plugins all the same.
To capture an event, invoke the `useAnalytics()` react hook and call the function it returns when the user performs the action you wish to track (e.g. merging a pull request):
```tsx
import { useAnalytics } from '@backstage/core-plugin-api';
const analytics = useAnalytics();
analytics.captureEvent('merge', pullRequestName);
```
Dont worry about having to stuff additional levels of detail into just the event action and subject, you can provide extra dimensional data on the `attributes` property, as well as a primary metric on the `value` property, like this:
```tsx
analytics.captureEvent('merge', pullRequestName, {
value: pullRequestAgeInMinutes,
attributes: {
org: orgName,
repo: repoName,
},
});
```
In situations where your plugin is tracking multiple events and you want all of those events to share common dimensional data, you can use the `<AnalyticsContext>`. Every event captured in child components underneath this context automatically inherits the values you set:
```tsx
import { AnalyticsContext } from '@backstage/core-plugin-api';
<AnalyticsContext attributes={{ vcsProvider: 'github' }}>
{children}
</AnalyticsContext
```
In fact, Backstage core uses an `<AnalyticsContext>` to automatically decorate every event with a corresponding plugin ID and an extension name in order to facilitate plugin-level analysis.
While the above should be enough to get you going, dont forget to check out [the complete guide to event capture](https://backstage.io/docs/plugins/analytics#capturing-events), which covers event naming considerations, testing, and more.
## Get involved
If you didnt know, now you know! If youre passionate about data and want to help push the Backstage analytics ecosystem forward, join us in the [#analytics channel on discord](https://discord.com/channels/687207715902193673/1007303347914690610), contribute [integration ideas](https://github.com/backstage/backstage/issues/new?assignees=&labels=plugin&template=plugin_template.md&title=%5BAnalytics+Module%5D+THE+ANALYTICS+TOOL+TO+INTEGRATE), or [suggest a new analytics event](https://github.com/backstage/backstage/issues/new?assignees=&labels=enhancement&template=feature_template.md&title=[Analytics%20Event]:%20THE+EVENT+TO+CAPTURE).
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

@@ -0,0 +1,9 @@
---
title: LDAP Auth
author: ImmobiliareLabs
authorUrl: https://github.com/immobiliare
category: Authentication
description: Authenticate users to an external LDAP server
documentation: https://github.com/immobiliare/backstage-plugin-ldap-auth/blob/main/README.md
iconUrl: https://avatars.githubusercontent.com/u/10090828
npmPackageName: '@immobiliarelabs/backstage-plugin-ldap-auth'
+1
View File
@@ -67,6 +67,7 @@
"concurrently": "^7.0.0",
"cross-env": "^7.0.0",
"e2e-test": "workspace:*",
"eslint": "^8.6.0",
"eslint-plugin-notice": "^0.9.10",
"fs-extra": "10.1.0",
"husky": "^8.0.0",
+2
View File
@@ -42,6 +42,8 @@
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0",
"react-dom": "^16.13.1 || ^17.0.0",
"react-router": "6.0.0-beta.0 || ^6.3.0",
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
"devDependencies": {
+5 -4
View File
@@ -66,10 +66,10 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"@octokit/rest": "^19.0.3",
"@roadiehq/backstage-plugin-buildkite": "^2.0.0",
"@roadiehq/backstage-plugin-github-insights": "^2.0.0",
"@roadiehq/backstage-plugin-github-pull-requests": "^2.0.0",
"@roadiehq/backstage-plugin-travis-ci": "^2.0.0",
"@roadiehq/backstage-plugin-buildkite": "^2.0.8",
"@roadiehq/backstage-plugin-github-insights": "^2.0.5",
"@roadiehq/backstage-plugin-github-pull-requests": "^2.2.7",
"@roadiehq/backstage-plugin-travis-ci": "^2.0.5",
"history": "^5.0.0",
"prop-types": "^15.7.2",
"react": "^17.0.2",
@@ -88,6 +88,7 @@
"@testing-library/user-event": "^14.0.0",
"@types/jquery": "^3.3.34",
"@types/node": "^16.11.26",
"@types/react": "*",
"@types/react-dom": "*",
"@types/zen-observable": "^0.8.0",
"cross-env": "^7.0.0",
+21
View File
@@ -5,6 +5,7 @@
```ts
import { CompoundEntityRef } from '@backstage/catalog-model';
import { Entity } from '@backstage/catalog-model';
import { SerializedError } from '@backstage/errors';
// @public
export type AddLocationRequest = {
@@ -65,6 +66,11 @@ export interface CatalogApi {
id: string,
options?: CatalogRequestOptions,
): Promise<void>;
validateEntity(
entity: Entity,
location: string,
options?: CatalogRequestOptions,
): Promise<ValidateEntityResponse>;
}
// @public
@@ -122,6 +128,11 @@ export class CatalogClient implements CatalogApi {
id: string,
options?: CatalogRequestOptions,
): Promise<void>;
validateEntity(
entity: Entity,
location: string,
options?: CatalogRequestOptions,
): Promise<ValidateEntityResponse>;
}
// @public
@@ -196,4 +207,14 @@ type Location_2 = {
target: string;
};
export { Location_2 as Location };
// @public
export type ValidateEntityResponse =
| {
valid: true;
}
| {
valid: false;
errors: SerializedError[];
};
```
@@ -295,4 +295,87 @@ describe('CatalogClient', () => {
await client.getLocationById('42');
});
});
describe('validateEntity', () => {
it('returns valid false when validation fails', async () => {
server.use(
rest.post(`${mockBaseUrl}/validate-entity`, (_req, res, ctx) => {
return res(
ctx.status(400),
ctx.json({
errors: [
{
message: 'Missing name',
},
],
}),
);
}),
);
expect(
await client.validateEntity(
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: '',
},
},
'http://example.com',
),
).toMatchObject({
valid: false,
errors: [
{
message: 'Missing name',
},
],
});
});
it('returns valid true when validation fails', async () => {
server.use(
rest.post(`${mockBaseUrl}/validate-entity`, (_req, res, ctx) => {
return res(ctx.status(200), ctx.text(''));
}),
);
expect(
await client.validateEntity(
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'good',
},
},
'http://example.com',
),
).toMatchObject({
valid: true,
});
});
it('throws unexpected error', async () => {
server.use(
rest.post(`${mockBaseUrl}/validate-entity`, (_req, res, ctx) => {
return res(ctx.status(500), ctx.json({}));
}),
);
await expect(() =>
client.validateEntity(
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'good',
},
},
'http://example.com',
),
).rejects.toThrow(/Request failed with 500 Error/);
});
});
});
@@ -36,6 +36,7 @@ import {
Location,
GetEntityFacetsRequest,
GetEntityFacetsResponse,
ValidateEntityResponse,
} from './types/api';
import { DiscoveryApi } from './types/discovery';
import { FetchApi } from './types/fetch';
@@ -353,6 +354,44 @@ export class CatalogClient implements CatalogApi {
);
}
/**
* {@inheritdoc CatalogApi.validateEntity}
*/
async validateEntity(
entity: Entity,
location: string,
options?: CatalogRequestOptions,
): Promise<ValidateEntityResponse> {
const response = await this.fetchApi.fetch(
`${await this.discoveryApi.getBaseUrl('catalog')}/validate-entity`,
{
headers: {
'Content-Type': 'application/json',
...(options?.token && { Authorization: `Bearer ${options?.token}` }),
},
method: 'POST',
body: JSON.stringify({ entity, location }),
},
);
if (response.ok) {
return {
valid: true,
};
}
if (response.status !== 400) {
throw await ResponseError.fromResponse(response);
}
const { errors = [] } = await response.json();
return {
valid: false,
errors,
};
}
//
// Private methods
//
+22
View File
@@ -15,6 +15,7 @@
*/
import { CompoundEntityRef, Entity } from '@backstage/catalog-model';
import { SerializedError } from '@backstage/errors';
/**
* This symbol can be used in place of a value when passed to filters in e.g.
@@ -269,6 +270,15 @@ export type AddLocationResponse = {
exists?: boolean;
};
/**
* The response type for {@link CatalogClient.validateEntity}
*
* @public
*/
export type ValidateEntityResponse =
| { valid: true }
| { valid: false; errors: SerializedError[] };
/**
* A client for interacting with the Backstage software catalog through its API.
*
@@ -390,4 +400,16 @@ export interface CatalogApi {
id: string,
options?: CatalogRequestOptions,
): Promise<void>;
/**
* Validate entity and its location.
*
* @param entity - Entity to validate
* @param location - URL location of the entity
*/
validateEntity(
entity: Entity,
location: string,
options?: CatalogRequestOptions,
): Promise<ValidateEntityResponse>;
}
@@ -27,5 +27,6 @@ export type {
Location,
GetEntityFacetsRequest,
GetEntityFacetsResponse,
ValidateEntityResponse,
} from './api';
export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status';
+29 -96
View File
@@ -28,6 +28,7 @@ import {
import { YarnInfoInspectData } from '../../lib/versioning/packages';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import { NotFoundError } from '@backstage/errors';
// Remove log coloring to simplify log matching
jest.mock('chalk', () => ({
@@ -54,7 +55,15 @@ jest.mock('ora', () => ({
jest.mock('../../lib/run', () => {
return {
run: jest.fn(),
runPlain: jest.fn(),
};
});
const mockFetchPackageInfo = jest.fn();
jest.mock('../../lib/versioning/packages', () => {
const actual = jest.requireActual('../../lib/versioning/packages');
return {
...actual,
fetchPackageInfo: (name: string) => mockFetchPackageInfo(name),
};
});
@@ -105,6 +114,14 @@ const lockfileMockResult = `${HEADER}
`;
describe('bump', () => {
beforeEach(() => {
mockFetchPackageInfo.mockImplementation(async name => ({
name: name,
'dist-tags': {
latest: REGISTRY_VERSIONS[name],
},
}));
});
afterEach(() => {
mockFs.restore();
jest.resetAllMocks();
@@ -138,17 +155,6 @@ describe('bump', () => {
jest
.spyOn(paths, 'resolveTargetRoot')
.mockImplementation((...path) => resolvePath('/', ...path));
jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) =>
JSON.stringify({
type: 'inspect',
data: {
name: name,
'dist-tags': {
latest: REGISTRY_VERSIONS[name],
},
},
}),
);
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
worker.use(
rest.get(
@@ -184,19 +190,10 @@ describe('bump', () => {
'Version bump complete!',
]);
expect(runObj.runPlain).toHaveBeenCalledTimes(3);
expect(runObj.runPlain).toHaveBeenCalledWith(
'yarn',
'info',
'--json',
'@backstage/core',
);
expect(runObj.runPlain).toHaveBeenCalledWith(
'yarn',
'info',
'--json',
'@backstage/theme',
);
expect(mockFetchPackageInfo).toHaveBeenCalledTimes(3);
expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/core');
expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/core-api');
expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/theme');
expect(runObj.run).toHaveBeenCalledTimes(1);
expect(runObj.run).toHaveBeenCalledWith(
@@ -251,17 +248,6 @@ describe('bump', () => {
jest
.spyOn(paths, 'resolveTargetRoot')
.mockImplementation((...path) => resolvePath('/', ...path));
jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) =>
JSON.stringify({
type: 'inspect',
data: {
name: name,
'dist-tags': {
latest: REGISTRY_VERSIONS[name],
},
},
}),
);
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
worker.use(
rest.get(
@@ -309,19 +295,9 @@ describe('bump', () => {
'Version bump complete!',
]);
expect(runObj.runPlain).toHaveBeenCalledTimes(2);
expect(runObj.runPlain).toHaveBeenCalledWith(
'yarn',
'info',
'--json',
'@backstage/core',
);
expect(runObj.runPlain).not.toHaveBeenCalledWith(
'yarn',
'info',
'--json',
'@backstage/theme',
);
expect(mockFetchPackageInfo).toHaveBeenCalledTimes(2);
expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/core');
expect(mockFetchPackageInfo).not.toHaveBeenCalledWith('@backstage/theme');
expect(runObj.run).toHaveBeenCalledTimes(1);
expect(runObj.run).toHaveBeenCalledWith(
@@ -372,17 +348,6 @@ describe('bump', () => {
},
}),
});
jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) =>
JSON.stringify({
type: 'inspect',
data: {
name: name,
'dist-tags': {
latest: REGISTRY_VERSIONS[name],
},
},
}),
);
jest
.spyOn(paths, 'resolveTargetRoot')
.mockImplementation((...path) => resolvePath('/', ...path));
@@ -480,17 +445,6 @@ describe('bump', () => {
jest
.spyOn(paths, 'resolveTargetRoot')
.mockImplementation((...path) => resolvePath('/', ...path));
jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) =>
JSON.stringify({
type: 'inspect',
data: {
name: name,
'dist-tags': {
latest: REGISTRY_VERSIONS[name],
},
},
}),
);
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
worker.use(
rest.get(
@@ -614,17 +568,6 @@ describe('bump', () => {
jest
.spyOn(paths, 'resolveTargetRoot')
.mockImplementation((...path) => resolvePath('/', ...path));
jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) =>
JSON.stringify({
type: 'inspect',
data: {
name: name,
'dist-tags': {
latest: REGISTRY_VERSIONS[name],
},
},
}),
);
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
worker.use(
rest.get(
@@ -672,19 +615,9 @@ describe('bump', () => {
'Version bump complete!',
]);
expect(runObj.runPlain).toHaveBeenCalledTimes(5);
expect(runObj.runPlain).toHaveBeenCalledWith(
'yarn',
'info',
'--json',
'@backstage/core',
);
expect(runObj.runPlain).toHaveBeenCalledWith(
'yarn',
'info',
'--json',
'@backstage/theme',
);
expect(mockFetchPackageInfo).toHaveBeenCalledTimes(5);
expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/core');
expect(mockFetchPackageInfo).toHaveBeenCalledWith('@backstage/theme');
expect(runObj.run).toHaveBeenCalledTimes(1);
expect(runObj.run).toHaveBeenCalledWith(
@@ -743,7 +676,7 @@ describe('bump', () => {
jest
.spyOn(paths, 'resolveTargetRoot')
.mockImplementation((...path) => resolvePath('/', ...path));
jest.spyOn(runObj, 'runPlain').mockImplementation(async () => '');
mockFetchPackageInfo.mockRejectedValue(new NotFoundError('Nope'));
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
worker.use(
rest.get(
+1 -1
View File
@@ -25,7 +25,7 @@ import { promisify } from 'util';
import { LogFunc } from './logging';
import { assertError, ForwardedError } from '@backstage/errors';
const execFile = promisify(execFileCb);
export const execFile = promisify(execFileCb);
type SpawnOptionsPartialEnv = Omit<SpawnOptions, 'env'> & {
env?: Partial<NodeJS.ProcessEnv>;
@@ -17,13 +17,20 @@
import mockFs from 'mock-fs';
import path from 'path';
import * as runObj from '../run';
import * as yarn from '../yarn';
import { fetchPackageInfo, mapDependencies } from './packages';
import { NotFoundError } from '../errors';
jest.mock('../run', () => {
return {
run: jest.fn(),
runPlain: jest.fn(),
execFile: jest.fn(),
};
});
jest.mock('../yarn', () => {
return {
detectYarnVersion: jest.fn(),
};
});
@@ -32,24 +39,55 @@ describe('fetchPackageInfo', () => {
jest.resetAllMocks();
});
it('should forward info', async () => {
jest
.spyOn(runObj, 'runPlain')
.mockResolvedValue(`{"type":"inspect","data":{"the":"data"}}`);
it('should forward info for yarn classic', async () => {
jest.spyOn(runObj, 'execFile').mockResolvedValue({
stdout: `{"type":"inspect","data":{"the":"data"}}`,
stderr: '',
});
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('classic');
await expect(fetchPackageInfo('my-package')).resolves.toEqual({
the: 'data',
});
expect(runObj.runPlain).toHaveBeenCalledWith(
expect(runObj.execFile).toHaveBeenCalledWith(
'yarn',
'info',
'--json',
'my-package',
['info', '--json', 'my-package'],
{ shell: true },
);
});
it('should throw if no info', async () => {
jest.spyOn(runObj, 'runPlain').mockResolvedValue('');
it('should forward info for yarn berry', async () => {
jest
.spyOn(runObj, 'execFile')
.mockResolvedValue({ stdout: `{"the":"data"}`, stderr: '' });
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('berry');
await expect(fetchPackageInfo('my-package')).resolves.toEqual({
the: 'data',
});
expect(runObj.execFile).toHaveBeenCalledWith(
'yarn',
['npm', 'info', '--json', 'my-package'],
{ shell: true },
);
});
it('should throw if no info with yarn classic', async () => {
jest
.spyOn(runObj, 'execFile')
.mockResolvedValue({ stdout: '', stderr: '' });
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('classic');
await expect(fetchPackageInfo('my-package')).rejects.toThrow(
new NotFoundError(`No package information found for package my-package`),
);
});
it('should throw if no info with yarn berry', async () => {
jest
.spyOn(runObj, 'execFile')
.mockRejectedValue({ stdout: 'bla bla bla Response Code: 404 bla bla' });
jest.spyOn(yarn, 'detectYarnVersion').mockResolvedValue('berry');
await expect(fetchPackageInfo('my-package')).rejects.toThrow(
new NotFoundError(`No package information found for package my-package`),
+40 -11
View File
@@ -13,10 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import minimatch from 'minimatch';
import { getPackages } from '@manypkg/get-packages';
import { runPlain } from '../../lib/run';
import { NotFoundError } from '../errors';
import { detectYarnVersion } from '../yarn';
import { execFile } from '../run';
const DEP_TYPES = [
'dependencies',
@@ -48,18 +50,45 @@ type PkgVersionInfo = {
export async function fetchPackageInfo(
name: string,
): Promise<YarnInfoInspectData> {
const output = await runPlain('yarn', 'info', '--json', name);
const yarnVersion = await detectYarnVersion();
if (!output) {
throw new NotFoundError(`No package information found for package ${name}`);
const cmd = yarnVersion === 'classic' ? ['info'] : ['npm', 'info'];
try {
const { stdout: output } = await execFile(
'yarn',
[...cmd, '--json', name],
{ shell: true },
);
if (!output) {
throw new NotFoundError(
`No package information found for package ${name}`,
);
}
if (yarnVersion === 'berry') {
return JSON.parse(output) as YarnInfoInspectData;
}
const info = JSON.parse(output) as YarnInfo;
if (info.type !== 'inspect') {
throw new Error(`Received unknown yarn info for ${name}, ${output}`);
}
return info.data as YarnInfoInspectData;
} catch (error) {
if (yarnVersion === 'classic') {
throw error;
}
if (error?.stdout.includes('Response Code: 404')) {
throw new NotFoundError(
`No package information found for package ${name}`,
);
}
throw error;
}
const info = JSON.parse(output) as YarnInfo;
if (info.type !== 'inspect') {
throw new Error(`Received unknown yarn info for ${name}, ${output}`);
}
return info.data as YarnInfoInspectData;
}
/** Map all dependencies in the repo as dependency => dependents */
+49
View File
@@ -0,0 +1,49 @@
/*
* Copyright 2022 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 { assertError, ForwardedError } from '@backstage/errors';
import { execFile as execFileCb } from 'child_process';
import { promisify } from 'util';
const execFile = promisify(execFileCb);
const versions = new Map<string, Promise<'classic' | 'berry'>>();
export function detectYarnVersion(dir?: string): Promise<'classic' | 'berry'> {
const cwd = dir ?? process.cwd();
if (versions.has(cwd)) {
return versions.get(cwd)!;
}
const promise = Promise.resolve().then(async () => {
try {
const { stdout } = await execFile('yarn', ['--version'], {
shell: true,
cwd,
});
return stdout.trim().startsWith('1.') ? 'classic' : 'berry';
} catch (error) {
assertError(error);
if ('stderr' in error) {
process.stderr.write(error.stderr as Buffer);
}
throw new ForwardedError('Failed to determine yarn version', error);
}
});
versions.set(cwd, promise);
return promise;
}
+1
View File
@@ -50,6 +50,7 @@
"peerDependencies": {
"@types/react": "^16.13.1 || ^17.0.0",
"react": "^16.13.1 || ^17.0.0",
"react-dom": "^16.13.1 || ^17.0.0",
"react-router": "6.0.0-beta.0 || ^6.3.0",
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
},
@@ -35,6 +35,7 @@ describe('CatalogIdentityClient', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
const tokenManager: jest.Mocked<TokenManager> = {
getToken: jest.fn(),
@@ -68,6 +68,7 @@ describe('createRouter', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
config = new ConfigReader({
@@ -100,6 +100,7 @@ describe('AwsS3EntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = AwsS3EntityProvider.fromConfig(config, {
@@ -71,6 +71,7 @@ describe('AzureDevOpsEntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = AzureDevOpsEntityProvider.fromConfig(config, {
@@ -127,6 +127,7 @@ describe('BitbucketCloudEntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = BitbucketCloudEntityProvider.fromConfig(config, {
logger,
@@ -221,6 +221,7 @@ describe('BitbucketServerEntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = BitbucketServerEntityProvider.fromConfig(config, {
logger,
@@ -296,6 +297,7 @@ describe('BitbucketServerEntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = BitbucketServerEntityProvider.fromConfig(config, {
logger,
@@ -83,6 +83,7 @@ describe('GerritEntityProvider', () => {
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
it('discovers projects from the api.', async () => {
@@ -146,6 +146,7 @@ describe('GitHubEntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = GitHubEntityProvider.fromConfig(config, {
@@ -233,6 +234,7 @@ describe('GitHubEntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = GitHubEntityProvider.fromConfig(config, {
@@ -306,6 +308,7 @@ describe('GitHubEntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = GitHubEntityProvider.fromConfig(config, {
@@ -404,6 +407,7 @@ it('apply full update on scheduled execution with topic exclusion taking priorit
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = GitHubEntityProvider.fromConfig(config, {
@@ -83,6 +83,7 @@ describe('GitHubOrgEntityProvider', () => {
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const logger = getVoidLogger();
@@ -155,6 +155,7 @@ describe('GitlabDiscoveryEntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = GitlabDiscoveryEntityProvider.fromConfig(config, {
logger,
@@ -253,6 +254,7 @@ describe('GitlabDiscoveryEntityProvider', () => {
const schedule = new PersistingTaskRunner();
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = GitlabDiscoveryEntityProvider.fromConfig(config, {
logger,
@@ -95,6 +95,7 @@ describe('MicrosoftGraphOrgEntityProvider', () => {
};
const entityProviderConnection: EntityProviderConnection = {
applyMutation: jest.fn(),
refresh: jest.fn(),
};
const provider = MicrosoftGraphOrgEntityProvider.fromConfig(
new ConfigReader(config),
@@ -157,6 +157,14 @@ export interface ProcessingDatabase {
*/
refresh(txOpaque: Transaction, options: RefreshOptions): Promise<void>;
/**
* Schedules a refresh for every entity that has a matching set of refresh key stored for it.
*/
refreshByRefreshKeys(
txOpaque: Transaction,
options: RefreshByKeyOptions,
): Promise<void>;
/**
* Lists all ancestors of a given entityRef.
*
@@ -26,7 +26,7 @@ describe('DefaultLocationStore', () => {
async function createLocationStore(databaseId: TestDatabaseId) {
const knex = await databases.init(databaseId);
await applyDatabaseMigrations(knex);
const connection = { applyMutation: jest.fn() };
const connection = { applyMutation: jest.fn(), refresh: jest.fn() };
const store = new DefaultLocationStore(knex);
await store.connect(connection);
return { store, connection };
@@ -22,6 +22,7 @@ import { ProcessingDatabase } from '../database/types';
import {
EntityProvider,
EntityProviderConnection,
EntityProviderRefreshOptions,
EntityProviderMutation,
} from '@backstage/plugin-catalog-node';
@@ -61,6 +62,16 @@ class Connection implements EntityProviderConnection {
}
}
async refresh(options: EntityProviderRefreshOptions): Promise<void> {
const db = this.config.processingDatabase;
await db.transaction(async (tx: any) => {
return db.refreshByRefreshKeys(tx, {
keys: options.keys,
});
});
}
private check(entities: Entity[]) {
for (const entity of entities) {
try {
@@ -59,6 +59,7 @@ describe('<CatalogGraphCard/>', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
apis = TestApiRegistry.from([catalogApiRef, catalog]);
@@ -92,6 +92,7 @@ describe('<CatalogGraphPage/>', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
wrapper = (
@@ -119,6 +119,7 @@ describe('<EntityRelationsGraph/>', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
Wrapper = ({ children }) => (
@@ -38,6 +38,7 @@ describe('useEntityStore', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
useApi.mockReturnValue(catalogApi);
@@ -100,6 +100,7 @@ describe('CatalogImportClient', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
let catalogImportClient: CatalogImportClient;
@@ -46,6 +46,7 @@ describe('<StepPrepareCreatePullRequest />', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
const errorApi: jest.Mocked<typeof errorApiRef.T> = {
+6
View File
@@ -126,6 +126,7 @@ export interface EntityProvider {
// @public
export interface EntityProviderConnection {
applyMutation(mutation: EntityProviderMutation): Promise<void>;
refresh(options: EntityProviderRefreshOptions): Promise<void>;
}
// @public
@@ -140,6 +141,11 @@ export type EntityProviderMutation =
removed: DeferredEntity[];
};
// @public (undocumented)
export type EntityProviderRefreshOptions = {
keys: string[];
};
// @public
export type EntityRelationSpec = {
source: CompoundEntityRef;
+1
View File
@@ -32,4 +32,5 @@ export type {
EntityProvider,
EntityProviderConnection,
EntityProviderMutation,
EntityProviderRefreshOptions,
} from './provider';
+12
View File
@@ -26,6 +26,13 @@ export type EntityProviderMutation =
| { type: 'full'; entities: DeferredEntity[] }
| { type: 'delta'; added: DeferredEntity[]; removed: DeferredEntity[] };
/**
* @public
*/
export type EntityProviderRefreshOptions = {
keys: string[];
};
/**
* The EntityProviderConnection is the connection between the catalog and the entity provider.
* The EntityProvider use this connection to add and remove entities from the catalog.
@@ -36,6 +43,11 @@ export interface EntityProviderConnection {
* Applies either a full or delta update to the catalog engine.
*/
applyMutation(mutation: EntityProviderMutation): Promise<void>;
/**
* Schedules a refresh on all of the entities that has a matching refresh key associated with the provided keys.
*/
refresh(options: EntityProviderRefreshOptions): Promise<void>;
}
/**
+8 -1
View File
@@ -364,7 +364,14 @@ export class EntityTagFilter implements EntityFilter {
}
// @public (undocumented)
export const EntityTagPicker: () => JSX.Element | null;
export const EntityTagPicker: (
props: EntityTagPickerProps,
) => JSX.Element | null;
// @public (undocumented)
export type EntityTagPickerProps = {
showCounts?: boolean;
};
// @public
export class EntityTextFilter implements EntityFilter {
@@ -28,7 +28,9 @@ const tags = ['tag1', 'tag2', 'tag3', 'tag4'];
describe('<EntityTagPicker/>', () => {
const mockCatalogApiRef = {
getEntityFacets: async () => ({
facets: { 'metadata.tags': tags.map(value => ({ value })) },
facets: {
'metadata.tags': tags.map((value, idx) => ({ value, count: idx })),
},
}),
} as unknown as CatalogApi;
@@ -68,6 +70,26 @@ describe('<EntityTagPicker/>', () => {
]);
});
it('renders tags with counts', async () => {
const rendered = render(
<TestApiProvider apis={[[catalogApiRef, mockCatalogApiRef]]}>
<MockEntityListContextProvider value={{}}>
<EntityTagPicker showCounts />
</MockEntityListContextProvider>
</TestApiProvider>,
);
await waitFor(() => expect(rendered.getByText('Tags')).toBeInTheDocument());
fireEvent.click(rendered.getByTestId('tag-picker-expand'));
expect(rendered.getAllByRole('option').map(o => o.textContent)).toEqual([
'tag1 (0)',
'tag2 (1)',
'tag3 (2)',
'tag4 (3)',
]);
});
it('respects the query parameter filter value', async () => {
const updateFilters = jest.fn();
const queryParameters = { tags: ['tag3'] };
@@ -49,7 +49,12 @@ const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;
/** @public */
export const EntityTagPicker = () => {
export type EntityTagPickerProps = {
showCounts?: boolean;
};
/** @public */
export const EntityTagPicker = (props: EntityTagPickerProps) => {
const classes = useStyles();
const {
updateFilters,
@@ -65,7 +70,9 @@ export const EntityTagPicker = () => {
filter: filters.kind?.getCatalogFilters(),
});
return facets[facet].map(({ value }) => value);
return Object.fromEntries(
facets[facet].map(({ value, count }) => [value, count]),
);
}, [filters.kind]);
const queryParamTags = useMemo(
@@ -91,7 +98,7 @@ export const EntityTagPicker = () => {
});
}, [selectedTags, updateFilters]);
if (!availableTags?.length) return null;
if (!Object.keys(availableTags ?? {}).length) return null;
return (
<Box pb={1} pt={1}>
@@ -99,7 +106,7 @@ export const EntityTagPicker = () => {
Tags
<Autocomplete
multiple
options={availableTags}
options={Object.keys(availableTags ?? {})}
value={selectedTags}
onChange={(_: object, value: string[]) => setSelectedTags(value)}
renderOption={(option, { selected }) => (
@@ -111,7 +118,11 @@ export const EntityTagPicker = () => {
checked={selected}
/>
}
label={option}
label={
props.showCounts
? `${option} (${availableTags?.[option]})`
: option
}
/>
)}
size="small"
@@ -15,4 +15,7 @@
*/
export { EntityTagPicker } from './EntityTagPicker';
export type { CatalogReactEntityTagPickerClassKey } from './EntityTagPicker';
export type {
CatalogReactEntityTagPickerClassKey,
EntityTagPickerProps,
} from './EntityTagPicker';
@@ -32,6 +32,7 @@ describe('<DefaultExplorePage />', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
@@ -33,6 +33,7 @@ describe('<DomainExplorerContent />', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
@@ -33,6 +33,7 @@ describe('<GroupsExplorerContent />', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
@@ -37,6 +37,7 @@ describe('<FossaPage />', () => {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
const fossaApi: jest.Mocked<FossaApi> = {
getFindingSummary: jest.fn(),
+10
View File
@@ -0,0 +1,10 @@
# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
version: v1.22.1
# ignores vulnerabilities until expiry date; change duration by modifying expiry date
ignore:
SNYK-JS-TAFFYDB-2992450:
- '*':
reason: Development dependency only
expires: 2023-03-07T20:23:00.000Z
created: 2022-09-07T20:23:00.000Z
patch: {}
@@ -53,6 +53,7 @@ function mockCatalogClient(entity?: Entity): jest.Mocked<CatalogApi> {
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
if (entity) {
mock.getEntityByRef.mockReturnValue(entity);
+32 -27
View File
@@ -2833,6 +2833,8 @@ __metadata:
"@types/react": ^16.13.1 || ^17.0.0
peerDependencies:
react: ^16.13.1 || ^17.0.0
react-dom: ^16.13.1 || ^17.0.0
react-router: 6.0.0-beta.0 || ^6.3.0
react-router-dom: 6.0.0-beta.0 || ^6.3.0
languageName: unknown
linkType: soft
@@ -7423,6 +7425,7 @@ __metadata:
peerDependencies:
"@types/react": ^16.13.1 || ^17.0.0
react: ^16.13.1 || ^17.0.0
react-dom: ^16.13.1 || ^17.0.0
react-router: 6.0.0-beta.0 || ^6.3.0
react-router-dom: 6.0.0-beta.0 || ^6.3.0
languageName: unknown
@@ -12491,9 +12494,9 @@ __metadata:
languageName: node
linkType: hard
"@roadiehq/backstage-plugin-buildkite@npm:^2.0.0":
version: 2.0.7
resolution: "@roadiehq/backstage-plugin-buildkite@npm:2.0.7"
"@roadiehq/backstage-plugin-buildkite@npm:^2.0.8":
version: 2.0.8
resolution: "@roadiehq/backstage-plugin-buildkite@npm:2.0.8"
dependencies:
"@backstage/catalog-model": ^1.0.0
"@backstage/core-components": ^0.11.0
@@ -12505,19 +12508,19 @@ __metadata:
"@material-ui/lab": 4.0.0-alpha.57
history: ^5.0.0
moment: ^2.29.1
react-router: 6.0.0-beta.0
react-router-dom: 6.0.0-beta.0
react-use: ^17.2.4
peerDependencies:
react: ^16.13.1 || ^17.0.0
react-dom: ^16.13.1 || ^17.0.0
checksum: cf7ba623c418d3ea7ea56c67b0fe10d6be7fe36531539c31e87b586897fcb9c3474a432239d7d6c01623ff6fc29fb4ae905564a9d2aae11e2ce54b8502cc1c29
react-router: 6.0.0-beta.0 || ^6.3.0
react-router-dom: 6.0.0-beta.0 || ^6.3.0
checksum: 589f89b53ff166ca9099ea8d2a5b6f96b87a3daf2e0cb115e94c8880639f69bc25a04cab2926cba0c99dc9bb70a159528c6f1fc10a9fba67e4fb3abe40bce0bf
languageName: node
linkType: hard
"@roadiehq/backstage-plugin-github-insights@npm:^2.0.0":
version: 2.0.4
resolution: "@roadiehq/backstage-plugin-github-insights@npm:2.0.4"
"@roadiehq/backstage-plugin-github-insights@npm:^2.0.5":
version: 2.0.5
resolution: "@roadiehq/backstage-plugin-github-insights@npm:2.0.5"
dependencies:
"@backstage/catalog-model": ^1.0.0
"@backstage/core-components": ^0.11.0
@@ -12533,19 +12536,19 @@ __metadata:
"@octokit/types": ^6.14.2
history: ^5.0.0
immer: 9.0.7
react-router: ^6.0.0-beta.0
react-use: ^17.2.4
zustand: 3.6.9
peerDependencies:
react: ^16.13.1 || ^17.0.0
react-dom: ^16.13.1 || ^17.0.0
checksum: 2cc6eb992e78d7ce32ecce09996bda4eb34d4af3564ab54d180543d3a195a8b6e93f3137724569c49e645d44bfc00f3802285418f690393c456700668be01c25
react-router: 6.0.0-beta.0 || ^6.3.0
checksum: 37f99d0478dee6acbbafaf334a2eb1f75c251e96f8b10cead0d6f6dc203667dca51ded935ca704900251c01493b6287a66814f79245410a79ad66c843a3d4803
languageName: node
linkType: hard
"@roadiehq/backstage-plugin-github-pull-requests@npm:^2.0.0":
version: 2.2.6
resolution: "@roadiehq/backstage-plugin-github-pull-requests@npm:2.2.6"
"@roadiehq/backstage-plugin-github-pull-requests@npm:^2.2.7":
version: 2.2.7
resolution: "@roadiehq/backstage-plugin-github-pull-requests@npm:2.2.7"
dependencies:
"@backstage/catalog-model": ^1.0.0
"@backstage/core-components": ^0.11.0
@@ -12563,18 +12566,18 @@ __metadata:
moment: ^2.27.0
msw: ^0.39.2
node-fetch: ^2.6.1
react-router: 6.0.0-beta.0
react-use: ^17.2.4
peerDependencies:
react: ^16.13.1 || ^17.0.0
react-dom: ^16.13.1 || ^17.0.0
checksum: 1b6d584a1e6cc668026d9bcdea7e5588ad22084412e5e2c52659aed9da0f90fa5edb81e8d90d2ff67b645e0fb96790b91cb0a1024f1682319c7fb4799b62baa8
react-router: 6.0.0-beta.0 || ^6.3.0
checksum: 2453f72c4b853e4d6f43cb27aaf27cbce5d122fbcdc71625b1e1493486acde551798524cc84f792d986397011ab458ba9b7e56eec34cde55660ccd69ea488cb0
languageName: node
linkType: hard
"@roadiehq/backstage-plugin-travis-ci@npm:^2.0.0":
version: 2.0.4
resolution: "@roadiehq/backstage-plugin-travis-ci@npm:2.0.4"
"@roadiehq/backstage-plugin-travis-ci@npm:^2.0.5":
version: 2.0.5
resolution: "@roadiehq/backstage-plugin-travis-ci@npm:2.0.5"
dependencies:
"@backstage/catalog-model": ^1.0.0
"@backstage/core-components": ^0.11.0
@@ -12588,13 +12591,13 @@ __metadata:
date-fns: ^2.18.0
history: ^5.0.0
moment: ^2.29.1
react-router: 6.0.0-beta.0
react-router-dom: 6.0.0-beta.0
react-use: ^17.2.4
peerDependencies:
react: ^16.13.1 || ^17.0.0
react-dom: ^16.13.1 || ^17.0.0
checksum: 1c0f3389f6415d23fe1926e3387433da7f3d22b4a237c3a690c894cfa4c281b21307a3ec51a2c48cfa115727cca182c15017ef1e58a2e9b893d89d06df6db35e
react-router: 6.0.0-beta.0 || ^6.3.0
react-router-dom: 6.0.0-beta.0 || ^6.3.0
checksum: bafcef69ca1b36585dbafcf8964d897942bd026f74dad81f372aed9ee1ce496a4821570f462b62b1b8d0cb86013615ad39e103827163bde8cf32037143b05c7a
languageName: node
linkType: hard
@@ -22447,16 +22450,17 @@ __metadata:
"@material-ui/lab": 4.0.0-alpha.57
"@octokit/rest": ^19.0.3
"@rjsf/core": ^3.2.1
"@roadiehq/backstage-plugin-buildkite": ^2.0.0
"@roadiehq/backstage-plugin-github-insights": ^2.0.0
"@roadiehq/backstage-plugin-github-pull-requests": ^2.0.0
"@roadiehq/backstage-plugin-travis-ci": ^2.0.0
"@roadiehq/backstage-plugin-buildkite": ^2.0.8
"@roadiehq/backstage-plugin-github-insights": ^2.0.5
"@roadiehq/backstage-plugin-github-pull-requests": ^2.2.7
"@roadiehq/backstage-plugin-travis-ci": ^2.0.5
"@testing-library/cypress": ^8.0.2
"@testing-library/jest-dom": ^5.10.1
"@testing-library/react": ^12.1.3
"@testing-library/user-event": ^14.0.0
"@types/jquery": ^3.3.34
"@types/node": ^16.11.26
"@types/react": "*"
"@types/react-dom": "*"
"@types/zen-observable": ^0.8.0
cross-env: ^7.0.0
@@ -34588,7 +34592,7 @@ __metadata:
languageName: node
linkType: hard
"react-router-stable@npm:react-router@^6.3.0, react-router@npm:6.3.0, react-router@npm:^6.0.0-beta.0, react-router@npm:^6.3.0":
"react-router-stable@npm:react-router@^6.3.0, react-router@npm:6.3.0, react-router@npm:^6.3.0":
version: 6.3.0
resolution: "react-router@npm:6.3.0"
dependencies:
@@ -36022,6 +36026,7 @@ __metadata:
concurrently: ^7.0.0
cross-env: ^7.0.0
e2e-test: "workspace:*"
eslint: ^8.6.0
eslint-plugin-notice: ^0.9.10
fs-extra: 10.1.0
husky: ^8.0.0