fix the analytics flakiness in search

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2023-11-07 13:49:13 +01:00
parent a957d4654f
commit f48cde800a
7 changed files with 92 additions and 171 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-react': patch
---
Emit search analytics in the search hook instead of in a dedicated component
@@ -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(
<TestApiProvider
apis={[
[configApiRef, configApiMock],
[searchApiRef, searchApiMock],
[analyticsApiRef, analyticsApiMock],
]}
>
<SearchContextProvider initialState={createInitialState({ types })}>
<SearchBar debounceTime={0} />
</SearchContextProvider>
</TestApiProvider>,
);
const textbox = screen.getByLabelText<HTMLInputElement>('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,
});
});
});
});
@@ -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<T>(Component: ComponentType<T>) {
return forwardRef<HTMLDivElement, T>((props, ref) => (
@@ -171,33 +171,29 @@ export const SearchBarBase: ForwardRefExoticComponent<SearchBarBaseProps> =
);
return (
<TrackSearch>
<TextField
id="search-bar-text-field"
data-testid="search-bar-next"
variant="outlined"
margin="normal"
inputRef={ref}
value={value}
label={label}
placeholder={inputPlaceholder}
InputProps={{
startAdornment,
endAdornment: clearButton
? clearButtonEndAdornment
: endAdornment,
...InputProps,
}}
inputProps={{
'aria-label': ariaLabel,
...inputProps,
}}
fullWidth={fullWidth}
onChange={handleChange}
onKeyDown={handleKeyDown}
{...rest}
/>
</TrackSearch>
<TextField
id="search-bar-text-field"
data-testid="search-bar-next"
variant="outlined"
margin="normal"
inputRef={ref}
value={value}
label={label}
placeholder={inputPlaceholder}
InputProps={{
startAdornment,
endAdornment: clearButton ? clearButtonEndAdornment : endAdornment,
...InputProps,
}}
inputProps={{
'aria-label': ariaLabel,
...inputProps,
}}
fullWidth={fullWidth}
onChange={handleChange}
onKeyDown={handleKeyDown}
{...rest}
/>
);
}),
);
@@ -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}</>;
};
@@ -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';
@@ -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],
]}
>
<SearchContextProvider initialState={initialState}>
@@ -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',
},
});
});
});
});
});
@@ -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<string>(initialValue.term);
const [types, setTypes] = useState<string[]>(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;