From af4980fb5d304fe0ec49f85fbe89b5c5e4c83d63 Mon Sep 17 00:00:00 2001 From: Emma Indal Date: Wed, 29 Dec 2021 14:10:01 +0100 Subject: [PATCH] [Search] SearchBar Analytics tracking (#8656) * test capture search bar events Signed-off-by: Emma Indal * capture search bar events Signed-off-by: Emma Indal * only run onChange method if provided, otherwise set term Signed-off-by: Emma Indal * changeset Signed-off-by: Emma Indal * unconditionally call analytics API Signed-off-by: Emma Indal * update changeset to be less specific to GA Signed-off-by: Emma Indal * move analytics tracking to SearchBarBase Signed-off-by: Emma Indal * add search to key events Signed-off-by: Emma Indal * capture types as analytics context attribute, only for SearchBar Signed-off-by: Emma Indal * move AnalyticsContext to within SearchContextProvider Signed-off-by: Emma Indal * refactor search tracking out to its own component Signed-off-by: Emma Indal * captures not only google analytics Signed-off-by: Emma Indal --- .changeset/search-odd-starfishes-raise.md | 5 + docs/plugins/analytics.md | 9 +- .../components/SearchBar/SearchBar.test.tsx | 139 +++++++++++++++++- .../src/components/SearchBar/SearchBar.tsx | 34 +++-- .../SearchContext/SearchContext.tsx | 8 +- .../SearchTracker/SearchTracker.tsx | 36 +++++ .../src/components/SearchTracker/index.ts | 16 ++ 7 files changed, 223 insertions(+), 24 deletions(-) create mode 100644 .changeset/search-odd-starfishes-raise.md create mode 100644 plugins/search/src/components/SearchTracker/SearchTracker.tsx create mode 100644 plugins/search/src/components/SearchTracker/index.ts diff --git a/.changeset/search-odd-starfishes-raise.md b/.changeset/search-odd-starfishes-raise.md new file mode 100644 index 0000000000..73627cf457 --- /dev/null +++ b/.changeset/search-odd-starfishes-raise.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search': patch +--- + +Captures the search term entered in the SearchBarBase as a `search` event. diff --git a/docs/plugins/analytics.md b/docs/plugins/analytics.md index ace9a89968..7fbd256e86 100644 --- a/docs/plugins/analytics.md +++ b/docs/plugins/analytics.md @@ -55,10 +55,11 @@ learn how to contribute the integration yourself! The following table summarizes events that, depending on the plugins you have installed, may be captured. -| Action | Provided By | Subject | -| ---------- | -------------- | ----------------------------------------- | -| `navigate` | Backstage Core | The URL of the page that was navigated to | -| `click` | Backstage Core | The text of the link that was clicked on | +| Action | Provided By | Subject | +| ---------- | -------------- | --------------------------------------------------- | +| `navigate` | Backstage Core | The URL of the page that was navigated to | +| `click` | Backstage Core | The text of the link that was clicked on | +| `search` | Backstage Core | The search term entered in any search bar component | If there is an event you'd like to see captured, please [open an issue][add-event] describing the event you want to see and the questions it diff --git a/plugins/search/src/components/SearchBar/SearchBar.test.tsx b/plugins/search/src/components/SearchBar/SearchBar.test.tsx index b3f30473ac..cb4008d494 100644 --- a/plugins/search/src/components/SearchBar/SearchBar.test.tsx +++ b/plugins/search/src/components/SearchBar/SearchBar.test.tsx @@ -20,10 +20,10 @@ import userEvent from '@testing-library/user-event'; import { SearchContextProvider } from '../SearchContext'; import { SearchBar } from './SearchBar'; -import { configApiRef } from '@backstage/core-plugin-api'; +import { configApiRef, analyticsApiRef } from '@backstage/core-plugin-api'; import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; import { searchApiRef } from '../../apis'; -import { TestApiRegistry } from '@backstage/test-utils'; +import { MockAnalyticsApi, TestApiRegistry } from '@backstage/test-utils'; jest.mock('@backstage/core-plugin-api', () => ({ ...jest.requireActual('@backstage/core-plugin-api'), @@ -38,9 +38,16 @@ describe('SearchBar', () => { }; const query = jest.fn().mockResolvedValue({}); + const analyticsApiSpy = new MockAnalyticsApi(); + let apiRegistry: TestApiRegistry; - const apiRegistry = TestApiRegistry.from( - [configApiRef, new ConfigReader({ app: { title: 'Mock title' } })], + apiRegistry = TestApiRegistry.from( + [ + configApiRef, + new ConfigReader({ + app: { title: 'Mock title' }, + }), + ], [searchApiRef, { query }], ); @@ -210,4 +217,128 @@ describe('SearchBar', () => { expect.objectContaining({ term: value }), ); }); + + it('does not capture analytics event if not enabled in app', async () => { + jest.useFakeTimers(); + + const debounceTime = 600; + + render( + + + + + , + , + ); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name })).toBeInTheDocument(); + }); + + const textbox = screen.getByRole('textbox', { name }); + + const value = 'value'; + + userEvent.type(textbox, value); + + act(() => { + jest.advanceTimersByTime(debounceTime); + }); + + await waitFor(() => expect(textbox).toHaveValue(value)); + + expect(analyticsApiSpy.getEvents()).toHaveLength(0); + }); + + it('captures analytics events if enabled in app', async () => { + jest.useFakeTimers(); + + const debounceTime = 600; + + apiRegistry = TestApiRegistry.from( + [analyticsApiRef, analyticsApiSpy], + [ + configApiRef, + new ConfigReader({ + app: { + title: 'Mock title', + analytics: { + ga: { + trackingId: 'xyz123', + }, + }, + }, + }), + ], + [searchApiRef, { query }], + ); + + render( + + + + + , + ); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name })).toBeInTheDocument(); + }); + + const textbox = screen.getByRole('textbox', { name }); + + const value = 'value'; + + userEvent.type(textbox, value); + + expect(analyticsApiSpy.getEvents()).toHaveLength(0); + + act(() => { + jest.advanceTimersByTime(debounceTime); + }); + + await waitFor(() => expect(textbox).toHaveValue(value)); + + expect(analyticsApiSpy.getEvents()).toHaveLength(1); + expect(analyticsApiSpy.getEvents()[0]).toEqual({ + action: 'search', + context: { + extension: 'App', + pluginId: 'root', + routeRef: 'unknown', + searchTypes: 'software-catalog,techdocs', + }, + subject: 'value', + }); + + userEvent.clear(textbox); + + // make sure new term is captured + userEvent.type(textbox, 'new value'); + + act(() => { + jest.advanceTimersByTime(debounceTime); + }); + + await waitFor(() => expect(textbox).toHaveValue('new value')); + + expect(analyticsApiSpy.getEvents()).toHaveLength(2); + expect(analyticsApiSpy.getEvents()[1]).toEqual({ + action: 'search', + context: { + extension: 'App', + pluginId: 'root', + routeRef: 'unknown', + searchTypes: 'software-catalog,techdocs', + }, + subject: 'new value', + }); + }); }); diff --git a/plugins/search/src/components/SearchBar/SearchBar.tsx b/plugins/search/src/components/SearchBar/SearchBar.tsx index 95bf1570e1..aa4b1e728a 100644 --- a/plugins/search/src/components/SearchBar/SearchBar.tsx +++ b/plugins/search/src/components/SearchBar/SearchBar.tsx @@ -33,6 +33,7 @@ import SearchIcon from '@material-ui/icons/Search'; import ClearButton from '@material-ui/icons/Clear'; import { useSearch } from '../SearchContext'; +import { TrackSearch } from '../SearchTracker'; /** * Props for {@link SearchBarBase}. @@ -119,18 +120,20 @@ export const SearchBarBase = ({ ); return ( - + + + ); }; @@ -150,8 +153,11 @@ export const SearchBar = ({ onChange, ...props }: SearchBarProps) => { const { term, setTerm } = useSearch(); const handleChange = (newValue: string) => { - setTerm(newValue); - if (onChange) onChange(newValue); + if (onChange) { + onChange(newValue); + } else { + setTerm(newValue); + } }; return ; diff --git a/plugins/search/src/components/SearchContext/SearchContext.tsx b/plugins/search/src/components/SearchContext/SearchContext.tsx index dd9310d782..300a191983 100644 --- a/plugins/search/src/components/SearchContext/SearchContext.tsx +++ b/plugins/search/src/components/SearchContext/SearchContext.tsx @@ -15,7 +15,7 @@ */ import { JsonObject } from '@backstage/types'; -import { useApi } from '@backstage/core-plugin-api'; +import { useApi, AnalyticsContext } from '@backstage/core-plugin-api'; import { SearchResultSet } from '@backstage/search-common'; import React, { createContext, @@ -130,7 +130,11 @@ export const SearchContextProvider = ({ fetchPreviousPage: hasPreviousPage ? fetchPreviousPage : undefined, }; - return ; + return ( + + + + ); }; export const useSearch = () => { diff --git a/plugins/search/src/components/SearchTracker/SearchTracker.tsx b/plugins/search/src/components/SearchTracker/SearchTracker.tsx new file mode 100644 index 0000000000..b9da9b5560 --- /dev/null +++ b/plugins/search/src/components/SearchTracker/SearchTracker.tsx @@ -0,0 +1,36 @@ +/* + * Copyright 2021 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 '../SearchContext'; + +/** + * Capture search event on term change. + */ +export const TrackSearch = ({ children }: { children: React.ReactChild }) => { + const analytics = useAnalytics(); + const { term } = useSearch(); + + useEffect(() => { + if (term) { + // Capture analytics search event with search term provided as value + analytics.captureEvent('search', term); + } + }, [analytics, term]); + + return <>{children}; +}; diff --git a/plugins/search/src/components/SearchTracker/index.ts b/plugins/search/src/components/SearchTracker/index.ts new file mode 100644 index 0000000000..5e6a75aa25 --- /dev/null +++ b/plugins/search/src/components/SearchTracker/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 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';