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:^"