@askdialog/dialog-sdk 2.9.1 → 2.11.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 +44 -10
- package/dist/Dialog.d.ts +21 -6
- package/dist/Dialog.d.ts.map +1 -1
- package/dist/Dialog.js +72 -12
- package/dist/__tests__/dialogCurrentProduct.spec.d.ts +2 -0
- package/dist/__tests__/dialogCurrentProduct.spec.d.ts.map +1 -0
- package/dist/__tests__/dialogCurrentProduct.spec.js +134 -0
- package/dist/__tests__/dialogSearchAnalytics.spec.js +1 -0
- package/dist/__tests__/publicTypes.spec.js +2 -1
- package/dist/__tests__/searchController.spec.js +126 -35
- package/dist/__tests__/searchControllerAttribution.spec.js +30 -26
- package/dist/__tests__/searchImpressions.spec.js +3 -20
- package/dist/__tests__/searchService.spec.js +65 -60
- package/dist/config/config.development.js +1 -1
- package/dist/config/config.production.js +1 -1
- package/dist/config/index.js +1 -1
- package/dist/searchController.d.ts +1 -1
- package/dist/searchController.d.ts.map +1 -1
- package/dist/searchController.js +34 -24
- package/dist/services/search.d.ts +3 -2
- package/dist/services/search.d.ts.map +1 -1
- package/dist/services/search.js +4 -9
- package/dist/types/constructor.d.ts +15 -0
- package/dist/types/constructor.d.ts.map +1 -1
- package/dist/types/search.d.ts +24 -22
- package/dist/types/search.d.ts.map +1 -1
- package/dist/types/search.js +7 -4
- package/dist/types/searchAnalytics.d.ts +2 -0
- package/dist/types/searchAnalytics.d.ts.map +1 -1
- package/dist/types/searchController.d.ts +13 -5
- package/dist/types/searchController.d.ts.map +1 -1
- package/dist/utils/searchControllerAnalytics.d.ts +4 -4
- package/dist/utils/searchControllerAnalytics.d.ts.map +1 -1
- package/dist/utils/searchControllerAnalytics.js +14 -16
- package/dist/utils/searchImpressions.d.ts.map +1 -1
- package/dist/utils/searchImpressions.js +1 -6
- package/dist/utils/searchSections.d.ts +5 -0
- package/dist/utils/searchSections.d.ts.map +1 -0
- package/dist/utils/searchSections.js +21 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -91,6 +91,29 @@ The getProduct function is used to display product information in the assistant.
|
|
|
91
91
|
|
|
92
92
|
When the client is instantiated, it will automatically insert into the DOM the Dialog Assistant script, so you can interact with the assistant using `sendProductMessage` or `sendGenericMessage`. This assistant runtime always loads — it owns the shopper identity, consent handling and analytics bridge — while the heavy assistant UI stays lazy-loaded and is not fetched eagerly.
|
|
93
93
|
|
|
94
|
+
### Declaring the current product page
|
|
95
|
+
|
|
96
|
+
The assistant answers product questions with the product context of the page the shopper is on. Entry points that carry a product id (like `sendProductMessage`) set it themselves, but entry points that don't — the floating bookmark, the resume-conversation surface, free-text questions — need the SDK to know what PDP the shopper is looking at, especially after navigating from one product page to another.
|
|
97
|
+
|
|
98
|
+
Declare it at construction on product pages:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
new Dialog({
|
|
102
|
+
apiKey: 'YOUR_API_KEY',
|
|
103
|
+
locale: 'fr',
|
|
104
|
+
product: { id: 'PRODUCT_ID', variantId: 'VARIANT_ID' }, // variantId optional
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
On single-page storefronts, update it on client-side navigation:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
client.setCurrentProduct('PRODUCT_ID'); // arrived on a PDP
|
|
112
|
+
client.clearCurrentProduct(); // left for a non-product page
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Ids must match the product ids of your Dialog product feed. Without a declared product, questions asked from the bookmark after a page change reach the assistant without product context and are answered generically.
|
|
116
|
+
|
|
94
117
|
### OneTrust auto-blocking
|
|
95
118
|
|
|
96
119
|
If your site uses OneTrust auto-blocking, it may neutralize the assistant script injected by the SDK (`type` rewritten to `text/plain`) for visitors who declined cookies — a `data-ot-ignore` on your own SDK `<script>` tag does not cover dynamically injected scripts. Two remedies, both merchant-side decisions:
|
|
@@ -196,21 +219,28 @@ Example of expected result:
|
|
|
196
219
|
|
|
197
220
|
- Search products
|
|
198
221
|
|
|
199
|
-
`client.search()` performs a typed
|
|
222
|
+
`client.search()` performs a typed, Algolia-shaped search through Dialog's public API, with no framework and no commerce callbacks required. Each entry targets an index named `<index>_<locale>` (index ∈ `products | collections | articles | pages`, locale ISO 639-1) — build the name with `searchIndexName(index, locale)`, which reduces any BCP-47 tag to its bare language.
|
|
200
223
|
|
|
201
224
|
```typescript
|
|
202
|
-
import { Dialog, DialogSearchError } from '@askdialog/dialog-sdk';
|
|
225
|
+
import { Dialog, DialogSearchError, searchIndexName } from '@askdialog/dialog-sdk';
|
|
203
226
|
import type { SearchResponse } from '@askdialog/dialog-sdk';
|
|
204
227
|
|
|
205
228
|
const client = new Dialog({ apiKey: 'YOUR_API_KEY', locale: 'fr' });
|
|
206
229
|
|
|
207
230
|
const response: SearchResponse = await client.search({
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
231
|
+
requests: [
|
|
232
|
+
{
|
|
233
|
+
indexName: searchIndexName('products', client.locale), // "products_fr"
|
|
234
|
+
query: 'shampoo',
|
|
235
|
+
page: 0, // optional, zero-indexed (default 0)
|
|
236
|
+
hitsPerPage: 20, // optional, 1-100 (default 20)
|
|
237
|
+
},
|
|
238
|
+
],
|
|
212
239
|
});
|
|
213
|
-
// response.
|
|
240
|
+
// response.results[n]: { index, hits, nbHits, page, nbPages, hitsPerPage,
|
|
241
|
+
// processingTimeMS, query, queryID }
|
|
242
|
+
// response.results[n].hits[m]: { objectID, title?, url?, handle?, imageUrl?,
|
|
243
|
+
// priceRange? }
|
|
214
244
|
```
|
|
215
245
|
|
|
216
246
|
With the IIFE bundle the results are plain runtime JSON (same shape, no types):
|
|
@@ -219,17 +249,19 @@ With the IIFE bundle the results are plain runtime JSON (same shape, no types):
|
|
|
219
249
|
<script src="https://d2m6yt8rnm4dos.cloudfront.net/dialog-sdk.X.Y.Z.min.js"></script>
|
|
220
250
|
<script>
|
|
221
251
|
const client = new window.DialogSDK.Dialog({ apiKey: 'YOUR_API_KEY', locale: 'fr' });
|
|
222
|
-
client
|
|
252
|
+
client
|
|
253
|
+
.search({ requests: [{ indexName: 'products_fr', query: 'shampoo' }] })
|
|
254
|
+
.then((response) => console.log(response.results[0].hits));
|
|
223
255
|
</script>
|
|
224
256
|
```
|
|
225
257
|
|
|
226
|
-
A non-2xx answer rejects with `DialogSearchError` — stable `name`, HTTP `status
|
|
258
|
+
A non-2xx answer rejects with `DialogSearchError` — stable `name`, HTTP `status` and `message` (e.g. `404 Index products_xx does not exist`, `400 Unknown parameter: foo`). Aborting rejects with the native `AbortError`, and network failures keep their native errors.
|
|
227
259
|
|
|
228
260
|
`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.
|
|
229
261
|
|
|
230
262
|
- Search controller
|
|
231
263
|
|
|
232
|
-
`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
|
|
264
|
+
`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 attribution events (`view_search_results` viewport impressions, `select_search_result` clicks). It searches the products index (`products_<locale>`) and exposes the products result entry as `state.response`; optional `sections` add other indices (`collections`, …) to the same request, exposed under `state.sections`. It has no framework or rendering dependency: raw JavaScript, React, Vue and Shopify integrations are rendering-and-routing adapters around it.
|
|
233
265
|
|
|
234
266
|
```typescript
|
|
235
267
|
import { createSearchController, Dialog, SearchStatus } from '@askdialog/dialog-sdk';
|
|
@@ -238,6 +270,7 @@ const client = new Dialog({ apiKey: 'YOUR_API_KEY', locale: 'fr' });
|
|
|
238
270
|
|
|
239
271
|
const controller = createSearchController({
|
|
240
272
|
search: (request, options) => client.search(request, options),
|
|
273
|
+
locale: client.locale, // names the searched index ("products_fr")
|
|
241
274
|
analytics: {
|
|
242
275
|
surface: 'search_page', // where results are displayed
|
|
243
276
|
trackViewSearchResults: (params) => client.trackViewSearchResults(params),
|
|
@@ -246,6 +279,7 @@ const controller = createSearchController({
|
|
|
246
279
|
navigate: (url) => router.push(url), // optional platform routing adapter
|
|
247
280
|
debounceMs: 250, // optional (default 250)
|
|
248
281
|
hitsPerPage: 12, // optional (default 12)
|
|
282
|
+
sections: [{ index: 'collections', hitsPerPage: 5 }], // optional, first page only; a function is resolved per request
|
|
249
283
|
});
|
|
250
284
|
|
|
251
285
|
const unsubscribe = controller.subscribe((state) => {
|
package/dist/Dialog.d.ts
CHANGED
|
@@ -19,7 +19,8 @@ export declare class Dialog {
|
|
|
19
19
|
private _eventsHandler;
|
|
20
20
|
private _ignoreOneTrustAutoBlock;
|
|
21
21
|
private _disableAddToCart;
|
|
22
|
-
|
|
22
|
+
private _currentProduct?;
|
|
23
|
+
constructor({ apiKey, locale, countryCode, callbacks, theme, userId, ignoreOneTrustAutoBlock, disableAddToCart, product, }: DialogConstructor);
|
|
23
24
|
get apiKey(): string;
|
|
24
25
|
get theme(): Theme;
|
|
25
26
|
get userId(): string;
|
|
@@ -29,15 +30,29 @@ export declare class Dialog {
|
|
|
29
30
|
private _createOrRetrieveUserId;
|
|
30
31
|
getSuggestions(productId: string): Promise<Suggestion>;
|
|
31
32
|
/**
|
|
32
|
-
* Typed
|
|
33
|
-
*
|
|
34
|
-
* `
|
|
35
|
-
*
|
|
36
|
-
*
|
|
33
|
+
* Typed multi-index search through the Nest public endpoint (Algolia-shaped,
|
|
34
|
+
* DAT-412). The locale travels in each entry's `indexName` (`products_fr`) —
|
|
35
|
+
* build it with `searchIndexName(index, locale)`. Stateless: one request per
|
|
36
|
+
* call, cancellation belongs to the caller via `options.signal` (previous
|
|
37
|
+
* searches are never cancelled automatically). Rejects with
|
|
38
|
+
* `DialogSearchError` on a non-2xx answer, and with the native AbortError /
|
|
39
|
+
* network error otherwise.
|
|
37
40
|
*/
|
|
38
41
|
search(request: SearchRequest, options?: SearchOptions): Promise<SearchResponse>;
|
|
39
42
|
openAssistant(params: OpenAssistantPayload): void;
|
|
40
43
|
closeAssistant(): void;
|
|
44
|
+
/**
|
|
45
|
+
* Declare the product of the current page. The assistant reads it as the
|
|
46
|
+
* conversation's product context whenever a question carries no product of
|
|
47
|
+
* its own (floating bookmark, resume surface, free-text input). Call it on
|
|
48
|
+
* every PDP navigation on single-page storefronts; ids must match the
|
|
49
|
+
* Dialog product feed.
|
|
50
|
+
*/
|
|
51
|
+
setCurrentProduct(productId: string, variantId?: string): void;
|
|
52
|
+
private static _isValidProductId;
|
|
53
|
+
/** Declare that the current page is not a product page. */
|
|
54
|
+
clearCurrentProduct(): void;
|
|
55
|
+
private _applyCurrentProductDataset;
|
|
41
56
|
sendProductMessage(params: ProductQuestionPayload): void;
|
|
42
57
|
sendGenericMessage(params: GenericQuestionPayload): void;
|
|
43
58
|
onAssistantEvent(listener: (event: AssistantEvent) => void): void;
|
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,
|
|
1
|
+
{"version":3,"file":"Dialog.d.ts","sourceRoot":"","sources":["../src/Dialog.ts"],"names":[],"mappings":"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"}
|
package/dist/Dialog.js
CHANGED
|
@@ -7,7 +7,7 @@ import { ANONYMOUS_CUSTOMER_ID, CUSTOMER_ID } from "./constants/user";
|
|
|
7
7
|
import { DialogEvents, } from "./types/events";
|
|
8
8
|
import { EventsHandler } from "./EventsHandler";
|
|
9
9
|
import { loadSuggestions } from "./services/suggestions";
|
|
10
|
-
import {
|
|
10
|
+
import { searchLexical } from "./services/search";
|
|
11
11
|
import { config } from "./config";
|
|
12
12
|
import { exposeSdkOnWindowDialog } from "./windowAudit";
|
|
13
13
|
export class Dialog {
|
|
@@ -21,13 +21,21 @@ export class Dialog {
|
|
|
21
21
|
_eventsHandler;
|
|
22
22
|
_ignoreOneTrustAutoBlock;
|
|
23
23
|
_disableAddToCart;
|
|
24
|
-
|
|
24
|
+
_currentProduct;
|
|
25
|
+
constructor({ apiKey, locale, countryCode, callbacks, theme, userId, ignoreOneTrustAutoBlock, disableAddToCart, product, }) {
|
|
25
26
|
this._apiKey = apiKey;
|
|
26
27
|
this._locale = locale;
|
|
27
28
|
this._countryCode = countryCode;
|
|
28
29
|
this._callbacks = callbacks;
|
|
29
30
|
this._ignoreOneTrustAutoBlock = ignoreOneTrustAutoBlock ?? false;
|
|
30
31
|
this._disableAddToCart = disableAddToCart ?? false;
|
|
32
|
+
this._currentProduct =
|
|
33
|
+
product !== undefined && Dialog._isValidProductId(product.id)
|
|
34
|
+
? product
|
|
35
|
+
: undefined;
|
|
36
|
+
if (product !== undefined && this._currentProduct === undefined) {
|
|
37
|
+
console.error("Dialog: `product.id` must be a non-empty string; ignoring the constructor option.");
|
|
38
|
+
}
|
|
31
39
|
this._theme = { ...defaultTheme, ...theme };
|
|
32
40
|
this._userId = this._createOrRetrieveUserId(userId);
|
|
33
41
|
this._eventsHandler = new EventsHandler(locale, userId);
|
|
@@ -69,18 +77,16 @@ export class Dialog {
|
|
|
69
77
|
return loadSuggestions(this._apiKey, this._locale, productId);
|
|
70
78
|
}
|
|
71
79
|
/**
|
|
72
|
-
* Typed
|
|
73
|
-
*
|
|
74
|
-
* `
|
|
75
|
-
*
|
|
76
|
-
*
|
|
80
|
+
* Typed multi-index search through the Nest public endpoint (Algolia-shaped,
|
|
81
|
+
* DAT-412). The locale travels in each entry's `indexName` (`products_fr`) —
|
|
82
|
+
* build it with `searchIndexName(index, locale)`. Stateless: one request per
|
|
83
|
+
* call, cancellation belongs to the caller via `options.signal` (previous
|
|
84
|
+
* searches are never cancelled automatically). Rejects with
|
|
85
|
+
* `DialogSearchError` on a non-2xx answer, and with the native AbortError /
|
|
86
|
+
* network error otherwise.
|
|
77
87
|
*/
|
|
78
88
|
search(request, options) {
|
|
79
|
-
return
|
|
80
|
-
...request,
|
|
81
|
-
locale: request.locale ?? this._locale,
|
|
82
|
-
countryCode: request.countryCode ?? this._countryCode,
|
|
83
|
-
}, options);
|
|
89
|
+
return searchLexical(this._apiKey, request, options);
|
|
84
90
|
}
|
|
85
91
|
// TODO: Not yet implemented on assistant
|
|
86
92
|
openAssistant(params) {
|
|
@@ -90,6 +96,57 @@ export class Dialog {
|
|
|
90
96
|
closeAssistant() {
|
|
91
97
|
this._eventsHandler.emitExternalEvent(DialogEvents.CLOSE_ASSISTANT);
|
|
92
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Declare the product of the current page. The assistant reads it as the
|
|
101
|
+
* conversation's product context whenever a question carries no product of
|
|
102
|
+
* its own (floating bookmark, resume surface, free-text input). Call it on
|
|
103
|
+
* every PDP navigation on single-page storefronts; ids must match the
|
|
104
|
+
* Dialog product feed.
|
|
105
|
+
*/
|
|
106
|
+
setCurrentProduct(productId, variantId) {
|
|
107
|
+
if (!Dialog._isValidProductId(productId)) {
|
|
108
|
+
console.error("Dialog: setCurrentProduct expects a non-empty string productId; ignoring the call. Use clearCurrentProduct() to declare a non-product page.");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
this._currentProduct = { id: productId, variantId };
|
|
112
|
+
this._applyCurrentProductDataset();
|
|
113
|
+
}
|
|
114
|
+
static _isValidProductId(productId) {
|
|
115
|
+
return typeof productId === "string" && productId.trim() !== "";
|
|
116
|
+
}
|
|
117
|
+
/** Declare that the current page is not a product page. */
|
|
118
|
+
clearCurrentProduct() {
|
|
119
|
+
this._currentProduct = undefined;
|
|
120
|
+
this._applyCurrentProductDataset();
|
|
121
|
+
}
|
|
122
|
+
// The dataset on the assistant mount node is the SDK⇄runtime contract for
|
|
123
|
+
// the page product: the runtime observes data-product-id / data-variant-id
|
|
124
|
+
// and needs no event plumbing, so SDK and runtime versions can drift.
|
|
125
|
+
_applyCurrentProductDataset() {
|
|
126
|
+
// Resolved via getElementById on purpose, NOT an instance reference: the
|
|
127
|
+
// assistant runtime reads the dataset the same way, so on a page where
|
|
128
|
+
// another integration already rendered #dialog-shopify-ai (duplicate-id
|
|
129
|
+
// case) writer and reader must land on the same — first — node.
|
|
130
|
+
const mountNode = document.getElementById("dialog-shopify-ai");
|
|
131
|
+
if (mountNode === null) {
|
|
132
|
+
// Assistant mount node absent (locale error at load, or the host page
|
|
133
|
+
// removed it) — the declaration is kept but cannot reach the runtime.
|
|
134
|
+
console.warn("Dialog: assistant mount node not found; the current product declaration has no effect.");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (this._currentProduct === undefined) {
|
|
138
|
+
delete mountNode.dataset.productId;
|
|
139
|
+
delete mountNode.dataset.variantId;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
mountNode.dataset.productId = this._currentProduct.id;
|
|
143
|
+
if (this._currentProduct.variantId === undefined) {
|
|
144
|
+
delete mountNode.dataset.variantId;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
mountNode.dataset.variantId = this._currentProduct.variantId;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
93
150
|
sendProductMessage(params) {
|
|
94
151
|
this._eventsHandler.emitExternalEvent(DialogEvents.SEND_MESSAGE, params);
|
|
95
152
|
}
|
|
@@ -199,6 +256,9 @@ export class Dialog {
|
|
|
199
256
|
div.dataset.disableAddToCart = "true";
|
|
200
257
|
}
|
|
201
258
|
document.body.appendChild(div);
|
|
259
|
+
if (this._currentProduct !== undefined) {
|
|
260
|
+
this._applyCurrentProductDataset();
|
|
261
|
+
}
|
|
202
262
|
setTimeout(() => {
|
|
203
263
|
const script = document.createElement("script");
|
|
204
264
|
if (this._ignoreOneTrustAutoBlock) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dialogCurrentProduct.spec.d.ts","sourceRoot":"","sources":["../../src/__tests__/dialogCurrentProduct.spec.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { Dialog } from "../Dialog";
|
|
3
|
+
// The full Dialog constructor touches window/localStorage; these tests only
|
|
4
|
+
// exercise the current-product dataset contract, so the instance is built
|
|
5
|
+
// from the prototype with just the fields the tested paths read.
|
|
6
|
+
const buildDialog = (currentProduct) => {
|
|
7
|
+
const dialog = Object.create(Dialog.prototype);
|
|
8
|
+
Object.assign(dialog, {
|
|
9
|
+
_apiKey: "api-key-1",
|
|
10
|
+
_userId: "user-1",
|
|
11
|
+
_locale: "fr-FR",
|
|
12
|
+
_ignoreOneTrustAutoBlock: false,
|
|
13
|
+
_disableAddToCart: false,
|
|
14
|
+
_currentProduct: currentProduct,
|
|
15
|
+
});
|
|
16
|
+
return dialog;
|
|
17
|
+
};
|
|
18
|
+
const stubDocument = (options = { mounted: true }) => {
|
|
19
|
+
const script = { setAttribute: vi.fn() };
|
|
20
|
+
const div = { dataset: {} };
|
|
21
|
+
// _loadAssistant appends the mount node; getElementById reflects that.
|
|
22
|
+
let mounted = options.mounted;
|
|
23
|
+
vi.stubGlobal("document", {
|
|
24
|
+
createElement: (tag) => (tag === "script" ? script : div),
|
|
25
|
+
getElementById: (id) => mounted && id === "dialog-shopify-ai" ? div : null,
|
|
26
|
+
body: {
|
|
27
|
+
appendChild: vi.fn(() => {
|
|
28
|
+
mounted = true;
|
|
29
|
+
}),
|
|
30
|
+
},
|
|
31
|
+
head: { insertBefore: vi.fn(), firstChild: null },
|
|
32
|
+
});
|
|
33
|
+
return { div };
|
|
34
|
+
};
|
|
35
|
+
// Fake timers keep the deferred script injection from firing after the
|
|
36
|
+
// document stub is torn down.
|
|
37
|
+
const loadAssistant = (dialog) => {
|
|
38
|
+
vi.useFakeTimers();
|
|
39
|
+
dialog._loadAssistant();
|
|
40
|
+
};
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
vi.unstubAllGlobals();
|
|
43
|
+
vi.useRealTimers();
|
|
44
|
+
vi.clearAllMocks();
|
|
45
|
+
});
|
|
46
|
+
describe("Dialog current product — constructor option", () => {
|
|
47
|
+
it("writes the product dataset on the mount node at load", () => {
|
|
48
|
+
const { div } = stubDocument();
|
|
49
|
+
loadAssistant(buildDialog({ id: "6980", variantId: "42" }));
|
|
50
|
+
expect(div.dataset.productId).toBe("6980");
|
|
51
|
+
expect(div.dataset.variantId).toBe("42");
|
|
52
|
+
});
|
|
53
|
+
it("omits the variant key when no variant is declared", () => {
|
|
54
|
+
const { div } = stubDocument();
|
|
55
|
+
loadAssistant(buildDialog({ id: "6980" }));
|
|
56
|
+
expect(div.dataset.productId).toBe("6980");
|
|
57
|
+
expect(div.dataset.variantId).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
it("leaves the mount node free of product keys by default", () => {
|
|
60
|
+
const { div } = stubDocument();
|
|
61
|
+
loadAssistant(buildDialog());
|
|
62
|
+
expect(div.dataset.productId).toBeUndefined();
|
|
63
|
+
expect(div.dataset.variantId).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe("Dialog current product — constructor validation", () => {
|
|
67
|
+
it("rejects an invalid constructor product id and keeps the mount node clean", () => {
|
|
68
|
+
const { div } = stubDocument();
|
|
69
|
+
const consoleError = vi
|
|
70
|
+
.spyOn(console, "error")
|
|
71
|
+
.mockImplementation(() => undefined);
|
|
72
|
+
const dialog = buildDialog();
|
|
73
|
+
Object.assign(dialog, { _currentProduct: undefined });
|
|
74
|
+
// Mirror the constructor guard through the public setter contract.
|
|
75
|
+
dialog.setCurrentProduct("");
|
|
76
|
+
loadAssistant(dialog);
|
|
77
|
+
expect(div.dataset.productId).toBeUndefined();
|
|
78
|
+
expect(consoleError).toHaveBeenCalled();
|
|
79
|
+
consoleError.mockRestore();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe("Dialog.setCurrentProduct / clearCurrentProduct", () => {
|
|
83
|
+
it("writes the dataset on the existing mount node", () => {
|
|
84
|
+
const { div } = stubDocument();
|
|
85
|
+
const dialog = buildDialog();
|
|
86
|
+
dialog.setCurrentProduct("6980", "42");
|
|
87
|
+
expect(div.dataset.productId).toBe("6980");
|
|
88
|
+
expect(div.dataset.variantId).toBe("42");
|
|
89
|
+
});
|
|
90
|
+
it("drops a stale variant when the next product has none", () => {
|
|
91
|
+
const { div } = stubDocument();
|
|
92
|
+
const dialog = buildDialog();
|
|
93
|
+
dialog.setCurrentProduct("6980", "42");
|
|
94
|
+
dialog.setCurrentProduct("7001");
|
|
95
|
+
expect(div.dataset.productId).toBe("7001");
|
|
96
|
+
expect(div.dataset.variantId).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
it("removes both keys on clearCurrentProduct", () => {
|
|
99
|
+
const { div } = stubDocument();
|
|
100
|
+
const dialog = buildDialog();
|
|
101
|
+
dialog.setCurrentProduct("6980", "42");
|
|
102
|
+
dialog.clearCurrentProduct();
|
|
103
|
+
expect(div.dataset.productId).toBeUndefined();
|
|
104
|
+
expect(div.dataset.variantId).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
it("rejects an empty productId without touching the dataset", () => {
|
|
107
|
+
const { div } = stubDocument();
|
|
108
|
+
const dialog = buildDialog();
|
|
109
|
+
const consoleError = vi
|
|
110
|
+
.spyOn(console, "error")
|
|
111
|
+
.mockImplementation(() => undefined);
|
|
112
|
+
dialog.setCurrentProduct(" ");
|
|
113
|
+
expect(div.dataset.productId).toBeUndefined();
|
|
114
|
+
expect(consoleError).toHaveBeenCalled();
|
|
115
|
+
consoleError.mockRestore();
|
|
116
|
+
});
|
|
117
|
+
it("warns when the mount node is missing", () => {
|
|
118
|
+
stubDocument({ mounted: false });
|
|
119
|
+
const dialog = buildDialog();
|
|
120
|
+
const consoleWarn = vi
|
|
121
|
+
.spyOn(console, "warn")
|
|
122
|
+
.mockImplementation(() => undefined);
|
|
123
|
+
dialog.setCurrentProduct("6980");
|
|
124
|
+
expect(consoleWarn).toHaveBeenCalled();
|
|
125
|
+
consoleWarn.mockRestore();
|
|
126
|
+
});
|
|
127
|
+
it("tolerates a missing mount node and applies the value at load", () => {
|
|
128
|
+
const { div } = stubDocument({ mounted: false });
|
|
129
|
+
const dialog = buildDialog();
|
|
130
|
+
expect(() => dialog.setCurrentProduct("6980")).not.toThrow();
|
|
131
|
+
loadAssistant(dialog);
|
|
132
|
+
expect(div.dataset.productId).toBe("6980");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -11,10 +11,11 @@ describe("public API types", () => {
|
|
|
11
11
|
expectTypeOf().parameters.toMatchTypeOf();
|
|
12
12
|
expectTypeOf().toMatchTypeOf();
|
|
13
13
|
expectTypeOf().toMatchTypeOf();
|
|
14
|
-
expectTypeOf().toEqualTypeOf();
|
|
15
14
|
expectTypeOf().returns.resolves.toEqualTypeOf();
|
|
16
15
|
expectTypeOf().toEqualTypeOf();
|
|
17
16
|
expectTypeOf().toEqualTypeOf();
|
|
17
|
+
expectTypeOf().toEqualTypeOf();
|
|
18
|
+
expectTypeOf().toEqualTypeOf();
|
|
18
19
|
expectTypeOf().toMatchTypeOf();
|
|
19
20
|
const error = new DialogSearchError({ status: 404, message: "not found" });
|
|
20
21
|
expectTypeOf(error.status).toEqualTypeOf();
|