Merge pull request #21470 from shmaram/sshmaram/add_filter_HomePageTopVisited_and_HomePageRecentlyVisited

support filter in HomePageVisitedByType
This commit is contained in:
Djam
2024-01-30 12:54:03 +01:00
committed by GitHub
12 changed files with 539 additions and 8 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-home': patch
---
Added filter support for HomePageVisitedByType in order to enable filtering entities from the list
+32
View File
@@ -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
+7 -1
View File
@@ -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;
}>;
};
+68
View File
@@ -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;
}>;
};
};
}
+4 -1
View File
@@ -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"
]
}
+15 -1
View File
@@ -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 }],
});
+87
View File
@@ -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 }]);
});
});
});
+85
View File
@@ -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);
}
+1
View File
@@ -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:^"