diff --git a/.changeset/old-cows-buy.md b/.changeset/old-cows-buy.md new file mode 100644 index 0000000000..14c9cf6eda --- /dev/null +++ b/.changeset/old-cows-buy.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-react': patch +--- + +Emit search analytics in the search hook instead of in a dedicated component diff --git a/plugins/search-react/src/components/SearchBar/SearchBar.test.tsx b/plugins/search-react/src/components/SearchBar/SearchBar.test.tsx index 8a3dd84286..6fcb3ef987 100644 --- a/plugins/search-react/src/components/SearchBar/SearchBar.test.tsx +++ b/plugins/search-react/src/components/SearchBar/SearchBar.test.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { screen, waitFor, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { configApiRef, analyticsApiRef } from '@backstage/core-plugin-api'; +import { configApiRef } from '@backstage/core-plugin-api'; import { ConfigReader } from '@backstage/core-app-api'; import { MockAnalyticsApi, @@ -298,63 +298,4 @@ describe('SearchBar', () => { expect(analyticsApiMock.getEvents()).toHaveLength(0); }); - - it('Captures analytics events if enabled in app', async () => { - const analyticsApiMock = new MockAnalyticsApi(); - - const types = ['techdocs', 'software-catalog']; - - await renderWithEffects( - - - - - , - ); - - const textbox = screen.getByLabelText('Search'); - - let value = 'value'; - await user.type(textbox, value); - await waitFor(() => { - expect(analyticsApiMock.getEvents()).toHaveLength(1); - expect(textbox).toHaveValue(value); - expect(analyticsApiMock.getEvents()[0]).toEqual({ - action: 'search', - context: { - extension: 'SearchBar', - pluginId: 'search', - routeRef: 'unknown', - searchTypes: types.toString(), - }, - subject: value, - }); - }); - - value = 'new value'; - await user.clear(textbox); - - // make sure new term is captured - await user.type(textbox, value); - await waitFor(() => { - expect(analyticsApiMock.getEvents()).toHaveLength(2); - expect(textbox).toHaveValue(value); - expect(analyticsApiMock.getEvents()[1]).toEqual({ - action: 'search', - context: { - extension: 'SearchBar', - pluginId: 'search', - routeRef: 'unknown', - searchTypes: types.toString(), - }, - subject: value, - }); - }); - }); }); diff --git a/plugins/search-react/src/components/SearchBar/SearchBar.tsx b/plugins/search-react/src/components/SearchBar/SearchBar.tsx index d101efa669..534fefbd8c 100644 --- a/plugins/search-react/src/components/SearchBar/SearchBar.tsx +++ b/plugins/search-react/src/components/SearchBar/SearchBar.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { AnalyticsContext, configApiRef, @@ -36,7 +37,6 @@ import React, { import useDebounce from 'react-use/lib/useDebounce'; import { SearchContextProvider, useSearch } from '../../context'; -import { TrackSearch } from '../SearchTracker'; function withContext(Component: ComponentType) { return forwardRef((props, ref) => ( @@ -171,33 +171,29 @@ export const SearchBarBase: ForwardRefExoticComponent = ); return ( - - - + ); }), ); diff --git a/plugins/search-react/src/components/SearchTracker/SearchTracker.tsx b/plugins/search-react/src/components/SearchTracker/SearchTracker.tsx deleted file mode 100644 index 6cdfd205fc..0000000000 --- a/plugins/search-react/src/components/SearchTracker/SearchTracker.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2022 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 React, { useEffect } from 'react'; -import { useAnalytics } from '@backstage/core-plugin-api'; -import { useSearch } from '../../context'; -import usePrevious from 'react-use/lib/usePrevious'; - -function useFallingEdge(next: boolean) { - const prev = usePrevious(next); - return prev && !next; -} - -/** - * Capture search event on term change. - */ -export const TrackSearch = ({ children }: { children: React.ReactChild }) => { - const analytics = useAnalytics(); - const { term, result } = useSearch(); - - const numberOfResults = result.value?.numberOfResults ?? undefined; - - // Stops the analtyics event from firing before the new search engine response is returned - const hasFinishedLoading = useFallingEdge(result.loading); - - useEffect(() => { - if (term && hasFinishedLoading) { - // Capture analytics search event with search term and numberOfResults (provided as value) - analytics.captureEvent('search', term, { - value: numberOfResults, - }); - } - }, [analytics, term, numberOfResults, hasFinishedLoading]); - - return <>{children}; -}; diff --git a/plugins/search-react/src/components/SearchTracker/index.ts b/plugins/search-react/src/components/SearchTracker/index.ts deleted file mode 100644 index 9932f2eaec..0000000000 --- a/plugins/search-react/src/components/SearchTracker/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright 2022 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 { TrackSearch } from './SearchTracker'; diff --git a/plugins/search-react/src/context/SearchContext.test.tsx b/plugins/search-react/src/context/SearchContext.test.tsx index f962bc1620..5e57c2aa84 100644 --- a/plugins/search-react/src/context/SearchContext.test.tsx +++ b/plugins/search-react/src/context/SearchContext.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { configApiRef } from '@backstage/core-plugin-api'; +import { analyticsApiRef, configApiRef } from '@backstage/core-plugin-api'; import { render, screen, @@ -32,7 +32,12 @@ import { import { searchApiRef } from '../api'; describe('SearchContext', () => { - const searchApiMock = { query: jest.fn() }; + const searchApiMock = { + query: jest.fn().mockResolvedValue({}), + } satisfies typeof searchApiRef.T; + const analyticsApiMock = { + captureEvent: jest.fn(), + } satisfies typeof analyticsApiRef.T; const wrapper = ({ children, initialState, config = {} }: any) => { const configApiMock = new MockConfigApi(config); @@ -41,6 +46,7 @@ describe('SearchContext', () => { apis={[ [configApiRef, configApiMock], [searchApiRef, searchApiMock], + [analyticsApiRef, analyticsApiMock], ]} > @@ -57,10 +63,6 @@ describe('SearchContext', () => { }; beforeEach(() => { - searchApiMock.query.mockResolvedValue({}); - }); - - afterAll(() => { jest.resetAllMocks(); }); @@ -376,9 +378,9 @@ describe('SearchContext', () => { await waitFor(() => { expect(result.current).toEqual(expect.objectContaining(initialState)); + expect(result.current.fetchNextPage).toBeDefined(); }); - expect(result.current.fetchNextPage).toBeDefined(); expect(result.current.fetchPreviousPage).toBeUndefined(); await act(async () => { @@ -421,4 +423,40 @@ describe('SearchContext', () => { }); }); }); + + describe('analytics', () => { + it('Captures analytics events if enabled in app', async () => { + searchApiMock.query.mockResolvedValue({ + results: [], + numberOfResults: 3, + }); + + const { result } = renderHook(() => useSearch(), { + wrapper: ({ children }) => wrapper({ children, initialState }), + }); + + await waitFor(() => { + expect(result.current).toEqual(expect.objectContaining(initialState)); + }); + + const term = 'term'; + + await act(async () => { + result.current.setTerm(term); + }); + + await waitFor(() => { + expect(analyticsApiMock.captureEvent).toHaveBeenCalledWith({ + action: 'search', + subject: 'term', + value: 3, + context: { + extension: 'App', + pluginId: 'root', + routeRef: 'unknown', + }, + }); + }); + }); + }); }); diff --git a/plugins/search-react/src/context/SearchContext.tsx b/plugins/search-react/src/context/SearchContext.tsx index f6fe08e866..6ce18501d9 100644 --- a/plugins/search-react/src/context/SearchContext.tsx +++ b/plugins/search-react/src/context/SearchContext.tsx @@ -34,6 +34,7 @@ import { AnalyticsContext, useApi, configApiRef, + useAnalytics, } from '@backstage/core-plugin-api'; import { SearchResultSet } from '@backstage/plugin-search-common'; @@ -114,6 +115,7 @@ const useSearchContextValue = ( initialValue: SearchContextState = defaultInitialSearchState, ) => { const searchApi = useApi(searchApiRef); + const analytics = useAnalytics(); const [term, setTerm] = useState(initialValue.term); const [types, setTypes] = useState(initialValue.types); @@ -128,17 +130,21 @@ const useSearchContextValue = ( const prevTerm = usePrevious(term); const prevFilters = usePrevious(filters); - const result = useAsync( - () => - searchApi.query({ - term, - types, - filters, - pageLimit, - pageCursor, - }), - [term, types, filters, pageLimit, pageCursor], - ); + const result = useAsync(async () => { + const resultSet = await searchApi.query({ + term, + types, + filters, + pageLimit, + pageCursor, + }); + if (term && resultSet.numberOfResults !== undefined) { + analytics.captureEvent('search', term, { + value: resultSet.numberOfResults, + }); + } + return resultSet; + }, [term, types, filters, pageLimit, pageCursor]); const hasNextPage = !result.loading && !result.error && result.value?.nextPageCursor;