Skip to content

Commit ffa02aa

Browse files
committed
fix(ga): fix for #606 issue
1 parent 17bea3b commit ffa02aa

File tree

13 files changed

+314
-120
lines changed

13 files changed

+314
-120
lines changed

packages/core/src/guided-answers-api.ts

+36-7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AxiosResponse } from 'axios';
1+
import type { AxiosResponse, CancelTokenSource } from 'axios';
22
import axios from 'axios';
33
import { default as xss } from 'xss';
44
import type {
@@ -35,6 +35,7 @@ const FEEDBACK_COMMENT = `dtp/api/${VERSION}/feedback/comment`;
3535
const FEEDBACK_OUTCOME = `dtp/api/${VERSION}/feedback/outcome`;
3636
const DEFAULT_MAX_RESULTS = 9999;
3737

38+
const previousToken: CancelTokenSource[] = [];
3839
/**
3940
* Returns API to programmatically access Guided Answers.
4041
*
@@ -171,13 +172,41 @@ async function getTrees(host: string, queryOptions?: GuidedAnswersQueryOptions):
171172
const query = queryOptions?.query ? encodeURIComponent(`"${queryOptions.query}"`) : '*';
172173
const urlGetParamString = convertQueryOptionsToGetParams(queryOptions?.filters, queryOptions?.paging);
173174
const url = `${host}${TREE_PATH}${query}${urlGetParamString}`;
174-
const response: AxiosResponse<GuidedAnswerTreeSearchResult> = await axios.get<GuidedAnswerTreeSearchResult>(url);
175-
const searchResult = response.data;
176-
if (!Array.isArray(searchResult?.trees)) {
177-
throw Error(
178-
`Query result from call '${url}' does not contain property 'trees' as array. Received response: '${searchResult}'`
179-
);
175+
176+
// Cancel the previous request if it exists
177+
if (previousToken.length) {
178+
const prev = previousToken.pop();
179+
prev?.cancel('Canceling previous request');
180180
}
181+
182+
// Create a new CancelToken for the current request
183+
const source = axios.CancelToken.source();
184+
previousToken.push(source);
185+
186+
let searchResult: GuidedAnswerTreeSearchResult = {
187+
resultSize: -1,
188+
trees: [],
189+
productFilters: [],
190+
componentFilters: []
191+
};
192+
await axios
193+
.get<GuidedAnswerTreeSearchResult>(url, {
194+
cancelToken: source.token
195+
})
196+
.then((response) => {
197+
searchResult = response.data;
198+
if (!Array.isArray(searchResult?.trees)) {
199+
throw Error(`Query result from call '${url}' does not contain property 'trees' as array`);
200+
}
201+
})
202+
.catch((thrown) => {
203+
if (axios.isCancel(thrown)) {
204+
console.log(`Request canceled: '${thrown.message}'`);
205+
} else {
206+
throw Error(`Error fetching selection: '${thrown.message}'`);
207+
}
208+
});
209+
181210
return searchResult;
182211
}
183212

packages/core/test/guided-answers-api.test.ts

+28
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import axios from 'axios';
2+
import type { CancelTokenStatic } from 'axios';
23
import type {
34
APIOptions,
45
FeedbackCommentPayload,
@@ -12,6 +13,7 @@ import { getGuidedAnswerApi } from '../src';
1213

1314
jest.mock('axios');
1415
const mockedAxios = axios as jest.Mocked<typeof axios>;
16+
type Canceler = (message?: string) => void;
1517
const currentVersion = getGuidedAnswerApi().getApiInfo().version;
1618

1719
describe('Guided Answers Api: getApiInfo()', () => {
@@ -24,6 +26,31 @@ describe('Guided Answers Api: getApiInfo()', () => {
2426
});
2527

2628
describe('Guided Answers Api: getTrees()', () => {
29+
/**
30+
* Class representing a CancelToken.
31+
*/
32+
class CancelToken {
33+
/**
34+
* Creates a cancel token source.
35+
* @returns {Object} An object containing the cancel function and token.
36+
*/
37+
public static source() {
38+
const cancel: Canceler = jest.fn();
39+
const token = new CancelToken();
40+
return {
41+
cancel,
42+
token
43+
};
44+
}
45+
}
46+
47+
mockedAxios.CancelToken = {
48+
source: jest.fn(() => ({
49+
cancel: jest.fn(),
50+
token: new CancelToken()
51+
}))
52+
} as any;
53+
2754
beforeEach(() => {
2855
jest.clearAllMocks();
2956
});
@@ -67,6 +94,7 @@ describe('Guided Answers Api: getTrees()', () => {
6794
componentFilters: [{ COMPONENT: 'C1', COUNT: 1 }],
6895
productFilters: [{ PRODUCT: 'P_one', COUNT: 1 }]
6996
};
97+
7098
let requestUrl = '';
7199
mockedAxios.get.mockImplementation((url) => {
72100
requestUrl = url;

packages/ide-extension/src/panel/guidedAnswersPanel.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -243,14 +243,16 @@ export class GuidedAnswersPanel {
243243
}
244244
this.loadingTimeout = setTimeout(() => {
245245
this.postActionToWebview(updateNetworkStatus('LOADING'));
246-
}, 2000);
246+
}, 50);
247247
}
248248
try {
249249
const trees = await this.guidedAnswerApi.getTrees(queryOptions);
250250
if (this.loadingTimeout) {
251251
clearTimeout(this.loadingTimeout);
252252
}
253-
this.postActionToWebview(updateNetworkStatus('OK'));
253+
if (trees.resultSize !== -1) {
254+
this.postActionToWebview(updateNetworkStatus('OK'));
255+
}
254256
return trees;
255257
} catch (e) {
256258
if (this.loadingTimeout) {

packages/webapp/package.json

+4
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@
2222
"@testing-library/dom": "8.20.1",
2323
"@testing-library/jest-dom": "6.2.0",
2424
"@testing-library/react": "12.1.5",
25+
"@types/lodash": "4.14.149",
2526
"@types/react": "17.0.62",
2627
"@types/react-copy-to-clipboard": "5.0.7",
2728
"@types/react-dom": "17.0.20",
2829
"@types/react-redux": "7.1.33",
30+
"@types/redux-logger": "3.0.9",
2931
"@types/redux-mock-store": "1.0.6",
3032
"autoprefixer": "10.4.16",
3133
"esbuild": "0.19.11",
@@ -37,13 +39,15 @@
3739
"i18next": "23.7.16",
3840
"jest-css-modules-transform": "4.4.2",
3941
"jest-environment-jsdom": "29.7.0",
42+
"lodash": "4.17.21",
4043
"path": "0.12.7",
4144
"postcss": "8.4.33",
4245
"react": "16.14.0",
4346
"react-dom": "16.14.0",
4447
"react-i18next": "13.2.2",
4548
"react-redux": "8.1.2",
4649
"redux": "4.2.1",
50+
"redux-logger": "3.0.6",
4751
"redux-mock-store": "1.5.4",
4852
"uuid": "9.0.1"
4953
},

packages/webapp/src/webview/state/middleware.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import i18next from 'i18next';
22
import type { Middleware, MiddlewareAPI, Dispatch, Action } from 'redux';
3+
import { createLogger } from 'redux-logger';
34
import type { GuidedAnswerActions } from '@sap/guided-answers-extension-types';
45
import {
56
GO_TO_PREVIOUS_PAGE,
@@ -40,7 +41,6 @@ export const communicationMiddleware: Middleware<
4041
// Add event handler, this will dispatch incoming state updates
4142
window.addEventListener('message', (event: MessageEvent) => {
4243
if (event.origin === window.origin) {
43-
console.log(i18next.t('MESSAGE_RECEIVED'), event);
4444
if (event.data && typeof event.data.type === 'string') {
4545
store.dispatch(event.data);
4646
}
@@ -58,7 +58,6 @@ export const communicationMiddleware: Middleware<
5858
action = next(action);
5959
if (action && typeof action.type === 'string') {
6060
window.vscode.postMessage(action);
61-
console.log(i18next.t('REACT_ACTION_POSTED'), action);
6261
}
6362
return action;
6463
};
@@ -81,6 +80,7 @@ const allowedTelemetryActions = new Set([
8180
UPDATE_BOOKMARKS,
8281
SYNCHRONIZE_BOOKMARK
8382
]);
83+
8484
export const telemetryMiddleware: Middleware<
8585
Dispatch<GuidedAnswerActions>,
8686
AppState,
@@ -115,3 +115,7 @@ export const restoreMiddleware: Middleware<Dispatch<GuidedAnswerActions>, AppSta
115115
return action;
116116
};
117117
};
118+
119+
export const loggerMiddleware = createLogger({
120+
duration: true
121+
});

packages/webapp/src/webview/state/store.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { configureStore } from '@reduxjs/toolkit';
22
import { bindActionCreators } from 'redux';
3-
import { telemetryMiddleware, communicationMiddleware, restoreMiddleware } from './middleware';
3+
import { telemetryMiddleware, communicationMiddleware, restoreMiddleware, loggerMiddleware } from './middleware';
44
import { getInitialState, reducer } from './reducers';
55
import * as AllActions from './actions';
66

77
export const store = configureStore({
88
reducer,
99
preloadedState: getInitialState(),
1010
devTools: false,
11-
middleware: [communicationMiddleware, telemetryMiddleware, restoreMiddleware]
11+
middleware: [communicationMiddleware, telemetryMiddleware, restoreMiddleware, loggerMiddleware]
1212
});
1313

1414
// bind actions to store

packages/webapp/src/webview/ui/components/App/App.tsx

+6-19
Original file line numberDiff line numberDiff line change
@@ -26,28 +26,15 @@ initIcons();
2626
export function App(): ReactElement {
2727
const appState = useSelector<AppState, AppState>((state) => state);
2828
useEffect(() => {
29-
const resultsContainer = document.getElementById('results-container');
30-
if (!resultsContainer) {
31-
return undefined;
32-
}
33-
//tree-item element height is ~100px, using 50px here to be on the safe side.
29+
//tree-item element height is ~100px, using 50px here to be on the safe side. Header uses ~300, minimum page size is 5.
3430
const setPageSizeByContainerHeight = (pxHeight: number): void => {
35-
actions.setPageSize(Math.ceil(pxHeight / 50));
31+
actions.setPageSize(Math.max(5, Math.ceil((pxHeight - 300) / 50)));
3632
};
37-
const resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
38-
const containerEntry = entries.find((entry) => entry?.target?.id === 'results-container');
39-
if (containerEntry) {
40-
setPageSizeByContainerHeight(containerEntry.contentRect.height);
41-
}
42-
});
43-
// Set initial page size
44-
setPageSizeByContainerHeight(resultsContainer.clientHeight);
45-
resizeObserver.observe(resultsContainer);
46-
return () => {
47-
if (resizeObserver) {
48-
resizeObserver.unobserve(resultsContainer);
49-
}
33+
window.onresize = () => {
34+
setPageSizeByContainerHeight(window.innerHeight);
5035
};
36+
setPageSizeByContainerHeight(window.innerHeight);
37+
return undefined;
5138
}, []);
5239

5340
function fetchData() {
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,102 @@
1-
import React from 'react';
1+
import React, { useCallback } from 'react';
2+
import debounce from 'lodash/debounce';
23
import { useSelector } from 'react-redux';
3-
import type { AppState } from '../../../../types';
4-
import { actions } from '../../../../state';
54
import { UISearchBox } from '@sap-ux/ui-components';
5+
import i18next from 'i18next';
66

7+
import type { AppState } from '../../../../types';
8+
import { actions } from '../../../../state';
79
import { Filters } from '../Filters';
810

9-
let timer: NodeJS.Timeout;
11+
const SEARCH_TIMEOUT = 150;
12+
1013
/**
14+
* SearchField component renders a search input field with debounce functionality.
15+
* It interacts with the Redux state to manage search queries and filters.
1116
*
12-
* @returns An input field
17+
* @returns {React.JSX.Element} The rendered search field component.
1318
*/
14-
export function SearchField() {
19+
export const SearchField: React.FC = (): React.JSX.Element => {
1520
const appState = useSelector<AppState, AppState>((state) => state);
1621

17-
const onChange = (value: string): void => {
18-
clearTimeout(timer);
19-
actions.setQueryValue(value);
20-
timer = setTimeout(() => {
21-
actions.searchTree({
22-
query: value,
23-
filters: {
24-
product: appState.selectedProductFilters,
25-
component: appState.selectedComponentFilters
26-
},
27-
paging: {
28-
responseSize: appState.pageSize,
29-
offset: 0
30-
}
31-
});
32-
}, 100);
22+
/**
23+
* Fetches the search results for the given search term and filters.
24+
*
25+
* @param {string} value - The search term.
26+
* @param {string[]} productFilters - The selected product filters.
27+
* @param {string[]} componentFilter - The selected component filters.
28+
* @param {number} pageSize - The number of results per page.
29+
*/
30+
const getTreesForSearchTerm = (
31+
value: string,
32+
productFilters: string[],
33+
componentFilter: string[],
34+
pageSize: number
35+
): void => {
36+
actions.searchTree({
37+
query: value,
38+
filters: {
39+
product: productFilters,
40+
component: componentFilter
41+
},
42+
paging: {
43+
responseSize: pageSize,
44+
offset: 0
45+
}
46+
});
47+
};
48+
49+
/**
50+
* Debounces the search input to avoid excessive API calls.
51+
*
52+
* @param {string} newSearchTerm - The new search term entered by the user.
53+
* @param {string[]} productFilters - The selected product filters.
54+
* @param {string[]} componentFilter - The selected component filters.
55+
* @param {number} pageSize - The number of results per page.
56+
*/
57+
const debounceSearch = useCallback(
58+
debounce(
59+
(newSearchTerm: string, productFilters: string[], componentFilter: string[], pageSize: number) =>
60+
getTreesForSearchTerm(newSearchTerm, productFilters, componentFilter, pageSize),
61+
SEARCH_TIMEOUT
62+
),
63+
[]
64+
);
65+
66+
/**
67+
* Clears the search input and triggers a debounced search with empty values.
68+
*/
69+
const onClearInput = (): void => {
70+
actions.setQueryValue('');
71+
debounceSearch('', appState.selectedProductFilters, appState.selectedComponentFilters, appState.pageSize);
72+
};
73+
74+
/**
75+
* Handles changes to the search input, updating the query value and triggering a debounced search.
76+
*
77+
* @param {React.ChangeEvent<HTMLInputElement>} [_] - The change event from the input field.
78+
* @param {string} [newSearchTerm] - The new search term entered by the user.
79+
*/
80+
const onChangeInput = (_?: React.ChangeEvent<HTMLInputElement> | undefined, newSearchTerm: string = ''): void => {
81+
actions.setQueryValue(newSearchTerm);
82+
debounceSearch(
83+
newSearchTerm,
84+
appState.selectedProductFilters,
85+
appState.selectedComponentFilters,
86+
appState.pageSize
87+
);
3388
};
3489

3590
return (
36-
<div className="guided-answer__header__searchField">
91+
<div className="guided-answer__header__searchField" id="search-field-container">
3792
<UISearchBox
3893
className="tree-search-field"
3994
value={appState.query}
40-
readOnly={appState.networkStatus === 'LOADING'}
41-
placeholder="Search Guided Answers"
95+
placeholder={i18next.t('SEARCH_GUIDED_ANSWERS')}
4296
id="search-field"
43-
onClear={() => onChange('')}
44-
onChange={(e: any) => onChange(e?.target?.value || '')}></UISearchBox>
97+
onClear={onClearInput}
98+
onChange={onChangeInput}></UISearchBox>
4599
{appState.activeScreen === 'SEARCH' && <Filters />}
46100
</div>
47101
);
48-
}
102+
};

0 commit comments

Comments
 (0)