@ordergroove/offers 2.45.6 → 2.46.1-alpha-PR-1285-2.53
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/CHANGELOG.md +11 -0
- package/dist/bundle-report.html +58 -54
- package/dist/examples.js +5 -5
- package/dist/examples.js.map +2 -2
- package/dist/offers.js +104 -77
- package/dist/offers.js.map +4 -4
- package/package.json +3 -3
- package/src/components/Offer.js +3 -1
- package/src/components/Price.js +31 -11
- package/src/components/Tooltip.js +127 -15
- package/src/components/__tests__/Price.spec.js +74 -1
- package/src/components/__tests__/Tooltip.spec.js +214 -3
- package/src/core/__tests__/experiments.spec.js +16 -3
- package/src/core/__tests__/reducer.spec.js +152 -1
- package/src/core/__tests__/selectors.spec.js +405 -1
- package/src/core/adapters.js +2 -0
- package/src/core/constants.js +7 -0
- package/src/core/experiments.js +3 -2
- package/src/core/reducer.ts +41 -9
- package/src/core/selectors.ts +66 -1
- package/src/core/types/api.ts +19 -1
- package/src/core/types/reducer.ts +14 -1
- package/src/shopify/__tests__/productPlan.spec.js +3 -3
- package/src/shopify/__tests__/shopifyMiddleware.spec.js +227 -6
- package/src/shopify/__tests__/shopifyReducer.spec.js +90 -17
- package/src/shopify/reducers/productPlans.ts +2 -1
- package/src/shopify/shopifyMiddleware.ts +45 -7
- package/src/shopify/shopifyReducer.ts +21 -0
- package/src/shopify/types/productPlan.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ordergroove/offers",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.46.1-alpha-PR-1285-2.53+40747106",
|
|
4
4
|
"description": "offer state component",
|
|
5
5
|
"author": "Eugenio Lattanzio <eugenio63@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/ordergroove/plush-toys#readme",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"throttle-debounce": "^2.1.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@ordergroove/offers-templates": "^0.
|
|
49
|
+
"@ordergroove/offers-templates": "^0.10.0",
|
|
50
50
|
"@types/lodash.memoize": "^4.1.9"
|
|
51
51
|
},
|
|
52
|
-
"gitHead": "
|
|
52
|
+
"gitHead": "407471063d9ba2bbeac751d0cd76bc46ab2ee279"
|
|
53
53
|
}
|
package/src/components/Offer.js
CHANGED
|
@@ -76,7 +76,9 @@ export class Offer extends TemplateElement {
|
|
|
76
76
|
productFrequency: { type: String },
|
|
77
77
|
isCart: { type: Boolean, attribute: 'cart' },
|
|
78
78
|
optedin: { type: Object },
|
|
79
|
-
variationId: { type: String }
|
|
79
|
+
variationId: { type: String },
|
|
80
|
+
/** Attribute to force reading prices from the Offer response instead of the selling plan. Only used for testing. */
|
|
81
|
+
overrideSellingPlanPrice: { type: Boolean, attribute: 'dev-override-selling-plan-price' }
|
|
80
82
|
};
|
|
81
83
|
}
|
|
82
84
|
|
package/src/components/Price.js
CHANGED
|
@@ -3,7 +3,11 @@ import { connect } from '../core/connect';
|
|
|
3
3
|
|
|
4
4
|
import { withProduct } from '../core/resolveProperties';
|
|
5
5
|
import { TemplateElement } from '../core/base';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
makeDiscountedProductPriceSelector,
|
|
8
|
+
makeProductDefaultFrequencySelector,
|
|
9
|
+
makeProductFrequencyOptedInSelector
|
|
10
|
+
} from '../core/selectors';
|
|
7
11
|
import { safeProductId } from '../core/utils';
|
|
8
12
|
|
|
9
13
|
export class Price extends withProduct(TemplateElement) {
|
|
@@ -13,9 +17,13 @@ export class Price extends withProduct(TemplateElement) {
|
|
|
13
17
|
regular: { type: Boolean, reflect: true },
|
|
14
18
|
subscription: { type: Boolean, reflect: true },
|
|
15
19
|
discount: { type: Boolean, reflect: true },
|
|
20
|
+
/** Force displaying the pay-as-you-go price. This is relevant when there is a prepaid plan that the user has opted into, and you still want to display the pay-as-you-go price for comparison */
|
|
16
21
|
payAsYouGo: { type: Boolean, reflect: true, attribute: 'pay-as-you-go' },
|
|
17
22
|
frequency: { type: Object },
|
|
18
|
-
|
|
23
|
+
/** If Shopify, this is derived from the selling plans attached to the product */
|
|
24
|
+
productPlans: { type: Object },
|
|
25
|
+
/** The discounted price, as calculated from the Offers API response */
|
|
26
|
+
discountedProductPriceFromOffers: { type: Object }
|
|
19
27
|
};
|
|
20
28
|
}
|
|
21
29
|
|
|
@@ -46,17 +54,28 @@ export class Price extends withProduct(TemplateElement) {
|
|
|
46
54
|
const frequency = this.frequency || this.configDefaultFrequency || this.offer?.defaultFrequency;
|
|
47
55
|
const plans = this.productPlans[realProductId] || [];
|
|
48
56
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return payAsYouGoPlan.subscriptionPrice;
|
|
53
|
-
}
|
|
57
|
+
let currentPlan = this.payAsYouGo
|
|
58
|
+
? plans.find(plan => plan.prepaidShipments === null || plan.prepaidShipments === undefined)
|
|
59
|
+
: plans.find(plan => plan.frequency === frequency);
|
|
54
60
|
|
|
55
|
-
const currentPlan = plans.find(plan => plan.frequency === frequency);
|
|
56
61
|
if (!currentPlan) return '';
|
|
57
|
-
const { regularPrice, discountRate, subscriptionPrice } = currentPlan;
|
|
58
62
|
|
|
59
|
-
|
|
63
|
+
// default to pulling from the selling plan
|
|
64
|
+
let { regularPrice, discountRate, subscriptionPrice } = currentPlan;
|
|
65
|
+
// if the selling plan has no price adjustments, then use the offer response to determine the discounted price
|
|
66
|
+
// this will be true for merchants on standardized offer profiles
|
|
67
|
+
// we still rely on the selling plan for prepaid subscriptions, for simplicity
|
|
68
|
+
if (
|
|
69
|
+
// overrideSellingPlanPrice is a dev flag to force using the offer price for testing purposes
|
|
70
|
+
(currentPlan.hasPriceAdjustments === false || this.offer?.overrideSellingPlanPrice) &&
|
|
71
|
+
!currentPlan.prepaidShipments
|
|
72
|
+
) {
|
|
73
|
+
({ regularPrice, discountRate, subscriptionPrice } = this.discountedProductPriceFromOffers);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// if payAsYouGo, always show the price even if no discount
|
|
77
|
+
// it's unclear if this was the original intention, but preserving existing behavior
|
|
78
|
+
if (subscriptionPrice === regularPrice && !this.payAsYouGo) return '';
|
|
60
79
|
|
|
61
80
|
if (this.regular) {
|
|
62
81
|
return regularPrice;
|
|
@@ -82,7 +101,8 @@ export class Price extends withProduct(TemplateElement) {
|
|
|
82
101
|
const mapStateToProps = (state, ownProps) => ({
|
|
83
102
|
productPlans: state.productPlans,
|
|
84
103
|
configDefaultFrequency: makeProductDefaultFrequencySelector(ownProps.product?.id)(state),
|
|
85
|
-
frequency: makeProductFrequencyOptedInSelector(ownProps.product)(state)
|
|
104
|
+
frequency: makeProductFrequencyOptedInSelector(ownProps.product)(state),
|
|
105
|
+
discountedProductPriceFromOffers: makeDiscountedProductPriceSelector(ownProps.product?.id)(state)
|
|
86
106
|
});
|
|
87
107
|
|
|
88
108
|
export default connect(mapStateToProps)(Price);
|
|
@@ -1,9 +1,32 @@
|
|
|
1
1
|
import { LitElement, html, css } from 'lit-element';
|
|
2
|
+
import { ifDefined } from 'lit-html/directives/if-defined.js';
|
|
3
|
+
|
|
4
|
+
const ACTIVATION_TYPES = {
|
|
5
|
+
AUTOMATIC: 'automatic',
|
|
6
|
+
MANUAL: 'manual'
|
|
7
|
+
};
|
|
2
8
|
|
|
3
9
|
export class Tooltip extends LitElement {
|
|
10
|
+
constructor() {
|
|
11
|
+
super();
|
|
12
|
+
this.triggerLabel = 'Show tooltip';
|
|
13
|
+
this.open = false;
|
|
14
|
+
/** Default is "automatic" for backwards compatibility with existing templates */
|
|
15
|
+
this.activationType = ACTIVATION_TYPES.AUTOMATIC;
|
|
16
|
+
}
|
|
17
|
+
|
|
4
18
|
static get properties() {
|
|
5
19
|
return {
|
|
6
|
-
placement: { type: String, default: 'bottom' }
|
|
20
|
+
placement: { type: String, default: 'bottom' },
|
|
21
|
+
/** Set the aria-label attribute of the trigger. */
|
|
22
|
+
triggerLabel: { type: String, attribute: 'trigger-label' },
|
|
23
|
+
/**
|
|
24
|
+
* "automatic" - show tooltip on hover and focus
|
|
25
|
+
* "manual" - show tooltip on hover and click
|
|
26
|
+
*/
|
|
27
|
+
activationType: { type: String, attribute: 'activation-type' },
|
|
28
|
+
/** Whether the tooltip is showing. Internal property; only here so that we re-render when it changes */
|
|
29
|
+
open: { type: Boolean, attribute: false }
|
|
7
30
|
};
|
|
8
31
|
}
|
|
9
32
|
|
|
@@ -19,11 +42,27 @@ export class Tooltip extends LitElement {
|
|
|
19
42
|
z-index: 9;
|
|
20
43
|
}
|
|
21
44
|
|
|
45
|
+
/* reset default button styles */
|
|
46
|
+
button.trigger {
|
|
47
|
+
all: unset;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* do not reset the button's default focus outline */
|
|
51
|
+
button.trigger:focus {
|
|
52
|
+
outline: revert;
|
|
53
|
+
}
|
|
54
|
+
|
|
22
55
|
.trigger {
|
|
23
56
|
display: block;
|
|
24
57
|
cursor: pointer;
|
|
25
58
|
}
|
|
26
59
|
|
|
60
|
+
/* for manual activation, hide the content completely from screen readers when the tooltip is closed */
|
|
61
|
+
/* otherwise, interactive elements may receive focus even when they are not visible */
|
|
62
|
+
[data-manual] .content {
|
|
63
|
+
visibility: hidden;
|
|
64
|
+
}
|
|
65
|
+
|
|
27
66
|
.content {
|
|
28
67
|
box-sizing: border-box;
|
|
29
68
|
font-family: var(--og-tooltip-family, inherit);
|
|
@@ -152,9 +191,8 @@ export class Tooltip extends LitElement {
|
|
|
152
191
|
border-left: solid var(--og-tooltip-background, #ececec) 10px;
|
|
153
192
|
}
|
|
154
193
|
|
|
155
|
-
.tooltip
|
|
156
|
-
|
|
157
|
-
.content:focus-within {
|
|
194
|
+
.tooltip[data-open] .content {
|
|
195
|
+
visibility: visible;
|
|
158
196
|
opacity: 1;
|
|
159
197
|
width: 200px;
|
|
160
198
|
pointer-events: auto;
|
|
@@ -165,12 +203,22 @@ export class Tooltip extends LitElement {
|
|
|
165
203
|
|
|
166
204
|
connectedCallback() {
|
|
167
205
|
super.connectedCallback();
|
|
168
|
-
this.
|
|
169
|
-
|
|
170
|
-
|
|
206
|
+
this.abortController = new AbortController();
|
|
207
|
+
const signal = this.abortController.signal;
|
|
208
|
+
|
|
209
|
+
this.addEventListener('mouseenter', this.handleMouseEnter.bind(this), { signal });
|
|
210
|
+
this.addEventListener('mouseleave', this.handleMouseLeave.bind(this), { signal });
|
|
211
|
+
this.addEventListener('focusin', this.handleFocusIn.bind(this), { signal });
|
|
212
|
+
this.addEventListener('focusout', this.handleFocusOut.bind(this), { signal });
|
|
213
|
+
this.addEventListener('keydown', this.handleKeyDown.bind(this), { signal });
|
|
214
|
+
|
|
215
|
+
document.addEventListener('click', this.handleDocumentClick.bind(this), { signal });
|
|
171
216
|
}
|
|
172
217
|
|
|
173
|
-
recalculatePosition() {
|
|
218
|
+
async recalculatePosition() {
|
|
219
|
+
// wait for state changes to apply
|
|
220
|
+
await this.updateComplete;
|
|
221
|
+
if (!this.open) return;
|
|
174
222
|
const trigger = this.shadowRoot.querySelector('.trigger');
|
|
175
223
|
const triggerRect = trigger.getBoundingClientRect();
|
|
176
224
|
const content = this.shadowRoot.querySelector('.content');
|
|
@@ -181,19 +229,83 @@ export class Tooltip extends LitElement {
|
|
|
181
229
|
content.style.top = `${(-1 * contentRect.height + triggerRect.height) / 2}px`;
|
|
182
230
|
}
|
|
183
231
|
|
|
232
|
+
handleMouseEnter() {
|
|
233
|
+
this.open = true;
|
|
234
|
+
this.recalculatePosition();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
handleMouseLeave() {
|
|
238
|
+
this.open = false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
handleFocusIn() {
|
|
242
|
+
if (this.activationType !== ACTIVATION_TYPES.AUTOMATIC) return;
|
|
243
|
+
this.open = true;
|
|
244
|
+
this.recalculatePosition();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
handleFocusOut(event) {
|
|
248
|
+
if (this.activationType !== ACTIVATION_TYPES.AUTOMATIC) return;
|
|
249
|
+
// keep the tooltip open if we're moving focus to another element inside the tooltip
|
|
250
|
+
if (!this.contains(event.relatedTarget)) {
|
|
251
|
+
this.open = false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
handleKeyDown(event) {
|
|
256
|
+
if (this.activationType !== ACTIVATION_TYPES.MANUAL) return;
|
|
257
|
+
// close the tooltip on Escape press
|
|
258
|
+
if (event.key === 'Escape' && this.open) {
|
|
259
|
+
this.open = false;
|
|
260
|
+
event.stopPropagation();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
handleClick() {
|
|
265
|
+
if (this.activationType !== ACTIVATION_TYPES.MANUAL) return;
|
|
266
|
+
this.open = !this.open;
|
|
267
|
+
this.recalculatePosition();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
handleDocumentClick(event) {
|
|
271
|
+
if (this.activationType !== ACTIVATION_TYPES.MANUAL || !this.open) return;
|
|
272
|
+
// close the tooltip if the user clicks outside of it
|
|
273
|
+
if (!this.contains(event.target)) {
|
|
274
|
+
this.open = false;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
184
278
|
disconnectedCallback() {
|
|
185
279
|
super.disconnectedCallback();
|
|
186
|
-
|
|
187
|
-
this.
|
|
280
|
+
// remove event listeners
|
|
281
|
+
this.abortController.abort();
|
|
188
282
|
}
|
|
189
283
|
|
|
190
284
|
render() {
|
|
285
|
+
// allow removing aria-label by setting trigger-label to any falsy value
|
|
286
|
+
// e.g. if the content inside the tooltip is sufficient
|
|
287
|
+
const triggerLabel = this.triggerLabel ? this.triggerLabel : undefined;
|
|
288
|
+
|
|
191
289
|
return html`
|
|
192
|
-
<span class="tooltip">
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
290
|
+
<span class="tooltip" ?data-open="${this.open}" ?data-manual="${this.activationType === ACTIVATION_TYPES.MANUAL}">
|
|
291
|
+
${this.activationType === ACTIVATION_TYPES.MANUAL
|
|
292
|
+
? html`
|
|
293
|
+
<button
|
|
294
|
+
class="trigger"
|
|
295
|
+
aria-label="${ifDefined(triggerLabel)}"
|
|
296
|
+
aria-expanded="${this.open}"
|
|
297
|
+
aria-controls="tooltip-content"
|
|
298
|
+
@click="${this.handleClick}"
|
|
299
|
+
>
|
|
300
|
+
<slot name="trigger">${this.trigger}</slot>
|
|
301
|
+
</button>
|
|
302
|
+
`
|
|
303
|
+
: html`
|
|
304
|
+
<span class="trigger" tabindex="0" aria-label="${ifDefined(triggerLabel)}">
|
|
305
|
+
<slot name="trigger">${this.trigger}</slot>
|
|
306
|
+
</span>
|
|
307
|
+
`}
|
|
308
|
+
<div class="content ${this.placement || 'bottom'}" role="tooltip" id="tooltip-content">
|
|
197
309
|
<slot name="content">${this.content}</slot>
|
|
198
310
|
</div>
|
|
199
311
|
</span>
|
|
@@ -18,7 +18,8 @@ async function renderPriceTemplate(
|
|
|
18
18
|
subscriptionPrice: '$0.90'
|
|
19
19
|
}
|
|
20
20
|
]
|
|
21
|
-
}
|
|
21
|
+
},
|
|
22
|
+
discountedProductPriceFromOffers: {}
|
|
22
23
|
}
|
|
23
24
|
) {
|
|
24
25
|
// make sure the element was cleaned up
|
|
@@ -27,6 +28,7 @@ async function renderPriceTemplate(
|
|
|
27
28
|
const element = document.querySelector(TAG_NAME_UNDER_TEST);
|
|
28
29
|
element.frequency = properties.frequency;
|
|
29
30
|
element.productPlans = properties.productPlans;
|
|
31
|
+
element.discountedProductPriceFromOffers = properties.discountedProductPriceFromOffers;
|
|
30
32
|
await element.updateComplete;
|
|
31
33
|
return element;
|
|
32
34
|
}
|
|
@@ -141,6 +143,28 @@ describe('Price', () => {
|
|
|
141
143
|
expect(ogPriceNullPrepaid).toContain('$4.50');
|
|
142
144
|
});
|
|
143
145
|
|
|
146
|
+
// note: it's unclear whether this behavior was intentional when first implemented, but codifying it with a test in case any merchant is relying on it
|
|
147
|
+
it('should render payAsYouGo price even if no discount', async () => {
|
|
148
|
+
const template = html`
|
|
149
|
+
<og-some-price pay-as-you-go product="yum id"></og-some-price>
|
|
150
|
+
`;
|
|
151
|
+
|
|
152
|
+
const priceDiv = await renderPriceTemplate(template, {
|
|
153
|
+
frequency: '1_3',
|
|
154
|
+
productPlans: {
|
|
155
|
+
'yum id': [
|
|
156
|
+
{
|
|
157
|
+
frequency: '1_3',
|
|
158
|
+
regularPrice: '$5.00',
|
|
159
|
+
discountRate: '$0.00',
|
|
160
|
+
subscriptionPrice: '$5.00'
|
|
161
|
+
}
|
|
162
|
+
]
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
expect(priceDiv.shadowRoot.innerHTML).toContain('$5.00');
|
|
166
|
+
});
|
|
167
|
+
|
|
144
168
|
it('should render empty price when subscription is equal to regular', async () => {
|
|
145
169
|
const template = html`
|
|
146
170
|
<og-some-price discount product="yum id"></og-some-price>
|
|
@@ -162,4 +186,53 @@ describe('Price', () => {
|
|
|
162
186
|
expect(ogPrice).not.toContain('10%');
|
|
163
187
|
expect(ogPrice).not.toContain('$1.00');
|
|
164
188
|
});
|
|
189
|
+
|
|
190
|
+
describe('discountedProductPriceFromOffers', () => {
|
|
191
|
+
async function setupDiscountedProductPriceTest(hasPriceAdjustments, extraPlanProps = {}) {
|
|
192
|
+
const template = html`
|
|
193
|
+
<og-some-price product="yum id"></og-some-price>
|
|
194
|
+
`;
|
|
195
|
+
return renderPriceTemplate(template, {
|
|
196
|
+
frequency: '1_3',
|
|
197
|
+
productPlans: {
|
|
198
|
+
'yum id': [
|
|
199
|
+
{
|
|
200
|
+
frequency: '1_3',
|
|
201
|
+
regularPrice: '$1.00',
|
|
202
|
+
discountRate: '10%',
|
|
203
|
+
subscriptionPrice: '$PLAN_PRICE',
|
|
204
|
+
hasPriceAdjustments: hasPriceAdjustments,
|
|
205
|
+
...extraPlanProps
|
|
206
|
+
}
|
|
207
|
+
]
|
|
208
|
+
},
|
|
209
|
+
discountedProductPriceFromOffers: {
|
|
210
|
+
regularPrice: '$1.00',
|
|
211
|
+
discountRate: '20%',
|
|
212
|
+
subscriptionPrice: '$OFFER_PRICE'
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
it('should render discountedProductPriceFromOffers when selling plan has no price adjustments', async () => {
|
|
218
|
+
const priceDiv = await setupDiscountedProductPriceTest(false);
|
|
219
|
+
|
|
220
|
+
const insideText = priceDiv.shadowRoot.textContent.trim();
|
|
221
|
+
expect(insideText).toBe('$OFFER_PRICE');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should not use discountedProductPriceFromOffers when selling plan has price adjustments', async () => {
|
|
225
|
+
const priceDiv = await setupDiscountedProductPriceTest(true);
|
|
226
|
+
|
|
227
|
+
const insideText = priceDiv.shadowRoot.textContent.trim();
|
|
228
|
+
expect(insideText).toBe('$PLAN_PRICE');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should use discountedProductPriceFromOffers for prepaid subscriptions even when plan does not have price adjustments', async () => {
|
|
232
|
+
const priceDiv = await setupDiscountedProductPriceTest(false, { prepaidShipments: 3 });
|
|
233
|
+
|
|
234
|
+
const insideText = priceDiv.shadowRoot.textContent.trim();
|
|
235
|
+
expect(insideText).toBe('$PLAN_PRICE');
|
|
236
|
+
});
|
|
237
|
+
});
|
|
165
238
|
});
|
|
@@ -16,17 +16,20 @@ describe('Tooltip', () => {
|
|
|
16
16
|
);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
const getTooltip = async placement => {
|
|
19
|
+
const getTooltip = async ({ placement, activationType } = {}) => {
|
|
20
20
|
const element = new Tooltip();
|
|
21
21
|
if (placement) {
|
|
22
22
|
element.setAttribute('placement', placement);
|
|
23
23
|
}
|
|
24
|
+
if (activationType) {
|
|
25
|
+
element.setAttribute('activation-type', activationType);
|
|
26
|
+
}
|
|
24
27
|
await appendToBody(element);
|
|
25
28
|
return element;
|
|
26
29
|
};
|
|
27
30
|
|
|
28
31
|
const checkContentStyles = async (expectedStyles, placement, contentStyles) => {
|
|
29
|
-
const element = await getTooltip(placement);
|
|
32
|
+
const element = await getTooltip({ placement });
|
|
30
33
|
const contentDiv = element.shadowRoot.querySelector('.content');
|
|
31
34
|
|
|
32
35
|
// Modify shadow content
|
|
@@ -36,7 +39,8 @@ describe('Tooltip', () => {
|
|
|
36
39
|
});
|
|
37
40
|
}
|
|
38
41
|
|
|
39
|
-
element.
|
|
42
|
+
element.open = true;
|
|
43
|
+
await element.recalculatePosition();
|
|
40
44
|
Object.keys(expectedStyles).forEach(key => expect(contentDiv.style[key]).toEqual(expectedStyles[key]));
|
|
41
45
|
};
|
|
42
46
|
|
|
@@ -75,4 +79,211 @@ describe('Tooltip', () => {
|
|
|
75
79
|
it('should not set style properties if placement is top-left', async () => {
|
|
76
80
|
await checkContentStyles({ top: '', left: '' }, 'top-left');
|
|
77
81
|
});
|
|
82
|
+
|
|
83
|
+
const ACTIVATION_TYPES = ['automatic', 'manual'];
|
|
84
|
+
|
|
85
|
+
ACTIVATION_TYPES.forEach(activationType => {
|
|
86
|
+
it(`${activationType} should show tooltip on mouseenter`, async () => {
|
|
87
|
+
const element = await getTooltip({ activationType });
|
|
88
|
+
expect(element.open).toBe(false);
|
|
89
|
+
|
|
90
|
+
element.dispatchEvent(new Event('mouseenter'));
|
|
91
|
+
await element.updateComplete;
|
|
92
|
+
|
|
93
|
+
expect(element.open).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it(`${activationType} should hide tooltip on mouseleave`, async () => {
|
|
97
|
+
const element = await getTooltip();
|
|
98
|
+
element.open = true;
|
|
99
|
+
await element.updateComplete;
|
|
100
|
+
|
|
101
|
+
element.dispatchEvent(new Event('mouseleave'));
|
|
102
|
+
await element.updateComplete;
|
|
103
|
+
|
|
104
|
+
expect(element.open).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it(`${activationType} should remove aria-label when trigger-label is empty`, async () => {
|
|
108
|
+
const element = await getTooltip({ activationType });
|
|
109
|
+
element.setAttribute('trigger-label', '');
|
|
110
|
+
await element.updateComplete;
|
|
111
|
+
|
|
112
|
+
const trigger = element.shadowRoot.querySelector('.trigger');
|
|
113
|
+
expect(trigger.hasAttribute('aria-label')).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it(`${activationType} should set aria-label when trigger-label has value`, async () => {
|
|
117
|
+
const element = await getTooltip({ activationType });
|
|
118
|
+
element.setAttribute('trigger-label', 'Custom tooltip label');
|
|
119
|
+
await element.updateComplete;
|
|
120
|
+
|
|
121
|
+
const trigger = element.shadowRoot.querySelector('.trigger');
|
|
122
|
+
expect(trigger.getAttribute('aria-label')).toBe('Custom tooltip label');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('automatic activation behavior', () => {
|
|
127
|
+
it('should show tooltip on focusin', async () => {
|
|
128
|
+
const element = await getTooltip();
|
|
129
|
+
expect(element.open).toBe(false);
|
|
130
|
+
|
|
131
|
+
element.dispatchEvent(new Event('focusin'));
|
|
132
|
+
await element.updateComplete;
|
|
133
|
+
|
|
134
|
+
expect(element.open).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should hide tooltip on focusout when focus moves outside', async () => {
|
|
138
|
+
const element = await getTooltip();
|
|
139
|
+
const externalElement = document.createElement('div');
|
|
140
|
+
document.body.appendChild(externalElement);
|
|
141
|
+
|
|
142
|
+
element.open = true;
|
|
143
|
+
await element.updateComplete;
|
|
144
|
+
|
|
145
|
+
const focusOutEvent = new FocusEvent('focusout', { relatedTarget: externalElement });
|
|
146
|
+
element.dispatchEvent(focusOutEvent);
|
|
147
|
+
await element.updateComplete;
|
|
148
|
+
|
|
149
|
+
expect(element.open).toBe(false);
|
|
150
|
+
document.body.removeChild(externalElement);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should keep tooltip open on focusout when focus stays within tooltip', async () => {
|
|
154
|
+
const element = await getTooltip();
|
|
155
|
+
const trigger = element.shadowRoot.querySelector('.trigger');
|
|
156
|
+
|
|
157
|
+
element.open = true;
|
|
158
|
+
await element.updateComplete;
|
|
159
|
+
|
|
160
|
+
// focus moving out of one element and back to the trigger
|
|
161
|
+
const focusOutEvent = new FocusEvent('focusout', { relatedTarget: trigger });
|
|
162
|
+
element.dispatchEvent(focusOutEvent);
|
|
163
|
+
await element.updateComplete;
|
|
164
|
+
|
|
165
|
+
expect(element.open).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should not open tooltip on click', async () => {
|
|
169
|
+
const element = await getTooltip();
|
|
170
|
+
const trigger = element.shadowRoot.querySelector('.trigger');
|
|
171
|
+
|
|
172
|
+
expect(element.open).toBe(false);
|
|
173
|
+
|
|
174
|
+
trigger.click();
|
|
175
|
+
await element.updateComplete;
|
|
176
|
+
|
|
177
|
+
expect(element.open).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should not do anything when clicking outside tooltip', async () => {
|
|
181
|
+
const element = await getTooltip();
|
|
182
|
+
element.open = true;
|
|
183
|
+
await element.updateComplete;
|
|
184
|
+
|
|
185
|
+
const outsideClick = new Event('click', { bubbles: true });
|
|
186
|
+
document.body.dispatchEvent(outsideClick);
|
|
187
|
+
await element.updateComplete;
|
|
188
|
+
|
|
189
|
+
expect(element.open).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('updates attributes', async () => {
|
|
193
|
+
const element = await getTooltip();
|
|
194
|
+
element.open = true;
|
|
195
|
+
await element.updateComplete;
|
|
196
|
+
|
|
197
|
+
let style = getComputedStyle(element.shadowRoot.querySelector('.content'));
|
|
198
|
+
expect(style.opacity).toBe('1');
|
|
199
|
+
|
|
200
|
+
element.open = false;
|
|
201
|
+
await element.updateComplete;
|
|
202
|
+
style = getComputedStyle(element.shadowRoot.querySelector('.content'));
|
|
203
|
+
expect(style.opacity).toBe('0');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('manual activation behavior', () => {
|
|
208
|
+
it('should toggle tooltip on click', async () => {
|
|
209
|
+
const element = await getTooltip({ activationType: 'manual' });
|
|
210
|
+
const trigger = element.shadowRoot.querySelector('.trigger');
|
|
211
|
+
|
|
212
|
+
expect(element.open).toBe(false);
|
|
213
|
+
|
|
214
|
+
trigger.click();
|
|
215
|
+
await element.updateComplete;
|
|
216
|
+
expect(element.open).toBe(true);
|
|
217
|
+
|
|
218
|
+
trigger.click();
|
|
219
|
+
await element.updateComplete;
|
|
220
|
+
expect(element.open).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should not open tooltip on focus', async () => {
|
|
224
|
+
const element = await getTooltip({ activationType: 'manual' });
|
|
225
|
+
|
|
226
|
+
expect(element.open).toBe(false);
|
|
227
|
+
|
|
228
|
+
element.dispatchEvent(new Event('focusin'));
|
|
229
|
+
await element.updateComplete;
|
|
230
|
+
|
|
231
|
+
expect(element.open).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should close tooltip when clicking outside', async () => {
|
|
235
|
+
const element = await getTooltip({ activationType: 'manual' });
|
|
236
|
+
element.open = true;
|
|
237
|
+
await element.updateComplete;
|
|
238
|
+
|
|
239
|
+
const outsideClick = new Event('click', { bubbles: true });
|
|
240
|
+
document.body.dispatchEvent(outsideClick);
|
|
241
|
+
await element.updateComplete;
|
|
242
|
+
|
|
243
|
+
expect(element.open).toBe(false);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('updates attributes', async () => {
|
|
247
|
+
const element = await getTooltip({ activationType: 'manual' });
|
|
248
|
+
element.open = true;
|
|
249
|
+
await element.updateComplete;
|
|
250
|
+
expect(element.shadowRoot.querySelector('.trigger').ariaExpanded).toBe('true');
|
|
251
|
+
let style = getComputedStyle(element.shadowRoot.querySelector('.content'));
|
|
252
|
+
expect(style.visibility).toBe('visible');
|
|
253
|
+
expect(style.opacity).toBe('1');
|
|
254
|
+
|
|
255
|
+
element.open = false;
|
|
256
|
+
await element.updateComplete;
|
|
257
|
+
expect(element.shadowRoot.querySelector('.trigger').ariaExpanded).toBe('false');
|
|
258
|
+
style = getComputedStyle(element.shadowRoot.querySelector('.content'));
|
|
259
|
+
expect(style.visibility).toBe('hidden');
|
|
260
|
+
expect(style.opacity).toBe('0');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should close tooltip on Escape key', async () => {
|
|
264
|
+
const element = await getTooltip({ activationType: 'manual' });
|
|
265
|
+
element.open = true;
|
|
266
|
+
await element.updateComplete;
|
|
267
|
+
|
|
268
|
+
expect(element.open).toBe(true);
|
|
269
|
+
|
|
270
|
+
const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' });
|
|
271
|
+
element.dispatchEvent(escapeEvent);
|
|
272
|
+
await element.updateComplete;
|
|
273
|
+
|
|
274
|
+
expect(element.open).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should not close tooltip on other keys', async () => {
|
|
278
|
+
const element = await getTooltip({ activationType: 'manual' });
|
|
279
|
+
element.open = true;
|
|
280
|
+
await element.updateComplete;
|
|
281
|
+
|
|
282
|
+
const enterEvent = new KeyboardEvent('keydown', { key: 'Alt' });
|
|
283
|
+
element.dispatchEvent(enterEvent);
|
|
284
|
+
await element.updateComplete;
|
|
285
|
+
|
|
286
|
+
expect(element.open).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
78
289
|
});
|
|
@@ -281,6 +281,19 @@ describe('experiments.shopify', () => {
|
|
|
281
281
|
|
|
282
282
|
const session_A = 'cda';
|
|
283
283
|
const session_B = 'abc';
|
|
284
|
+
|
|
285
|
+
function expectPlanFrequenciesToMatch(actualProductPlans, expectedProductPlans) {
|
|
286
|
+
for (const productId in expectedProductPlans) {
|
|
287
|
+
const actualPlans = actualProductPlans[productId];
|
|
288
|
+
const expectedPlans = expectedProductPlans[productId];
|
|
289
|
+
|
|
290
|
+
const actualFrequencies = actualPlans.map(plan => plan.frequency).sort();
|
|
291
|
+
const expectedFrequencies = expectedPlans.map(plan => plan.frequency).sort();
|
|
292
|
+
|
|
293
|
+
expect(actualFrequencies).toEqual(expectedFrequencies);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
284
297
|
it('should not change selling plan groups if no experiment matches', async () => {
|
|
285
298
|
const experiments = {
|
|
286
299
|
public_id: '123',
|
|
@@ -302,7 +315,7 @@ describe('experiments.shopify', () => {
|
|
|
302
315
|
|
|
303
316
|
await new Promise(r => setTimeout(r, 10));
|
|
304
317
|
|
|
305
|
-
|
|
318
|
+
expectPlanFrequenciesToMatch(store.getState().productPlans, expectedNoModifiedProductPlans);
|
|
306
319
|
});
|
|
307
320
|
|
|
308
321
|
it('should change selling plan groups if experiment matches variant A', async () => {
|
|
@@ -360,7 +373,7 @@ describe('experiments.shopify', () => {
|
|
|
360
373
|
}
|
|
361
374
|
]
|
|
362
375
|
};
|
|
363
|
-
|
|
376
|
+
expectPlanFrequenciesToMatch(store.getState().productPlans, expectedVariantAProductPlans);
|
|
364
377
|
});
|
|
365
378
|
|
|
366
379
|
it('should change selling plan groups if experiment matches variant B', async () => {
|
|
@@ -413,6 +426,6 @@ describe('experiments.shopify', () => {
|
|
|
413
426
|
]
|
|
414
427
|
};
|
|
415
428
|
|
|
416
|
-
|
|
429
|
+
expectPlanFrequenciesToMatch(store.getState().productPlans, expectedVariantBProductPlans);
|
|
417
430
|
});
|
|
418
431
|
});
|