@nyris/nyris-webapp 0.3.97 → 0.3.99
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/asset-manifest.json +7 -6
- package/build/index.html +1 -1
- package/build/js/settings.example.js +41 -0
- package/build/static/css/main.5cb72c09.css +4 -0
- package/build/static/css/main.5cb72c09.css.map +1 -0
- package/build/static/js/main.2339271b.js +3 -0
- package/build/static/js/{main.9e7fb38a.js.map → main.2339271b.js.map} +1 -1
- package/build/static/media/plate_info.6beaea36d35053be3a45.png +0 -0
- package/package.json +3 -3
- package/public/js/settings.example.js +41 -0
- package/src/common/assets/images/plate_info.png +0 -0
- package/src/components/AuthApiKeySync.tsx +34 -0
- package/src/components/Cart.tsx +41 -0
- package/src/components/ChatAssistant/useChatAssistantLogic.ts +3 -2
- package/src/components/GoBackButton.tsx +29 -2
- package/src/components/Header.tsx +3 -0
- package/src/components/HitsPerPage.tsx +55 -18
- package/src/components/ImagePreview.tsx +31 -1
- package/src/components/ImageUpload.tsx +14 -3
- package/src/components/Inquiry/InquiryModal.tsx +3 -0
- package/src/components/LogoutModal.tsx +2 -0
- package/src/components/Pagination.tsx +33 -6
- package/src/components/PostFilter/PostFilter.tsx +8 -1
- package/src/components/PostFilter/PostFilterComponent.tsx +8 -22
- package/src/components/PostFilter/PostFilterFindApi.test.tsx +249 -0
- package/src/components/PostFilter/PostFilterFindApi.tsx +128 -52
- package/src/components/PreFilter/PreFilter.tsx +10 -2
- package/src/components/Product/Product.tsx +60 -10
- package/src/components/Product/ProductDetailView.tsx +23 -1
- package/src/components/Product/ProductDetailViewModal.tsx +5 -1
- package/src/components/Product/ProductList.tsx +51 -16
- package/src/components/Rfq/RfqModal.tsx +3 -0
- package/src/components/ScopeSelect/ScopeSelectForm.tsx +514 -0
- package/src/components/ScopeSelect/ScopeSelectImageActions.tsx +62 -0
- package/src/components/ScopeSelect/ScopeSelectImagePreview.tsx +141 -0
- package/src/components/ScopeSelect/ScopeSelectInfo.tsx +68 -0
- package/src/components/ScopeSelect/ScopeSelectInput.tsx +123 -0
- package/src/components/ScopeSelect/ScopeSelectModal.tsx +310 -0
- package/src/components/ScopeSelect/identifyTypePlateImage.test.ts +129 -0
- package/src/components/ScopeSelect/identifyTypePlateImage.ts +83 -0
- package/src/components/ScopeSelect/parseScopeIdResponse.test.ts +103 -0
- package/src/components/ScopeSelect/parseScopeIdResponse.ts +72 -0
- package/src/components/ScopeSelect/scopeSelectHelpers.ts +54 -0
- package/src/components/SidePanel.tsx +43 -0
- package/src/components/TextSearch.tsx +80 -14
- package/src/components/Tooltip/TooltipComponent.tsx +12 -4
- package/src/components/TypePlateUpload.tsx +132 -0
- package/src/hooks/useEffectiveGroundingResults.ts +8 -6
- package/src/hooks/useGoodResultsChat.ts +8 -22
- package/src/hooks/useImageSearch.ts +325 -20
- package/src/hooks/useMachinePartsListingPagination.ts +95 -0
- package/src/hooks/useResultEvaluator.ts +43 -19
- package/src/index.css +1 -1
- package/src/index.tsx +4 -0
- package/src/layouts/AppLayout.tsx +95 -9
- package/src/pages/Home.tsx +60 -14
- package/src/pages/Logout.tsx +2 -0
- package/src/pages/Result.tsx +206 -20
- package/src/providers/AuthProvider.tsx +5 -0
- package/src/services/Feedback.ts +224 -35
- package/src/services/feedbackSubtypes.ts +63 -0
- package/src/services/filter.ts +4 -3
- package/src/services/initialMachinePartsText.payload.test.ts +61 -0
- package/src/services/initialMachinePartsText.test.ts +33 -0
- package/src/services/initialMachinePartsText.ts +11 -0
- package/src/services/machinePartsPage.test.ts +74 -0
- package/src/services/machinePartsPage.ts +120 -0
- package/src/services/normalizeChatResponse.ts +26 -0
- package/src/services/scopeLookup.test.ts +67 -0
- package/src/services/scopeLookup.ts +39 -0
- package/src/services/visualSearch.listingOptions.test.ts +79 -0
- package/src/services/visualSearch.ts +76 -7
- package/src/services/vizo.ts +17 -5
- package/src/stores/machinePartsListing/machinePartsListingStore.ts +35 -0
- package/src/stores/request/filter/filter.initialState.ts +1 -0
- package/src/stores/request/filter/filter.slice.ts +2 -0
- package/src/stores/result/prodcuts/products.initialState.ts +1 -0
- package/src/stores/result/prodcuts/products.slice.ts +4 -0
- package/src/stores/scope/scopeStore.ts +67 -4
- package/src/stores/types.ts +19 -14
- package/src/translations.ts +213 -0
- package/src/types.ts +40 -0
- package/src/utils/derivePostFilterFacets.ts +68 -0
- package/src/utils/fetchProductImage.ts +1 -0
- package/src/utils/normalizeChatAssistantResponse.ts +227 -0
- package/src/utils/nyrisApiKey.ts +115 -0
- package/src/utils/productCardImageUrl.test.ts +41 -0
- package/src/utils/productCardImageUrl.ts +49 -0
- package/src/utils/textSearchFilter.test.ts +41 -0
- package/src/utils/textSearchFilter.ts +13 -6
- package/build/static/css/main.7c7f5447.css +0 -4
- package/build/static/css/main.7c7f5447.css.map +0 -1
- package/build/static/js/main.9e7fb38a.js +0 -3
- /package/build/static/js/{main.9e7fb38a.js.LICENSE.txt → main.2339271b.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recommended subtype vocabulary for Feedback API events.
|
|
3
|
+
*
|
|
4
|
+
* Subtypes are free strings on the API side, so customers can add their own,
|
|
5
|
+
* but these constants are the default vocabulary the search suite converges on.
|
|
6
|
+
* Casing is normalized (trimmed + lowercased) before sending; the API does the
|
|
7
|
+
* same at ingestion.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const FEEDBACK_EVENT = {
|
|
11
|
+
CLICK: 'click',
|
|
12
|
+
CONVERSION: 'conversion',
|
|
13
|
+
FEEDBACK: 'feedback',
|
|
14
|
+
REGION: 'region',
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
/** Interaction actions on the result set. */
|
|
18
|
+
export const CLICK_SUBTYPE = {
|
|
19
|
+
RESULT_CARD: 'result_card',
|
|
20
|
+
EXPAND_DETAILS: 'expand_details',
|
|
21
|
+
SIMILAR_PART_SEARCH: 'similar_part_search',
|
|
22
|
+
COPY_DETAILS: 'copy_details',
|
|
23
|
+
RAISE_INQUIRY: 'raise_inquiry',
|
|
24
|
+
FILTER_APPLIED: 'filter_applied',
|
|
25
|
+
PRE_FILTER_APPLIED: 'pre_filter_applied',
|
|
26
|
+
CHAT_WITH_VIZO: 'chat_with_vizo',
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
/** Actions that create business value. Omit subtype for legacy conversions. */
|
|
30
|
+
export const CONVERSION_SUBTYPE = {
|
|
31
|
+
ADD_TO_CART: 'add_to_cart',
|
|
32
|
+
PURCHASE: 'purchase',
|
|
33
|
+
DOWNLOAD_CART: 'download_cart',
|
|
34
|
+
SHARE_CART: 'share_cart',
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
/** Explicit result-quality feedback. */
|
|
38
|
+
export const FEEDBACK_SUBTYPE = {
|
|
39
|
+
THUMBS_UP: 'thumbs_up',
|
|
40
|
+
THUMBS_DOWN: 'thumbs_down',
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
/** How the searched image region was selected. */
|
|
44
|
+
export const REGION_SUBTYPE = {
|
|
45
|
+
CROP: 'crop',
|
|
46
|
+
OBJECT_DETECTION_CLICK: 'object_detection_click',
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
export type FeedbackEventType =
|
|
50
|
+
(typeof FEEDBACK_EVENT)[keyof typeof FEEDBACK_EVENT];
|
|
51
|
+
export type ClickSubtype = (typeof CLICK_SUBTYPE)[keyof typeof CLICK_SUBTYPE];
|
|
52
|
+
export type ConversionSubtype =
|
|
53
|
+
(typeof CONVERSION_SUBTYPE)[keyof typeof CONVERSION_SUBTYPE];
|
|
54
|
+
export type FeedbackSubtype =
|
|
55
|
+
(typeof FEEDBACK_SUBTYPE)[keyof typeof FEEDBACK_SUBTYPE];
|
|
56
|
+
export type RegionSubtype =
|
|
57
|
+
(typeof REGION_SUBTYPE)[keyof typeof REGION_SUBTYPE];
|
|
58
|
+
|
|
59
|
+
/** Normalizes a subtype the same way the API does at ingestion. */
|
|
60
|
+
export const normalizeSubtype = (subtype?: string): string | undefined => {
|
|
61
|
+
const normalized = subtype?.trim().toLowerCase();
|
|
62
|
+
return normalized ? normalized : undefined;
|
|
63
|
+
};
|
package/src/services/filter.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import NyrisAPI, { NyrisAPISettings } from '@nyris/nyris-api';
|
|
2
|
+
import { getNyrisSettings } from 'utils/nyrisApiKey';
|
|
2
3
|
|
|
3
4
|
export interface Filter {
|
|
4
5
|
key?: string;
|
|
@@ -9,7 +10,7 @@ export const getFilters = async (
|
|
|
9
10
|
number: number = 10,
|
|
10
11
|
settings: NyrisAPISettings,
|
|
11
12
|
) => {
|
|
12
|
-
const nyrisApi = new NyrisAPI(settings);
|
|
13
|
+
const nyrisApi = new NyrisAPI(getNyrisSettings(settings));
|
|
13
14
|
return nyrisApi.getFilters(number);
|
|
14
15
|
};
|
|
15
16
|
|
|
@@ -18,7 +19,7 @@ export const searchFilters = async (
|
|
|
18
19
|
value: string,
|
|
19
20
|
settingsNyris: NyrisAPISettings,
|
|
20
21
|
) => {
|
|
21
|
-
const nyrisApi = new NyrisAPI(settingsNyris);
|
|
22
|
+
const nyrisApi = new NyrisAPI(getNyrisSettings(settingsNyris));
|
|
22
23
|
const newValue = value ? value : '';
|
|
23
24
|
return nyrisApi.searchFilters(key, newValue);
|
|
24
25
|
};
|
|
@@ -32,6 +33,6 @@ export const getScopes = async (
|
|
|
32
33
|
scopeIds: string,
|
|
33
34
|
settings: NyrisAPISettings,
|
|
34
35
|
): Promise<ScopeResponse> => {
|
|
35
|
-
const nyrisApi = new NyrisAPI(settings);
|
|
36
|
+
const nyrisApi = new NyrisAPI(getNyrisSettings(settings));
|
|
36
37
|
return nyrisApi.getScopes(scopeIds);
|
|
37
38
|
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import NyrisAPI, { NyrisAPISettings } from '@nyris/nyris-api';
|
|
2
|
+
|
|
3
|
+
function entries(body: FormData) {
|
|
4
|
+
return Array.from(body.entries()).map(([key, value]) => [key, String(value)]);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
async function capture(
|
|
8
|
+
text: string | undefined,
|
|
9
|
+
scopeIds: string[],
|
|
10
|
+
) {
|
|
11
|
+
const api = new NyrisAPI({
|
|
12
|
+
apiKey: 'test-key',
|
|
13
|
+
baseUrl: 'https://api.nyris.io',
|
|
14
|
+
jpegQuality: 0.9,
|
|
15
|
+
maxWidth: 1024,
|
|
16
|
+
maxHeight: 1024,
|
|
17
|
+
xOptions: false,
|
|
18
|
+
} as NyrisAPISettings);
|
|
19
|
+
const request = jest.fn().mockResolvedValue({ data: { results: [] } });
|
|
20
|
+
(api as unknown as { httpClient: { request: jest.Mock } }).httpClient = {
|
|
21
|
+
request,
|
|
22
|
+
};
|
|
23
|
+
await api.find(
|
|
24
|
+
text !== undefined ? { text } : {},
|
|
25
|
+
undefined,
|
|
26
|
+
undefined,
|
|
27
|
+
undefined,
|
|
28
|
+
undefined,
|
|
29
|
+
scopeIds,
|
|
30
|
+
);
|
|
31
|
+
return request.mock.calls[0][0] as {
|
|
32
|
+
url: string;
|
|
33
|
+
data: FormData | null;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('initial machine find payload', () => {
|
|
38
|
+
it('sends the caller-provided item-key batch and scope id', async () => {
|
|
39
|
+
const itemKeys = Array.from(
|
|
40
|
+
{ length: 15 },
|
|
41
|
+
(_, index) => `ITEM-${index + 1}`,
|
|
42
|
+
);
|
|
43
|
+
const text = itemKeys.slice(10).join(', ');
|
|
44
|
+
const config = await capture(text, ['TestScope2']);
|
|
45
|
+
|
|
46
|
+
expect(config.url).toBe('https://api.nyris.io/find/v1.1');
|
|
47
|
+
expect(entries(config.data as FormData)).toEqual([
|
|
48
|
+
['text', text],
|
|
49
|
+
['scope[0]', 'TestScope2'],
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('sends a keyword without item keys when a machine scope is active', async () => {
|
|
54
|
+
const config = await capture('pump', ['TestScope2']);
|
|
55
|
+
|
|
56
|
+
expect(entries(config.data as FormData)).toEqual([
|
|
57
|
+
['text', 'pump'],
|
|
58
|
+
['scope[0]', 'TestScope2'],
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { initialMachinePartsText } from './initialMachinePartsText';
|
|
2
|
+
|
|
3
|
+
function keys(count: number) {
|
|
4
|
+
return Array.from({ length: count }, (_, index) => `ITEM-${index + 1}`);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
describe('initialMachinePartsText', () => {
|
|
8
|
+
it('joins fewer than 20 item keys in order', () => {
|
|
9
|
+
const itemKeys = keys(3);
|
|
10
|
+
expect(initialMachinePartsText(itemKeys)).toBe(
|
|
11
|
+
'ITEM-1, ITEM-2, ITEM-3',
|
|
12
|
+
);
|
|
13
|
+
expect(itemKeys).toEqual(keys(3));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('joins exactly 20 item keys', () => {
|
|
17
|
+
const text = initialMachinePartsText(keys(20));
|
|
18
|
+
expect(text?.split(', ')).toHaveLength(20);
|
|
19
|
+
expect(text?.endsWith('ITEM-20')).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('stops at the first 20 item keys', () => {
|
|
23
|
+
const itemKeys = keys(21);
|
|
24
|
+
const text = initialMachinePartsText(itemKeys);
|
|
25
|
+
expect(text?.split(', ')).toHaveLength(20);
|
|
26
|
+
expect(text?.includes('ITEM-21')).toBe(false);
|
|
27
|
+
expect(itemKeys).toHaveLength(21);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns undefined when there are no item keys', () => {
|
|
31
|
+
expect(initialMachinePartsText([])).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_MACHINE_PARTS_PAGE_SIZE,
|
|
3
|
+
machinePartsPageText,
|
|
4
|
+
} from './machinePartsPage';
|
|
5
|
+
|
|
6
|
+
/** First page at the default page size. The source array is not changed. */
|
|
7
|
+
export function initialMachinePartsText(
|
|
8
|
+
itemKeys: readonly string[],
|
|
9
|
+
): string | undefined {
|
|
10
|
+
return machinePartsPageText(itemKeys, 0, DEFAULT_MACHINE_PARTS_PAGE_SIZE);
|
|
11
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isScopeOnlyMachinePartsListing,
|
|
3
|
+
machinePartsPageCount,
|
|
4
|
+
machinePartsPageItemKeys,
|
|
5
|
+
machinePartsPageText,
|
|
6
|
+
visibleMachinePartsPageIndexes,
|
|
7
|
+
} from './machinePartsPage';
|
|
8
|
+
|
|
9
|
+
function keys(count: number) {
|
|
10
|
+
return Array.from({ length: count }, (_, index) => `ITEM-${index + 1}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('machinePartsPage', () => {
|
|
14
|
+
it('splits 15 item keys into pages of 10 without overlap', () => {
|
|
15
|
+
const itemKeys = keys(15);
|
|
16
|
+
expect(machinePartsPageCount(itemKeys.length, 10)).toBe(2);
|
|
17
|
+
expect(machinePartsPageItemKeys(itemKeys, 0, 10)).toEqual(keys(10));
|
|
18
|
+
expect(machinePartsPageItemKeys(itemKeys, 1, 10)).toEqual(
|
|
19
|
+
keys(15).slice(10),
|
|
20
|
+
);
|
|
21
|
+
expect(machinePartsPageText(itemKeys, 1, 10)).toBe(
|
|
22
|
+
'ITEM-11, ITEM-12, ITEM-13, ITEM-14, ITEM-15',
|
|
23
|
+
);
|
|
24
|
+
expect(itemKeys).toHaveLength(15);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('counts pages for 611 item keys from the key list, not the response', () => {
|
|
28
|
+
expect(machinePartsPageCount(611, 10)).toBe(62);
|
|
29
|
+
expect(machinePartsPageCount(611, 20)).toBe(31);
|
|
30
|
+
expect(machinePartsPageCount(611, 30)).toBe(21);
|
|
31
|
+
expect(machinePartsPageCount(611, 40)).toBe(16);
|
|
32
|
+
expect(machinePartsPageCount(611, 50)).toBe(13);
|
|
33
|
+
|
|
34
|
+
const itemKeys = keys(611);
|
|
35
|
+
const lastPage = machinePartsPageItemKeys(itemKeys, 30, 20);
|
|
36
|
+
expect(lastPage).toHaveLength(11);
|
|
37
|
+
expect(lastPage[0]).toBe('ITEM-601');
|
|
38
|
+
expect(lastPage[lastPage.length - 1]).toBe('ITEM-611');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('keeps a full last page when the key count divides evenly', () => {
|
|
42
|
+
expect(machinePartsPageCount(10000, 50)).toBe(200);
|
|
43
|
+
expect(machinePartsPageItemKeys(keys(10000), 199, 50)).toHaveLength(50);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns no text for an empty page', () => {
|
|
47
|
+
expect(machinePartsPageText(keys(15), 2, 10)).toBeUndefined();
|
|
48
|
+
expect(machinePartsPageCount(0, 20)).toBe(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('shows every page when the count fits, otherwise a window', () => {
|
|
52
|
+
expect(visibleMachinePartsPageIndexes(0, 2)).toEqual([0, 1]);
|
|
53
|
+
expect(visibleMachinePartsPageIndexes(0, 62)).toHaveLength(7);
|
|
54
|
+
expect(visibleMachinePartsPageIndexes(61, 62)).toContain(61);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('treats an active machine scope as a listing even if Algolia is on', () => {
|
|
58
|
+
const listing = {
|
|
59
|
+
scopeEnabled: true,
|
|
60
|
+
scopeId: 'TestScope2',
|
|
61
|
+
itemKeyCount: 15,
|
|
62
|
+
keyword: '',
|
|
63
|
+
hasSearchImage: false,
|
|
64
|
+
hasPostFilters: false,
|
|
65
|
+
};
|
|
66
|
+
expect(isScopeOnlyMachinePartsListing(listing)).toBe(true);
|
|
67
|
+
expect(
|
|
68
|
+
isScopeOnlyMachinePartsListing({ ...listing, keyword: 'pump' }),
|
|
69
|
+
).toBe(false);
|
|
70
|
+
expect(
|
|
71
|
+
isScopeOnlyMachinePartsListing({ ...listing, scopeEnabled: false }),
|
|
72
|
+
).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
export const DEFAULT_MACHINE_PARTS_PAGE_SIZE = 20;
|
|
2
|
+
|
|
3
|
+
export function normalizeMachinePartsPageSize(pageSize: number): number {
|
|
4
|
+
if (!Number.isFinite(pageSize) || pageSize <= 0) {
|
|
5
|
+
return DEFAULT_MACHINE_PARTS_PAGE_SIZE;
|
|
6
|
+
}
|
|
7
|
+
return Math.floor(pageSize);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** 0-based page index, matching the existing pagination control. */
|
|
11
|
+
export function normalizeMachinePartsPageIndex(pageIndex: number): number {
|
|
12
|
+
if (!Number.isFinite(pageIndex) || pageIndex <= 0) {
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
return Math.floor(pageIndex);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function machinePartsPageCount(
|
|
19
|
+
itemKeyCount: number,
|
|
20
|
+
pageSize: number,
|
|
21
|
+
): number {
|
|
22
|
+
if (!Number.isFinite(itemKeyCount) || itemKeyCount <= 0) {
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
const size = normalizeMachinePartsPageSize(pageSize);
|
|
26
|
+
return Math.ceil(itemKeyCount / size);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Does not mutate `itemKeys`. */
|
|
30
|
+
export function machinePartsPageItemKeys(
|
|
31
|
+
itemKeys: readonly string[],
|
|
32
|
+
pageIndex: number,
|
|
33
|
+
pageSize: number,
|
|
34
|
+
): string[] {
|
|
35
|
+
const size = normalizeMachinePartsPageSize(pageSize);
|
|
36
|
+
const start = normalizeMachinePartsPageIndex(pageIndex) * size;
|
|
37
|
+
return itemKeys.slice(start, start + size);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function machinePartsPageText(
|
|
41
|
+
itemKeys: readonly string[],
|
|
42
|
+
pageIndex: number,
|
|
43
|
+
pageSize: number,
|
|
44
|
+
): string | undefined {
|
|
45
|
+
const text = machinePartsPageItemKeys(itemKeys, pageIndex, pageSize).join(
|
|
46
|
+
', ',
|
|
47
|
+
);
|
|
48
|
+
if (text.length === 0) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
return text;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Page numbers shown around the current page. Indexes are 0-based. */
|
|
55
|
+
export function visibleMachinePartsPageIndexes(
|
|
56
|
+
pageIndex: number,
|
|
57
|
+
pageCount: number,
|
|
58
|
+
padding = 3,
|
|
59
|
+
): number[] {
|
|
60
|
+
if (pageCount <= 0) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
const current = Math.min(
|
|
64
|
+
normalizeMachinePartsPageIndex(pageIndex),
|
|
65
|
+
pageCount - 1,
|
|
66
|
+
);
|
|
67
|
+
if (pageCount <= padding * 2 + 1) {
|
|
68
|
+
return Array.from({ length: pageCount }, (_, index) => index);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let start = Math.max(0, current - padding);
|
|
72
|
+
let end = Math.min(pageCount - 1, current + padding);
|
|
73
|
+
if (current - padding < 0) {
|
|
74
|
+
end = Math.min(pageCount - 1, end + (padding - current));
|
|
75
|
+
}
|
|
76
|
+
if (current + padding > pageCount - 1) {
|
|
77
|
+
start = Math.max(0, start - (current + padding - (pageCount - 1)));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const pages: number[] = [];
|
|
81
|
+
for (let index = start; index <= end; index += 1) {
|
|
82
|
+
pages.push(index);
|
|
83
|
+
}
|
|
84
|
+
return pages;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function hasActivePostFilters(
|
|
88
|
+
selections: Record<string, string[]> | undefined,
|
|
89
|
+
): boolean {
|
|
90
|
+
return Object.values(selections || {}).some(
|
|
91
|
+
values => Array.isArray(values) && values.length > 0,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function isScopeOnlyMachinePartsListing({
|
|
96
|
+
scopeEnabled,
|
|
97
|
+
scopeId,
|
|
98
|
+
itemKeyCount,
|
|
99
|
+
keyword,
|
|
100
|
+
hasSearchImage,
|
|
101
|
+
hasPostFilters,
|
|
102
|
+
}: {
|
|
103
|
+
scopeEnabled: boolean;
|
|
104
|
+
scopeId: string | null;
|
|
105
|
+
itemKeyCount: number;
|
|
106
|
+
keyword: string;
|
|
107
|
+
hasSearchImage: boolean;
|
|
108
|
+
hasPostFilters: boolean;
|
|
109
|
+
}): boolean {
|
|
110
|
+
if (!scopeEnabled) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
if (!scopeId || itemKeyCount <= 0) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
if (keyword.trim().length > 0 || hasSearchImage || hasPostFilters) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chat API sometimes returns the JSON payload as a string, optionally
|
|
3
|
+
* wrapped in a markdown code fence (```json ... ```). Parse such strings into
|
|
4
|
+
* objects; leave plain-text responses (e.g. request_quality) untouched.
|
|
5
|
+
*
|
|
6
|
+
* A JSON object embedded in surrounding text is left as text. Callers must not
|
|
7
|
+
* treat that leftover text as structured data.
|
|
8
|
+
*/
|
|
9
|
+
export function normalizeChatResponse(data: unknown): unknown {
|
|
10
|
+
if (typeof data !== 'string') {
|
|
11
|
+
return data;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const fenced = data.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
|
15
|
+
const candidate = (fenced ? fenced[1] : data).trim();
|
|
16
|
+
|
|
17
|
+
if (!candidate.startsWith('{') && !candidate.startsWith('[')) {
|
|
18
|
+
return data;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(candidate);
|
|
23
|
+
} catch {
|
|
24
|
+
return data;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { getScopes } from 'services/filter';
|
|
2
|
+
import { lookupScopeById } from './scopeLookup';
|
|
3
|
+
|
|
4
|
+
jest.mock('services/filter', () => ({
|
|
5
|
+
getScopes: jest.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
jest.mock('utils/nyrisApiKey', () => ({
|
|
9
|
+
isNyrisApiKeyReady: () => true,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
const getScopesMock = getScopes as jest.MockedFunction<typeof getScopes>;
|
|
13
|
+
|
|
14
|
+
describe('lookupScopeById', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
getScopesMock.mockReset();
|
|
17
|
+
window.settings = {
|
|
18
|
+
scopes: { scopeID: true },
|
|
19
|
+
apiKey: 'test-key',
|
|
20
|
+
} as typeof window.settings;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('sends the serial number unchanged', async () => {
|
|
24
|
+
getScopesMock.mockResolvedValue({
|
|
25
|
+
itemKeys: ['sku-1'],
|
|
26
|
+
filterAttributes: [],
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const result = await lookupScopeById('Ab-12/c 9');
|
|
30
|
+
|
|
31
|
+
expect(getScopesMock).toHaveBeenCalledWith(
|
|
32
|
+
'Ab-12/c 9',
|
|
33
|
+
window.settings,
|
|
34
|
+
);
|
|
35
|
+
expect(result).toEqual({
|
|
36
|
+
status: 'found',
|
|
37
|
+
itemKeys: ['sku-1'],
|
|
38
|
+
filterAttributes: [],
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('does not call the scopes API for a blank id', async () => {
|
|
43
|
+
const result = await lookupScopeById(' ');
|
|
44
|
+
|
|
45
|
+
expect(getScopesMock).not.toHaveBeenCalled();
|
|
46
|
+
expect(result).toEqual({ status: 'not_found' });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns empty when the machine has no item keys', async () => {
|
|
50
|
+
getScopesMock.mockResolvedValue({
|
|
51
|
+
itemKeys: [],
|
|
52
|
+
filterAttributes: [],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await expect(lookupScopeById('2234GH')).resolves.toEqual({
|
|
56
|
+
status: 'empty',
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns not found when the scopes request fails', async () => {
|
|
61
|
+
getScopesMock.mockRejectedValue(new Error('unavailable'));
|
|
62
|
+
|
|
63
|
+
await expect(lookupScopeById('2234GH')).resolves.toEqual({
|
|
64
|
+
status: 'not_found',
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { getScopes } from 'services/filter';
|
|
2
|
+
import { isNyrisApiKeyReady } from 'utils/nyrisApiKey';
|
|
3
|
+
import { TypePlateLookupResult } from 'components/ScopeSelect/identifyTypePlateImage';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET /v1/scopes?scopeIds=... using the existing client encoding.
|
|
7
|
+
* Does not write the scope store. Callers apply a machine only after confirm.
|
|
8
|
+
* The id is sent unchanged; trimming only rejects a blank value.
|
|
9
|
+
*/
|
|
10
|
+
export async function lookupScopeById(
|
|
11
|
+
scopeId: string,
|
|
12
|
+
): Promise<TypePlateLookupResult> {
|
|
13
|
+
const settings = window.settings;
|
|
14
|
+
if (!settings.scopes?.scopeID) {
|
|
15
|
+
return { status: 'unavailable' };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!isNyrisApiKeyReady()) {
|
|
19
|
+
return { status: 'unavailable' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (scopeId.trim().length === 0 || scopeId.trim() === 'NOT_FOUND') {
|
|
23
|
+
return { status: 'not_found' };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const scopesResponse = await getScopes(scopeId, settings);
|
|
28
|
+
const itemKeys = (scopesResponse.itemKeys || []).filter(Boolean);
|
|
29
|
+
const filterAttributes = scopesResponse.filterAttributes || [];
|
|
30
|
+
|
|
31
|
+
if (itemKeys.length === 0) {
|
|
32
|
+
return { status: 'empty' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { status: 'found', itemKeys, filterAttributes };
|
|
36
|
+
} catch {
|
|
37
|
+
return { status: 'not_found' };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { machinePartsPageText } from './machinePartsPage';
|
|
2
|
+
import { machinePartsListingXOptions } from './visualSearch';
|
|
3
|
+
|
|
4
|
+
const X_OPTIONS = 'hybrid_search.text.reranking=false';
|
|
5
|
+
|
|
6
|
+
function keys(count: number) {
|
|
7
|
+
return Array.from({ length: count }, (_, index) => `ITEM-${index + 1}`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const listing = {
|
|
11
|
+
hasSearchImage: false,
|
|
12
|
+
hasFilters: false,
|
|
13
|
+
scopeId: 'TestScope2',
|
|
14
|
+
itemKeys: keys(15),
|
|
15
|
+
keyword: '',
|
|
16
|
+
pageSize: 10,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe('machinePartsListingXOptions', () => {
|
|
20
|
+
it('sends the override for each scope-only item key page', () => {
|
|
21
|
+
expect(
|
|
22
|
+
machinePartsListingXOptions({
|
|
23
|
+
...listing,
|
|
24
|
+
pageIndex: 0,
|
|
25
|
+
text: machinePartsPageText(listing.itemKeys, 0, 10),
|
|
26
|
+
}),
|
|
27
|
+
).toBe(X_OPTIONS);
|
|
28
|
+
expect(
|
|
29
|
+
machinePartsListingXOptions({
|
|
30
|
+
...listing,
|
|
31
|
+
pageIndex: 1,
|
|
32
|
+
text: machinePartsPageText(listing.itemKeys, 1, 10),
|
|
33
|
+
}),
|
|
34
|
+
).toBe(X_OPTIONS);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('omits the override for a keyword, image, filters, or another page', () => {
|
|
38
|
+
const firstPage = machinePartsPageText(listing.itemKeys, 0, 10);
|
|
39
|
+
expect(
|
|
40
|
+
machinePartsListingXOptions({
|
|
41
|
+
...listing,
|
|
42
|
+
pageIndex: 1,
|
|
43
|
+
text: firstPage,
|
|
44
|
+
}),
|
|
45
|
+
).toBeUndefined();
|
|
46
|
+
expect(
|
|
47
|
+
machinePartsListingXOptions({
|
|
48
|
+
...listing,
|
|
49
|
+
pageIndex: 0,
|
|
50
|
+
text: 'pump',
|
|
51
|
+
keyword: 'pump',
|
|
52
|
+
}),
|
|
53
|
+
).toBeUndefined();
|
|
54
|
+
expect(
|
|
55
|
+
machinePartsListingXOptions({
|
|
56
|
+
...listing,
|
|
57
|
+
pageIndex: 0,
|
|
58
|
+
text: firstPage,
|
|
59
|
+
hasSearchImage: true,
|
|
60
|
+
}),
|
|
61
|
+
).toBeUndefined();
|
|
62
|
+
expect(
|
|
63
|
+
machinePartsListingXOptions({
|
|
64
|
+
...listing,
|
|
65
|
+
pageIndex: 0,
|
|
66
|
+
text: firstPage,
|
|
67
|
+
hasFilters: true,
|
|
68
|
+
}),
|
|
69
|
+
).toBeUndefined();
|
|
70
|
+
expect(
|
|
71
|
+
machinePartsListingXOptions({
|
|
72
|
+
...listing,
|
|
73
|
+
pageIndex: 0,
|
|
74
|
+
text: firstPage,
|
|
75
|
+
scopeId: null,
|
|
76
|
+
}),
|
|
77
|
+
).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
});
|