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;