diff --git a/.changeset/wise-flies-laugh.md b/.changeset/wise-flies-laugh.md
new file mode 100644
index 0000000000..4b31b4e9ba
--- /dev/null
+++ b/.changeset/wise-flies-laugh.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-home': patch
+---
+
+Added filter support for HomePageVisitedByType in order to enable filtering entities from the list
diff --git a/plugins/home/README.md b/plugins/home/README.md
index d41ae57162..066e637725 100644
--- a/plugins/home/README.md
+++ b/plugins/home/README.md
@@ -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
diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md
index dd6e22c57d..023c649ab4 100644
--- a/plugins/home/api-report.md
+++ b/plugins/home/api-report.md
@@ -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;
}>;
};
diff --git a/plugins/home/config.d.ts b/plugins/home/config.d.ts
new file mode 100644
index 0000000000..3834a14d89
--- /dev/null
+++ b/plugins/home/config.d.ts
@@ -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;
+ }>;
+ };
+ };
+}
diff --git a/plugins/home/package.json b/plugins/home/package.json
index c64ef1919e..e8fa2b666c 100644
--- a/plugins/home/package.json
+++ b/plugins/home/package.json
@@ -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"
]
}
diff --git a/plugins/home/src/api/VisitsApi.ts b/plugins/home/src/api/VisitsApi.ts
index ad750d81f2..610f4be483 100644
--- a/plugins/home/src/api/VisitsApi.ts
+++ b/plugins/home/src/api/VisitsApi.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;
}>;
};
diff --git a/plugins/home/src/api/VisitsStorageApi.test.ts b/plugins/home/src/api/VisitsStorageApi.test.ts
index 6faeb62350..e361c624b9 100644
--- a/plugins/home/src/api/VisitsStorageApi.test.ts
+++ b/plugins/home/src/api/VisitsStorageApi.test.ts
@@ -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 }],
});
diff --git a/plugins/home/src/api/config.test.ts b/plugins/home/src/api/config.test.ts
new file mode 100644
index 0000000000..c21d856038
--- /dev/null
+++ b/plugins/home/src/api/config.test.ts
@@ -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 }]);
+ });
+ });
+});
diff --git a/plugins/home/src/api/config.ts b/plugins/home/src/api/config.ts
new file mode 100644
index 0000000000..65833c13d5
--- /dev/null
+++ b/plugins/home/src/api/config.ts
@@ -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;
+ }
+}
diff --git a/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx b/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx
index 4442c319e6..2a7a6481ba 100644
--- a/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx
+++ b/plugins/home/src/homePageComponents/VisitedByType/Content.test.tsx
@@ -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('', () => {
+ beforeEach(() => {
+ mockVisitsApi = {
+ save: async () => visits[0],
+ list: async () => visits,
+ };
+ });
+
+ afterEach(() => jest.resetAllMocks());
+
it('renders', async () => {
const { getByText } = await renderInTestApp(
@@ -117,9 +138,139 @@ describe('', () => {
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(
+
+
+
+
+ ,
+ );
+ 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(
+
+
+
+
+ ,
+ );
+
+ 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(
+
+
+
+
+ ,
+ );
+ await waitFor(() => {
+ expect(listSpy).toHaveBeenCalledWith({
+ limit: 8,
+ orderBy: [
+ {
+ direction: 'desc',
+ field: 'timestamp',
+ },
+ ],
+ filterBy: [
+ {
+ field: 'pathname',
+ operator: '==',
+ value: '/explore',
+ },
+ ],
+ });
+ });
+ });
});
describe('', () => {
+ beforeEach(() => {
+ mockVisitsApi = {
+ save: async () => visits[0],
+ list: async () => visits,
+ };
+ });
+
+ afterEach(() => jest.resetAllMocks());
+
it('renders', async () => {
const { getByText } = await renderInTestApp(
@@ -133,4 +284,73 @@ describe('', () => {
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(
+
+
+
+
+ ,
+ );
+
+ 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(
+
+
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(listSpy).toHaveBeenCalledWith({
+ limit: 8,
+ orderBy: [
+ {
+ direction: 'desc',
+ field: 'hits',
+ },
+ ],
+ filterBy: [],
+ });
+ });
+ });
});
diff --git a/plugins/home/src/homePageComponents/VisitedByType/Content.tsx b/plugins/home/src/homePageComponents/VisitedByType/Content.tsx
index 4e847b717f..6c9d889d72 100644
--- a/plugins/home/src/homePageComponents/VisitedByType/Content.tsx
+++ b/plugins/home/src/homePageComponents/VisitedByType/Content.tsx
@@ -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);
}
diff --git a/yarn.lock b/yarn.lock
index 79d27b5501..786f6a31f4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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:^"