Skip to content

Commit c658469

Browse files
fix(global-header): Emit search analytics data from global header searchbar (#520)
* fix(global-header): Emit search analytics data from global header search bar * add changeset * add analytics API in the application
1 parent 3099c9c commit c658469

7 files changed

Lines changed: 127 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-global-header': patch
3+
---
4+
5+
Emit search analytics events for search and discover events

workspaces/global-header/packages/app/src/apis.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
ScmAuth,
2121
} from '@backstage/integration-react';
2222
import {
23+
analyticsApiRef,
24+
AnalyticsEvent,
2325
AnyApiFactory,
2426
configApiRef,
2527
createApiFactory,
@@ -44,4 +46,11 @@ export const apis: AnyApiFactory[] = [
4446
factory: ({ fetchApi, discoveryApi }) =>
4547
new NotificationsClient({ fetchApi, discoveryApi }),
4648
}),
49+
50+
createApiFactory(analyticsApiRef, {
51+
captureEvent: (event: AnalyticsEvent) => {
52+
// eslint-disable-next-line no-console
53+
console.log('Captured event:', event);
54+
},
55+
}),
4756
];

workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchBar.test.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ import { mockApis, TestApiProvider } from '@backstage/test-utils';
2525
import { MemoryRouter, useNavigate } from 'react-router-dom';
2626
import { configApiRef } from '@backstage/core-plugin-api';
2727

28+
jest.mock('../../hooks/useDebouncedCallback', () => ({
29+
useDebouncedCallback: (fn: (...args: any[]) => void) => fn,
30+
}));
31+
2832
const createInitialState = ({
2933
term = 'term',
3034
filters = {},

workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchBar.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ import React, { useEffect, useRef, useState } from 'react';
1818
import {
1919
SearchResultState,
2020
SearchResultProps,
21+
useSearch,
2122
} from '@backstage/plugin-search-react';
2223
import Autocomplete from '@mui/material/Autocomplete';
2324
import { createSearchLink } from '../../utils/stringUtils';
2425
import { useNavigate } from 'react-router-dom';
2526
import { SearchInput } from './SearchInput';
2627
import { SearchOption } from './SearchOption';
2728
import { useTheme } from '@mui/material/styles';
29+
import { useDebouncedCallback } from '../../hooks/useDebouncedCallback';
2830

2931
interface SearchBarProps {
3032
query: SearchResultProps['query'];
@@ -36,6 +38,12 @@ export const SearchBar = (props: SearchBarProps) => {
3638
const theme = useTheme();
3739
const [highlightedIndex, setHighlightedIndex] = useState(-1);
3840
const highlightedIndexRef = useRef(highlightedIndex);
41+
const { setTerm } = useSearch();
42+
43+
const onInputChange = useDebouncedCallback((_, inputValue) => {
44+
setSearchTerm(inputValue);
45+
setTerm(inputValue);
46+
}, 300);
3947

4048
useEffect(() => {
4149
highlightedIndexRef.current = highlightedIndex;
@@ -64,7 +72,7 @@ export const SearchBar = (props: SearchBarProps) => {
6472
loading={loading}
6573
value={query?.term ?? ''}
6674
getOptionLabel={option => option ?? ''}
67-
onInputChange={(_, inputValue) => setSearchTerm(inputValue)}
75+
onInputChange={onInputChange}
6876
onHighlightChange={(_, option) =>
6977
setHighlightedIndex(options.indexOf(option ?? ''))
7078
}

workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchResultItem.test.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,24 @@
1515
*/
1616

1717
import React from 'react';
18-
import { render, screen } from '@testing-library/react';
18+
import { fireEvent, render, screen } from '@testing-library/react';
1919
import { SearchResultItem } from './SearchResultItem';
2020
import { BrowserRouter as Router } from 'react-router-dom';
2121
import { Result, SearchDocument } from '@backstage/plugin-search-common';
22+
import { useAnalytics } from '@backstage/core-plugin-api';
2223

2324
jest.mock('../../utils/stringUtils', () => ({
2425
highlightMatch: jest.fn((text, _) => text),
2526
}));
2627

28+
jest.mock('@backstage/core-plugin-api', () => {
29+
const actual = jest.requireActual('@backstage/core-plugin-api');
30+
return {
31+
...actual,
32+
useAnalytics: jest.fn(),
33+
};
34+
});
35+
2736
describe('SearchResultItem', () => {
2837
const renderProps = {};
2938
const query = { term: 'test' };
@@ -93,4 +102,37 @@ describe('SearchResultItem', () => {
93102

94103
expect(screen.getByRole('link')).toHaveAttribute('href', '/');
95104
});
105+
106+
it('should trigger the analytics discover event', () => {
107+
const result = {
108+
rank: 1,
109+
document: { title: 'Result 1', location: '/result-1' },
110+
} as Result<SearchDocument>;
111+
112+
const captureEventMock = jest.fn();
113+
(useAnalytics as jest.Mock).mockReturnValue({
114+
captureEvent: captureEventMock,
115+
});
116+
117+
render(
118+
<Router>
119+
<SearchResultItem
120+
option="Result 1"
121+
query={query}
122+
result={result}
123+
renderProps={renderProps}
124+
/>
125+
</Router>,
126+
);
127+
const resultItem = screen.getByText('Result 1');
128+
129+
fireEvent.click(resultItem);
130+
131+
expect(useAnalytics).toHaveBeenCalled();
132+
expect(captureEventMock).toHaveBeenCalled();
133+
expect(captureEventMock).toHaveBeenCalledWith('discover', 'Result 1', {
134+
attributes: { to: '/result-1' },
135+
value: 1,
136+
});
137+
});
96138
});

workspaces/global-header/plugins/global-header/src/components/SearchComponent/SearchResultItem.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import Typography from '@mui/material/Typography';
2222
import { highlightMatch } from '../../utils/stringUtils';
2323
import { SearchResultProps } from '@backstage/plugin-search-react';
2424
import { Result, SearchDocument } from '@backstage/plugin-search-common';
25+
import { useAnalytics } from '@backstage/core-plugin-api';
2526

2627
interface SearchResultItemProps {
2728
option: string;
@@ -37,14 +38,26 @@ export const SearchResultItem = ({
3738
renderProps,
3839
}: SearchResultItemProps) => {
3940
const isNoResultsFound = option === 'No results found';
41+
const analytics = useAnalytics();
42+
4043
return (
4144
<Box
4245
component={isNoResultsFound ? 'div' : Link}
4346
to={result?.document.location}
4447
underline="none"
4548
sx={{ width: '100%', ...(isNoResultsFound ? {} : { cursor: 'pointer' }) }}
4649
>
47-
<ListItem {...renderProps} sx={{ py: 1 }}>
50+
<ListItem
51+
{...renderProps}
52+
sx={{ py: 1 }}
53+
onClick={e => {
54+
analytics.captureEvent('discover', result?.document.title ?? '', {
55+
attributes: { to: result?.document.location ?? '#' },
56+
value: result?.rank,
57+
});
58+
renderProps?.onClick?.(e);
59+
}}
60+
>
4861
<Typography sx={{ color: 'text.primary', flexGrow: 1 }}>
4962
{isNoResultsFound
5063
? option
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import React from 'react';
17+
18+
export const useDebouncedCallback = <T extends (...args: any[]) => void>(
19+
callback: T,
20+
delay: number,
21+
) => {
22+
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
23+
24+
React.useEffect(() => {
25+
return () => {
26+
if (timeoutRef.current) {
27+
clearTimeout(timeoutRef.current);
28+
}
29+
};
30+
}, []);
31+
32+
return React.useCallback(
33+
(...args: Parameters<T>) => {
34+
if (timeoutRef.current) {
35+
clearTimeout(timeoutRef.current);
36+
}
37+
timeoutRef.current = setTimeout(() => {
38+
callback(...args);
39+
}, delay);
40+
},
41+
[callback, delay],
42+
);
43+
};

0 commit comments

Comments
 (0)