diff --git a/studio/src/constants.js b/studio/src/constants.js index aa7d2e2e..5303e0ff 100644 --- a/studio/src/constants.js +++ b/studio/src/constants.js @@ -25,3 +25,5 @@ export const ANALYTICS_LINK_IDS = [ // TODO remove these? export const EVENT_CHANGE = 'change'; export const EVENT_INPUT = 'input'; + +export const EVENT_OST_SELECT = 'ost-select'; diff --git a/studio/src/editor-panel.js b/studio/src/editor-panel.js index 20eb333c..5f67a59d 100644 --- a/studio/src/editor-panel.js +++ b/studio/src/editor-panel.js @@ -166,6 +166,7 @@ export default class EditorPanel extends LitElement { handleKeyDown(event) { if (event.code === 'Escape') this.close(); + if (!event.ctrlKey) return; if (event.code === 'ArrowLeft' && event.shiftKey) this.updatePosition('left'); if (event.code === 'ArrowRight' && event.shiftKey) diff --git a/studio/src/maslib.js b/studio/src/maslib.js index 1b96dd8d..3e05e32c 100644 --- a/studio/src/maslib.js +++ b/studio/src/maslib.js @@ -11,7 +11,7 @@ const miloLibs = getMiloLibs(); const injectMasLib = () => { const script = document.createElement('script'); - script.setAttribute('src', `${miloLibs}/libs/deps/mas/mas.js`); + script.setAttribute('src', `${miloLibs}/libs/features/mas/dist/mas.js`); script.setAttribute('type', 'module'); document.head.prepend(script); }; diff --git a/studio/src/rte/ost.js b/studio/src/rte/ost.js index 42c1f469..8d07ecdb 100644 --- a/studio/src/rte/ost.js +++ b/studio/src/rte/ost.js @@ -1,9 +1,14 @@ import { html } from 'lit'; -import { CHECKOUT_CTA_TEXTS } from '../constants.js'; +import { CHECKOUT_CTA_TEXTS, EVENT_OST_SELECT } from '../constants.js'; -let ostRoot; +let ostRoot = document.getElementById('ost'); let closeFunction; +if (!ostRoot) { + ostRoot = document.createElement('div'); + document.body.appendChild(ostRoot); +} + export const ostDefaults = { aosApiKey: 'wcms-commerce-ims-user-prod', checkoutClientId: 'creative', @@ -17,7 +22,7 @@ export const ostDefaults = { displayRecurrence: true, displayPerUnit: false, displayTax: false, - displayOldPrice: false, + displayOldPrice: true, forceTaxExclusive: true, }, wcsApiKey: 'wcms-commerce-ims-ro-user-cc', @@ -86,6 +91,7 @@ const OST_OPTION_ATTRIBUTE_MAPPING = { displayTax: 'data-display-tax', forceTaxExclusive: 'data-tax-exclusive', isPerpetual: 'data-perpetual', + storedPromoOverride: 'data-promotion-code', wcsOsi: 'data-wcs-osi', workflow: 'data-checkout-workflow', workflowStep: 'data-checkout-workflow-step', @@ -99,7 +105,7 @@ export const OST_OPTION_ATTRIBUTE_MAPPING_REVERSE = Object.fromEntries( ); const OST_OPTION_DEFAULTS = { - displayOldPrice: false, + displayOldPrice: true, displayPerUnit: false, displayRecurrence: true, displayTax: false, @@ -145,7 +151,7 @@ export function onSelect(offerSelectorId, type, offer, options, promoOverride) { } ostRoot.dispatchEvent( - new CustomEvent('use', { + new CustomEvent(EVENT_OST_SELECT, { detail: attributes, bubbles: true, }), @@ -163,10 +169,6 @@ export function getOffferSelectorTool() { } export function openOfferSelectorTool(offerElement) { - if (!ostRoot) { - ostRoot = document.createElement('div'); - document.body.appendChild(ostRoot); - } let searchOfferSelectorId; const aosAccessToken = localStorage.getItem('masAccessToken') ?? window.adobeid.authorize(); @@ -196,12 +198,15 @@ export function openOfferSelectorTool(offerElement) { } }); - ['promotionCode', 'checkoutType', 'workflowStep', 'country'].forEach( - (key) => { - const value = offerSelectorPlaceholderOptions[key]; - if (value) searchParameters.append(key, value); - }, - ); + [ + 'storedPromoOverride', + 'checkoutType', + 'workflowStep', + 'country', + ].forEach((key) => { + const value = offerSelectorPlaceholderOptions[key]; + if (value) searchParameters.append(key, value); + }); } ostRoot.style.display = 'block'; closeFunction = window.ost.openOfferSelectorTool({ diff --git a/studio/src/rte/rte-field.js b/studio/src/rte/rte-field.js index 66692de0..471fb1e6 100644 --- a/studio/src/rte/rte-field.js +++ b/studio/src/rte/rte-field.js @@ -13,6 +13,8 @@ import { } from './ost.js'; import prosemirrorStyles from './prosemirror.css.js'; +import { EVENT_OST_SELECT } from '../constants.js'; +import throttle from '../utils/throttle.js'; const CUSTOM_ELEMENT_CHECKOUT_LINK = 'checkout-link'; const CUSTOM_ELEMENT_INLINE_PRICE = 'inline-price'; @@ -85,6 +87,8 @@ class RteField extends LitElement { readOnly: { type: Boolean, attribute: 'readonly' }, showLinkEditor: { type: Boolean, state: true }, defaultLinkStyle: { type: String, attribute: 'default-link-style' }, + maxLength: { type: Number, attribute: 'max-length' }, + length: { type: Number, state: true }, }; static get styles() { @@ -110,6 +114,10 @@ class RteField extends LitElement { background-color: var(--spectrum-global-color-gray-50); } + .exceeded { + color: var(--spectrum-global-color-red-700); + } + rte-link-editor { display: contents; } @@ -120,6 +128,10 @@ class RteField extends LitElement { align-content: center; } + .price-unit-type:before { + content: ' '; + } + a.accent, a.primary-outline, a.secondary, @@ -207,6 +219,8 @@ class RteField extends LitElement { this.showLinkEditor = false; this.inline = false; this.link = false; + this.maxLength = 70; + this.length = 0; this.#boundHandlers = { escKey: this.#handleEscKey.bind(this), ostEvent: this.#handleOstEvent.bind(this), @@ -214,6 +228,7 @@ class RteField extends LitElement { blur: this.#handleBlur.bind(this), focus: this.#handleFocus.bind(this), doubleClickOn: this.#handleDoubleClickOn.bind(this), + updateLength: throttle(this.#updateLength.bind(this), 100), }; } @@ -227,7 +242,14 @@ class RteField extends LitElement { document.addEventListener('keydown', this.#boundHandlers.escKey, { capture: true, }); - document.addEventListener('use', this.#boundHandlers.ostEvent); + document.addEventListener( + EVENT_OST_SELECT, + this.#boundHandlers.ostEvent, + ); + this.updateLengthInterval = setInterval( + this.#boundHandlers.updateLength, + 1000, + ); } disconnectedCallback() { @@ -235,8 +257,12 @@ class RteField extends LitElement { document.removeEventListener('keydown', this.#boundHandlers.escKey, { capture: true, }); - document.removeEventListener('use', this.#boundHandlers.ostEvent); + document.removeEventListener( + EVENT_OST_SELECT, + this.#boundHandlers.ostEvent, + ); this.editorView?.destroy(); + clearInterval(this.updateLengthInterval); } #initEditorSchema() { @@ -468,6 +494,7 @@ class RteField extends LitElement { this.editorView.updateState(newState); if (newState.doc) { + this.#boundHandlers.updateLength(); const value = this.#serializeContent(newState); // skip change event during initialization const isFirstChange = this.value === null; @@ -629,7 +656,7 @@ class RteField extends LitElement { : state.schema.nodes.link; // Fixed to use 'link' node type const mergedAttributes = { - ...(selection.node?.attrs ?? {}), + class: selection.node?.attrs.class, ...attributes, }; @@ -669,6 +696,10 @@ class RteField extends LitElement { !selection.node.attrs['data-wcs-osi']; } + #updateLength() { + this.length = this.editorView.dom.innerText.length; + } + async openLinkEditor() { const attrs = this.#getLinkAttrs(); this.showLinkEditor = true; @@ -788,12 +819,18 @@ class RteField extends LitElement { } render() { + const lengthExceeded = this.length > this.maxLength; return html` ${this.#formatButtons} ${this.#linkEditorButton} ${this.#unlinkEditorButton} ${this.#offerSelectorToolButton}
+

+ ${this.length}/${this.maxLength} +

${this.linkEditor} `; } diff --git a/studio/src/utils/throttle.js b/studio/src/utils/throttle.js new file mode 100644 index 00000000..994101cb --- /dev/null +++ b/studio/src/utils/throttle.js @@ -0,0 +1,24 @@ +function throttle(func, limit) { + let lastFunc; + let lastRan; + return function (...args) { + const context = this; + if (!lastRan) { + func.apply(context, args); + lastRan = Date.now(); + } else { + clearTimeout(lastFunc); + lastFunc = setTimeout( + function () { + if (Date.now() - lastRan >= limit) { + func.apply(context, args); + lastRan = Date.now(); + } + }, + limit - (Date.now() - lastRan), + ); + } + }; +} + +export default throttle; diff --git a/studio/test/rte/ost.test.js b/studio/test/rte/ost.test.js new file mode 100644 index 00000000..ff966715 --- /dev/null +++ b/studio/test/rte/ost.test.js @@ -0,0 +1,93 @@ +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; + +import { EVENT_OST_SELECT } from '../../src/constants.js'; + +describe('onSelect', () => { + let dispatchEventStub; + let ostRoot; + let onSelect; + + before(async () => { + ostRoot = document.createElement('div'); + ostRoot.id = 'ost'; + document.body.appendChild(ostRoot); + ({ onSelect } = await import('../../src/rte/ost.js')); + dispatchEventStub = sinon.stub(ostRoot, 'dispatchEvent'); + }); + + beforeEach(() => { + dispatchEventStub.reset(); + }); + + it('should dispatch an event with correct attributes for price', () => { + const offerSelectorId = 'test-id'; + const type = 'price'; + const offer = {}; + const options = { + displayOldPrice: false, + }; + const promoOverride = 'PROMO123'; + + onSelect(offerSelectorId, type, offer, options, promoOverride); + + const expectedAttributes = { + 'data-wcs-osi': offerSelectorId, + 'data-template': type, + is: 'inline-price', + 'data-display-old-price': false, + 'data-promotion-code': promoOverride, + }; + + expect(dispatchEventStub.calledOnce).to.be.true; + const event = dispatchEventStub.getCall(0).args[0]; + expect(event.type).to.equal(EVENT_OST_SELECT); + expect(event.detail).to.deep.equal(expectedAttributes); + }); + + it('should dispatch an event with correct attributes for checkout link', () => { + const offerSelectorId = 'test-id'; + const type = 'checkoutUrl'; + const offer = {}; + const options = { + ctaText: 'buy-now', + }; + const promoOverride = null; + + onSelect(offerSelectorId, type, offer, options, promoOverride); + + const expectedAttributes = { + 'data-wcs-osi': offerSelectorId, + 'data-template': type, + is: 'checkout-link', + text: 'Buy now', + 'data-analytics-id': 'buy-now', + }; + + expect(dispatchEventStub.calledOnce).to.be.true; + const event = dispatchEventStub.getCall(0).args[0]; + expect(event.type).to.equal(EVENT_OST_SELECT); + expect(event.detail).to.deep.equal(expectedAttributes); + }); + + it('should not include promo code if not provided for price', () => { + const offerSelectorId = 'test-id'; + const type = 'price'; + const offer = {}; + const options = {}; + const promoOverride = null; + + onSelect(offerSelectorId, type, offer, options, promoOverride); + + const expectedAttributes = { + 'data-wcs-osi': offerSelectorId, + 'data-template': type, + is: 'inline-price', + }; + + expect(dispatchEventStub.calledOnce).to.be.true; + const event = dispatchEventStub.getCall(0).args[0]; + expect(event.type).to.equal(EVENT_OST_SELECT); + expect(event.detail).to.deep.equal(expectedAttributes); + }); +}); diff --git a/studio/test/rte/rte-field.test.html b/studio/test/rte/rte-field.test.html index 064879d6..834fe871 100644 --- a/studio/test/rte/rte-field.test.html +++ b/studio/test/rte/rte-field.test.html @@ -38,7 +38,7 @@ import '../../src/swc.js'; import '../../src/rte/rte-field.js'; import '../../src/rte/rte-link-editor.js'; - import '@adobecom/milo/libs/deps/mas/mas.js'; + import '@adobecom/milo/libs/features/mas/dist/mas.js'; import { mockFetch } from '@adobecom/milo/libs/features/mas/test/mocks/fetch.js'; import { withWcs } from '@adobecom/milo/libs/features/mas/test/mocks/wcs.js'; @@ -276,6 +276,17 @@ ); }); + it('should display char counter in red when max length is exceeded', async function() { + const rte = await createFromTemplate('rte-maxlength', this.test.title); + const span = rte.shadowRoot.querySelector('#counter').firstElementChild; + await delay(100); + expect(span.classList.contains('exceeded')).to.be.false; + rte.setAttribute('max-length', 40); + await delay(100); + expect(span.classList.contains('exceeded')).to.be.true; + }); + + it.skip('dev', async function () { const rte = await createFromTemplate( 'rte-dev', @@ -399,6 +410,11 @@ > +