diff --git a/nala/studio/copy-field/specs/copy_field.spec.js b/nala/studio/copy-field/specs/copy_field.spec.js new file mode 100644 index 000000000..698398697 --- /dev/null +++ b/nala/studio/copy-field/specs/copy_field.spec.js @@ -0,0 +1,20 @@ +export default { + FeatureName: 'M@S Studio Copy Field', + features: [ + { + tcid: '0', + name: '@studio-copy-field-popover-tax-label', + // MWPW-193548 reproducer fragment in the acom surface, fr_FR locale. + // FR_fr is in DISPLAY_ALL_TAX_COUNTRIES so the rendered card preview + // carries a `.price-tax-inclusivity` "TTC" label; the Copy Field popover + // preview must mirror that — before the fix it dropped to `26,21 €/mois`. + path: '/studio.html', + browserParams: '#locale=fr_FR&page=fragment-editor&path=acom&fragmentId=', + data: { + cardid: '7f72b2a4-2ebc-48e6-bcec-4239b5aa35b2', + priceField: 'Prices', + }, + tags: '@mas-studio @copy-field', + }, + ], +}; diff --git a/nala/studio/copy-field/tests/copy_field.test.js b/nala/studio/copy-field/tests/copy_field.test.js new file mode 100644 index 000000000..9e1dc5b84 --- /dev/null +++ b/nala/studio/copy-field/tests/copy_field.test.js @@ -0,0 +1,49 @@ +import { test, expect, studio, editor, miloLibs, setTestPage } from '../../../libs/mas-test.js'; +import CopyFieldSpec from '../specs/copy_field.spec.js'; + +const { features } = CopyFieldSpec; + +test.describe('M@S Studio Copy Field test suite', () => { + // @studio-copy-field-popover-tax-label - MWPW-193548: + // The Copy Field popover preview must include the locale-driven tax label + // ("TTC" for fr_FR) that the rendered card preview shows. Before the fix + // the popover dropped the label and emitted only the bare price. + test(`${features[0].name},${features[0].tags}`, async ({ page, baseURL }) => { + const { data } = features[0]; + const testPage = `${baseURL}${features[0].path}${miloLibs}${features[0].browserParams}${data.cardid}`; + setTestPage(testPage); + + await test.step('step-1: Go to MAS Studio fragment editor page', async () => { + await page.goto(testPage); + await page.waitForLoadState('domcontentloaded'); + }); + + let renderedTaxLabel; + + await test.step('step-2: Wait for editor panel and rendered card preview', async () => { + await expect(editor.panel).toBeVisible({ timeout: 15000 }); + await expect(await studio.getCard(data.cardid)).toBeVisible({ timeout: 15000 }); + }); + + await test.step('step-3: Verify rendered card preview shows the locale tax label', async () => { + const card = await studio.getCard(data.cardid); + const taxLabel = card.locator('span[is="inline-price"][data-template="price"] .price-tax-inclusivity').first(); + await expect(taxLabel).toBeVisible(); + await expect(taxLabel).not.toHaveClass(/(^|\s)disabled(\s|$)/); + renderedTaxLabel = (await taxLabel.textContent())?.trim(); + expect(renderedTaxLabel).toBeTruthy(); + }); + + await test.step('step-4: Open Copy Field popover from the side nav', async () => { + await expect(studio.copyFieldButton).toBeVisible(); + await studio.copyFieldButton.click(); + await expect(studio.copyFieldPopover).toBeVisible({ timeout: 5000 }); + }); + + await test.step('step-5: Verify popover preview includes the same tax label', async () => { + const valueLocator = studio.copyFieldRowValue(data.priceField); + await expect(valueLocator).toBeVisible(); + await expect(valueLocator).toContainText(renderedTaxLabel); + }); + }); +}); diff --git a/nala/studio/studio.page.js b/nala/studio/studio.page.js index 0a0d67c68..fccb27824 100644 --- a/nala/studio/studio.page.js +++ b/nala/studio/studio.page.js @@ -92,6 +92,12 @@ export default class StudioPage { this.publishCardButton = this.sideNav.locator('mas-side-nav-item[label="Publish"]'); this.createVariationButton = this.sideNav.locator('mas-side-nav-item[label="Create Variation"]'); this.versionHistoryButton = this.sideNav.locator('mas-side-nav-item[label="History"]'); + this.copyFieldButton = this.sideNav.locator('mas-side-nav-item[label="Copy Field"]'); + // Side-nav renders the Copy Field popover inside its shadow root; Playwright CSS selectors pierce shadow. + this.copyFieldPopover = this.sideNav.locator('sp-popover[open]'); + this.copyFieldRow = (label) => + this.copyFieldPopover.locator('sp-menu-item', { has: this.page.locator(`.field-label:text-is("${label}")`) }); + this.copyFieldRowValue = (label) => this.copyFieldRow(label).locator('.field-value'); this.homeButton = this.sideNav.locator('mas-side-nav-item[label="Home"]'); this.offersButton = this.sideNav.locator('mas-side-nav-item[label="Offers"]'); this.fragmentsButton = this.sideNav.locator('mas-side-nav-item[label="Fragments"]'); diff --git a/studio/src/mas-side-nav.js b/studio/src/mas-side-nav.js index d7860837b..b38c1ddf7 100644 --- a/studio/src/mas-side-nav.js +++ b/studio/src/mas-side-nav.js @@ -474,12 +474,13 @@ class MasSideNav extends LitElement { if (candidateIdx === -1) return; usedIndices.add(candidateIdx); const candidate = resolvedInlinePrices[candidateIdx]; - // Use full text (with per-unit/tax) when the source inline-price - // requested those labels; otherwise use just the core price amount. - const wantsExtras = - sourceAttrs.get('data-display-per-unit') === 'true' || sourceAttrs.get('data-display-tax') === 'true'; + // Mirror the rendered preview: per-unit and tax labels are resolved + // from locale defaults (e.g. FR_fr → "TTC"), not authored on the + // source span, so the rendered DOM is the source of truth. Fall + // back to coreText only if the rendered output had no labels. + const renderedText = candidate.formattedText || candidate.coreText; const temp = doc.createElement('span'); - temp.innerHTML = wantsExtras ? candidate.formattedText : candidate.coreText; + temp.innerHTML = renderedText; inlinePrice.replaceWith(...temp.childNodes); }); return doc.body.innerHTML; diff --git a/studio/test/mas-side-nav.test.js b/studio/test/mas-side-nav.test.js index 8a88c64fa..a1e191572 100644 --- a/studio/test/mas-side-nav.test.js +++ b/studio/test/mas-side-nav.test.js @@ -169,6 +169,45 @@ describe('MasSideNav – Copy Field', () => { expect(priceField.preview).to.equal('$9.99/mo'); }); + it('should preserve locale-driven tax label rendered on the price (e.g. FR_fr "TTC")', () => { + // Reproduces MWPW-193548: the source span has no data-display-tax, + // but the rendered preview shows the locale-default tax label. + // The Copy Field popover preview must mirror the rendered output. + const sourceFragment = mockFragment([ + { + name: 'prices', + values: ['

'], + }, + ]); + const previewFragment = mockFragment([ + { + name: 'prices', + values: ['

'], + }, + ]); + const editor = mockEditor(sourceFragment, previewFragment); + const card = document.createElement('merch-card'); + const resolvedPrice = document.createElement('span'); + resolvedPrice.setAttribute('is', 'inline-price'); + resolvedPrice.setAttribute('data-template', 'price'); + resolvedPrice.setAttribute('data-wcs-osi', 'abc'); + const priceInner = document.createElement('span'); + priceInner.className = 'price'; + priceInner.append(document.createTextNode('26,21 €/mois')); + const taxLabel = document.createElement('span'); + taxLabel.className = 'price-tax-inclusivity'; + taxLabel.textContent = 'TTC'; + priceInner.append(taxLabel); + resolvedPrice.append(priceInner); + card.append(resolvedPrice); + editor.querySelector = sandbox.stub().withArgs('merch-card').returns(card); + editorStub.withArgs('mas-fragment-editor').returns(editor); + + const priceField = el.copyableFields.find((f) => f.name === 'prices'); + expect(priceField.preview).to.include('TTC'); + expect(priceField.preview).to.include('26,21'); + }); + it('should resolve inline-price tokens inside description from rendered preview card', () => { const sourceFragment = mockFragment([ { diff --git a/web-components/dist/mas-field.js b/web-components/dist/mas-field.js index f81d9a982..ba529b0fc 100644 --- a/web-components/dist/mas-field.js +++ b/web-components/dist/mas-field.js @@ -1,8 +1,8 @@ -var O=n=>{throw TypeError(n)};var C=(n,E,e)=>E.has(n)||O("Cannot "+e);var a=(n,E,e)=>(C(n,E,"read from private field"),e?e.call(n):E.get(n)),_=(n,E,e)=>E.has(n)?O("Cannot add the same private member more than once"):E instanceof WeakSet?E.add(n):E.set(n,e),d=(n,E,e,t)=>(C(n,E,"write to private field"),t?t.call(n,e):E.set(n,e),e),i=(n,E,e)=>(C(n,E,"access private method"),e);var w=Object.freeze({MONTH:"MONTH",YEAR:"YEAR",TWO_YEARS:"TWO_YEARS",THREE_YEARS:"THREE_YEARS",PERPETUAL:"PERPETUAL",TERM_LICENSE:"TERM_LICENSE",ACCESS_PASS:"ACCESS_PASS",THREE_MONTHS:"THREE_MONTHS",SIX_MONTHS:"SIX_MONTHS"}),k=Object.freeze({ANNUAL:"ANNUAL",MONTHLY:"MONTHLY",TWO_YEARS:"TWO_YEARS",THREE_YEARS:"THREE_YEARS",P1D:"P1D",P1Y:"P1Y",P3Y:"P3Y",P10Y:"P10Y",P15Y:"P15Y",P3D:"P3D",P7D:"P7D",P30D:"P30D",HALF_YEARLY:"HALF_YEARLY",QUARTERLY:"QUARTERLY"});var U='span[is="inline-price"][data-wcs-osi]',g='a[is="checkout-link"][data-wcs-osi],button[is="checkout-button"][data-wcs-osi]';var V='a[is="upt-link"]',K=`${U},${g},${V}`;var N="aem:load";var W=Object.freeze({SEGMENTATION:"segmentation",BUNDLE:"bundle",COMMITMENT:"commitment",RECOMMENDATION:"recommendation",EMAIL:"email",PAYMENT:"payment",CHANGE_PLAN_TEAM_PLANS:"change-plan/team-upgrade/plans",CHANGE_PLAN_TEAM_PAYMENT:"change-plan/team-upgrade/payment"});var B=Object.freeze({STAGE:"STAGE",PRODUCTION:"PRODUCTION",LOCAL:"LOCAL"});var F="mas-field",b=/(accent|primary|secondary)(-(outline|link))?/,G=` +var x=t=>{throw TypeError(t)};var C=(t,r,e)=>r.has(t)||x("Cannot "+e);var i=(t,r,e)=>(C(t,r,"read from private field"),e?e.call(t):r.get(t)),l=(t,r,e)=>r.has(t)?x("Cannot add the same private member more than once"):r instanceof WeakSet?r.add(t):r.set(t,e),A=(t,r,e,n)=>(C(t,r,"write to private field"),n?n.call(t,e):r.set(t,e),e),a=(t,r,e)=>(C(t,r,"access private method"),e);var k=Object.freeze({MONTH:"MONTH",YEAR:"YEAR",TWO_YEARS:"TWO_YEARS",THREE_YEARS:"THREE_YEARS",PERPETUAL:"PERPETUAL",TERM_LICENSE:"TERM_LICENSE",ACCESS_PASS:"ACCESS_PASS",THREE_MONTHS:"THREE_MONTHS",SIX_MONTHS:"SIX_MONTHS"}),$=Object.freeze({ANNUAL:"ANNUAL",MONTHLY:"MONTHLY",TWO_YEARS:"TWO_YEARS",THREE_YEARS:"THREE_YEARS",P1D:"P1D",P1Y:"P1Y",P3Y:"P3Y",P10Y:"P10Y",P15Y:"P15Y",P3D:"P3D",P7D:"P7D",P30D:"P30D",HALF_YEARLY:"HALF_YEARLY",QUARTERLY:"QUARTERLY"});var b='span[is="inline-price"][data-wcs-osi]',V='a[is="checkout-link"][data-wcs-osi],button[is="checkout-button"][data-wcs-osi]';var y='a[is="upt-link"]',q=`${b},${V},${y}`;var S="aem:load";var Q=Object.freeze({SEGMENTATION:"segmentation",BUNDLE:"bundle",COMMITMENT:"commitment",RECOMMENDATION:"recommendation",EMAIL:"email",PAYMENT:"payment",CHANGE_PLAN_TEAM_PLANS:"change-plan/team-upgrade/plans",CHANGE_PLAN_TEAM_PAYMENT:"change-plan/team-upgrade/payment"});var Z=Object.freeze({STAGE:"STAGE",PRODUCTION:"PRODUCTION",LOCAL:"LOCAL"});var M="mas-ff-defaults";var G="mas-commerce-service";function O(){return document.getElementsByTagName(G)?.[0]}var D="mas-field",v=/(accent|primary|secondary)(-(outline|link))?/;function P(t,r){if(!t?.closest?.(D))return r;r[M]=!0}function B(t){!t?.providers||t.providers.has(P)||t.providers.price(P)}var K=` mas-field div[slot="footer"] { display: flex; gap: 24px; flex-wrap: wrap; align-items: center; } -`;if(!document.querySelector("style[data-mas-field]")){let n=document.createElement("style");n.setAttribute("data-mas-field",""),n.textContent=G,document.head.append(n)}var A,m,R,l,S,o,h,P,f,x,M,I,D,H,L=class extends HTMLElement{constructor(){super(...arguments);_(this,o);_(this,A,null);_(this,m,!1);_(this,R,null);_(this,l,null);_(this,S,e=>{e.target===this.aemFragment&&(d(this,R,e.detail?.fields||null),d(this,m,!0),i(this,o,M).call(this))})}static get observedAttributes(){return["field"]}attributeChangedCallback(e,t,r){e==="field"&&(d(this,A,r),i(this,o,M).call(this))}connectedCallback(){this.addEventListener(N,a(this,S)),i(this,o,h).call(this),this.aemFragment?.setAttribute("hidden","")}disconnectedCallback(){this.removeEventListener(N,a(this,S))}checkReady(){return a(this,m)?Promise.resolve(!0):new Promise(e=>{this.addEventListener(N,()=>e(!0),{once:!0})})}get aemFragment(){return this.querySelector("aem-fragment")}};A=new WeakMap,m=new WeakMap,R=new WeakMap,l=new WeakMap,S=new WeakMap,o=new WeakSet,h=function(){if(a(this,l)?.isConnected)return a(this,l);let e=this.querySelector(':scope > span[data-role="mas-field-content"]');if(e)return d(this,l,e),e;let t=document.createElement("span");return t.setAttribute("data-role","mas-field-content"),this.append(t),d(this,l,t),t},P=function(e){return e&&typeof e=="object"&&"value"in e?e.value:e},f=function(e){let t=e?.match(/^(.+)\[(\d+)\]$/);return t?{fieldName:t[1],index:parseInt(t[2],10)}:{fieldName:e,index:null}},x=function(e,t){if(typeof e!="string")return null;let r=document.createElement("template");r.innerHTML=e;let s=[...r.content.querySelectorAll("a")][t-1];return s?(s.removeAttribute("class"),s.outerHTML):null},M=function(){if(!a(this,R)||!a(this,A))return;let{fieldName:e,index:t}=i(this,o,f).call(this,a(this,A)),r=i(this,o,P).call(this,a(this,R)[e]);if(r===void 0)return;let s=i(this,o,h).call(this),c;if(t!==null){if(c=i(this,o,x).call(this,r,t),c===null)return}else c=i(this,o,H).call(this,r);if(typeof c=="string"){if(a(this,A)==="ctas"){let u=i(this,o,D).call(this,c);if(u){s.replaceChildren(u);return}}s.innerHTML=c;return}s.textContent=c==null?"":String(c)},I=function(e){if(!!!e.getAttribute("data-wcs-osi"))return e.cloneNode(!0);let r=b.exec(e.className??"")?.[0]??"accent",s=r.startsWith("accent"),c=r.includes("-link"),T=customElements.get("checkout-link")?.createCheckoutLink(e.dataset,e.textContent)??(()=>{let p=document.createElement("a",{is:"checkout-link"});return p.innerHTML=`${e.textContent}`,p})();for(let{name:p,value:Y}of e.attributes)["class","is","href"].includes(p)||T.setAttribute(p,Y);return T.firstElementChild?.classList.add("spectrum-Button-label"),c||(T.classList.add("button","con-button"),s?T.classList.add("blue"):r.startsWith("primary")&&!r.includes("-outline")&&T.classList.add("fill")),T},D=function(e){let r=[...new DOMParser().parseFromString(e,"text/html").body.querySelectorAll("a")];if(!r.length)return null;let s=document.createElement("div");return s.setAttribute("slot","footer"),s.append(...r.map(c=>i(this,o,I).call(this,c))),s},H=function(e){if(typeof e!="string")return e;let t=e.trim();if(!(t.startsWith("

")&&t.endsWith("

")))return e;let s=t.slice(3,-4);return s.includes("

")?e:s};customElements.define(F,L); +`;if(!document.querySelector("style[data-mas-field]")){let t=document.createElement("style");t.setAttribute("data-mas-field",""),t.textContent=K,document.head.append(t)}var _,m,T,d,R,o,L,I,H,g,f,w,U,Y,N=class extends HTMLElement{constructor(){super(...arguments);l(this,o);l(this,_,null);l(this,m,!1);l(this,T,null);l(this,d,null);l(this,R,e=>{e.target===this.aemFragment&&(A(this,T,e.detail?.fields||null),A(this,m,!0),a(this,o,f).call(this))})}static get observedAttributes(){return["field"]}attributeChangedCallback(e,n,s){e==="field"&&(A(this,_,s),a(this,o,f).call(this))}connectedCallback(){this.addEventListener(S,i(this,R)),a(this,o,L).call(this),this.aemFragment?.setAttribute("hidden",""),B(O())}disconnectedCallback(){this.removeEventListener(S,i(this,R))}checkReady(){return i(this,m)?Promise.resolve(!0):new Promise(e=>{this.addEventListener(S,()=>e(!0),{once:!0})})}get aemFragment(){return this.querySelector("aem-fragment")}};_=new WeakMap,m=new WeakMap,T=new WeakMap,d=new WeakMap,R=new WeakMap,o=new WeakSet,L=function(){if(i(this,d)?.isConnected)return i(this,d);let e=this.querySelector(':scope > span[data-role="mas-field-content"]');if(e)return A(this,d,e),e;let n=document.createElement("span");return n.setAttribute("data-role","mas-field-content"),this.append(n),A(this,d,n),n},I=function(e){return e&&typeof e=="object"&&"value"in e?e.value:e},H=function(e){let n=e?.match(/^(.+)\[(\d+)\]$/);return n?{fieldName:n[1],index:parseInt(n[2],10)}:{fieldName:e,index:null}},g=function(e,n){if(typeof e!="string")return null;let s=document.createElement("template");s.innerHTML=e;let c=[...s.content.querySelectorAll("a")][n-1];return c?(c.removeAttribute("class"),c.outerHTML):null},f=function(){if(!i(this,T)||!i(this,_))return;let{fieldName:e,index:n}=a(this,o,H).call(this,i(this,_)),s=a(this,o,I).call(this,i(this,T)[e]);if(s===void 0)return;let c=a(this,o,L).call(this),E;if(n!==null){if(E=a(this,o,g).call(this,s,n),E===null)return}else E=a(this,o,Y).call(this,s);if(typeof E=="string"){if(i(this,_)==="ctas"){let h=a(this,o,U).call(this,E);if(h){c.replaceChildren(h);return}}c.innerHTML=E;return}c.textContent=E==null?"":String(E)},w=function(e){if(!!!e.getAttribute("data-wcs-osi"))return e.cloneNode(!0);let s=v.exec(e.className??"")?.[0]??"accent",c=s.startsWith("accent"),E=s.includes("-link"),u=customElements.get("checkout-link")?.createCheckoutLink(e.dataset,e.textContent)??(()=>{let p=document.createElement("a",{is:"checkout-link"});return p.innerHTML=`${e.textContent}`,p})();for(let{name:p,value:F}of e.attributes)["class","is","href"].includes(p)||u.setAttribute(p,F);return u.firstElementChild?.classList.add("spectrum-Button-label"),E||(u.classList.add("button","con-button"),c?u.classList.add("blue"):s.startsWith("primary")&&!s.includes("-outline")&&u.classList.add("fill")),u},U=function(e){let s=[...new DOMParser().parseFromString(e,"text/html").body.querySelectorAll("a")];if(!s.length)return null;let c=document.createElement("div");return c.setAttribute("slot","footer"),c.append(...s.map(E=>a(this,o,w).call(this,E))),c},Y=function(e){if(typeof e!="string")return e;let n=e.trim();if(!(n.startsWith("

")&&n.endsWith("

")))return e;let c=n.slice(3,-4);return c.includes("

")?e:c};customElements.define(D,N);export{P as priceOptionsProvider}; diff --git a/web-components/src/mas-field.js b/web-components/src/mas-field.js index e1bf95033..3b8628fd9 100644 --- a/web-components/src/mas-field.js +++ b/web-components/src/mas-field.js @@ -1,8 +1,27 @@ -import { EVENT_AEM_LOAD } from './constants.js'; +import { EVENT_AEM_LOAD, FF_DEFAULTS } from './constants.js'; +import { getService } from './utils.js'; const MAS_FIELD_TAG = 'mas-field'; const CHECKOUT_STYLE_PATTERN = /(accent|primary|secondary)(-(outline|link))?/; +/** + * Opts headless mas-field-hosted inline-prices into FF_DEFAULTS so they + * resolve displayTax / displayPerUnit from country+language defaults + * (the same way merch-card does for its aem-fragment-backed prices). + * Without this, prices rendered through miss + * locale-driven labels like the FR_fr "TTC" tax indicator. + */ +export function priceOptionsProvider(element, options) { + if (!element?.closest?.(MAS_FIELD_TAG)) return options; + options[FF_DEFAULTS] = true; +} + +function registerPriceOptionsProvider(service) { + if (!service?.providers || service.providers.has(priceOptionsProvider)) + return; + service.providers.price(priceOptionsProvider); +} + const MAS_FIELD_STYLES = ` mas-field div[slot="footer"] { display: flex; @@ -49,6 +68,7 @@ class MasField extends HTMLElement { this.addEventListener(EVENT_AEM_LOAD, this.#onFragmentLoad); this.#ensureContentElement(); this.aemFragment?.setAttribute('hidden', ''); + registerPriceOptionsProvider(getService()); } /** Cleans up the event listener when removed from the DOM. */ diff --git a/web-components/test/mas-field.test.js b/web-components/test/mas-field.test.js index efdebe3ce..43996dca6 100644 --- a/web-components/test/mas-field.test.js +++ b/web-components/test/mas-field.test.js @@ -1,6 +1,8 @@ import { expect } from '@esm-bundle/chai'; import sinon from 'sinon'; import '../src/mas-field.js'; +import { priceOptionsProvider } from '../src/mas-field.js'; +import { FF_DEFAULTS } from '../src/constants.js'; const CTA_HTML = 'Buy now'; @@ -440,3 +442,39 @@ describe('mas-field – non-string field values', () => { ).to.equal(''); }); }); + +describe('mas-field – price options provider (locale defaults)', () => { + afterEach(() => { + document.body + .querySelectorAll('mas-field, span[is="inline-price"]') + .forEach((el) => el.remove()); + }); + + it('opts inline-prices inside mas-field into FF_DEFAULTS', () => { + const masField = document.createElement('mas-field'); + const inline = document.createElement('span'); + inline.setAttribute('is', 'inline-price'); + masField.append(inline); + document.body.append(masField); + + const options = {}; + priceOptionsProvider(inline, options); + expect(options[FF_DEFAULTS]).to.equal(true); + }); + + it('does not opt into FF_DEFAULTS for inline-prices outside mas-field', () => { + const inline = document.createElement('span'); + inline.setAttribute('is', 'inline-price'); + document.body.append(inline); + + const options = {}; + priceOptionsProvider(inline, options); + expect(options[FF_DEFAULTS]).to.be.undefined; + }); + + it('safely no-ops when element is null', () => { + const options = {}; + expect(() => priceOptionsProvider(null, options)).to.not.throw(); + expect(options[FF_DEFAULTS]).to.be.undefined; + }); +});