diff --git a/studio/src/common/components/mas-select-items-table.css.js b/studio/src/common/components/mas-select-items-table.css.js index 9a36fc6b6..668adb243 100644 --- a/studio/src/common/components/mas-select-items-table.css.js +++ b/studio/src/common/components/mas-select-items-table.css.js @@ -1,6 +1,7 @@ import { css } from 'lit'; import { tableHeaderBaseStyles, + tableBodyBaseStyles, tableCellBaseStyles, tableColumnIconStyles, tableSelectedRowStyles, @@ -9,6 +10,7 @@ import { export const styles = [ tableHeaderBaseStyles, + tableBodyBaseStyles, tableCellBaseStyles, tableColumnIconStyles, tableSelectedRowStyles, @@ -28,11 +30,6 @@ export const styles = [ border-radius: 12px; } - .fragments-table sp-table-head { - border: none; - border-radius: 0; - } - .fragments-table sp-table-head sp-table-head-cell:first-of-type, .fragments-table sp-table-head sp-table-head-cell:last-of-type, .fragments-table sp-table-head sp-table-checkbox-cell:first-of-type { @@ -50,6 +47,7 @@ export const styles = [ z-index: 10; background: var(--spectrum-gray-75); box-shadow: 0 -2px 0 0 var(--spectrum-gray-75); + border-bottom: 1px solid var(--spectrum-gray-300); } sp-table-head sp-table-head-cell, @@ -64,6 +62,10 @@ export const styles = [ sp-table-cell { word-break: break-word; } + + sp-table-body > mas-collapsible-table-row + mas-collapsible-table-row { + border-top: 1px solid var(--spectrum-gray-300); + } } .fragments-table[selects='multiple'] { diff --git a/studio/src/common/styles/table-styles.css.js b/studio/src/common/styles/table-styles.css.js index 3dd655b15..dda4f8d8e 100644 --- a/studio/src/common/styles/table-styles.css.js +++ b/studio/src/common/styles/table-styles.css.js @@ -21,13 +21,6 @@ export const tableHeaderBaseStyles = css` --mod-table-border-radius: 0; } - .item-table sp-table-head { - border-top: 1px solid var(--spectrum-gray-300); - border-left: 1px solid var(--spectrum-gray-300); - border-right: 1px solid var(--spectrum-gray-300); - border-radius: 12px 12px 0 0; - } - .item-table sp-table-head-cell { align-content: center; } @@ -41,6 +34,12 @@ export const tableHeaderBaseStyles = css` } `; +export const tableBodyBaseStyles = css` + .item-table sp-table-body { + border: none; + } +`; + export const tableColumnIconStyles = css` .table-icon-cell { display: flex; diff --git a/studio/src/common/utils/items-loader.js b/studio/src/common/utils/items-loader.js index fd02b9f05..5a0810fde 100644 --- a/studio/src/common/utils/items-loader.js +++ b/studio/src/common/utils/items-loader.js @@ -14,6 +14,74 @@ import { enrichCards, } from './item-loading.js'; +/** + * Resolves parent OSI once when any variation lacks its own OSI (prefers in-memory parent), loads WCS offer + * data per variation via {@link loadOfferData} fallback (no mutation of fragment payloads), and attaches studioPath. + * @param {Object} params + * @param {{ path: string, parentFragment?: Object }} params.parent - Parent card path and optional in-memory parent fragment (e.g. from store) so OSI can be read without fetching by path first + * @param {Array} params.variations - Variation fragments with fieldTags + * @param {Object} params.repository - MasRepository with aem.getFragmentByPath + * @param {AbortSignal} params.signal - Optional abort for offer requests + * @returns {Promise>} Same variations with studioPath and offerData + */ +async function enrichGroupedVariationsWithOffers({ parent, variations, repository, signal }) { + if (!variations?.length || !repository) return []; + + const needsParentOsiFallback = variations.some((variation) => !new Fragment(variation).getFieldValue('osi')); + + let parentWcsOsi; + if (needsParentOsiFallback) { + parentWcsOsi = parent.parentFragment ? new Fragment(parent.parentFragment).getFieldValue('osi') : undefined; + if (!parentWcsOsi) { + const parentFromAem = await repository.aem.getFragmentByPath(parent.path); + parentWcsOsi = parentFromAem ? new Fragment(parentFromAem).getFieldValue('osi') : undefined; + } + } + + const offerDataResults = await processConcurrently( + variations, + (variation) => + loadOfferData(variation, signal, 10000, { + fallbackWcsOsi: parentWcsOsi, + }), + VARIATIONS_CONCURRENCY_LIMIT, + ); + + return variations.map((variation, index) => ({ + ...variation, + studioPath: getFragmentName(new Fragment(variation)), + offerData: offerDataResults[index] ?? null, + })); +} + +/** + * Resolves WCS offer data for a view-only table row: uses the fragment's own OSI, or parent card OSI when the row is a grouped variation without OSI (same idea as {@link enrichGroupedVariationsWithOffers}). + * @param {Object} card - Fragment payload (may be a parent card or a /pzn/ variation) + * @param {Object} repository - MasRepository + * @param {AbortSignal} [signal] - Abort signal + * @returns {Promise} + */ +async function loadOfferDataForViewOnlyCard(card, repository, signal) { + if (!repository || !card) return null; + + const ownOsi = new Fragment(card).getFieldValue('osi'); + if (ownOsi) { + return loadOfferData(card, signal); + } + + let fallbackWcsOsi; + if (repository.resolveHydratedParentFragment && Fragment.isGroupedVariationPath(card.path)) { + try { + const parentFromAem = await repository.resolveHydratedParentFragment(card.path); + fallbackWcsOsi = new Fragment(parentFromAem).getFieldValue('osi'); + } catch (err) { + console.warn(`Failed to load parent fragment for grouped variation ${card.id}:`, err.message); + } + } + + return loadOfferData(card, signal, 10000, { fallbackWcsOsi }); +} + /** * Loads grouped variations for a card fragment * @param {Object} card - Card object with path, references, fields diff --git a/studio/src/common/utils/render-utils.js b/studio/src/common/utils/render-utils.js index 4c8c26b5b..0ca04fc2f 100644 --- a/studio/src/common/utils/render-utils.js +++ b/studio/src/common/utils/render-utils.js @@ -29,7 +29,7 @@ export function renderFragmentStatusCell(status) { export function getItemTypeLabel(item) { if (!item) return 'Unknown'; if (Fragment.isGroupedVariationPath(item.path)) return 'Grouped variation'; - if (item.model?.path?.includes('/dictionary/')) return 'Placeholder'; + if (item.model?.path?.includes('/dictionnary/')) return 'Placeholder'; if (item.model?.path === COLLECTION_MODEL_PATH) return 'Collection'; if (item.model?.path === CARD_MODEL_PATH) return 'Default'; return 'Unknown'; diff --git a/studio/src/translation/mas-collapsible-table-row.css.js b/studio/src/translation/mas-collapsible-table-row.css.js index c1d54f4e2..8acf0799a 100644 --- a/studio/src/translation/mas-collapsible-table-row.css.js +++ b/studio/src/translation/mas-collapsible-table-row.css.js @@ -12,6 +12,12 @@ export const styles = [ tableSelectedRowStyles, loadingContainerFlexStyles, css` + :host { + display: block; + width: 100%; + box-sizing: border-box; + } + .loading-container--flex { padding: 10px; width: 100%; @@ -97,65 +103,13 @@ export const styles = [ } .nested-content { - --connector-offset: 30px; - position: relative; - margin-left: 60px; - } - - .nested-content.has-connector::before { - content: ''; - position: absolute; - left: calc(-1 * var(--connector-offset)); - top: 0; - bottom: var(--nested-content-connector-bottom, 0px); - width: 1px; - background-color: var(--spectrum-gray-400); + margin-left: 30px; } .nested-content sp-table { width: 100%; } - .nested-content sp-table-body { - position: relative; - } - - .nested-content sp-table-body::before { - content: ''; - position: absolute; - left: calc(-1 * var(--connector-offset)); - top: 0; - width: 1px; - background-color: var(--spectrum-gray-400); - } - - .nested-content sp-table-body sp-table-row { - position: relative; - } - - .nested-content sp-table-body sp-table-row:not(.variation-details-row)::before { - content: ''; - position: absolute; - left: -30px; - top: 50%; - transform: translateY(-50%); - width: 30px; - height: 1px; - background-color: var(--spectrum-gray-400); - } - - .nested-content sp-table-body sp-table-row:not(.variation-details-row)::after { - content: ''; - position: absolute; - left: -3px; - top: 50%; - transform: translate(-50%, -50%); - width: 6px; - height: 6px; - border-radius: 50%; - background-color: var(--spectrum-gray-400); - } - .nested-content sp-table-body sp-table-row:first-of-type:not(.variation-details-row) { sp-table-cell:first-of-type { border-top-left-radius: 12px; diff --git a/studio/src/translation/mas-collapsible-table-row.js b/studio/src/translation/mas-collapsible-table-row.js index be3b6aad2..b550d59ec 100644 --- a/studio/src/translation/mas-collapsible-table-row.js +++ b/studio/src/translation/mas-collapsible-table-row.js @@ -17,7 +17,6 @@ export class MasCollapsibleTableRow extends LitElement { isTopLevelExpanded: { type: Boolean }, expandedVariationsPaths: { type: Set, state: true }, isLoadingVariations: { type: Boolean, state: true }, - resizeObserver: { type: Object }, repository: { type: Object, state: true }, getDisplayName: { type: Function }, renderFragmentStatusCell: { type: Function }, @@ -43,7 +42,8 @@ export class MasCollapsibleTableRow extends LitElement { } this.isTopLevelExpanded = false; this.expandedVariationsPaths = new Set(); - this.resizeObserver = null; + this.variationsController = new ReactiveController(this, [getItemsSelectionStore().groupedVariationsByParent]); + this.selectedCardsController = new ReactiveController(this, [getItemsSelectionStore().selectedCards]); this.variationsController = new ReactiveController(this, [getItemsSelectionStore().groupedVariationsByParent]); this.selectedCardsController = new ReactiveController(this, [getItemsSelectionStore().selectedCards]); } @@ -55,26 +55,6 @@ export class MasCollapsibleTableRow extends LitElement { this.repository = document.querySelector('mas-repository'); } - updated(changedProperties) { - super.updated(changedProperties); - if (changedProperties.has('isTopLevelExpanded')) { - if (this.isTopLevelExpanded) { - requestAnimationFrame(() => { - this.#updateConnectorBottom(); - this.#observeResize(); - }); - } else { - this.resizeObserver?.disconnect(); - } - } - } - - disconnectedCallback() { - super.disconnectedCallback(); - this.resizeObserver?.disconnect(); - this.resizeObserver = null; - } - get variationPaths() { return new Fragment(this.topLevelCard).getVariations() || []; } @@ -119,6 +99,7 @@ export class MasCollapsibleTableRow extends LitElement { value=${variationPath} ?selected=${isSelected} aria-selected=${isSelected ? 'true' : 'false'} + @click=${(event) => this.#onRowClickForSelection(event, variationPath)} > this.#toggleExpandVariation(e, variationPath)} > ${isExpanded - ? html`` - : html``} + ? html`` + : html``} this.#toggleSelect(e, variationPath)} + @change=${(event) => this.#toggleSelect(event, variationPath)} > ${repeat(this.cells, (cell) => this[`render${cell}`](variation) ?? nothing)} @@ -165,8 +146,8 @@ export class MasCollapsibleTableRow extends LitElement { @click=${this.#toggleExpandTopLevel} > ${this.isTopLevelExpanded - ? html`` - : html``} + ? html`` + : html``} ` : html``} @@ -257,37 +238,15 @@ export class MasCollapsibleTableRow extends LitElement { } } - #hasConnector(tab) { - return tab?.key === 'groupedVariation' && this.topLevelCardVariationsByPaths.size > 0; - } - - /** Updates the bottom position of the connector between the nested content and the last row of the selected tab panel */ - #updateConnectorBottom() { - const nestedContent = this.shadowRoot?.querySelector('.nested-content'); - if (!nestedContent) return; - - const selectedTabPanel = nestedContent.querySelector('sp-tab-panel[selected]'); - const rows = selectedTabPanel?.querySelectorAll('sp-table-row:not(.variation-details-row)'); - const lastRow = rows?.[rows.length - 1]; - if (!lastRow) return; - - let connectorBottom = lastRow.offsetHeight / 2 + 16; - if (this.expandedVariationsPaths.has(lastRow.getAttribute('value'))) { - connectorBottom += lastRow.nextElementSibling?.offsetHeight ?? 0; - } - nestedContent.style.setProperty('--nested-content-connector-bottom', `${connectorBottom}px`); - } - - /** Observes the resize of the nested content when user changes the window width, and updates the bottom position of the connector */ - #observeResize() { - const nestedContent = this.shadowRoot?.querySelector('.nested-content'); - if (!nestedContent) return; - - this.resizeObserver?.disconnect(); - this.resizeObserver = new ResizeObserver(() => { - this.#updateConnectorBottom(); + #onRowClickForSelection(e, path) { + const shouldIgnore = e.composedPath().some((node) => { + if (!(node instanceof Element)) return false; + if (node.tagName === 'SP-CHECKBOX') return true; + if (node.classList?.contains('expand-button')) return true; + if (node.tagName === 'SP-ACTION-BUTTON') return true; }); - this.resizeObserver.observe(nestedContent); + if (shouldIgnore) return; + this.#toggleSelect(e, path); } #toggleSelect(e, path) { @@ -369,12 +328,13 @@ export class MasCollapsibleTableRow extends LitElement { value=${this.topLevelCard.path} ?selected=${isSelected} aria-selected=${isSelected ? 'true' : 'false'} + @click=${(e) => this.#onRowClickForSelection(e, this.topLevelCard.path)} > ${this.isTopLevelExpanded - ? html`` - : html``} + ? html`` + : html``} @@ -389,7 +349,7 @@ export class MasCollapsibleTableRow extends LitElement { ${this.isTopLevelExpanded ? html`
-
+
${repeat( this.tabs, diff --git a/studio/src/translation/mas-translation.css.js b/studio/src/translation/mas-translation.css.js index 45f26b5c7..479b62a22 100644 --- a/studio/src/translation/mas-translation.css.js +++ b/studio/src/translation/mas-translation.css.js @@ -1,10 +1,11 @@ import { css } from 'lit'; -import { tableHeaderBaseStyles, tableCellBaseStyles } from './translation-common-styles.css.js'; +import { tableHeaderBaseStyles, tableBodyBaseStyles, tableCellBaseStyles } from './translation-common-styles.css.js'; import { skeletonStyles } from '../common/skeleton-styles.css.js'; export const styles = [ skeletonStyles, tableHeaderBaseStyles, + tableBodyBaseStyles, tableCellBaseStyles, css` .translation-container { diff --git a/studio/src/translation/translation-common-styles.css.js b/studio/src/translation/translation-common-styles.css.js index e3d213c44..617c10865 100644 --- a/studio/src/translation/translation-common-styles.css.js +++ b/studio/src/translation/translation-common-styles.css.js @@ -1,6 +1,11 @@ import { css } from 'lit'; -export { ghostButtonStyles, tableHeaderBaseStyles, tableCellBaseStyles } from '../common/styles/table-styles.css.js'; +export { + ghostButtonStyles, + tableHeaderBaseStyles, + tableBodyBaseStyles, + tableCellBaseStyles, +} from '../common/styles/table-styles.css.js'; export const loadingContainerCenteredStyles = css` .loading-container--absolute { diff --git a/studio/test/translation/mas-collapsible-table-row.test.js b/studio/test/translation/mas-collapsible-table-row.test.js index 3f6a1ab69..7496e5fba 100644 --- a/studio/test/translation/mas-collapsible-table-row.test.js +++ b/studio/test/translation/mas-collapsible-table-row.test.js @@ -148,66 +148,6 @@ describe('MasCollapsibleTableRow', () => { }); }); - describe('connector visibility', () => { - it('should not add has-connector class when tab is not groupedVariation', async () => { - const topLevelCard = createMockTopLevelCard({ variationPaths: ['/path/v1'] }); - setupCardVariationsInStore(topLevelCard.path, []); - const el = await fixture( - html``, - ); - await el.updateComplete; - const nestedContent = el.shadowRoot.querySelector('.nested-content'); - expect(nestedContent?.classList.contains('has-connector')).to.be.false; - }); - - it('should not add has-connector class when tab is groupedVariation but no variation paths', async () => { - const topLevelCard = createMockTopLevelCard({ variationPaths: [] }); - setupCardVariationsInStore(topLevelCard.path, []); - const el = await fixture( - html``, - ); - await el.updateComplete; - const nestedContent = el.shadowRoot.querySelector('.nested-content'); - expect(nestedContent?.classList.contains('has-connector')).to.be.false; - }); - - it('should add has-connector class when groupedVariation tab is selected and has variation paths', async () => { - const topLevelCard = createMockTopLevelCard({ variationPaths: ['/path/v1'] }); - const mockVariation = { path: '/path/v1', title: 'Var 1' }; - setupCardVariationsInStore(topLevelCard.path, [mockVariation]); - const el = await fixture( - html``, - ); - await el.updateComplete; - const nestedContent = el.shadowRoot.querySelector('.nested-content'); - expect(nestedContent?.classList.contains('has-connector')).to.be.true; - }); - - it('should not add has-connector class when groupedVariation tab is selected and has no variation paths', async () => { - const topLevelCard = createMockTopLevelCard({ variationPaths: [] }); - setupCardVariationsInStore(topLevelCard.path, []); - const el = await fixture( - html``, - ); - await el.updateComplete; - const nestedContent = el.shadowRoot.querySelector('.nested-content'); - expect(nestedContent?.classList.contains('has-connector')).to.be.false; - }); - }); - describe('rendering', () => { it('should render main table row with topLevelCard path', async () => { const topLevelCard = createMockTopLevelCard(); @@ -576,6 +516,32 @@ describe('MasCollapsibleTableRow', () => { await el.updateComplete; expect(Store.translationProjects.selectedCards.value).to.not.include(topLevelCard.path); }); + + it('should add path to selectedCards when row is clicked outside checkbox', async () => { + const topLevelCard = createMockTopLevelCard(); + Store.translationProjects.selectedCards.set([]); + const el = await fixture( + html``, + ); + const row = el.shadowRoot.querySelector('sp-table-row'); + const titleCell = row.querySelector('sp-table-cell:nth-of-type(4)'); + titleCell.click(); + await el.updateComplete; + expect(Store.translationProjects.selectedCards.value).to.include(topLevelCard.path); + }); + + it('should not change selectedCards when expand button is clicked', async () => { + const topLevelCard = createMockTopLevelCard({ variationPaths: [] }); + setupCardVariationsInStore(topLevelCard.path, []); + Store.translationProjects.selectedCards.set([]); + const el = await fixture( + html``, + ); + const expandButton = el.shadowRoot.querySelector('.expand-button'); + expandButton.click(); + await el.updateComplete; + expect(Store.translationProjects.selectedCards.value).to.deep.equal([]); + }); }); describe('grouped variations tab', () => { @@ -850,16 +816,6 @@ describe('MasCollapsibleTableRow', () => { }); describe('lifecycle', () => { - it('should remove the resize observer on disconnect', async () => { - const topLevelCard = createMockTopLevelCard({ variationPaths: [] }); - setupCardVariationsInStore(topLevelCard.path, []); - const el = await fixture( - html``, - ); - el.remove(); - expect(el.resizeObserver).to.be.null; - }); - it('should set value attribute from topLevelCard path in connectedCallback', async () => { const topLevelCard = createMockTopLevelCard({ path: '/custom/path' }); const el = await fixture( diff --git a/studio/test/translation/mas-select-items-table.test.js b/studio/test/translation/mas-select-items-table.test.js index 38a459e1d..2871f6c71 100644 --- a/studio/test/translation/mas-select-items-table.test.js +++ b/studio/test/translation/mas-select-items-table.test.js @@ -966,6 +966,22 @@ describe('MasSelectItemsTable', () => { }); }); + describe('viewOnly loading spinner (reactive state)', () => { + it('should hide spinner when viewOnlyLoading becomes false without a store update', async () => { + const card = createMockCard('/path/card1', 'Card 1'); + const el = await fixture(html``); + el.viewOnlyLoading = true; + el.viewOnlyFragments = []; + await el.updateComplete; + expect(el.shadowRoot.querySelector('sp-progress-circle')).to.exist; + el.viewOnlyLoading = false; + el.viewOnlyFragments = [card]; + await el.updateComplete; + expect(el.shadowRoot.querySelector('sp-progress-circle')).to.be.null; + expect(el.shadowRoot.querySelector('sp-table')).to.exist; + }); + }); + describe('viewOnly mode rendering', () => { it('should render table when viewOnlyFragments has items', async () => { const card = createMockCard('/path/card1', 'Card 1'); diff --git a/studio/test/translation/translation-items-loader.test.js b/studio/test/translation/translation-items-loader.test.js index 086f89c09..e85486dd5 100644 --- a/studio/test/translation/translation-items-loader.test.js +++ b/studio/test/translation/translation-items-loader.test.js @@ -338,6 +338,106 @@ describe('translation-items-loader', () => { expect(items[0].groupedVariations).to.be.an('array'); }); + it('should resolve offerData for a grouped variation row via parent OSI when variation has no osi', async () => { + const parentPath = '/content/dam/mas/acom/en_US/cards/parent-card'; + const variationPath = `${parentPath}/pzn/var-a`; + const mockParent = { + path: parentPath, + title: 'Parent', + model: { path: CARD_MODEL_PATH }, + tags: [], + fields: [{ name: 'osi', values: ['parent-osi'] }], + references: [], + }; + const mockVariation = { + path: variationPath, + title: 'Var A', + model: { path: CARD_MODEL_PATH }, + tags: [], + fields: [{ name: 'name', values: ['v'] }], + fieldTags: [{ id: 't1', name: 'T1' }], + references: [], + }; + const resolveHydratedParentFragment = sinon.stub().resolves(mockParent); + const repo = { + aem: { + getFragmentByPath: sinon.stub().callsFake((requestedPath) => { + if (requestedPath === variationPath) return Promise.resolve(mockVariation); + return Promise.resolve(null); + }), + }, + resolveHydratedParentFragment, + }; + const onItems = sinon.stub(); + + await loadSelectedFragments([variationPath], TABLE_TYPE.CARDS, repo, { onItems }); + + expect(onItems.called).to.be.true; + const items = onItems.firstCall.args[0]; + expect(items).to.have.lengthOf(1); + expect(items[0].path).to.equal(variationPath); + expect(repo.aem.getFragmentByPath.calledWith(variationPath)).to.be.true; + expect(resolveHydratedParentFragment.calledOnceWith(variationPath)).to.be.true; + expect(items[0].offerData).to.deep.equal({ offerId: 'test-offer-id' }); + }); + + it('should not call resolveHydratedParentFragment when grouped variation row has its own osi', async () => { + const parentPath = '/content/dam/mas/acom/en_US/cards/parent-card'; + const variationPath = `${parentPath}/pzn/var-own-osi`; + const mockVariation = { + path: variationPath, + title: 'Var', + model: { path: CARD_MODEL_PATH }, + tags: [], + fields: [ + { name: 'osi', values: ['variation-osi'] }, + { name: 'name', values: ['v'] }, + ], + fieldTags: [{ id: 't1', name: 'T1' }], + references: [], + }; + const resolveHydratedParentFragment = sinon.stub().resolves(null); + const repo = { + aem: { + getFragmentByPath: sinon.stub().resolves(mockVariation), + }, + resolveHydratedParentFragment, + }; + const onItems = sinon.stub(); + + await loadSelectedFragments([variationPath], TABLE_TYPE.CARDS, repo, { onItems }); + + expect(resolveHydratedParentFragment.called).to.be.false; + const items = onItems.firstCall.args[0]; + expect(items[0].offerData).to.deep.equal({ offerId: 'test-offer-id' }); + }); + + it('should leave offerData null for grouped variation without osi when resolveHydratedParentFragment returns null', async () => { + const parentPath = '/content/dam/mas/acom/en_US/cards/parent-card'; + const variationPath = `${parentPath}/pzn/var-no-parent`; + const mockVariation = { + path: variationPath, + title: 'Var', + model: { path: CARD_MODEL_PATH }, + tags: [], + fields: [{ name: 'name', values: ['v'] }], + fieldTags: [{ id: 't1', name: 'T1' }], + references: [], + }; + const repo = { + aem: { + getFragmentByPath: sinon.stub().resolves(mockVariation), + }, + resolveHydratedParentFragment: sinon.stub().resolves(null), + }; + const onItems = sinon.stub(); + + await loadSelectedFragments([variationPath], TABLE_TYPE.CARDS, repo, { onItems }); + + const items = onItems.firstCall.args[0]; + expect(items[0].offerData).to.be.null; + }); + it('should call onItems with empty array on error and not throw', async () => { const onItems = sinon.stub(); const repo = { @@ -511,6 +611,94 @@ describe('translation-items-loader', () => { expect(result.get(cardPath1).get('/card/1/v1')).to.exist; expect(result.get(cardPath2).get(varPath2)).to.exist; }); + + it('should resolve offerData using parent card OSI when variation has empty osi values', async () => { + const cardPath = '/content/dam/mas/acom/en_US/cards/parent-with-osi'; + const variationPath = `${cardPath}/pzn/locale-pl`; + const mockVariation = { + path: variationPath, + fieldTags: [{ id: 'tag1', name: 'Tag1' }], + fields: [{ name: 'osi', values: [] }], + }; + const mockParent = { + path: cardPath, + fields: [{ name: 'osi', values: ['parent-wcs-osi'] }], + }; + const repo = { + aem: { + getFragmentByPath: sinon.stub().callsFake((requestedPath) => { + if (requestedPath === variationPath) return Promise.resolve(mockVariation); + if (requestedPath === cardPath) return Promise.resolve(mockParent); + return Promise.resolve(null); + }), + }, + }; + + await loadCardVariations(cardPath, [variationPath], repo); + + expect(repo.aem.getFragmentByPath.calledWith(cardPath)).to.be.true; + const variation = Store.translationProjects.groupedVariationsByParent.value?.get(cardPath)?.get(variationPath); + expect(variation).to.exist; + expect(variation.offerData).to.deep.equal({ offerId: 'test-offer-id' }); + }); + + it('should fetch parent path once for OSI when multiple variations lack OSI', async () => { + const cardPath = '/content/dam/mas/acom/en_US/cards/multi-parent'; + const variationPathA = `${cardPath}/pzn/a`; + const variationPathB = `${cardPath}/pzn/b`; + const mockVariation = (path) => ({ + path, + fieldTags: [{ id: 'tag1', name: 'Tag1' }], + fields: [], + }); + const mockParent = { + path: cardPath, + fields: [{ name: 'osi', values: ['shared-parent-osi'] }], + }; + const repo = { + aem: { + getFragmentByPath: sinon.stub().callsFake((requestedPath) => { + if (requestedPath === variationPathA) return Promise.resolve(mockVariation(variationPathA)); + if (requestedPath === variationPathB) return Promise.resolve(mockVariation(variationPathB)); + if (requestedPath === cardPath) return Promise.resolve(mockParent); + return Promise.resolve(null); + }), + }, + }; + + await loadCardVariations(cardPath, [variationPathA, variationPathB], repo); + + const parentFetchCalls = repo.aem.getFragmentByPath.getCalls().filter((call) => call.args[0] === cardPath); + expect(parentFetchCalls.length).to.equal(1); + }); + + it('should resolve offerData when variation has no osi field and parent provides OSI', async () => { + const cardPath = '/content/dam/mas/acom/en_US/cards/no-osi-field'; + const variationPath = `${cardPath}/pzn/v1`; + const mockVariation = { + path: variationPath, + fieldTags: [{ id: 'tag1', name: 'Tag1' }], + fields: [{ name: 'variant', values: ['default'] }], + }; + const mockParent = { + path: cardPath, + fields: [{ name: 'osi', values: ['parent-wcs-osi'] }], + }; + const repo = { + aem: { + getFragmentByPath: sinon.stub().callsFake((requestedPath) => { + if (requestedPath === variationPath) return Promise.resolve(mockVariation); + if (requestedPath === cardPath) return Promise.resolve(mockParent); + return Promise.resolve(null); + }), + }, + }; + + await loadCardVariations(cardPath, [variationPath], repo); + + const variation = Store.translationProjects.groupedVariationsByParent.value?.get(cardPath)?.get(variationPath); + expect(variation.offerData).to.deep.equal({ offerId: 'test-offer-id' }); + }); }); describe('fetchVariationByPath', () => { @@ -570,6 +758,65 @@ describe('translation-items-loader', () => { expect(variation).to.have.property('offerData'); expect(variation).to.have.property('studioPath'); }); + + it('should resolve offerData using parent OSI when variation has empty osi values', async () => { + const cardPath = '/content/dam/mas/acom/en_US/cards/parent-with-osi'; + const fullVariationPath = `${cardPath}/pzn/var-locale`; + const mockVariation = { + path: fullVariationPath, + fieldTags: [{ id: 'tag1', name: 'Tag1' }], + fields: [{ name: 'osi', values: [] }], + }; + const mockParent = { + path: cardPath, + fields: [{ name: 'osi', values: ['parent-wcs-osi'] }], + }; + const repo = { + aem: { + getFragmentByPath: sinon.stub().callsFake((requestedPath) => { + if (requestedPath === fullVariationPath) return Promise.resolve(mockVariation); + if (requestedPath === cardPath) return Promise.resolve(mockParent); + return Promise.resolve(null); + }), + }, + }; + + const result = await fetchVariationByPath(fullVariationPath, repo); + + expect(result).to.be.true; + expect(repo.aem.getFragmentByPath.calledWith(cardPath)).to.be.true; + const variation = Store.translationProjects.groupedVariationsByParent.value?.get(cardPath)?.get(fullVariationPath); + expect(variation.offerData).to.deep.equal({ offerId: 'test-offer-id' }); + }); + + it('should resolve offerData when variation omits osi field and parent has OSI', async () => { + const cardPath = '/content/dam/mas/acom/en_US/cards/parent-no-osi-field'; + const fullVariationPath = `${cardPath}/pzn/locale-xx`; + const mockVariation = { + path: fullVariationPath, + fieldTags: [{ id: 'tag1', name: 'Tag1' }], + fields: [{ name: 'variant', values: ['default'] }], + }; + const mockParent = { + path: cardPath, + fields: [{ name: 'osi', values: ['parent-wcs-osi'] }], + }; + const repo = { + aem: { + getFragmentByPath: sinon.stub().callsFake((requestedPath) => { + if (requestedPath === fullVariationPath) return Promise.resolve(mockVariation); + if (requestedPath === cardPath) return Promise.resolve(mockParent); + return Promise.resolve(null); + }), + }, + }; + + const result = await fetchVariationByPath(fullVariationPath, repo); + + expect(result).to.be.true; + const variation = Store.translationProjects.groupedVariationsByParent.value?.get(cardPath)?.get(fullVariationPath); + expect(variation.offerData).to.deep.equal({ offerId: 'test-offer-id' }); + }); }); describe('fetchUnresolvedVariations', () => {