diff --git a/.changeset/nasty-forks-beg.md b/.changeset/nasty-forks-beg.md new file mode 100644 index 0000000000..0662431453 --- /dev/null +++ b/.changeset/nasty-forks-beg.md @@ -0,0 +1,14 @@ +--- +'@backstage/plugin-search-react': minor +'@backstage/plugin-search': minor +--- + +The SearchPage component can now be configured via app-config.yaml with default query parameters to define how it behaves when it is first loaded or reset. Check out the following example: + +```yaml +search: + query: + pageLimit: 50 +``` + +Acceptable values for `pageLimit` are `10`, `25`, `50` or `100`. diff --git a/.github/uffizzi/uffizzi.production.app-config.yaml b/.github/uffizzi/uffizzi.production.app-config.yaml index 4f64e5ffcb..34f4c90f0b 100644 --- a/.github/uffizzi/uffizzi.production.app-config.yaml +++ b/.github/uffizzi/uffizzi.production.app-config.yaml @@ -280,3 +280,7 @@ gocd: permission: enabled: true + +search: + query: + pageLimit: 50 diff --git a/plugins/search-react/src/components/SearchBar/SearchBar.test.tsx b/plugins/search-react/src/components/SearchBar/SearchBar.test.tsx index 9b4664aceb..ce3c285852 100644 --- a/plugins/search-react/src/components/SearchBar/SearchBar.test.tsx +++ b/plugins/search-react/src/components/SearchBar/SearchBar.test.tsx @@ -43,10 +43,6 @@ const createInitialState = ({ describe('SearchBar', () => { const user = userEvent.setup({ delay: null }); - const query = jest.fn().mockResolvedValue({ results: [] }); - - const searchApiMock = { query }; - const configApiMock = new ConfigReader({ app: { title: 'Mock title', @@ -58,6 +54,8 @@ describe('SearchBar', () => { }, }); + const searchApiMock = { query: jest.fn().mockResolvedValue({ results: [] }) }; + beforeEach(() => { jest.clearAllMocks(); }); @@ -172,7 +170,7 @@ describe('SearchBar', () => { await waitFor(() => expect(textbox).toHaveValue(value)); - expect(query).toHaveBeenLastCalledWith( + expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ term: value }), ); @@ -204,7 +202,7 @@ describe('SearchBar', () => { await waitFor(() => expect(textbox).toHaveValue('')); - expect(query).toHaveBeenLastCalledWith( + expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ term: '' }), ); }); @@ -255,7 +253,7 @@ describe('SearchBar', () => { await user.type(textbox, value); await waitFor(() => - expect(query).not.toHaveBeenLastCalledWith( + expect(searchApiMock.query).not.toHaveBeenLastCalledWith( expect.objectContaining({ term: value }), ), ); @@ -266,7 +264,7 @@ describe('SearchBar', () => { await waitFor(() => expect(textbox).toHaveValue(value)); - expect(query).toHaveBeenLastCalledWith( + expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ term: value }), ); diff --git a/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.test.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.test.tsx index 14c8967b57..765d2a26d1 100644 --- a/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.test.tsx +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { TestApiProvider } from '@backstage/test-utils'; +import { MockConfigApi, TestApiProvider } from '@backstage/test-utils'; import { screen, render, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -22,6 +22,7 @@ import React from 'react'; import { searchApiRef } from '../../api'; import { SearchContextProvider, useSearch } from '../../context'; import { SearchFilter } from './SearchFilter'; +import { configApiRef } from '@backstage/core-plugin-api'; const SearchContextFilterSpy = ({ name }: { name: string }) => { const { filters } = useSearch(); @@ -34,7 +35,16 @@ const SearchContextFilterSpy = ({ name }: { name: string }) => { }; describe('SearchFilter.Autocomplete', () => { - const query = jest.fn().mockResolvedValue({}); + const configApiMock = new MockConfigApi({ + search: { + query: { + pageLimit: 100, + }, + }, + }); + + const searchApiMock = { query: jest.fn().mockResolvedValue({ results: [] }) }; + const emptySearchContext = { term: '', types: [], @@ -46,7 +56,12 @@ describe('SearchFilter.Autocomplete', () => { it('renders as expected', async () => { render( - + @@ -67,7 +82,12 @@ describe('SearchFilter.Autocomplete', () => { it('renders as expected with async values', async () => { render( - + values} /> @@ -88,7 +108,12 @@ describe('SearchFilter.Autocomplete', () => { it('does not affect unrelated filter state', async () => { render( - + { describe('single', () => { it('renders as expected with defaultValue', async () => { render( - + { it('renders as expected with initial context', async () => { render( - + { it('sets filter state when selecting a value', async () => { render( - + @@ -221,7 +261,12 @@ describe('SearchFilter.Autocomplete', () => { describe('multiple', () => { it('renders as expected with defaultValue', async () => { render( - + { it('renders as expected with initial context', async () => { render( - + { it('respects tag limit configuration', async () => { render( - + { it('sets filter state when selecting a value', async () => { render( - + diff --git a/plugins/search-react/src/components/SearchFilter/SearchFilter.test.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.test.tsx index 89a1818e8d..c51a4832e3 100644 --- a/plugins/search-react/src/components/SearchFilter/SearchFilter.test.tsx +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.test.tsx @@ -18,15 +18,12 @@ import React from 'react'; import { screen, render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useApi } from '@backstage/core-plugin-api'; +import { configApiRef } from '@backstage/core-plugin-api'; import { SearchContextProvider } from '../../context'; import { SearchFilter } from './SearchFilter'; - -jest.mock('@backstage/core-plugin-api', () => ({ - ...jest.requireActual('@backstage/core-plugin-api'), - useApi: jest.fn().mockReturnValue({}), -})); +import { MockConfigApi, TestApiProvider } from '@backstage/test-utils'; +import { searchApiRef } from '../../api'; describe('SearchFilter', () => { const initialState = { @@ -40,8 +37,15 @@ describe('SearchFilter', () => { const values = ['value1', 'value2']; const filters = { unrelated: 'unrelated' }; - const query = jest.fn().mockResolvedValue({}); - (useApi as jest.Mock).mockReturnValue({ query: query }); + const configApiMock = new MockConfigApi({ + search: { + query: { + pagelimit: 10, + }, + }, + }); + + const searchApiMock = { query: jest.fn().mockResolvedValue({ results: [] }) }; afterAll(() => { jest.resetAllMocks(); @@ -58,9 +62,16 @@ describe('SearchFilter', () => { describe('Checkbox', () => { it('Renders field name and values when provided as props', async () => { render( - - - , + + + + + , ); await waitFor(() => { @@ -77,16 +88,23 @@ describe('SearchFilter', () => { it('Renders correctly based on filter state', async () => { render( - - - , + + + + , ); await waitFor(() => { @@ -101,14 +119,21 @@ describe('SearchFilter', () => { it('Renders correctly based on defaultValue', async () => { render( - - - , + + + + + , ); await waitFor(() => { @@ -123,9 +148,16 @@ describe('SearchFilter', () => { it('Checking / unchecking a value sets filter state', async () => { render( - - - , + + + + + , ); await waitFor(() => { @@ -137,7 +169,7 @@ describe('SearchFilter', () => { // Check the box. await userEvent.click(checkBox); await waitFor(() => { - expect(query).toHaveBeenLastCalledWith( + expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ filters: { field: [values[0]] } }), ); }); @@ -145,7 +177,7 @@ describe('SearchFilter', () => { // Uncheck the box. await userEvent.click(checkBox); await waitFor(() => { - expect(query).toHaveBeenLastCalledWith( + expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ filters: {} }), ); }); @@ -153,9 +185,16 @@ describe('SearchFilter', () => { it('Checking / unchecking a value maintains unrelated filter state', async () => { render( - - - , + + + + + , ); await waitFor(() => { @@ -167,7 +206,7 @@ describe('SearchFilter', () => { // Check the box. await userEvent.click(checkBox); await waitFor(() => { - expect(query).toHaveBeenLastCalledWith( + expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ filters: { ...filters, field: [values[0]] }, }), @@ -177,7 +216,7 @@ describe('SearchFilter', () => { // Uncheck the box. await userEvent.click(checkBox); await waitFor(() => { - expect(query).toHaveBeenLastCalledWith( + expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ filters }), ); }); @@ -187,9 +226,16 @@ describe('SearchFilter', () => { describe('Select', () => { it('Renders field name and values when provided as props', async () => { render( - - - , + + + + + , ); await waitFor(() => { @@ -212,13 +258,20 @@ describe('SearchFilter', () => { it('Renders values when provided asynchronously', async () => { render( - - values} - /> - , + + + values} + /> + + , ); await waitFor(() => { @@ -244,16 +297,23 @@ describe('SearchFilter', () => { it('Renders correctly based on filter state', async () => { render( - - - , + + + + , ); await waitFor(() => { @@ -280,14 +340,21 @@ describe('SearchFilter', () => { it('Renders correctly based on defaultValue', async () => { render( - - - , + + + + + , ); await waitFor(() => { @@ -314,9 +381,16 @@ describe('SearchFilter', () => { it('Selecting a value sets filter state', async () => { render( - - - , + + + + + , ); await waitFor(() => { @@ -334,7 +408,7 @@ describe('SearchFilter', () => { await userEvent.click(screen.getByRole('option', { name: values[0] })); await waitFor(() => { - expect(query).toHaveBeenLastCalledWith( + expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ filters: { [name]: values[0] }, }), @@ -350,7 +424,7 @@ describe('SearchFilter', () => { await userEvent.click(screen.getByRole('option', { name: 'All' })); await waitFor(() => { - expect(query).toHaveBeenLastCalledWith( + expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ filters: {}, }), @@ -360,14 +434,21 @@ describe('SearchFilter', () => { it('Selecting a value maintains unrelated filter state', async () => { render( - - - , + + + + , ); await waitFor(() => { @@ -385,7 +466,7 @@ describe('SearchFilter', () => { await userEvent.click(screen.getByRole('option', { name: values[0] })); await waitFor(() => { - expect(query).toHaveBeenLastCalledWith( + expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ filters: { ...filters, [name]: values[0] }, }), @@ -401,7 +482,7 @@ describe('SearchFilter', () => { await userEvent.click(screen.getByRole('option', { name: 'All' })); await waitFor(() => { - expect(query).toHaveBeenLastCalledWith( + expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ filters }), ); }); diff --git a/plugins/search-react/src/components/SearchFilter/hooks.test.tsx b/plugins/search-react/src/components/SearchFilter/hooks.test.tsx index 80c4a3a3a2..5135cd4ae6 100644 --- a/plugins/search-react/src/components/SearchFilter/hooks.test.tsx +++ b/plugins/search-react/src/components/SearchFilter/hooks.test.tsx @@ -15,19 +15,32 @@ */ import React from 'react'; import { ApiProvider } from '@backstage/core-app-api'; -import { TestApiRegistry } from '@backstage/test-utils'; +import { MockConfigApi, TestApiRegistry } from '@backstage/test-utils'; import { renderHook } from '@testing-library/react-hooks'; import { searchApiRef } from '../../api'; import { SearchContextProvider, useSearch } from '../../context'; import { useDefaultFilterValue, useAsyncFilterValues } from './hooks'; +import { configApiRef } from '@backstage/core-plugin-api'; jest.useFakeTimers(); describe('SearchFilter.hooks', () => { describe('useDefaultFilterValue', () => { - const query = jest.fn().mockResolvedValue({}); - const mockApis = TestApiRegistry.from([searchApiRef, { query }]); + const configApiMock = new MockConfigApi({ + search: { + query: { + pageLimit: 100, + }, + }, + }); + const searchApiMock = { + query: jest.fn().mockResolvedValue({ results: [] }), + }; + const mockApis = TestApiRegistry.from( + [searchApiRef, searchApiMock], + [configApiRef, configApiMock], + ); const wrapper = ({ children, overrides = {}, diff --git a/plugins/search-react/src/components/SearchPagination/SearchPagination.test.tsx b/plugins/search-react/src/components/SearchPagination/SearchPagination.test.tsx index 65e65d9436..7c86e16e95 100644 --- a/plugins/search-react/src/components/SearchPagination/SearchPagination.test.tsx +++ b/plugins/search-react/src/components/SearchPagination/SearchPagination.test.tsx @@ -18,27 +18,47 @@ import React from 'react'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { renderWithEffects, TestApiProvider } from '@backstage/test-utils'; +import { + MockConfigApi, + renderWithEffects, + TestApiProvider, +} from '@backstage/test-utils'; import { searchApiRef } from '../../api'; import { SearchContextProvider } from '../../context'; import { SearchPagination } from './SearchPagination'; - -const query = jest.fn().mockResolvedValue({ - results: [], - nextPageCursor: 'Mg==', - previousPageCursor: 'MA==', -}); +import { configApiRef } from '@backstage/core-plugin-api'; describe('SearchPagination', () => { + const configApiMock = new MockConfigApi({ + search: { + query: { + pagelimit: 10, + }, + }, + }); + + const searchApiMock = { + query: jest.fn().mockResolvedValue({ + results: [], + nextPageCursor: 'Mg==', + previousPageCursor: 'MA==', + }), + }; + beforeEach(() => { jest.clearAllMocks(); }); it('Renders without exploding', async () => { await renderWithEffects( - + @@ -54,7 +74,12 @@ describe('SearchPagination', () => { it('Define default page limit options', async () => { await renderWithEffects( - + @@ -74,7 +99,12 @@ describe('SearchPagination', () => { it('Accept custom page limit label', async () => { const label = 'Page limit:'; await renderWithEffects( - + @@ -86,7 +116,12 @@ describe('SearchPagination', () => { it('Show the total in text', async () => { await renderWithEffects( - + @@ -98,7 +133,12 @@ describe('SearchPagination', () => { it('Accept custom page limit text', async () => { await renderWithEffects( - + `${from}-${to} of more than ${to}`} @@ -112,7 +152,12 @@ describe('SearchPagination', () => { it('Accept custom page limit options', async () => { await renderWithEffects( - + @@ -131,7 +176,12 @@ describe('SearchPagination', () => { it('Set page limit in the context', async () => { await renderWithEffects( - + @@ -142,7 +192,7 @@ describe('SearchPagination', () => { await userEvent.click(screen.getByText('10')); - expect(query).toHaveBeenCalledWith( + expect(searchApiMock.query).toHaveBeenCalledWith( expect.objectContaining({ pageLimit: 10, }), @@ -158,7 +208,12 @@ describe('SearchPagination', () => { }; await renderWithEffects( - + @@ -169,7 +224,7 @@ describe('SearchPagination', () => { expect(screen.getByText('51-75')).toBeInTheDocument(); - expect(query).toHaveBeenLastCalledWith( + expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ pageCursor: 'Mg==', // page: 2 }), @@ -179,7 +234,7 @@ describe('SearchPagination', () => { expect(screen.getByText('26-50')).toBeInTheDocument(); - expect(query).toHaveBeenLastCalledWith( + expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ pageCursor: 'MQ==', // page: 1 }), @@ -195,7 +250,12 @@ describe('SearchPagination', () => { }; await renderWithEffects( - + @@ -205,7 +265,7 @@ describe('SearchPagination', () => { await userEvent.click(screen.getByText('25')); // first click to open the options await userEvent.click(screen.getByText('10')); // second click to select the option - expect(query).toHaveBeenLastCalledWith( + expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ pageCursor: undefined, pageLimit: 10, diff --git a/plugins/search-react/src/context/SearchContext.test.tsx b/plugins/search-react/src/context/SearchContext.test.tsx index edee2f153f..df71a848b0 100644 --- a/plugins/search-react/src/context/SearchContext.test.tsx +++ b/plugins/search-react/src/context/SearchContext.test.tsx @@ -14,29 +14,36 @@ * limitations under the License. */ -import { useApi } from '@backstage/core-plugin-api'; +import { configApiRef } from '@backstage/core-plugin-api'; import { render, screen, waitFor } from '@testing-library/react'; import { act, renderHook } from '@testing-library/react-hooks'; +import { MockConfigApi, TestApiProvider } from '@backstage/test-utils'; import React from 'react'; import { SearchContextProvider, useSearch, useSearchContextCheck, } from './SearchContext'; - -jest.mock('@backstage/core-plugin-api', () => ({ - ...jest.requireActual('@backstage/core-plugin-api'), - useApi: jest.fn(), -})); +import { searchApiRef } from '../api'; describe('SearchContext', () => { - const query = jest.fn(); + const searchApiMock = { query: jest.fn() }; - const wrapper = ({ children, initialState }: any) => ( - - {children} - - ); + const wrapper = ({ children, initialState, config = {} }: any) => { + const configApiMock = new MockConfigApi(config); + return ( + + + {children} + + + ); + }; const initialState = { term: '', @@ -45,8 +52,7 @@ describe('SearchContext', () => { }; beforeEach(() => { - query.mockResolvedValue({}); - (useApi as jest.Mock).mockReturnValue({ query: query }); + searchApiMock.query.mockResolvedValue({}); }); afterAll(() => { @@ -56,11 +62,7 @@ describe('SearchContext', () => { it('Passes children', async () => { const text = 'text'; - render( - - {text} - , - ); + render(wrapper({ children: text, initialState })); await waitFor(() => { expect(screen.getByText(text)).toBeInTheDocument(); @@ -95,17 +97,59 @@ describe('SearchContext', () => { expect(result.current).toEqual(true); }); - it('Uses initial state values', async () => { - const { result, waitForNextUpdate } = renderHook(() => useSearch(), { - wrapper, - initialProps: { - initialState, - }, + describe('Uses initial state values', () => { + it('Uses default initial state values', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSearch(), { + wrapper, + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual( + expect.objectContaining({ + term: '', + types: [], + filters: {}, + pageLimit: undefined, + pageCursor: undefined, + }), + ); }); - await waitForNextUpdate(); + it('Uses provided initial state values', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSearch(), { + wrapper, + initialProps: { + initialState, + }, + }); - expect(result.current).toEqual(expect.objectContaining(initialState)); + await waitForNextUpdate(); + + expect(result.current).toEqual(expect.objectContaining(initialState)); + }); + + it('Uses page limit provided via config api', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSearch(), { + wrapper, + initialProps: { + initialState, + config: { + search: { + query: { + pageLimit: 100, + }, + }, + }, + }, + }); + + await waitForNextUpdate(); + + expect(result.current).toEqual( + expect.objectContaining({ ...initialState, pageLimit: 100 }), + ); + }); }); describe('Resets cursor', () => { @@ -235,7 +279,7 @@ describe('SearchContext', () => { await waitForNextUpdate(); - expect(query).toHaveBeenLastCalledWith({ + expect(searchApiMock.query).toHaveBeenLastCalledWith({ term, types: ['*'], filters: {}, @@ -260,7 +304,7 @@ describe('SearchContext', () => { await waitForNextUpdate(); - expect(query).toHaveBeenLastCalledWith({ + expect(searchApiMock.query).toHaveBeenLastCalledWith({ types, term: '', filters: {}, @@ -285,7 +329,7 @@ describe('SearchContext', () => { await waitForNextUpdate(); - expect(query).toHaveBeenLastCalledWith({ + expect(searchApiMock.query).toHaveBeenLastCalledWith({ filters, term: '', types: ['*'], @@ -310,7 +354,7 @@ describe('SearchContext', () => { await waitForNextUpdate(); - expect(query).toHaveBeenLastCalledWith({ + expect(searchApiMock.query).toHaveBeenLastCalledWith({ pageLimit, term: '', types: ['*'], @@ -336,7 +380,7 @@ describe('SearchContext', () => { await waitForNextUpdate(); - expect(query).toHaveBeenLastCalledWith({ + expect(searchApiMock.query).toHaveBeenLastCalledWith({ pageCursor, term: '', types: ['*'], @@ -345,7 +389,7 @@ describe('SearchContext', () => { }); it('provides function for fetch the next page', async () => { - query.mockResolvedValue({ + searchApiMock.query.mockResolvedValue({ results: [], nextPageCursor: 'NEXT', }); @@ -368,7 +412,7 @@ describe('SearchContext', () => { await waitForNextUpdate(); - expect(query).toHaveBeenLastCalledWith({ + expect(searchApiMock.query).toHaveBeenLastCalledWith({ term: '', types: ['*'], filters: {}, @@ -377,7 +421,7 @@ describe('SearchContext', () => { }); it('provides function for fetch the previous page', async () => { - query.mockResolvedValue({ + searchApiMock.query.mockResolvedValue({ results: [], previousPageCursor: 'PREVIOUS', }); @@ -400,7 +444,7 @@ describe('SearchContext', () => { await waitForNextUpdate(); - expect(query).toHaveBeenLastCalledWith({ + expect(searchApiMock.query).toHaveBeenLastCalledWith({ term: '', types: ['*'], filters: {}, diff --git a/plugins/search-react/src/context/SearchContext.tsx b/plugins/search-react/src/context/SearchContext.tsx index 94e51c0668..add62b39df 100644 --- a/plugins/search-react/src/context/SearchContext.tsx +++ b/plugins/search-react/src/context/SearchContext.tsx @@ -30,7 +30,11 @@ import { createVersionedValueMap, } from '@backstage/version-bridge'; import { JsonObject } from '@backstage/types'; -import { AnalyticsContext, useApi } from '@backstage/core-plugin-api'; +import { + AnalyticsContext, + useApi, + configApiRef, +} from '@backstage/core-plugin-api'; import { SearchResultSet } from '@backstage/plugin-search-common'; import { searchApiRef } from '../api'; @@ -239,10 +243,20 @@ export const SearchContextProvider = (props: SearchContextProviderProps) => { const { initialState, inheritParentContextIfAvailable, children } = props; const hasParentContext = useSearchContextCheck(); + const configApi = useApi(configApiRef); + + const searchContextInitialState = { + ...searchInitialState, + ...(initialState || {}), + pageLimit: + configApi.getOptionalNumber('search.query.pageLimit') || + initialState?.pageLimit, + }; + return hasParentContext && inheritParentContextIfAvailable ? ( <>{children} ) : ( - + {children} ); diff --git a/plugins/search/README.md b/plugins/search/README.md index 3803b71e43..e6b664338d 100644 --- a/plugins/search/README.md +++ b/plugins/search/README.md @@ -8,6 +8,22 @@ Development is ongoing. You can follow the progress and contribute at the Backst Run `yarn dev` in the root directory, and then navigate to [/search](http://localhost:3000/search) to check out the plugin. +### Optional Settings + +Configure the search query values via `app-config.yaml` to define how it behaves by default. + +```yaml +# app-config.yaml +search: + query: + pageLimit: 50 +``` + +Acceptable values for `pageLimit` are `10`, `25`, `50` or `100`. + +**NOTE**: Currently this configuration only reflects the initial state of the Search React components. This means that +it defines how it behaves when it is first loaded or reset. + ### Areas of Responsibility This search plugin is primarily responsible for the following: diff --git a/plugins/search/config.d.ts b/plugins/search/config.d.ts new file mode 100644 index 0000000000..d9a5ce4a1b --- /dev/null +++ b/plugins/search/config.d.ts @@ -0,0 +1,35 @@ +/* + * 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. + */ + +export interface Config { + /** Configuration options for the search plugin */ + search?: { + /** + * An object representing the default search query configuration. + * By configuring and modifying the values of this object, + * you can customize the default values of the search queries + * and define how it behaves by default. + */ + query?: { + /** + * A number indicating the maximum number of results to be returned + * per page during pagination. + * @visibility frontend + */ + pageLimit?: 10 | 25 | 50 | 100; + }; + }; +} diff --git a/plugins/search/package.json b/plugins/search/package.json index d2616a6e35..9559a4d703 100644 --- a/plugins/search/package.json +++ b/plugins/search/package.json @@ -70,6 +70,8 @@ "msw": "^1.0.0" }, "files": [ - "dist" - ] + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/search/src/components/SearchModal/SearchModal.test.tsx b/plugins/search/src/components/SearchModal/SearchModal.test.tsx index 33c2df9ab8..10be8ba6f8 100644 --- a/plugins/search/src/components/SearchModal/SearchModal.test.tsx +++ b/plugins/search/src/components/SearchModal/SearchModal.test.tsx @@ -36,16 +36,17 @@ jest.mock('react-router-dom', () => ({ })); describe('SearchModal', () => { - const query = jest.fn().mockResolvedValue({ results: [] }); + const configApiMock = new ConfigReader({ app: { title: 'Mock app' } }); + const searchApiMock = { query: jest.fn().mockResolvedValue({ results: [] }) }; const apiRegistry = TestApiRegistry.from( - [configApiRef, new ConfigReader({ app: { title: 'Mock app' } })], - [searchApiRef, { query }], + [configApiRef, configApiMock], + [searchApiRef, searchApiMock], ); beforeEach(() => { - query.mockClear(); navigate.mockClear(); + searchApiMock.query.mockClear(); }); const toggleModal = jest.fn(); @@ -63,7 +64,7 @@ describe('SearchModal', () => { ); expect(screen.getByRole('dialog')).toBeInTheDocument(); - expect(query).toHaveBeenCalledTimes(1); + expect(searchApiMock.query).toHaveBeenCalledTimes(1); }); it('Should use parent search context if defined', async () => { @@ -88,7 +89,7 @@ describe('SearchModal', () => { ); expect(screen.getByRole('dialog')).toBeInTheDocument(); - expect(query).toHaveBeenCalledWith(initialState); + expect(searchApiMock.query).toHaveBeenCalledWith(initialState); }); it('Should create a local search context if a parent is not defined', async () => { @@ -104,7 +105,7 @@ describe('SearchModal', () => { ); expect(screen.getByRole('dialog')).toBeInTheDocument(); - expect(query).toHaveBeenCalledWith({ + expect(searchApiMock.query).toHaveBeenCalledWith({ term: '', filters: {}, types: [], @@ -141,7 +142,7 @@ describe('SearchModal', () => { }, ); - expect(query).toHaveBeenCalledTimes(1); + expect(searchApiMock.query).toHaveBeenCalledTimes(1); await userEvent.keyboard('{Escape}'); expect(toggleModal).toHaveBeenCalledTimes(1); }); @@ -198,7 +199,7 @@ describe('SearchModal', () => { }, ); - expect(query).toHaveBeenCalledWith( + expect(searchApiMock.query).toHaveBeenCalledWith( expect.objectContaining({ term: 'term' }), ); @@ -230,7 +231,7 @@ describe('SearchModal', () => { }, ); - expect(query).toHaveBeenCalledWith( + expect(searchApiMock.query).toHaveBeenCalledWith( expect.objectContaining({ term: 'term' }), ); diff --git a/plugins/search/src/components/SearchType/SearchType.Accordion.test.tsx b/plugins/search/src/components/SearchType/SearchType.Accordion.test.tsx index e0acee025a..556a9550a0 100644 --- a/plugins/search/src/components/SearchType/SearchType.Accordion.test.tsx +++ b/plugins/search/src/components/SearchType/SearchType.Accordion.test.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { TestApiProvider } from '@backstage/test-utils'; +import { MockConfigApi, TestApiProvider } from '@backstage/test-utils'; import { act, render, waitFor } from '@testing-library/react'; import user from '@testing-library/user-event'; import { @@ -23,6 +23,7 @@ import { SearchContextProvider, } from '@backstage/plugin-search-react'; import { SearchType } from './SearchType'; +import { configApiRef } from '@backstage/core-plugin-api'; const setTypesMock = jest.fn(); const setPageCursorMock = jest.fn(); @@ -40,7 +41,15 @@ jest.mock('@backstage/plugin-search-react', () => ({ })); describe('SearchType.Accordion', () => { - const query = jest.fn(); + const configApiMock = new MockConfigApi({ + search: { + query: { + pagelimit: 10, + }, + }, + }); + + const searchApiMock = { query: jest.fn() }; const expectedLabel = 'Expected Label'; const expectedType = { @@ -50,12 +59,20 @@ describe('SearchType.Accordion', () => { }; beforeEach(() => { - query.mockResolvedValue({ results: [], numberOfResults: 1234 }); + searchApiMock.query.mockResolvedValue({ + results: [], + numberOfResults: 1234, + }); }); const Wrapper = ({ children }: { children: React.ReactNode }) => { return ( - + {children} ); @@ -146,13 +163,13 @@ describe('SearchType.Accordion', () => { , ); - expect(query).toHaveBeenCalledWith({ + expect(searchApiMock.query).toHaveBeenCalledWith({ term: 'abc', types: [], filters: { foo: 'bar' }, pageLimit: 0, }); - expect(query).toHaveBeenCalledWith({ + expect(searchApiMock.query).toHaveBeenCalledWith({ term: 'abc', types: [expectedType.value], filters: {}, diff --git a/plugins/search/src/components/SearchType/SearchType.Tabs.test.tsx b/plugins/search/src/components/SearchType/SearchType.Tabs.test.tsx index 16df3a8f65..c0d8549253 100644 --- a/plugins/search/src/components/SearchType/SearchType.Tabs.test.tsx +++ b/plugins/search/src/components/SearchType/SearchType.Tabs.test.tsx @@ -15,7 +15,7 @@ */ import React from 'react'; -import { TestApiProvider } from '@backstage/test-utils'; +import { MockConfigApi, TestApiProvider } from '@backstage/test-utils'; import { act, render } from '@testing-library/react'; import user from '@testing-library/user-event'; import { @@ -23,6 +23,7 @@ import { searchApiRef, } from '@backstage/plugin-search-react'; import { SearchType } from './SearchType'; +import { configApiRef } from '@backstage/core-plugin-api'; const setTypesMock = jest.fn(); const setPageCursorMock = jest.fn(); @@ -38,7 +39,14 @@ jest.mock('@backstage/plugin-search-react', () => ({ })); describe('SearchType.Tabs', () => { - const query = jest.fn().mockResolvedValue({}); + const searchApiMock = { query: jest.fn().mockResolvedValue({ results: [] }) }; + const configApiMock = new MockConfigApi({ + search: { + query: { + pageLimit: 100, + }, + }, + }); const expectedType = { value: 'expected-type', @@ -47,7 +55,12 @@ describe('SearchType.Tabs', () => { const Wrapper = ({ children }: { children: React.ReactNode }) => { return ( - + {children} ); diff --git a/plugins/search/src/components/SearchType/SearchType.test.tsx b/plugins/search/src/components/SearchType/SearchType.test.tsx index a80782921f..353b45c0fb 100644 --- a/plugins/search/src/components/SearchType/SearchType.test.tsx +++ b/plugins/search/src/components/SearchType/SearchType.test.tsx @@ -14,17 +14,16 @@ * limitations under the License. */ -import { useApi } from '@backstage/core-plugin-api'; +import { configApiRef } from '@backstage/core-plugin-api'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { SearchContextProvider } from '@backstage/plugin-search-react'; +import { + SearchContextProvider, + searchApiRef, +} from '@backstage/plugin-search-react'; import { SearchType } from './SearchType'; - -jest.mock('@backstage/core-plugin-api', () => ({ - ...jest.requireActual('@backstage/core-plugin-api'), - useApi: jest.fn().mockReturnValue({}), -})); +import { MockConfigApi, TestApiProvider } from '@backstage/test-utils'; describe('SearchType', () => { const initialState = { @@ -37,8 +36,15 @@ describe('SearchType', () => { const values = ['value1', 'value2']; const typeValues = ['preselected']; - const query = jest.fn().mockResolvedValue({}); - (useApi as jest.Mock).mockReturnValue({ query: query }); + const configApiMock = new MockConfigApi({ + search: { + query: { + pagelimit: 10, + }, + }, + }); + + const searchApiMock = { query: jest.fn().mockResolvedValue({ results: [] }) }; afterAll(() => { jest.resetAllMocks(); @@ -47,9 +53,16 @@ describe('SearchType', () => { describe('Type Filter', () => { it('Renders field name and values when provided as props', async () => { render( - - - , + + + + + , ); await waitFor(() => { @@ -72,14 +85,21 @@ describe('SearchType', () => { it('Renders correctly based on type filter state', async () => { render( - - - , + + + + , ); await waitFor(() => { @@ -103,9 +123,16 @@ describe('SearchType', () => { it('Renders correctly based on type filter defaultValue', async () => { render( - - - , + + + + + , ); await waitFor(() => { @@ -129,9 +156,16 @@ describe('SearchType', () => { it('Selecting a value sets type filter state', async () => { render( - - - , + + + + + , ); await waitFor(() => { @@ -149,7 +183,7 @@ describe('SearchType', () => { await userEvent.click(screen.getByRole('option', { name: values[0] })); await waitFor(() => { - expect(query).toHaveBeenLastCalledWith( + expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ types: [values[0]], }), @@ -165,14 +199,21 @@ describe('SearchType', () => { it('Selecting none defaults to empty state', async () => { render( - - - , + + + + , ); await waitFor(() => { @@ -190,7 +231,7 @@ describe('SearchType', () => { await userEvent.click(screen.getByRole('option', { name: values[0] })); await waitFor(() => { - expect(query).toHaveBeenLastCalledWith( + expect(searchApiMock.query).toHaveBeenLastCalledWith( expect.objectContaining({ types: [...typeValues, values[0]], }), @@ -206,7 +247,9 @@ describe('SearchType', () => { await userEvent.click(screen.getByRole('option', { name: values[0] })); await waitFor(() => { - expect(query).toHaveBeenLastCalledWith(expect.objectContaining([])); + expect(searchApiMock.query).toHaveBeenLastCalledWith( + expect.objectContaining([]), + ); }); }); });