Merge pull request #21470 from shmaram/sshmaram/add_filter_HomePageTopVisited_and_HomePageRecentlyVisited
support filter in HomePageVisitedByType
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-home': patch
|
||||
---
|
||||
|
||||
Added filter support for HomePageVisitedByType in order to enable filtering entities from the list
|
||||
@@ -325,6 +325,38 @@ export default app.createRoot(
|
||||
);
|
||||
```
|
||||
|
||||
You can filter the items that are shown in the component.
|
||||
this can be done by using the config file.
|
||||
Filtering is done by using 3 parameters:
|
||||
|
||||
- `field` - define which field to filter. can be one of the following
|
||||
- `id`: string
|
||||
- `name`: string
|
||||
- `pathname`: string
|
||||
- `hits`: number
|
||||
- `timestamp`: number
|
||||
- `entityRef`: string
|
||||
- `operator` - can be one of the following `'<' | '<=' | '==' | '!=' | '>' | '>=' | 'contains'`
|
||||
- `value` - the value of the filter
|
||||
|
||||
```yaml
|
||||
home:
|
||||
recentVisits:
|
||||
filterBy:
|
||||
- field:
|
||||
operator:
|
||||
value:
|
||||
topVisits:
|
||||
filterBy:
|
||||
- field:
|
||||
operator:
|
||||
value:
|
||||
```
|
||||
|
||||
`filterBy` configs that are not defined in the above format will be ignored.
|
||||
|
||||
In order to validate the config you can use `backstage/cli config:check`
|
||||
|
||||
## Contributing
|
||||
|
||||
### Homepage Components
|
||||
|
||||
@@ -169,6 +169,9 @@ export const homePlugin: BackstagePlugin<
|
||||
{}
|
||||
>;
|
||||
|
||||
// @public
|
||||
export const isOperator: (s: string) => s is Operators;
|
||||
|
||||
// @public
|
||||
export type LayoutConfiguration = {
|
||||
component: ReactElement | string;
|
||||
@@ -181,6 +184,9 @@ export type LayoutConfiguration = {
|
||||
resizable?: boolean;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type Operators = '<' | '<=' | '==' | '!=' | '>' | '>=' | 'contains';
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export type RendererProps = RendererProps_2;
|
||||
|
||||
@@ -271,7 +277,7 @@ export type VisitsApiQueryParams = {
|
||||
}>;
|
||||
filterBy?: Array<{
|
||||
field: keyof Visit;
|
||||
operator: '<' | '<=' | '==' | '!=' | '>' | '>=' | 'contains';
|
||||
operator: Operators;
|
||||
value: string | number;
|
||||
}>;
|
||||
};
|
||||
|
||||
Vendored
+68
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2020 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 interface Config {
|
||||
home?: {
|
||||
/**
|
||||
* Top visited plugin
|
||||
* @visibility frontend
|
||||
*/
|
||||
topVisits?: {
|
||||
/**
|
||||
* Filter By config
|
||||
* @visibility frontend
|
||||
*/
|
||||
filterBy?: Array<{
|
||||
/**
|
||||
* @visibility frontend
|
||||
*/
|
||||
field: string;
|
||||
/**
|
||||
* @visibility frontend
|
||||
*/
|
||||
operator: string;
|
||||
/**
|
||||
* @visibility frontend
|
||||
*/
|
||||
value: string | number;
|
||||
}>;
|
||||
};
|
||||
/**
|
||||
* Recent visited plugin
|
||||
* @visibility frontend
|
||||
*/
|
||||
recentVisits?: {
|
||||
/**
|
||||
* Filter By config
|
||||
* @visibility frontend
|
||||
*/
|
||||
filterBy?: Array<{
|
||||
/**
|
||||
* @visibility frontend
|
||||
*/
|
||||
field: string;
|
||||
/**
|
||||
* @visibility frontend
|
||||
*/
|
||||
operator: string;
|
||||
/**
|
||||
* @visibility frontend
|
||||
*/
|
||||
value: string | number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"configSchema": "config.d.ts",
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"build": "backstage-cli package build",
|
||||
@@ -49,6 +50,7 @@
|
||||
"dependencies": {
|
||||
"@backstage/catalog-client": "workspace:^",
|
||||
"@backstage/catalog-model": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/core-compat-api": "workspace:^",
|
||||
"@backstage/core-components": "workspace:^",
|
||||
@@ -88,6 +90,7 @@
|
||||
"@types/react-grid-layout": "^1.3.2"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"config.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,20 @@
|
||||
|
||||
import { createApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
/**
|
||||
* @public
|
||||
* The operators that can be used in filter.
|
||||
*/
|
||||
export type Operators = '<' | '<=' | '==' | '!=' | '>' | '>=' | 'contains';
|
||||
|
||||
/**
|
||||
* @public
|
||||
* Type guard for operators.
|
||||
*/
|
||||
export const isOperator = (s: string): s is Operators => {
|
||||
return ['<', '<=', '==', '!=', '>', '>=', 'contains'].includes(s);
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
* Model for a visit entity.
|
||||
@@ -84,7 +98,7 @@ export type VisitsApiQueryParams = {
|
||||
*/
|
||||
filterBy?: Array<{
|
||||
field: keyof Visit;
|
||||
operator: '<' | '<=' | '==' | '!=' | '>' | '>=' | 'contains';
|
||||
operator: Operators;
|
||||
value: string | number;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -250,7 +250,7 @@ describe('VisitsStorageApi.create', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters by timestamp with >', async () => {
|
||||
it('filters by timestamp with value >', async () => {
|
||||
const visits = await api.list({
|
||||
filterBy: [{ field: 'timestamp', operator: '>', value: baseDate }],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2023 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 { readFilterConfig, createFilterByQueryParamFromConfig } from './config';
|
||||
import { MockConfigApi } from '@backstage/test-utils';
|
||||
|
||||
describe('config', () => {
|
||||
describe('readFilterConfig', () => {
|
||||
it('returns filter data', async () => {
|
||||
const mockConfig = new MockConfigApi({
|
||||
field: 'pathname',
|
||||
operator: '==',
|
||||
value: '/home',
|
||||
});
|
||||
const res = readFilterConfig(mockConfig);
|
||||
expect(res).toEqual({
|
||||
field: 'pathname',
|
||||
operator: '==',
|
||||
value: '/home',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined for invalid filter', async () => {
|
||||
const mockInvalidConfig = new MockConfigApi({
|
||||
myField: 'pathname',
|
||||
operator: '==',
|
||||
value: '3',
|
||||
});
|
||||
const res = readFilterConfig(mockInvalidConfig);
|
||||
expect(res).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFilterByQueryParamFromConfig', () => {
|
||||
it('returns filter data', async () => {
|
||||
const mockConfig1 = new MockConfigApi({
|
||||
field: 'id',
|
||||
operator: '==',
|
||||
value: '3',
|
||||
});
|
||||
const mockConfig2 = new MockConfigApi({
|
||||
field: 'pathname',
|
||||
operator: '==',
|
||||
value: 'path',
|
||||
});
|
||||
const res = createFilterByQueryParamFromConfig([
|
||||
mockConfig1,
|
||||
mockConfig2,
|
||||
]);
|
||||
expect(res).toEqual([
|
||||
{ field: 'id', operator: '==', value: '3' },
|
||||
{ field: 'pathname', operator: '==', value: 'path' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns only valid filters', async () => {
|
||||
const mockValidConfig = new MockConfigApi({
|
||||
field: 'id',
|
||||
operator: '==',
|
||||
value: 3,
|
||||
});
|
||||
const mockInvalidConfig = new MockConfigApi({
|
||||
myField: 'pathname',
|
||||
operator: '==',
|
||||
value: 'path',
|
||||
});
|
||||
const res = createFilterByQueryParamFromConfig([
|
||||
mockValidConfig,
|
||||
mockInvalidConfig,
|
||||
]);
|
||||
expect(res).toEqual([{ field: 'id', operator: '==', value: 3 }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2023 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 {
|
||||
Visit,
|
||||
VisitsApiQueryParams,
|
||||
Operators,
|
||||
isOperator,
|
||||
} from './VisitsApi';
|
||||
import { Config } from '@backstage/config';
|
||||
|
||||
/**
|
||||
* Reads a single FilterBy config.
|
||||
*
|
||||
* @param config - The single config object
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
|
||||
export function readFilterConfig(config: Config):
|
||||
| {
|
||||
field: keyof Visit;
|
||||
operator: Operators;
|
||||
value: string | number;
|
||||
}
|
||||
| undefined {
|
||||
try {
|
||||
const field = config.getString('field') as keyof Visit;
|
||||
const operator = config.getString('operator');
|
||||
const value = getValue(config);
|
||||
if (isOperator(operator) && value !== undefined) {
|
||||
return { field, operator, value };
|
||||
}
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
// invalid filter config - ignore filter
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getValue(config: Config) {
|
||||
let value = undefined;
|
||||
try {
|
||||
value = config.getString('value');
|
||||
} catch (error) {
|
||||
try {
|
||||
value = config.getNumber('value');
|
||||
} catch {
|
||||
// value is not string or number - ignore filter
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a set of filter by configs.
|
||||
*
|
||||
* @param configs - All of the config objects
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function createFilterByQueryParamFromConfig(
|
||||
configs: Config[],
|
||||
): VisitsApiQueryParams['filterBy'] | undefined {
|
||||
try {
|
||||
return configs
|
||||
.map(readFilterConfig)
|
||||
.filter(Boolean) as VisitsApiQueryParams['filterBy'];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,15 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Content } from './Content';
|
||||
import { TestApiProvider, renderInTestApp } from '@backstage/test-utils';
|
||||
import {
|
||||
TestApiProvider,
|
||||
renderInTestApp,
|
||||
MockConfigApi,
|
||||
} from '@backstage/test-utils';
|
||||
import { visitsApiRef } from '../../api';
|
||||
import { ContextProvider } from './Context';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { configApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
const visits = [
|
||||
{
|
||||
@@ -29,14 +34,30 @@ const visits = [
|
||||
hits: 35,
|
||||
timestamp: Date.now() - 86400_000,
|
||||
},
|
||||
{
|
||||
id: 'tech-radar',
|
||||
name: 'Tech Radar',
|
||||
pathname: '/tech-radar',
|
||||
hits: 40,
|
||||
timestamp: Date.now() - 360_000,
|
||||
},
|
||||
];
|
||||
|
||||
const mockVisitsApi = {
|
||||
let mockVisitsApi = {
|
||||
save: async () => visits[0],
|
||||
list: async () => visits,
|
||||
};
|
||||
|
||||
describe('<Content kind="recent"/>', () => {
|
||||
beforeEach(() => {
|
||||
mockVisitsApi = {
|
||||
save: async () => visits[0],
|
||||
list: async () => visits,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => jest.resetAllMocks());
|
||||
|
||||
it('renders', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
|
||||
@@ -117,9 +138,139 @@ describe('<Content kind="recent"/>', () => {
|
||||
expect(container.querySelectorAll('li')[0]).toBeVisible();
|
||||
expect(container.querySelectorAll('li')[1]).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('allows recent items to be filtered using config', async () => {
|
||||
const configApiMock = new MockConfigApi({
|
||||
home: {
|
||||
recentVisits: {
|
||||
filterBy: [
|
||||
{
|
||||
field: 'pathname',
|
||||
operator: '==',
|
||||
value: '/tech-radar',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const listSpy = jest.spyOn(mockVisitsApi, 'list');
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[visitsApiRef, mockVisitsApi],
|
||||
]}
|
||||
>
|
||||
<ContextProvider>
|
||||
<Content kind="recent" />
|
||||
</ContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(listSpy).toHaveBeenCalledWith({
|
||||
limit: 8,
|
||||
orderBy: [
|
||||
{
|
||||
direction: 'desc',
|
||||
field: 'timestamp',
|
||||
},
|
||||
],
|
||||
filterBy: [{ field: 'pathname', operator: '==', value: '/tech-radar' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows all recent items when there is no filtering in the config', async () => {
|
||||
const listSpy = jest.spyOn(mockVisitsApi, 'list');
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
|
||||
<ContextProvider>
|
||||
<Content kind="recent" />
|
||||
</ContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listSpy).toHaveBeenCalledWith({
|
||||
limit: 8,
|
||||
orderBy: [
|
||||
{
|
||||
direction: 'desc',
|
||||
field: 'timestamp',
|
||||
},
|
||||
],
|
||||
filterBy: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('allows recent items to have no filter if the filter config is not valid', async () => {
|
||||
const configApiMock = new MockConfigApi({
|
||||
home: {
|
||||
recentVisits: {
|
||||
filterBy: [
|
||||
{
|
||||
operator: '==',
|
||||
value: '/tech-radar',
|
||||
},
|
||||
{
|
||||
field: 'pathname',
|
||||
operator: '==',
|
||||
value: '/explore',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const listSpy = jest.spyOn(mockVisitsApi, 'list');
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[visitsApiRef, mockVisitsApi],
|
||||
]}
|
||||
>
|
||||
<ContextProvider>
|
||||
<Content kind="recent" />
|
||||
</ContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(listSpy).toHaveBeenCalledWith({
|
||||
limit: 8,
|
||||
orderBy: [
|
||||
{
|
||||
direction: 'desc',
|
||||
field: 'timestamp',
|
||||
},
|
||||
],
|
||||
filterBy: [
|
||||
{
|
||||
field: 'pathname',
|
||||
operator: '==',
|
||||
value: '/explore',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('<Content kind="top"/>', () => {
|
||||
beforeEach(() => {
|
||||
mockVisitsApi = {
|
||||
save: async () => visits[0],
|
||||
list: async () => visits,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => jest.resetAllMocks());
|
||||
|
||||
it('renders', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
|
||||
@@ -133,4 +284,73 @@ describe('<Content kind="top"/>', () => {
|
||||
expect(getByText('Explore Backstage')).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('allows top items to be filtered using config', async () => {
|
||||
const configApiMock = new MockConfigApi({
|
||||
home: {
|
||||
topVisits: {
|
||||
filterBy: [
|
||||
{
|
||||
field: 'pathname',
|
||||
operator: '==',
|
||||
value: '/explore',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const listSpy = jest.spyOn(mockVisitsApi, 'list');
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[visitsApiRef, mockVisitsApi],
|
||||
]}
|
||||
>
|
||||
<ContextProvider>
|
||||
<Content kind="top" />
|
||||
</ContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listSpy).toHaveBeenCalledWith({
|
||||
limit: 8,
|
||||
orderBy: [
|
||||
{
|
||||
direction: 'desc',
|
||||
field: 'hits',
|
||||
},
|
||||
],
|
||||
filterBy: [{ field: 'pathname', operator: '==', value: '/explore' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows all top items when there is no filtering in the config', async () => {
|
||||
const listSpy = jest.spyOn(mockVisitsApi, 'list');
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[visitsApiRef, mockVisitsApi]]}>
|
||||
<ContextProvider>
|
||||
<Content kind="top" />
|
||||
</ContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listSpy).toHaveBeenCalledWith({
|
||||
limit: 8,
|
||||
orderBy: [
|
||||
{
|
||||
direction: 'desc',
|
||||
field: 'hits',
|
||||
},
|
||||
],
|
||||
filterBy: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,10 +15,11 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { createFilterByQueryParamFromConfig } from '../../api/config';
|
||||
import { VisitedByType } from './VisitedByType';
|
||||
import { Visit, visitsApiRef } from '../../api/VisitsApi';
|
||||
import { Visit, visitsApiRef } from '../../api';
|
||||
import { ContextValueOnly, useContext } from './Context';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { configApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
/** @public */
|
||||
@@ -60,22 +61,31 @@ export const Content = ({
|
||||
setContext(state => ({ ...state, ...context }));
|
||||
}, [setContext, kind, visits, loading, numVisitsOpen, numVisitsTotal]);
|
||||
|
||||
const config = useApi(configApiRef);
|
||||
// Fetches data from visitsApi in case visits and loading are not provided
|
||||
const visitsApi = useApi(visitsApiRef);
|
||||
const { loading: reqLoading } = useAsync(async () => {
|
||||
if (!visits && !loading && kind === 'recent') {
|
||||
const filterBy = createFilterByQueryParamFromConfig(
|
||||
config.getOptionalConfigArray('home.recentVisits.filterBy') ?? [],
|
||||
);
|
||||
return await visitsApi
|
||||
.list({
|
||||
limit: numVisitsTotal ?? 8,
|
||||
orderBy: [{ field: 'timestamp', direction: 'desc' }],
|
||||
...(filterBy && { filterBy }),
|
||||
})
|
||||
.then(setVisits);
|
||||
}
|
||||
if (!visits && !loading && kind === 'top') {
|
||||
const filterBy = createFilterByQueryParamFromConfig(
|
||||
config.getOptionalConfigArray('home.topVisits.filterBy') ?? [],
|
||||
);
|
||||
return await visitsApi
|
||||
.list({
|
||||
limit: numVisitsTotal ?? 8,
|
||||
orderBy: [{ field: 'hits', direction: 'desc' }],
|
||||
...(filterBy && { filterBy }),
|
||||
})
|
||||
.then(setVisits);
|
||||
}
|
||||
|
||||
@@ -6993,6 +6993,7 @@ __metadata:
|
||||
"@backstage/catalog-client": "workspace:^"
|
||||
"@backstage/catalog-model": "workspace:^"
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/config": "workspace:^"
|
||||
"@backstage/core-app-api": "workspace:^"
|
||||
"@backstage/core-compat-api": "workspace:^"
|
||||
"@backstage/core-components": "workspace:^"
|
||||
|
||||
Reference in New Issue
Block a user