@askdialog/dialog-sdk 2.5.0 → 2.6.0
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/README.md +46 -22
- package/dist/Dialog.d.ts +12 -0
- package/dist/Dialog.d.ts.map +1 -1
- package/dist/Dialog.js +21 -0
- package/dist/EventsHandler.d.ts.map +1 -1
- package/dist/EventsHandler.js +2 -0
- package/dist/__tests__/dialogSearchAnalytics.spec.d.ts +2 -0
- package/dist/__tests__/dialogSearchAnalytics.spec.d.ts.map +1 -0
- package/dist/__tests__/dialogSearchAnalytics.spec.js +90 -0
- package/dist/__tests__/searchController.spec.d.ts +2 -0
- package/dist/__tests__/searchController.spec.d.ts.map +1 -0
- package/dist/__tests__/searchController.spec.js +254 -0
- package/dist/__tests__/searchControllerAttribution.spec.d.ts +2 -0
- package/dist/__tests__/searchControllerAttribution.spec.d.ts.map +1 -0
- package/dist/__tests__/searchControllerAttribution.spec.js +176 -0
- package/dist/__tests__/searchImpressions.spec.d.ts +2 -0
- package/dist/__tests__/searchImpressions.spec.d.ts.map +1 -0
- package/dist/__tests__/searchImpressions.spec.js +218 -0
- package/dist/config/config.development.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/searchController.d.ts +3 -0
- package/dist/searchController.d.ts.map +1 -0
- package/dist/searchController.js +164 -0
- package/dist/types/events.d.ts +5 -2
- package/dist/types/events.d.ts.map +1 -1
- package/dist/types/events.js +2 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +2 -0
- package/dist/types/searchAnalytics.d.ts +30 -0
- package/dist/types/searchAnalytics.d.ts.map +1 -0
- package/dist/types/searchAnalytics.js +10 -0
- package/dist/types/searchController.d.ts +80 -0
- package/dist/types/searchController.d.ts.map +1 -0
- package/dist/types/searchController.js +8 -0
- package/dist/utils/searchControllerAnalytics.d.ts +14 -0
- package/dist/utils/searchControllerAnalytics.d.ts.map +1 -0
- package/dist/utils/searchControllerAnalytics.js +54 -0
- package/dist/utils/searchImpressions.d.ts +21 -0
- package/dist/utils/searchImpressions.d.ts.map +1 -0
- package/dist/utils/searchImpressions.js +126 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -205,32 +205,56 @@ With the IIFE bundle the results are plain runtime JSON (same shape, no types):
|
|
|
205
205
|
|
|
206
206
|
A non-2xx answer rejects with `DialogSearchError` — stable `name`, HTTP `status`, optional machine-readable `code` (e.g. `SEARCH_INDEX_NOT_FOUND`) and `message`. Aborting rejects with the native `AbortError`, and network failures keep their native errors.
|
|
207
207
|
|
|
208
|
-
|
|
208
|
+
`client.search()` itself is stateless: no debounce, no cache, no automatic cancellation of previous searches. For search-as-you-type, use the search controller below instead of hand-rolling those.
|
|
209
|
+
|
|
210
|
+
- Search controller
|
|
211
|
+
|
|
212
|
+
`createSearchController()` wraps the stateless transport with the stateful behavior every search UI needs — debounce (immediate on explicit submission), cancellation of the in-flight request, stale-response protection (a late response never replaces newer results, even if the transport ignores the abort), pagination that resets on a new query, `idle` / `loading` / `success` / `empty` / `error` states, retry, and the DEC-2448 attribution events (`view_search_results` viewport impressions, `select_search_result` clicks) with a consistent `query_id`. It has no framework or rendering dependency: raw JavaScript, React, Vue and Shopify integrations are rendering-and-routing adapters around it.
|
|
209
213
|
|
|
210
214
|
```typescript
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
215
|
+
import { createSearchController, Dialog, SearchStatus } from '@askdialog/dialog-sdk';
|
|
216
|
+
|
|
217
|
+
const client = new Dialog({ apiKey: 'YOUR_API_KEY', locale: 'fr' });
|
|
218
|
+
|
|
219
|
+
const controller = createSearchController({
|
|
220
|
+
search: (request, options) => client.search(request, options),
|
|
221
|
+
analytics: {
|
|
222
|
+
surface: 'search_page', // where results are displayed
|
|
223
|
+
trackViewSearchResults: (params) => client.trackViewSearchResults(params),
|
|
224
|
+
trackSelectSearchResult: (params) => client.trackSelectSearchResult(params),
|
|
225
|
+
},
|
|
226
|
+
navigate: (url) => router.push(url), // optional platform routing adapter
|
|
227
|
+
debounceMs: 250, // optional (default 250)
|
|
228
|
+
hitsPerPage: 12, // optional (default 12)
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const unsubscribe = controller.subscribe((state) => {
|
|
232
|
+
// state: { status, query, page, response?, error? }
|
|
233
|
+
if (state.status === SearchStatus.SUCCESS) {
|
|
234
|
+
renderCards(state.response.hits).forEach((element, index) => {
|
|
235
|
+
controller.observeResult(element, index); // viewport impression
|
|
236
|
+
element.onclick = () => controller.selectResult(index); // attribution, then `navigate`
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
input.oninput = () => controller.setQuery(input.value); // debounced
|
|
242
|
+
form.onsubmit = () => controller.submit(input.value); // immediate
|
|
243
|
+
nextButton.onclick = () => controller.setPage(controller.getState().page + 1);
|
|
244
|
+
retryButton.onclick = () => controller.retry();
|
|
245
|
+
// On teardown (SPA unmount): cancel in-flight work and detach observers.
|
|
246
|
+
controller.dispose();
|
|
232
247
|
```
|
|
233
248
|
|
|
249
|
+
The adapter contract for a framework binding (React, Vue, Shopify):
|
|
250
|
+
|
|
251
|
+
- **Rendering** — subscribe to the controller (`subscribe`/`getState` fit React's `useSyncExternalStore` and a Vue `shallowRef` updated by the listener) and render the five states; never re-implement debounce, `AbortController` or race protection locally.
|
|
252
|
+
- **Attribution** — call `observeResult(element, index)` for every rendered result and `selectResult(index)` on every result click (including middle-click/cmd+click). Do not `preventDefault` a same-tab navigation: attribution is recorded first and the events survive it.
|
|
253
|
+
- **Routing** — platform navigation and URL synchronization (query params, history) stay in the adapter: pass `navigate` for router-driven platforms, or let plain `<a href>` links navigate natively.
|
|
254
|
+
- **Lifecycle** — create one controller per search surface and `dispose()` it on unmount.
|
|
255
|
+
|
|
256
|
+
The raw JavaScript reference adapter lives in [`packages/search-example`](../search-example).
|
|
257
|
+
|
|
234
258
|
- Handler for fetch product
|
|
235
259
|
|
|
236
260
|
The `getProduct` callback is called by the assistant to display product information. You must return an object matching the `SimplifiedProduct` interface.
|
package/dist/Dialog.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { DetailedLocaleInfo } from "./utils/localization";
|
|
|
4
4
|
import { Suggestion } from "./types/suggestion";
|
|
5
5
|
import { AddToCartInput, GenericQuestionPayload, LegacyCheckoutParams, OpenAssistantPayload, ProductQuestionPayload, SubmitCheckoutParams } from "./types/events";
|
|
6
6
|
import { SimplifiedProduct } from "./types/product";
|
|
7
|
+
import { SelectSearchResultParams, ViewSearchResultsParams } from "./types/searchAnalytics";
|
|
7
8
|
import { EventsHandler } from "./EventsHandler";
|
|
8
9
|
import { SearchOptions, SearchRequest, SearchResponse } from "./types/search";
|
|
9
10
|
import { AssistantEvent } from "./types/assistantEvent";
|
|
@@ -45,6 +46,17 @@ export declare class Dialog {
|
|
|
45
46
|
private _getCallbacksOrThrow;
|
|
46
47
|
registerAddToCartEvent(input: AddToCartInput): void;
|
|
47
48
|
registerSubmitCheckoutEvent(params: SubmitCheckoutParams | LegacyCheckoutParams): void;
|
|
49
|
+
/**
|
|
50
|
+
* Emit a batch of viewport impressions. The batching/dedup semantics live
|
|
51
|
+
* in `createSearchImpressionTracker` — wire its `emit` here, or call
|
|
52
|
+
* directly with `items: []` for a rendered no-results state.
|
|
53
|
+
*/
|
|
54
|
+
trackViewSearchResults(params: ViewSearchResultsParams): void;
|
|
55
|
+
/**
|
|
56
|
+
* Emit on a result click — including auxclick / cmd+click, not only before
|
|
57
|
+
* a same-tab navigation. Force the clicked item's impression first.
|
|
58
|
+
*/
|
|
59
|
+
trackSelectSearchResult(params: SelectSearchResultParams): void;
|
|
48
60
|
private _loadAssistant;
|
|
49
61
|
}
|
|
50
62
|
//# sourceMappingURL=Dialog.d.ts.map
|
package/dist/Dialog.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Dialog.d.ts","sourceRoot":"","sources":["../src/Dialog.ts"],"names":[],"mappings":"AAIA,OAAO,EAAmB,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACzE,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AACtC,OAAO,EACL,kBAAkB,EAEnB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,EACL,cAAc,EAEd,sBAAsB,EACtB,oBAAoB,EACpB,oBAAoB,EACpB,sBAAsB,EACtB,oBAAoB,EACrB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAGhD,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAExD,qBAAa,MAAM;IACjB,gBAAuB,OAAO,SAAuB;IAErD,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,YAAY,CAAC,CAAS;IAE9B,OAAO,CAAC,UAAU,CAAC,CAAkB;IACrC,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,cAAc,CAAgB;IACtC,OAAO,CAAC,wBAAwB,CAAU;gBAE9B,EACV,MAAM,EACN,MAAM,EACN,WAAW,EACX,SAAS,EACT,KAAK,EACL,MAAM,EACN,uBAAuB,GACxB,EAAE,iBAAiB;IAapB,IAAW,MAAM,IAAI,MAAM,CAE1B;IACD,IAAW,KAAK,IAAI,KAAK,CAExB;IACD,IAAW,MAAM,IAAI,MAAM,CAE1B;IACD,IAAW,MAAM,IAAI,MAAM,CAE1B;IACD,IAAW,aAAa,IAAI,aAAa,CAExC;IAEM,2BAA2B,IAAI,kBAAkB,GAAG,IAAI;IAI/D,OAAO,CAAC,uBAAuB;IAkBlB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAInE;;;;;;OAMG;IACI,MAAM,CACX,OAAO,EAAE,aAAa,EACtB,OAAO,CAAC,EAAE,aAAa,GACtB,OAAO,CAAC,cAAc,CAAC;IAKnB,aAAa,CAAC,MAAM,EAAE,oBAAoB,GAAG,IAAI;IAKjD,cAAc,IAAI,IAAI;IAItB,kBAAkB,CAAC,MAAM,EAAE,sBAAsB,GAAG,IAAI;IAIxD,kBAAkB,CAAC,MAAM,EAAE,sBAAsB,GAAG,IAAI;IAOxD,gBAAgB,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,GAAG,IAAI;IAIjE,sBAAsB,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI;IAInD,UAAU,CACf,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,iBAAiB,CAAC;IAUhB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;IAS5D,OAAO,CAAC,oBAAoB;IAUrB,sBAAsB,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI;IAcnD,2BAA2B,CAChC,MAAM,EAAE,oBAAoB,GAAG,oBAAoB,GAClD,IAAI;IA0BP,OAAO,CAAC,cAAc;CAkCvB"}
|
|
1
|
+
{"version":3,"file":"Dialog.d.ts","sourceRoot":"","sources":["../src/Dialog.ts"],"names":[],"mappings":"AAIA,OAAO,EAAmB,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACzE,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AACtC,OAAO,EACL,kBAAkB,EAEnB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,EACL,cAAc,EAEd,sBAAsB,EACtB,oBAAoB,EACpB,oBAAoB,EACpB,sBAAsB,EACtB,oBAAoB,EACrB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EACL,wBAAwB,EACxB,uBAAuB,EACxB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAGhD,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAExD,qBAAa,MAAM;IACjB,gBAAuB,OAAO,SAAuB;IAErD,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,YAAY,CAAC,CAAS;IAE9B,OAAO,CAAC,UAAU,CAAC,CAAkB;IACrC,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,cAAc,CAAgB;IACtC,OAAO,CAAC,wBAAwB,CAAU;gBAE9B,EACV,MAAM,EACN,MAAM,EACN,WAAW,EACX,SAAS,EACT,KAAK,EACL,MAAM,EACN,uBAAuB,GACxB,EAAE,iBAAiB;IAapB,IAAW,MAAM,IAAI,MAAM,CAE1B;IACD,IAAW,KAAK,IAAI,KAAK,CAExB;IACD,IAAW,MAAM,IAAI,MAAM,CAE1B;IACD,IAAW,MAAM,IAAI,MAAM,CAE1B;IACD,IAAW,aAAa,IAAI,aAAa,CAExC;IAEM,2BAA2B,IAAI,kBAAkB,GAAG,IAAI;IAI/D,OAAO,CAAC,uBAAuB;IAkBlB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAInE;;;;;;OAMG;IACI,MAAM,CACX,OAAO,EAAE,aAAa,EACtB,OAAO,CAAC,EAAE,aAAa,GACtB,OAAO,CAAC,cAAc,CAAC;IAKnB,aAAa,CAAC,MAAM,EAAE,oBAAoB,GAAG,IAAI;IAKjD,cAAc,IAAI,IAAI;IAItB,kBAAkB,CAAC,MAAM,EAAE,sBAAsB,GAAG,IAAI;IAIxD,kBAAkB,CAAC,MAAM,EAAE,sBAAsB,GAAG,IAAI;IAOxD,gBAAgB,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,GAAG,IAAI;IAIjE,sBAAsB,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI;IAInD,UAAU,CACf,SAAS,EAAE,MAAM,EACjB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,iBAAiB,CAAC;IAUhB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;IAS5D,OAAO,CAAC,oBAAoB;IAUrB,sBAAsB,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI;IAcnD,2BAA2B,CAChC,MAAM,EAAE,oBAAoB,GAAG,oBAAoB,GAClD,IAAI;IA0BP;;;;OAIG;IACI,sBAAsB,CAAC,MAAM,EAAE,uBAAuB,GAAG,IAAI;IAUpE;;;OAGG;IACI,uBAAuB,CAAC,MAAM,EAAE,wBAAwB,GAAG,IAAI;IAUtE,OAAO,CAAC,cAAc;CAkCvB"}
|
package/dist/Dialog.js
CHANGED
|
@@ -148,6 +148,27 @@ export class Dialog {
|
|
|
148
148
|
currency: params.currency,
|
|
149
149
|
});
|
|
150
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Emit a batch of viewport impressions. The batching/dedup semantics live
|
|
153
|
+
* in `createSearchImpressionTracker` — wire its `emit` here, or call
|
|
154
|
+
* directly with `items: []` for a rendered no-results state.
|
|
155
|
+
*/
|
|
156
|
+
trackViewSearchResults(params) {
|
|
157
|
+
this._eventsHandler.emitExternalEvent(DialogEvents.TRACK_VIEW_SEARCH_RESULTS, {
|
|
158
|
+
userId: this._userId,
|
|
159
|
+
...params,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Emit on a result click — including auxclick / cmd+click, not only before
|
|
164
|
+
* a same-tab navigation. Force the clicked item's impression first.
|
|
165
|
+
*/
|
|
166
|
+
trackSelectSearchResult(params) {
|
|
167
|
+
this._eventsHandler.emitExternalEvent(DialogEvents.TRACK_SELECT_SEARCH_RESULT, {
|
|
168
|
+
userId: this._userId,
|
|
169
|
+
...params,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
151
172
|
_loadAssistant() {
|
|
152
173
|
const localeInfo = getDetailedLocaleInfo(this._locale, this._countryCode);
|
|
153
174
|
if (localeInfo === null) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"EventsHandler.d.ts","sourceRoot":"","sources":["../src/EventsHandler.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,qBAAqB,EACrB,aAAa,EAEb,4BAA4B,EAC7B,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAuB,WAAW,EAAgB,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"EventsHandler.d.ts","sourceRoot":"","sources":["../src/EventsHandler.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,qBAAqB,EACrB,aAAa,EAEb,4BAA4B,EAC7B,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAuB,WAAW,EAAgB,MAAM,gBAAgB,CAAC;AAiBhF,qBAAa,aAAa;IACxB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAAC,CAAS;IACzB,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,eAAe,CAAuB;gBAElC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;IAKpC,iBAAiB,CACtB,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,EACzB,OAAO,CAAC,EAAE,WAAW,CAAC,SAAS,CAAC,GAC/B,IAAI;IAYA,mBAAmB,IAAI,IAAI;IAU3B,kBAAkB,IAAI,IAAI;IAIjC,OAAO,CAAC,sBAAsB;IAWvB,kBAAkB,CACvB,IAAI,EAAE,cAAc,CAAC,MAAM,CAAC,EAC5B,OAAO,CAAC,EAAE,4BAA4B,GACrC,IAAI;IAQA,gBAAgB,CACrB,QAAQ,EAAE,CACR,KAAK,EAAE,cAAc,CAAC,aAAa,GAAG,qBAAqB,CAAC,KACzD,IAAI,GACR,MAAM,IAAI;CA0Bd"}
|
package/dist/EventsHandler.js
CHANGED
|
@@ -6,6 +6,8 @@ import { DIALOG_CUSTOM_EVENT, DialogEvents } from "./types/events";
|
|
|
6
6
|
const BUFFERED_EVENT_TYPES = new Set([
|
|
7
7
|
DialogEvents.TRACK_ADD_TO_CART,
|
|
8
8
|
DialogEvents.TRACK_SUBMIT_CHECKOUT,
|
|
9
|
+
DialogEvents.TRACK_VIEW_SEARCH_RESULTS,
|
|
10
|
+
DialogEvents.TRACK_SELECT_SEARCH_RESULT,
|
|
9
11
|
]);
|
|
10
12
|
export class EventsHandler {
|
|
11
13
|
_locale;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dialogSearchAnalytics.spec.d.ts","sourceRoot":"","sources":["../../src/__tests__/dialogSearchAnalytics.spec.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { Dialog } from "../Dialog";
|
|
3
|
+
import { EventsHandler } from "../EventsHandler";
|
|
4
|
+
import { DIALOG_CUSTOM_EVENT, DialogEvents } from "../types/events";
|
|
5
|
+
// The Dialog constructor loads the assistant into the DOM; these tests only
|
|
6
|
+
// exercise the search tracking methods, so the instance is built from the
|
|
7
|
+
// prototype with just the fields those methods read.
|
|
8
|
+
const buildDialog = () => {
|
|
9
|
+
const eventsHandler = new EventsHandler("fr", "user-1");
|
|
10
|
+
const emitExternalEvent = vi.fn();
|
|
11
|
+
eventsHandler.emitExternalEvent =
|
|
12
|
+
emitExternalEvent;
|
|
13
|
+
const dialog = Object.create(Dialog.prototype);
|
|
14
|
+
Object.assign(dialog, {
|
|
15
|
+
_eventsHandler: eventsHandler,
|
|
16
|
+
_userId: "user-1",
|
|
17
|
+
});
|
|
18
|
+
return { dialog, emitExternalEvent };
|
|
19
|
+
};
|
|
20
|
+
const envelope = {
|
|
21
|
+
query_id: "query-1",
|
|
22
|
+
surface: "search_page",
|
|
23
|
+
page: 2,
|
|
24
|
+
total_hits: 1247,
|
|
25
|
+
query_length: 14,
|
|
26
|
+
};
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.unstubAllGlobals();
|
|
29
|
+
vi.clearAllMocks();
|
|
30
|
+
});
|
|
31
|
+
describe("Dialog search analytics", () => {
|
|
32
|
+
it("emits TRACK_VIEW_SEARCH_RESULTS with the snake_case envelope, items and userId", () => {
|
|
33
|
+
const { dialog, emitExternalEvent } = buildDialog();
|
|
34
|
+
const items = [
|
|
35
|
+
{ product_id: "product-1", position: 21 },
|
|
36
|
+
{ product_id: "product-2", position: 22 },
|
|
37
|
+
];
|
|
38
|
+
dialog.trackViewSearchResults({ ...envelope, items });
|
|
39
|
+
expect(emitExternalEvent).toHaveBeenCalledWith(DialogEvents.TRACK_VIEW_SEARCH_RESULTS, { userId: "user-1", ...envelope, items });
|
|
40
|
+
});
|
|
41
|
+
it("represents a rendered no-results state as empty items with total_hits 0", () => {
|
|
42
|
+
const { dialog, emitExternalEvent } = buildDialog();
|
|
43
|
+
dialog.trackViewSearchResults({ ...envelope, total_hits: 0, items: [] });
|
|
44
|
+
expect(emitExternalEvent).toHaveBeenCalledWith(DialogEvents.TRACK_VIEW_SEARCH_RESULTS, { userId: "user-1", ...envelope, total_hits: 0, items: [] });
|
|
45
|
+
});
|
|
46
|
+
it("emits TRACK_SELECT_SEARCH_RESULT with a single-item array", () => {
|
|
47
|
+
const { dialog, emitExternalEvent } = buildDialog();
|
|
48
|
+
const item = { product_id: "product-1", position: 22 };
|
|
49
|
+
dialog.trackSelectSearchResult({ ...envelope, items: [item] });
|
|
50
|
+
expect(emitExternalEvent).toHaveBeenCalledWith(DialogEvents.TRACK_SELECT_SEARCH_RESULT, { userId: "user-1", ...envelope, items: [item] });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe("EventsHandler search event buffering", () => {
|
|
54
|
+
it("buffers both search events until consumer-ready, then flushes them intact and in order", () => {
|
|
55
|
+
const dispatchEvent = vi.fn();
|
|
56
|
+
vi.stubGlobal("window", { dispatchEvent });
|
|
57
|
+
const eventsHandler = new EventsHandler("fr", "user-1");
|
|
58
|
+
const item = { product_id: "product-1", position: 22 };
|
|
59
|
+
eventsHandler.emitExternalEvent(DialogEvents.TRACK_VIEW_SEARCH_RESULTS, {
|
|
60
|
+
...envelope,
|
|
61
|
+
items: [],
|
|
62
|
+
});
|
|
63
|
+
eventsHandler.emitExternalEvent(DialogEvents.TRACK_SELECT_SEARCH_RESULT, {
|
|
64
|
+
...envelope,
|
|
65
|
+
items: [item],
|
|
66
|
+
});
|
|
67
|
+
expect(dispatchEvent).not.toHaveBeenCalled();
|
|
68
|
+
eventsHandler.notifyConsumerReady();
|
|
69
|
+
const dispatched = dispatchEvent.mock.calls.map(([event]) => ({
|
|
70
|
+
name: event.type,
|
|
71
|
+
detail: event.detail,
|
|
72
|
+
}));
|
|
73
|
+
expect(dispatched).toEqual([
|
|
74
|
+
{
|
|
75
|
+
name: DIALOG_CUSTOM_EVENT,
|
|
76
|
+
detail: {
|
|
77
|
+
type: DialogEvents.TRACK_VIEW_SEARCH_RESULTS,
|
|
78
|
+
payload: { ...envelope, items: [] },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: DIALOG_CUSTOM_EVENT,
|
|
83
|
+
detail: {
|
|
84
|
+
type: DialogEvents.TRACK_SELECT_SEARCH_RESULT,
|
|
85
|
+
payload: { ...envelope, items: [item] },
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"searchController.spec.d.ts","sourceRoot":"","sources":["../../src/__tests__/searchController.spec.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/* eslint max-lines: ["error", 400] */
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createSearchController } from "../searchController";
|
|
4
|
+
import { SearchStatus } from "../types/searchController";
|
|
5
|
+
vi.mock("../utils/searchImpressions", () => ({
|
|
6
|
+
createSearchImpressionTracker: () => ({
|
|
7
|
+
setContext: vi.fn(),
|
|
8
|
+
observe: vi.fn(),
|
|
9
|
+
forceImpression: vi.fn(),
|
|
10
|
+
flush: vi.fn(),
|
|
11
|
+
disconnect: vi.fn(),
|
|
12
|
+
}),
|
|
13
|
+
}));
|
|
14
|
+
const DEBOUNCE_MS = 250;
|
|
15
|
+
const response = (overrides = {}) => ({
|
|
16
|
+
queryId: "qid-1",
|
|
17
|
+
hits: [
|
|
18
|
+
{ id: "h1", score: 1, product: { id: "p1" } },
|
|
19
|
+
{ id: "h2", score: 0.9, product: { id: "p2" } },
|
|
20
|
+
],
|
|
21
|
+
nbHits: 2,
|
|
22
|
+
page: 0,
|
|
23
|
+
nbPages: 3,
|
|
24
|
+
hitsPerPage: 12,
|
|
25
|
+
processingTimeMs: 5,
|
|
26
|
+
query: "shoes",
|
|
27
|
+
...overrides,
|
|
28
|
+
});
|
|
29
|
+
const deferred = () => {
|
|
30
|
+
let resolve;
|
|
31
|
+
let reject;
|
|
32
|
+
const promise = new Promise((res, rej) => {
|
|
33
|
+
resolve = res;
|
|
34
|
+
reject = rej;
|
|
35
|
+
});
|
|
36
|
+
return { promise, resolve, reject };
|
|
37
|
+
};
|
|
38
|
+
// Lets the controller's `await search(...)` continuation run.
|
|
39
|
+
const settle = async () => {
|
|
40
|
+
await Promise.resolve();
|
|
41
|
+
await Promise.resolve();
|
|
42
|
+
};
|
|
43
|
+
const search = vi.fn();
|
|
44
|
+
const trackViewSearchResults = vi.fn();
|
|
45
|
+
const trackSelectSearchResult = vi.fn();
|
|
46
|
+
const states = [];
|
|
47
|
+
const createController = () => {
|
|
48
|
+
const controller = createSearchController({
|
|
49
|
+
search,
|
|
50
|
+
analytics: {
|
|
51
|
+
surface: "search_page",
|
|
52
|
+
trackViewSearchResults,
|
|
53
|
+
trackSelectSearchResult,
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
controller.subscribe((state) => states.push(state));
|
|
57
|
+
return controller;
|
|
58
|
+
};
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
vi.useFakeTimers();
|
|
61
|
+
});
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
vi.useRealTimers();
|
|
64
|
+
vi.clearAllMocks();
|
|
65
|
+
states.length = 0;
|
|
66
|
+
});
|
|
67
|
+
describe("createSearchController", () => {
|
|
68
|
+
it("starts idle", () => {
|
|
69
|
+
const controller = createController();
|
|
70
|
+
expect(controller.getState()).toMatchObject({
|
|
71
|
+
status: SearchStatus.IDLE,
|
|
72
|
+
query: "",
|
|
73
|
+
page: 0,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
it("debounces rapid input into a single request for the last query", async () => {
|
|
77
|
+
const controller = createController();
|
|
78
|
+
search.mockResolvedValue(response({ query: "shoes" }));
|
|
79
|
+
controller.setQuery("sh");
|
|
80
|
+
vi.advanceTimersByTime(DEBOUNCE_MS - 1);
|
|
81
|
+
controller.setQuery("sho");
|
|
82
|
+
vi.advanceTimersByTime(DEBOUNCE_MS - 1);
|
|
83
|
+
controller.setQuery("shoes");
|
|
84
|
+
vi.advanceTimersByTime(DEBOUNCE_MS);
|
|
85
|
+
await settle();
|
|
86
|
+
expect(search).toHaveBeenCalledTimes(1);
|
|
87
|
+
expect(search.mock.calls[0][0]).toMatchObject({ query: "shoes", page: 0 });
|
|
88
|
+
expect(controller.getState().status).toBe(SearchStatus.SUCCESS);
|
|
89
|
+
});
|
|
90
|
+
it("submits immediately without waiting for the debounce", async () => {
|
|
91
|
+
const controller = createController();
|
|
92
|
+
search.mockResolvedValue(response());
|
|
93
|
+
controller.setQuery("sho");
|
|
94
|
+
controller.submit("shoes");
|
|
95
|
+
await settle();
|
|
96
|
+
expect(search).toHaveBeenCalledTimes(1);
|
|
97
|
+
expect(search.mock.calls[0][0]).toMatchObject({ query: "shoes" });
|
|
98
|
+
expect(controller.getState().status).toBe(SearchStatus.SUCCESS);
|
|
99
|
+
});
|
|
100
|
+
it("aborts the previous in-flight request when a new one starts", async () => {
|
|
101
|
+
const controller = createController();
|
|
102
|
+
const first = deferred();
|
|
103
|
+
const second = deferred();
|
|
104
|
+
search
|
|
105
|
+
.mockReturnValueOnce(first.promise)
|
|
106
|
+
.mockReturnValueOnce(second.promise);
|
|
107
|
+
controller.submit("shoes");
|
|
108
|
+
const firstSignal = search.mock.calls[0][1]?.signal;
|
|
109
|
+
controller.submit("boots");
|
|
110
|
+
expect(firstSignal?.aborted).toBe(true);
|
|
111
|
+
second.resolve(response({ query: "boots", queryId: "qid-2" }));
|
|
112
|
+
await settle();
|
|
113
|
+
expect(controller.getState().response?.query).toBe("boots");
|
|
114
|
+
});
|
|
115
|
+
it("never lets a late stale response replace newer results, even without transport cancellation", async () => {
|
|
116
|
+
const controller = createController();
|
|
117
|
+
const stale = deferred();
|
|
118
|
+
const fresh = deferred();
|
|
119
|
+
search
|
|
120
|
+
.mockReturnValueOnce(stale.promise)
|
|
121
|
+
.mockReturnValueOnce(fresh.promise);
|
|
122
|
+
controller.submit("shoes");
|
|
123
|
+
controller.submit("boots");
|
|
124
|
+
fresh.resolve(response({ query: "boots", queryId: "qid-2" }));
|
|
125
|
+
await settle();
|
|
126
|
+
// The stale transport ignored the abort and answers after the fresh one.
|
|
127
|
+
stale.resolve(response({ query: "shoes", queryId: "qid-1" }));
|
|
128
|
+
await settle();
|
|
129
|
+
expect(controller.getState().status).toBe(SearchStatus.SUCCESS);
|
|
130
|
+
expect(controller.getState().response?.query).toBe("boots");
|
|
131
|
+
});
|
|
132
|
+
it("swallows the AbortError of a superseded request", async () => {
|
|
133
|
+
const controller = createController();
|
|
134
|
+
const first = deferred();
|
|
135
|
+
search
|
|
136
|
+
.mockReturnValueOnce(first.promise)
|
|
137
|
+
.mockResolvedValueOnce(response({ query: "boots", queryId: "qid-2" }));
|
|
138
|
+
controller.submit("shoes");
|
|
139
|
+
controller.submit("boots");
|
|
140
|
+
first.reject(new DOMException("The operation was aborted.", "AbortError"));
|
|
141
|
+
await settle();
|
|
142
|
+
expect(controller.getState().status).toBe(SearchStatus.SUCCESS);
|
|
143
|
+
expect(controller.getState().error).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
it("surfaces errors and retries the same query and page", async () => {
|
|
146
|
+
const controller = createController();
|
|
147
|
+
const failure = new TypeError("Failed to fetch");
|
|
148
|
+
search
|
|
149
|
+
.mockResolvedValueOnce(response())
|
|
150
|
+
.mockRejectedValueOnce(failure)
|
|
151
|
+
.mockResolvedValueOnce(response({ page: 2 }));
|
|
152
|
+
controller.submit("shoes");
|
|
153
|
+
await settle();
|
|
154
|
+
controller.setPage(2);
|
|
155
|
+
await settle();
|
|
156
|
+
expect(controller.getState()).toMatchObject({
|
|
157
|
+
status: SearchStatus.ERROR,
|
|
158
|
+
error: failure,
|
|
159
|
+
response: undefined,
|
|
160
|
+
});
|
|
161
|
+
controller.retry();
|
|
162
|
+
await settle();
|
|
163
|
+
expect(search).toHaveBeenCalledTimes(3);
|
|
164
|
+
expect(search.mock.calls[2][0]).toMatchObject({ query: "shoes", page: 2 });
|
|
165
|
+
expect(controller.getState().status).toBe(SearchStatus.SUCCESS);
|
|
166
|
+
});
|
|
167
|
+
it("ignores retry outside the error state", async () => {
|
|
168
|
+
const controller = createController();
|
|
169
|
+
search.mockResolvedValue(response());
|
|
170
|
+
controller.submit("shoes");
|
|
171
|
+
await settle();
|
|
172
|
+
controller.retry();
|
|
173
|
+
await settle();
|
|
174
|
+
expect(search).toHaveBeenCalledTimes(1);
|
|
175
|
+
});
|
|
176
|
+
it("reports the empty status on zero hits", async () => {
|
|
177
|
+
const controller = createController();
|
|
178
|
+
search.mockResolvedValue(response({ hits: [], nbHits: 0, nbPages: 0 }));
|
|
179
|
+
controller.submit("shoes");
|
|
180
|
+
await settle();
|
|
181
|
+
expect(controller.getState().status).toBe(SearchStatus.EMPTY);
|
|
182
|
+
});
|
|
183
|
+
it("resets to idle and invalidates in-flight work when the query gets too short", async () => {
|
|
184
|
+
const controller = createController();
|
|
185
|
+
const inFlight = deferred();
|
|
186
|
+
search.mockReturnValueOnce(inFlight.promise);
|
|
187
|
+
controller.submit("shoes");
|
|
188
|
+
controller.setQuery("s");
|
|
189
|
+
inFlight.resolve(response());
|
|
190
|
+
await settle();
|
|
191
|
+
expect(search).toHaveBeenCalledTimes(1);
|
|
192
|
+
expect(controller.getState().status).toBe(SearchStatus.IDLE);
|
|
193
|
+
});
|
|
194
|
+
it("resets pagination when a new query is submitted", async () => {
|
|
195
|
+
const controller = createController();
|
|
196
|
+
search.mockResolvedValue(response());
|
|
197
|
+
controller.submit("shoes");
|
|
198
|
+
await settle();
|
|
199
|
+
controller.setPage(2);
|
|
200
|
+
await settle();
|
|
201
|
+
controller.submit("boots");
|
|
202
|
+
await settle();
|
|
203
|
+
expect(search.mock.calls[2][0]).toMatchObject({ query: "boots", page: 0 });
|
|
204
|
+
});
|
|
205
|
+
it("flushes a pending debounced query instead of paginating stale results", async () => {
|
|
206
|
+
const controller = createController();
|
|
207
|
+
search.mockResolvedValue(response());
|
|
208
|
+
controller.submit("shoes");
|
|
209
|
+
await settle();
|
|
210
|
+
controller.setQuery("boots");
|
|
211
|
+
controller.setPage(1);
|
|
212
|
+
await settle();
|
|
213
|
+
expect(search).toHaveBeenCalledTimes(2);
|
|
214
|
+
expect(search.mock.calls[1][0]).toMatchObject({ query: "boots", page: 0 });
|
|
215
|
+
});
|
|
216
|
+
it("ignores pagination without a committed query", () => {
|
|
217
|
+
const controller = createController();
|
|
218
|
+
controller.setPage(1);
|
|
219
|
+
expect(search).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
it("notifies subscribers and stops after unsubscribe", async () => {
|
|
222
|
+
const controller = createController();
|
|
223
|
+
search.mockResolvedValue(response());
|
|
224
|
+
const listener = vi.fn();
|
|
225
|
+
const unsubscribe = controller.subscribe(listener);
|
|
226
|
+
controller.submit("shoes");
|
|
227
|
+
await settle();
|
|
228
|
+
const notified = listener.mock.calls.length;
|
|
229
|
+
unsubscribe();
|
|
230
|
+
controller.submit("boots");
|
|
231
|
+
await settle();
|
|
232
|
+
expect(notified).toBeGreaterThan(0);
|
|
233
|
+
expect(listener).toHaveBeenCalledTimes(notified);
|
|
234
|
+
});
|
|
235
|
+
it("stops all work after dispose", async () => {
|
|
236
|
+
const controller = createController();
|
|
237
|
+
const inFlight = deferred();
|
|
238
|
+
search.mockReturnValueOnce(inFlight.promise);
|
|
239
|
+
controller.submit("shoes");
|
|
240
|
+
const signal = search.mock.calls[0][1]?.signal;
|
|
241
|
+
controller.dispose();
|
|
242
|
+
expect(signal?.aborted).toBe(true);
|
|
243
|
+
inFlight.resolve(response());
|
|
244
|
+
await settle();
|
|
245
|
+
controller.setQuery("boots");
|
|
246
|
+
controller.submit("boots");
|
|
247
|
+
controller.setPage(1);
|
|
248
|
+
controller.retry();
|
|
249
|
+
vi.advanceTimersByTime(DEBOUNCE_MS);
|
|
250
|
+
await settle();
|
|
251
|
+
expect(search).toHaveBeenCalledTimes(1);
|
|
252
|
+
expect(controller.getState().status).toBe(SearchStatus.LOADING);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"searchControllerAttribution.spec.d.ts","sourceRoot":"","sources":["../../src/__tests__/searchControllerAttribution.spec.ts"],"names":[],"mappings":""}
|