fix the analytics flakiness in search
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user