From e5b2c4e6162ae655eb98d16033d5180cb3af519e Mon Sep 17 00:00:00 2001 From: Andrei Tanasa Date: Thu, 9 Apr 2026 20:12:12 +0300 Subject: [PATCH 1/9] MWPW-192158: add item loading utils for bulk publish --- .../src/common/utils/item-loading-browser.js | 54 +++ studio/src/common/utils/item-loading.js | 398 ++++++++++++++++++ .../test/common/item-loading-browser.test.js | 75 ++++ studio/test/common/item-loading.test.js | 343 +++++++++++++++ 4 files changed, 870 insertions(+) create mode 100644 studio/src/common/utils/item-loading-browser.js create mode 100644 studio/src/common/utils/item-loading.js create mode 100644 studio/test/common/item-loading-browser.test.js create mode 100644 studio/test/common/item-loading.test.js diff --git a/studio/src/common/utils/item-loading-browser.js b/studio/src/common/utils/item-loading-browser.js new file mode 100644 index 000000000..4bdc23053 --- /dev/null +++ b/studio/src/common/utils/item-loading-browser.js @@ -0,0 +1,54 @@ +import { getService } from '../../utils.js'; + +/** + * Loads offer data for a fragment using its OSI field. + * @param {Object} fragment + * @param {Object} options + * @param {Map} [options.cache] + * @param {AbortSignal} [options.signal] + * @param {number} [options.timeoutMs] + * @returns {Promise} + */ +export async function loadOfferData(fragment, { cache = new Map(), signal, timeoutMs = 10000 } = {}) { + const wcsOsi = fragment?.fields?.find(({ name }) => name === 'osi')?.values?.[0]; + if (!wcsOsi) return null; + + try { + if (cache.has(wcsOsi)) { + return cache.get(wcsOsi); + } + + if (signal?.aborted) return null; + + const service = getService(); + const priceOptions = service.collectPriceOptions({ wcsOsi }); + const [offersPromise] = service.resolveOfferSelectors(priceOptions); + if (!offersPromise) return null; + + let timeoutId; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error('Request timeout')), timeoutMs); + }); + + try { + const [offer] = await Promise.race([offersPromise, timeoutPromise]); + clearTimeout(timeoutId); + + if (signal?.aborted) return null; + + cache.set(wcsOsi, offer); + return offer; + } catch (error) { + clearTimeout(timeoutId); + throw error; + } + } catch (error) { + console.warn(`Failed to load offer data for fragment ${fragment?.id}:`, error.message); + + if (!signal?.aborted) { + cache.set(wcsOsi, null); + } + + return null; + } +} diff --git a/studio/src/common/utils/item-loading.js b/studio/src/common/utils/item-loading.js new file mode 100644 index 000000000..7b8ba6e03 --- /dev/null +++ b/studio/src/common/utils/item-loading.js @@ -0,0 +1,398 @@ +import { Fragment } from '../../aem/fragment.js'; +import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../../constants.js'; + +export const OFFER_DATA_CONCURRENCY_LIMIT = 5; +export const VARIATIONS_CONCURRENCY_LIMIT = 5; + +/** + * Yields control to the event loop. + * @returns {Promise} + */ +export async function yieldToMain() { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +/** + * Processes async tasks with a concurrency limit and periodic yielding. + * @param {Array} items + * @param {Function} asyncFn + * @param {number} concurrencyLimit + * @param {number} batchSize + * @returns {Promise} + */ +export async function processConcurrently(items, asyncFn, concurrencyLimit, batchSize = 20) { + const results = new Array(items.length); + const executing = []; + let processedCount = 0; + + for (let i = 0; i < items.length; i++) { + const promise = Promise.resolve().then(() => asyncFn(items[i], i)); + results[i] = promise; + + if (concurrencyLimit <= items.length) { + const execution = promise.then(() => { + executing.splice(executing.indexOf(execution), 1); + processedCount += 1; + }); + executing.push(execution); + + if (executing.length >= concurrencyLimit) { + await Promise.race(executing); + } + + if (processedCount > 0 && processedCount % batchSize === 0) { + await yieldToMain(); + } + } + } + + await Promise.all(executing); + return Promise.all(results); +} + +/** + * Returns a flat path -> item map. + * @param {Array} items + * @returns {Map} + */ +export function buildItemsByPath(items = []) { + return new Map(items.map((item) => [item.path, item])); +} + +/** + * Selects items from a path map in the same order as selectedPaths. + * @param {Array} selectedPaths + * @param {Map} itemsByPath + * @returns {Array} + */ +export function selectItemsByPath(selectedPaths = [], itemsByPath = new Map()) { + return selectedPaths.map((path) => itemsByPath.get(path)).filter(Boolean); +} + +/** + * Flattens grouped variations by parent into a path -> variation map. + * @param {Map>} groupedVariationsByParent + * @returns {Map} + */ +export function flattenGroupedVariationsByParent(groupedVariationsByParent = new Map()) { + const flattened = new Map(); + for (const variationsMap of groupedVariationsByParent.values()) { + for (const [path, variation] of variationsMap) { + flattened.set(path, variation); + } + } + return flattened; +} + +/** + * Parses mixed fragment entries into cards and collections with optional display names. + * @param {Array<{ value?: Object }>} allFragments + * @param {Object} options + * @param {Function} [options.getDisplayName] + * @returns {{ allCards: Array, allCollections: Array }} + */ +export function parseFragmentsFromStore(allFragments = [], { getDisplayName } = {}) { + return allFragments.reduce( + (acc, fragmentStore) => { + const fragment = fragmentStore?.value ?? fragmentStore; + const mappedFragment = { + ...fragment, + ...(getDisplayName ? { studioPath: getDisplayName(fragment) } : {}), + }; + + if (fragment?.model?.path === CARD_MODEL_PATH) { + acc.allCards.push(mappedFragment); + } else if (fragment?.model?.path === COLLECTION_MODEL_PATH) { + acc.allCollections.push(mappedFragment); + } + + return acc; + }, + { allCards: [], allCollections: [] }, + ); +} + +/** + * Loads items by path and enriches them with an optional display name. + * @param {Array} selectedPaths + * @param {Object} options + * @param {Function} options.getByPath + * @param {Function} [options.getDisplayName] + * @returns {Promise>} + */ +export async function loadItemsByPath(selectedPaths, { getByPath, getDisplayName } = {}) { + if (!selectedPaths?.length || !getByPath) { + return []; + } + + const fragments = await processConcurrently( + selectedPaths, + async (path) => { + try { + const fragmentData = await getByPath(path); + return { + ...fragmentData, + ...(getDisplayName ? { studioPath: getDisplayName(new Fragment(fragmentData)) } : {}), + }; + } catch (error) { + console.warn(`Failed to fetch fragment at ${path}:`, error.message); + return null; + } + }, + OFFER_DATA_CONCURRENCY_LIMIT, + ); + + return fragments.filter(Boolean); +} + +/** + * Loads grouped variations for a card fragment. + * @param {Object} card + * @param {Object} options + * @param {Function} options.getByPath + * @param {Function} [options.getOfferData] + * @param {AbortSignal} [options.signal] + * @param {Map} [options.offerDataCache] + * @param {Function} [options.getDisplayName] + * @returns {Promise>} + */ +export async function loadGroupedVariations( + card, + { getByPath, getOfferData, signal, offerDataCache = new Map(), getDisplayName } = {}, +) { + if (!getByPath) return []; + + const fragment = new Fragment(card); + const groupedRefs = fragment.listGroupedVariations(); + if (!groupedRefs?.length) return []; + + const variations = await processConcurrently( + groupedRefs, + async (ref) => { + if (signal?.aborted) return null; + + try { + return await getByPath(ref.path); + } catch (error) { + console.warn(`Failed to fetch grouped variation at ${ref.path}:`, error.message); + return null; + } + }, + VARIATIONS_CONCURRENCY_LIMIT, + ); + + const validVariations = variations.filter( + (variation) => variation && Array.isArray(variation.fieldTags) && variation.fieldTags.length > 0, + ); + + const offerDataResults = getOfferData + ? await processConcurrently( + validVariations, + (variation) => getOfferData(variation, { cache: offerDataCache, signal }), + VARIATIONS_CONCURRENCY_LIMIT, + ) + : validVariations.map(() => null); + + return validVariations.map((variation, index) => ({ + ...variation, + ...(getDisplayName ? { studioPath: getDisplayName(new Fragment(variation)) } : {}), + offerData: offerDataResults[index] ?? null, + })); +} + +/** + * Enriches cards with offer data and grouped variations. + * @param {Array} cards + * @param {Object} options + * @param {Function} [options.getByPath] + * @param {Function} [options.getOfferData] + * @param {AbortSignal} [options.signal] + * @param {Map} [options.offerDataCache] + * @param {Function} [options.getDisplayName] + * @param {Map} [options.existingOfferDataByPath] + * @param {Map>} [options.existingGroupedVariationsByPath] + * @returns {Promise>} + */ +export async function enrichCards( + cards, + { + getByPath, + getOfferData, + signal, + offerDataCache = new Map(), + getDisplayName, + existingOfferDataByPath = new Map(), + existingGroupedVariationsByPath = new Map(), + } = {}, +) { + const offerDataByPath = new Map(existingOfferDataByPath); + const groupedVariationsByPath = new Map(existingGroupedVariationsByPath); + + const cardsNeedingOfferData = getOfferData ? cards.filter((card) => !offerDataByPath.has(card.path)) : []; + + if (cardsNeedingOfferData.length > 0) { + const offerDataResults = await processConcurrently( + cardsNeedingOfferData, + (card) => getOfferData(card, { cache: offerDataCache, signal }), + OFFER_DATA_CONCURRENCY_LIMIT, + ); + + if (signal?.aborted) return []; + await yieldToMain(); + + cardsNeedingOfferData.forEach((card, index) => { + offerDataByPath.set(card.path, offerDataResults[index]); + }); + } + + const cardsNeedingGroupedVariations = getByPath ? cards.filter((card) => !groupedVariationsByPath.has(card.path)) : []; + + if (cardsNeedingGroupedVariations.length > 0) { + const groupedVariationsResults = await processConcurrently( + cardsNeedingGroupedVariations, + (card) => + loadGroupedVariations(card, { + getByPath, + getOfferData, + signal, + offerDataCache, + getDisplayName, + }), + OFFER_DATA_CONCURRENCY_LIMIT, + ); + + if (signal?.aborted) return []; + await yieldToMain(); + + cardsNeedingGroupedVariations.forEach((card, index) => { + groupedVariationsByPath.set(card.path, groupedVariationsResults[index] ?? []); + }); + } + + const enrichedCards = cards.map((card) => ({ + ...card, + offerData: offerDataByPath.get(card.path) ?? null, + groupedVariations: groupedVariationsByPath.get(card.path) ?? [], + })); + + if (enrichedCards.length > 50) { + await yieldToMain(); + } + + if (signal?.aborted) return []; + return enrichedCards; +} + +/** + * Fetches and enriches a grouped variation by path. + * @param {string} variationPath + * @param {Object} options + * @param {Function} options.getByPath + * @param {Function} [options.getOfferData] + * @param {Map} [options.offerDataCache] + * @param {Function} [options.getDisplayName] + * @returns {Promise<{ parentCardPath: string, variation: Object }|null>} + */ +export async function fetchVariationDataByPath( + variationPath, + { getByPath, getOfferData, offerDataCache = new Map(), getDisplayName } = {}, +) { + if (!getByPath || !Fragment.isGroupedVariationPath(variationPath)) return null; + + const pznIdx = variationPath.indexOf('/pzn/'); + if (pznIdx === -1) return null; + const parentCardPath = variationPath.substring(0, pznIdx); + + try { + const variation = await getByPath(variationPath); + if (!variation || !Array.isArray(variation.fieldTags) || variation.fieldTags.length === 0) return null; + + const offerData = getOfferData ? await getOfferData(variation, { cache: offerDataCache }) : null; + + return { + parentCardPath, + variation: { + ...variation, + ...(getDisplayName ? { studioPath: getDisplayName(new Fragment(variation)) } : {}), + offerData, + }, + }; + } catch (error) { + console.warn(`Failed to fetch variation at ${variationPath}:`, error.message); + return null; + } +} + +/** + * Loads grouped variations for a card and returns them keyed by path. + * @param {Array} variationPaths + * @param {Object} options + * @param {Function} options.getByPath + * @param {Function} [options.getOfferData] + * @param {Map} [options.offerDataCache] + * @param {Function} [options.getDisplayName] + * @returns {Promise>} + */ +export async function loadCardVariationsByPath( + variationPaths, + { getByPath, getOfferData, offerDataCache = new Map(), getDisplayName } = {}, +) { + if (!variationPaths?.length || !getByPath) return new Map(); + + const variations = await processConcurrently( + variationPaths, + async (path) => { + try { + return await getByPath(path); + } catch (error) { + console.warn(`Failed to fetch variation at ${path}:`, error.message); + return null; + } + }, + VARIATIONS_CONCURRENCY_LIMIT, + ); + + const validVariations = variations.filter( + (variation) => variation && Array.isArray(variation.fieldTags) && variation.fieldTags.length > 0, + ); + + const offerDataResults = getOfferData + ? await processConcurrently( + validVariations, + (variation) => getOfferData(variation, { cache: offerDataCache }), + VARIATIONS_CONCURRENCY_LIMIT, + ) + : validVariations.map(() => null); + + return new Map( + validVariations.map((variation, index) => [ + variation.path, + { + ...variation, + ...(getDisplayName ? { studioPath: getDisplayName(new Fragment(variation)) } : {}), + offerData: offerDataResults[index] ?? null, + }, + ]), + ); +} +/** + * Parses placeholder stores into a flat array of placeholders + * @param {Array<{ value?: Object, get?: Function }>} placeholderStores + * @param {Object} options + * @param {Function} [options.getDisplayName] + * @returns {Array} + */ +export function parsePlaceholdersFromStore(placeholderStores = [], { getDisplayName } = {}) { + return placeholderStores + .map((store) => { + const placeholder = store?.get?.() ?? store?.value ?? store; + if (!placeholder?.key) return null; + return { + ...placeholder, + ...(getDisplayName ? { studioPath: getDisplayName(placeholder) } : {}), + }; + }) + .filter(Boolean); +} diff --git a/studio/test/common/item-loading-browser.test.js b/studio/test/common/item-loading-browser.test.js new file mode 100644 index 000000000..c7a6478b5 --- /dev/null +++ b/studio/test/common/item-loading-browser.test.js @@ -0,0 +1,75 @@ +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; +import { loadOfferData } from '../../src/common/utils/item-loading-browser.js'; + +describe('common/utils/item-loading-browser', () => { + let sandbox; + let commerceService; + + const createMockCommerceService = () => { + const service = document.createElement('mas-commerce-service'); + service.collectPriceOptions = sinon.stub().returns({}); + service.resolveOfferSelectors = sinon.stub().returns([Promise.resolve([{ offerId: 'test-offer-id' }])]); + document.body.appendChild(service); + return service; + }; + + const removeMockCommerceService = () => { + const service = document.querySelector('mas-commerce-service'); + if (service) service.remove(); + }; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + commerceService = createMockCommerceService(); + }); + + afterEach(() => { + sandbox.restore(); + removeMockCommerceService(); + }); + + it('returns null when the fragment does not have an OSI field', async () => { + const offerData = await loadOfferData({ + path: '/content/dam/mas/acom/en_US/cards/card-without-osi', + fields: [], + }); + + expect(offerData).to.equal(null); + expect(commerceService.collectPriceOptions.called).to.be.false; + }); + + it('reuses cached data for subsequent calls', async () => { + const cache = new Map(); + const fragment = { + path: '/content/dam/mas/acom/en_US/cards/card1', + fields: [{ name: 'osi', values: ['osi-123'] }], + }; + + const firstResult = await loadOfferData(fragment, { cache }); + const secondResult = await loadOfferData(fragment, { cache }); + + expect(firstResult).to.deep.equal({ offerId: 'test-offer-id' }); + expect(secondResult).to.deep.equal({ offerId: 'test-offer-id' }); + expect(commerceService.collectPriceOptions.calledOnce).to.be.true; + expect(cache.get('osi-123')).to.deep.equal({ offerId: 'test-offer-id' }); + }); + + it('returns null and caches the failure when offer loading throws', async () => { + commerceService.resolveOfferSelectors = sandbox.stub().returns([Promise.reject(new Error('boom'))]); + const cache = new Map(); + + const offerData = await loadOfferData( + { + id: 'card-1', + path: '/content/dam/mas/acom/en_US/cards/card1', + fields: [{ name: 'osi', values: ['osi-123'] }], + }, + { cache }, + ); + + expect(offerData).to.equal(null); + expect(cache.has('osi-123')).to.be.true; + expect(cache.get('osi-123')).to.equal(null); + }); +}); diff --git a/studio/test/common/item-loading.test.js b/studio/test/common/item-loading.test.js new file mode 100644 index 000000000..f5d408008 --- /dev/null +++ b/studio/test/common/item-loading.test.js @@ -0,0 +1,343 @@ +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; +import { Fragment } from '../../src/aem/fragment.js'; +import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../../src/constants.js'; +import { + buildItemsByPath, + enrichCards, + fetchVariationDataByPath, + flattenGroupedVariationsByParent, + loadCardVariationsByPath, + loadGroupedVariations, + loadItemsByPath, + parseFragmentsFromStore, + parsePlaceholdersFromStore, + processConcurrently, + selectItemsByPath, +} from '../../src/common/utils/item-loading.js'; + +describe('common/utils/item-loading', () => { + let sandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('buildItemsByPath creates a path-indexed map', () => { + const items = [ + { path: '/a', title: 'A' }, + { path: '/b', title: 'B' }, + ]; + + const map = buildItemsByPath(items); + + expect(map.get('/a')).to.equal(items[0]); + expect(map.get('/b')).to.equal(items[1]); + }); + + it('selectItemsByPath preserves selected path order and filters missing items', () => { + const first = { path: '/a', title: 'A' }; + const second = { path: '/b', title: 'B' }; + const map = new Map([ + ['/a', first], + ['/b', second], + ]); + + const selected = selectItemsByPath(['/b', '/missing', '/a'], map); + + expect(selected).to.deep.equal([second, first]); + }); + + it('flattenGroupedVariationsByParent flattens nested maps', () => { + const variation = { path: '/card/pzn/v1', title: 'Variation' }; + const flattened = flattenGroupedVariationsByParent(new Map([['/card', new Map([['/card/pzn/v1', variation]])]])); + + expect(flattened.get('/card/pzn/v1')).to.equal(variation); + }); + + it('processConcurrently preserves result order while respecting the concurrency limit', async () => { + let activeCount = 0; + let maxActiveCount = 0; + + const results = await processConcurrently( + [1, 2, 3, 4], + async (item) => { + activeCount += 1; + maxActiveCount = Math.max(maxActiveCount, activeCount); + await new Promise((resolve) => setTimeout(resolve, 5)); + activeCount -= 1; + return item * 2; + }, + 2, + 2, + ); + + expect(results).to.deep.equal([2, 4, 6, 8]); + expect(maxActiveCount).to.equal(2); + }); + + it('parseFragmentsFromStore splits cards and collections and applies display name', () => { + const getDisplayName = sandbox.stub().returns('Display Name'); + const allFragments = [ + { + value: { + path: '/card', + title: 'Card', + model: { path: CARD_MODEL_PATH }, + }, + }, + { + value: { + path: '/collection', + title: 'Collection', + model: { path: COLLECTION_MODEL_PATH }, + }, + }, + ]; + + const { allCards, allCollections } = parseFragmentsFromStore(allFragments, { getDisplayName }); + + expect(allCards).to.have.lengthOf(1); + expect(allCards[0].studioPath).to.equal('Display Name'); + expect(allCollections).to.have.lengthOf(1); + expect(allCollections[0].studioPath).to.equal('Display Name'); + }); + + it('loadItemsByPath fetches items and decorates them with a display name', async () => { + const getDisplayName = sandbox.stub().returns('Display Name'); + const fragmentData = { + path: '/content/dam/mas/acom/en_US/cards/card1', + title: 'Card 1', + model: { path: CARD_MODEL_PATH }, + fields: [], + }; + const getByPath = sandbox.stub().resolves(fragmentData); + + const items = await loadItemsByPath([fragmentData.path], { getByPath, getDisplayName }); + + expect(items).to.have.lengthOf(1); + expect(items[0].studioPath).to.equal('Display Name'); + expect(getByPath.calledOnceWith(fragmentData.path)).to.be.true; + }); + + it('loadItemsByPath skips paths that fail to load and keeps the successful items', async () => { + const getByPath = sandbox.stub(); + getByPath.onFirstCall().resolves({ + path: '/content/dam/mas/acom/en_US/cards/card1', + title: 'Card 1', + model: { path: CARD_MODEL_PATH }, + fields: [], + }); + getByPath.onSecondCall().rejects(new Error('not found')); + + const items = await loadItemsByPath( + ['/content/dam/mas/acom/en_US/cards/card1', '/content/dam/mas/acom/en_US/cards/missing'], + { getByPath }, + ); + + expect(items).to.have.lengthOf(1); + expect(items[0].path).to.equal('/content/dam/mas/acom/en_US/cards/card1'); + }); + + it('loadGroupedVariations returns enriched grouped variations for valid refs only', async () => { + const getDisplayName = sandbox.stub().returns('Variation Name'); + const getOfferData = sandbox.stub().resolves({ offerId: 'test-offer-id' }); + const validVariationPath = '/content/dam/mas/acom/en_US/cards/card1/pzn/v1'; + const invalidVariationPath = '/content/dam/mas/acom/en_US/cards/card1/pzn/v2'; + const card = new Fragment({ + path: '/content/dam/mas/acom/en_US/cards/card1', + title: 'Card 1', + model: { path: CARD_MODEL_PATH }, + fields: [{ name: 'variations', values: [validVariationPath, invalidVariationPath] }], + references: [{ path: validVariationPath }, { path: invalidVariationPath }], + }); + const getByPath = sandbox.stub(); + + getByPath.withArgs(validVariationPath).resolves({ + path: validVariationPath, + fieldTags: [{ id: 'tag-1' }], + fields: [{ name: 'osi', values: ['osi-123'] }], + }); + getByPath.withArgs(invalidVariationPath).resolves({ + path: invalidVariationPath, + fieldTags: [], + fields: [{ name: 'osi', values: ['osi-456'] }], + }); + + const variations = await loadGroupedVariations(card, { getByPath, getOfferData, getDisplayName }); + + expect(variations).to.have.lengthOf(1); + expect(variations[0].path).to.equal(validVariationPath); + expect(variations[0].studioPath).to.equal('Variation Name'); + expect(variations[0].offerData).to.deep.equal({ offerId: 'test-offer-id' }); + }); + + it('fetchVariationDataByPath resolves and enriches grouped variations', async () => { + const getDisplayName = sandbox.stub().returns('Variation Name'); + const getOfferData = sandbox.stub().resolves({ offerId: 'test-offer-id' }); + const variationPath = '/content/dam/mas/acom/en_US/cards/parent/pzn/v1'; + const getByPath = sandbox.stub().resolves({ + path: variationPath, + fieldTags: [{ id: 'tag-1' }], + fields: [{ name: 'osi', values: ['osi-123'] }], + }); + + const result = await fetchVariationDataByPath(variationPath, { getByPath, getOfferData, getDisplayName }); + + expect(result.parentCardPath).to.equal('/content/dam/mas/acom/en_US/cards/parent'); + expect(result.variation.path).to.equal(variationPath); + expect(result.variation.studioPath).to.equal('Variation Name'); + expect(result.variation.offerData).to.deep.equal({ offerId: 'test-offer-id' }); + }); + + it('fetchVariationDataByPath returns null for a non-grouped variation path', async () => { + const getByPath = sandbox.stub(); + + const result = await fetchVariationDataByPath('/content/dam/mas/acom/en_US/cards/card1', { getByPath }); + + expect(result).to.equal(null); + expect(getByPath.called).to.be.false; + }); + + it('loadCardVariationsByPath returns a keyed map of enriched variations', async () => { + const getDisplayName = sandbox.stub().returns('Variation Name'); + const getOfferData = sandbox.stub().resolves({ offerId: 'test-offer-id' }); + const variationPath = '/content/dam/mas/acom/en_US/cards/parent/pzn/v1'; + const getByPath = sandbox.stub().resolves({ + path: variationPath, + fieldTags: [{ id: 'tag-1' }], + fields: [{ name: 'osi', values: ['osi-123'] }], + }); + + const variationsByPath = await loadCardVariationsByPath([variationPath], { + getByPath, + getOfferData, + getDisplayName, + }); + + expect(variationsByPath.get(variationPath).studioPath).to.equal('Variation Name'); + expect(variationsByPath.get(variationPath).offerData).to.deep.equal({ offerId: 'test-offer-id' }); + }); + + it('loadCardVariationsByPath filters out invalid variations', async () => { + const validVariationPath = '/content/dam/mas/acom/en_US/cards/parent/pzn/v1'; + const invalidVariationPath = '/content/dam/mas/acom/en_US/cards/parent/pzn/v2'; + const getByPath = sandbox.stub(); + + getByPath.withArgs(validVariationPath).resolves({ + path: validVariationPath, + fieldTags: [{ id: 'tag-1' }], + fields: [{ name: 'osi', values: ['osi-123'] }], + }); + getByPath.withArgs(invalidVariationPath).resolves({ + path: invalidVariationPath, + fieldTags: [], + fields: [{ name: 'osi', values: ['osi-456'] }], + }); + + const variationsByPath = await loadCardVariationsByPath([validVariationPath, invalidVariationPath], { getByPath }); + + expect(variationsByPath.size).to.equal(1); + expect(variationsByPath.has(validVariationPath)).to.be.true; + expect(variationsByPath.has(invalidVariationPath)).to.be.false; + }); + + it('enrichCards adds offer data and grouped variations while reusing existing data', async () => { + const groupedVariationPath = '/content/dam/mas/acom/en_US/cards/card1/pzn/v1'; + const cardData = new Fragment({ + path: '/content/dam/mas/acom/en_US/cards/card1', + title: 'Card 1', + model: { path: CARD_MODEL_PATH }, + tags: [], + fields: [ + { name: 'osi', values: ['osi-123'] }, + { name: 'variations', values: [groupedVariationPath] }, + ], + references: [{ path: groupedVariationPath }], + }); + const getByPath = sandbox.stub().resolves({ + path: groupedVariationPath, + fieldTags: [{ id: 'tag-1' }], + fields: [{ name: 'osi', values: ['osi-variation'] }], + }); + const getOfferData = sandbox.stub().resolves({ offerId: 'variation-offer' }); + + const cards = await enrichCards([cardData], { + getByPath, + getOfferData, + getDisplayName: () => 'Display Name', + existingOfferDataByPath: new Map([[cardData.path, { offerId: 'cached-offer' }]]), + }); + + expect(cards).to.have.lengthOf(1); + expect(cards[0].offerData).to.deep.equal({ offerId: 'cached-offer' }); + expect(cards[0].groupedVariations).to.have.lengthOf(1); + expect(cards[0].groupedVariations[0].path).to.equal(groupedVariationPath); + }); + + it('enrichCards returns an empty array when the signal is already aborted after loading offer data', async () => { + const abortController = new AbortController(); + abortController.abort(); + const cardData = new Fragment({ + path: '/content/dam/mas/acom/en_US/cards/card1', + title: 'Card 1', + model: { path: CARD_MODEL_PATH }, + tags: [], + fields: [{ name: 'osi', values: ['osi-123'] }], + }); + const getOfferData = sandbox.stub().resolves({ offerId: 'offer-1' }); + + const cards = await enrichCards([cardData], { + getByPath: sandbox.stub(), + getOfferData, + signal: abortController.signal, + }); + + expect(cards).to.deep.equal([]); + }); + it('parsePlaceholdersFromStore extracts placeholders and applies display name', () => { + const getDisplayName = sinon.stub().returns('placeholder: buy-now'); + const stores = [ + { + get: () => ({ + key: 'buy-now', + value: 'Buy now', + path: '/content/dam/mas/acom/en_US/placeholders/buy-now', + status: 'Published', + }), + }, + { + get: () => ({ + key: 'save-now', + value: 'Save now', + path: '/content/dam/mas/acom/en_US/placeholders/save-now', + status: 'Draft', + }), + }, + ]; + + const result = parsePlaceholdersFromStore(stores, { getDisplayName }); + + expect(result).to.have.lengthOf(2); + expect(result[0].key).to.equal('buy-now'); + expect(result[0].studioPath).to.equal('placeholder: buy-now'); + expect(result[1].key).to.equal('save-now'); + }); + + it('parsePlaceholdersFromStore filters out entries without a key', () => { + const stores = [ + { get: () => ({ key: 'valid', value: 'Yes' }) }, + { get: () => ({ value: 'No key here' }) }, + { get: () => null }, + ]; + + const result = parsePlaceholdersFromStore(stores); + + expect(result).to.have.lengthOf(1); + expect(result[0].key).to.equal('valid'); + }); +}); From e4479227d6484bc14b00bbcbf138b2ba8f66ae05 Mon Sep 17 00:00:00 2001 From: Andrei Tanasa Date: Tue, 14 Apr 2026 15:46:52 +0300 Subject: [PATCH 2/9] MWPW-192158: Add shared selection UI components --- .../components/common-table-styles.css.js | 101 ++++++ .../components/mas-expandable-card-row.css.js | 144 ++++++++ .../components/mas-expandable-card-row.js | 330 ++++++++++++++++++ .../components/mas-item-selector.css.js | 62 ++++ .../common/components/mas-item-selector.js | 264 ++++++++++++++ .../mas-items-search-filters.css.js | 74 ++++ .../components/mas-items-search-filters.js | 308 ++++++++++++++++ .../mas-items-selected-panel.css.js | 50 +++ .../components/mas-items-selected-panel.js | 72 ++++ .../common/components/mas-items-table.css.js | 49 +++ .../src/common/components/mas-items-table.js | 224 ++++++++++++ studio/src/common/utils/item-loading.js | 1 + studio/src/common/utils/render-utils.js | 51 +++ .../mas-expandable-card-row.test.js | 63 ++++ .../components/mas-item-selector.test.js | 95 +++++ .../mas-items-search-filters.test.js | 104 ++++++ .../mas-items-selected-panel.test.js | 48 +++ .../common/components/mas-items-table.test.js | 94 +++++ studio/test/common/utils/render-utils.test.js | 80 +++++ 19 files changed, 2214 insertions(+) create mode 100644 studio/src/common/components/common-table-styles.css.js create mode 100644 studio/src/common/components/mas-expandable-card-row.css.js create mode 100644 studio/src/common/components/mas-expandable-card-row.js create mode 100644 studio/src/common/components/mas-item-selector.css.js create mode 100644 studio/src/common/components/mas-item-selector.js create mode 100644 studio/src/common/components/mas-items-search-filters.css.js create mode 100644 studio/src/common/components/mas-items-search-filters.js create mode 100644 studio/src/common/components/mas-items-selected-panel.css.js create mode 100644 studio/src/common/components/mas-items-selected-panel.js create mode 100644 studio/src/common/components/mas-items-table.css.js create mode 100644 studio/src/common/components/mas-items-table.js create mode 100644 studio/src/common/utils/render-utils.js create mode 100644 studio/test/common/components/mas-expandable-card-row.test.js create mode 100644 studio/test/common/components/mas-item-selector.test.js create mode 100644 studio/test/common/components/mas-items-search-filters.test.js create mode 100644 studio/test/common/components/mas-items-selected-panel.test.js create mode 100644 studio/test/common/components/mas-items-table.test.js create mode 100644 studio/test/common/utils/render-utils.test.js diff --git a/studio/src/common/components/common-table-styles.css.js b/studio/src/common/components/common-table-styles.css.js new file mode 100644 index 000000000..053506eed --- /dev/null +++ b/studio/src/common/components/common-table-styles.css.js @@ -0,0 +1,101 @@ +import { css } from 'lit'; + +export const ghostButtonStyles = css` + .ghost-button { + --mod-button-background-color-default: transparent; + --mod-button-background-color-hover: var(--spectrum-gray-200); + } +`; + +export const loadingContainerStyles = css` + .loading-container--flex { + display: flex; + justify-content: center; + align-items: center; + } + + .loading-container--absolute { + position: absolute; + top: 50%; + right: 50%; + transform: translate(-50%, -50%); + } +`; + +export const tableHeaderStyles = css` + .items-table { + --mod-table-header-background-color: var(--spectrum-gray-50); + --mod-table-border-radius: 0; + } + + .items-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; + } + + .items-table sp-table-head-cell { + align-content: center; + } + + .items-table sp-table-head-cell:first-of-type { + border-top-left-radius: 12px; + } + + .items-table sp-table-head-cell:last-of-type { + border-top-right-radius: 12px; + } +`; + +export const tableCellStyles = css` + .items-table sp-table-cell, + sp-table-cell { + display: flex; + align-items: center; + } + + .status-cell { + display: flex; + align-items: center; + gap: 6px; + + .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--spectrum-gray-500); + } + + .status-dot.green { + background-color: var(--spectrum-green-700); + } + + .status-dot.blue { + background-color: var(--spectrum-blue-800); + } + } +`; + +export const tableIconCellStyles = css` + .icon-cell { + display: flex; + align-items: center; + flex: 0; + } + + .icon-cell--chevron { + padding: 29px; + } + + .icon-cell--checkbox { + padding: 22px; + } +`; + +export const tableSelectedRowStyles = css` + sp-table-row[selected] { + --mod-table-row-background-color: var(--spectrum-blue-200); + --spectrum-table-cell-background-color: var(--spectrum-blue-200); + } +`; diff --git a/studio/src/common/components/mas-expandable-card-row.css.js b/studio/src/common/components/mas-expandable-card-row.css.js new file mode 100644 index 000000000..3680248f1 --- /dev/null +++ b/studio/src/common/components/mas-expandable-card-row.css.js @@ -0,0 +1,144 @@ +import { css } from 'lit'; +import { + tableIconCellStyles, + tableCellStyles, + tableSelectedRowStyles, + loadingContainerStyles, +} from './common-table-styles.css.js'; + +export const styles = [ + tableIconCellStyles, + tableCellStyles, + tableSelectedRowStyles, + loadingContainerStyles, + css` + .loading-container--flex { + padding: 10px; + width: 100%; + } + + .offer-id { + color: var(--spectrum-blue-900); + + div { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin-right: 4px; + word-break: break-all; + text-overflow: ellipsis; + } + + div:hover { + text-decoration: underline; + color: var(--spectrum-blue-1000); + } + + sp-action-button { + --mod-actionbutton-content-color-default: var(--spectrum-blue-900); + } + + sp-tooltip { + word-break: break-all; + } + } + + .tags-cell { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + + .tags-label { + margin-left: 6px; + } + + .expand-button { + background: none; + border: none; + } + + sp-tabs { + padding: 0 20px 16px 0; + background-color: var(--spectrum-gray-50); + } + + sp-tab-panel { + padding-top: 16px; + } + + sp-tab-panel sp-table-body { + border: none; + } + + .nested-content-container { + background-color: var(--spectrum-gray-50); + } + + .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); + } + + .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); + } + + .variation-details-row { + sp-table-cell { + background-color: var(--spectrum-gray-50); + } + + sp-table-cell:first-of-type { + padding: 25px; + flex: 0; + } + + sp-table-cell:nth-of-type(2) { + padding: 22px; + flex: 0; + } + + sp-tag { + --mod-tag-background-color: var(--spectrum-gray-100); + --mod-tag-border-color: transparent; + } + } + `, +]; diff --git a/studio/src/common/components/mas-expandable-card-row.js b/studio/src/common/components/mas-expandable-card-row.js new file mode 100644 index 000000000..25dc0689e --- /dev/null +++ b/studio/src/common/components/mas-expandable-card-row.js @@ -0,0 +1,330 @@ +import { LitElement, html, nothing } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { styles } from './mas-expandable-card-row.css.js'; +import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../../constants.js'; +import { renderFragmentStatusCell } from '../utils/render-utils.js'; +import { Fragment } from '../../aem/fragment.js'; + +/** + * An expandable table row for cards with grouped variations and promotions. + * Props-driven: receives card data and variations, emits events for interactions. + * + * @fires selection-changed - detail: { path: string, selected: boolean } + * @fires load-variations - detail: { cardPath: string, variationPaths: string[] } + * @fires show-toast - detail: { text: string, variant: string } + * + * @property {Object} card - The card fragment data + * @property {Map} variationsByPath - Map of variationPath -> variation data for this card + * @property {Set} selectedPaths - Set of currently selected paths + * @property {boolean} viewOnly - Whether in read-only mode + * @property {boolean} loadingVariations - Whether variations are being loaded + */ +export class MasExpandableCardRow extends LitElement { + static styles = styles; + + static properties = { + card: { type: Object }, + variationsByPath: { type: Object }, + selectedPaths: { type: Object }, + viewOnly: { type: Boolean }, + loadingVariations: { type: Boolean }, + isExpanded: { type: Boolean, state: true }, + expandedVariationsPaths: { type: Object, state: true }, + }; + + constructor() { + super(); + this.card = null; + this.variationsByPath = new Map(); + this.selectedPaths = new Set(); + this.viewOnly = false; + this.loadingVariations = false; + this.isExpanded = false; + this.expandedVariationsPaths = new Set(); + this.tabs = [ + { label: 'Promotion', key: 'promotion', disabled: true }, + { label: 'Grouped variation', key: 'groupedVariation', selected: true }, + ]; + } + + connectedCallback() { + super.connectedCallback(); + this.setAttribute('value', this.card?.path ?? ''); + } + + get variationPaths() { + return new Fragment(this.card).getVariations() || []; + } + + get cells() { + return this.viewOnly + ? ['OfferName', 'Title', 'OfferId', 'StudioPath', 'ItemType', 'Status'] + : ['OfferName', 'Title', 'OfferId', 'StudioPath', 'Status']; + } + + get isGroupedVariation() { + return Fragment.isGroupedVariationPath(this.card?.path); + } + + #toggleSelect(e, path) { + e.stopPropagation(); + const isSelected = this.selectedPaths.has(path); + this.dispatchEvent( + new CustomEvent('selection-changed', { + detail: { path, selected: !isSelected }, + bubbles: true, + composed: true, + }), + ); + } + + #toggleExpand(e) { + e.stopPropagation(); + this.isExpanded = !this.isExpanded; + if (this.isExpanded && this.variationPaths.length > 0 && this.variationsByPath.size === 0) { + this.dispatchEvent( + new CustomEvent('load-variations', { + detail: { cardPath: this.card.path, variationPaths: this.variationPaths }, + bubbles: true, + composed: true, + }), + ); + } + } + + #toggleExpandVariation(e, path) { + e.stopPropagation(); + const newSet = new Set(this.expandedVariationsPaths); + if (newSet.has(path)) newSet.delete(path); + else newSet.add(path); + this.expandedVariationsPaths = newSet; + } + + async #copyToClipboard(e, text) { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(text); + this.dispatchEvent( + new CustomEvent('show-toast', { + detail: { text: 'Offer ID copied to clipboard', variant: 'positive' }, + bubbles: true, + composed: true, + }), + ); + } catch (err) { + console.error('Failed to copy:', err); + } + } + + renderTitle(item) { + return html`${item?.title || 'no title'}`; + } + + renderOfferName(item) { + return html` + ${item?.tags?.find(({ id }) => id.startsWith('mas:product_code/'))?.title || 'no offer name'} + `; + } + + renderStudioPath(item) { + return html`${item?.studioPath || 'no path'}`; + } + + renderOfferId(item) { + const { offerId } = item?.offerData || {}; + return html` + + ${offerId + ? html` +
${offerId}
+ ${offerId} +
+ this.#copyToClipboard(e, offerId)} + > + + ` + : 'no offer data'} +
+ `; + } + + renderStatus(item) { + return renderFragmentStatusCell(item?.status); + } + + renderItemType(item) { + if (Fragment.isGroupedVariationPath(item?.path)) return html`Grouped variation`; + if (item?.model?.path?.includes('/dictionary/')) return html`Placeholder`; + if (item?.model?.path === COLLECTION_MODEL_PATH) return html`Collection`; + if (item?.model?.path === CARD_MODEL_PATH) return html`Default`; + return html`no type`; + } + + renderTags(item) { + const tagNames = item?.fieldTags?.map(({ name }) => name) || []; + if (!tagNames.length) return html`no tags`; + return html` +
Grouped variation tags
+ ${repeat(tagNames, (name) => html`${name}`)} +
`; + } + + renderPromoCode(item) { + const code = item?.fields?.find((f) => f.name === 'promoCode')?.values[0] || 'no promo code'; + return html`${code}`; + } + + get groupedVariationTabTemplate() { + if (this.loadingVariations) { + return html`
+ +
`; + } + + const filteredPaths = this.variationPaths.filter( + (path) => Fragment.isGroupedVariationPath(path) && this.variationsByPath.has(path), + ); + + if (filteredPaths.length === 0) { + return html`
No grouped variations found
`; + } + + return html` + + ${repeat(filteredPaths, (variationPath) => { + const variation = this.variationsByPath.get(variationPath); + const isSelected = this.selectedPaths.has(variationPath); + const isExpanded = this.expandedVariationsPaths.has(variationPath); + return html` + + + this.#toggleExpandVariation(e, variationPath)} + > + ${isExpanded + ? html`` + : html``} + + + + this.#toggleSelect(e, variationPath)} + > + + ${repeat(this.cells, (cell) => this[`render${cell}`](variation) ?? nothing)} + + ${isExpanded ? this.#renderVariationDetailsRow(variationPath) : nothing} + `; + })} + + `; + } + + get promotionTabTemplate() { + return html`
To be implemented
`; + } + + #renderVariationDetailsRow(variationPath) { + const variation = this.variationsByPath.get(variationPath); + if (this.loadingVariations) { + return html` + + + +
+ +
+
+
`; + } + return html` + + + ${this.renderPromoCode(variation)} + + ${this.renderTags(variation)} + + + `; + } + + render() { + if (!this.card) return nothing; + + const isSelected = this.selectedPaths.has(this.card.path); + const selectedTab = this.tabs.find((t) => t.selected); + + if (this.viewOnly) { + return html` + + ${this.isGroupedVariation + ? html` + + ${this.isExpanded + ? html`` + : html``} + + ` + : html``} + ${repeat(this.cells, (cell) => this[`render${cell}`](this.card) ?? nothing)} + + ${this.isExpanded ? this.#renderVariationDetailsRow(this.card.path) : nothing} + `; + } + + return html` + + + + ${this.isExpanded + ? html`` + : html``} + + + + this.#toggleSelect(e, this.card.path)} + > + + ${repeat(this.cells, (cell) => this[`render${cell}`](this.card) ?? nothing)} + + + ${this.isExpanded + ? html`
+
+ + ${repeat( + this.tabs, + (tab) => + html`${tab.label}`, + )} + ${repeat( + this.tabs, + (tab) => + html`${this[`${tab.key}TabTemplate`] ?? nothing}`, + )} + +
+
` + : nothing} + `; + } +} + +customElements.define('mas-expandable-card-row', MasExpandableCardRow); diff --git a/studio/src/common/components/mas-item-selector.css.js b/studio/src/common/components/mas-item-selector.css.js new file mode 100644 index 000000000..ee91ecd95 --- /dev/null +++ b/studio/src/common/components/mas-item-selector.css.js @@ -0,0 +1,62 @@ +import { css } from 'lit'; +import { ghostButtonStyles } from './common-table-styles.css.js'; + +export const styles = [ + ghostButtonStyles, + css` + sp-tab-panel[selected] { + display: flex; + flex-direction: column; + gap: 12px; + } + + .container { + display: flex; + width: 80vw; + } + + .container.view-only { + width: 100%; + } + + sp-tab-panel.view-only { + padding: 20px 0 0 0; + } + + sp-toast { + position: fixed; + bottom: 40px; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + } + + .selected-items-count { + position: fixed; + bottom: 98px; + right: 22px; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 6px; + + sp-button { + min-width: 156px; + font-weight: 500; + } + + sp-button[disabled] sp-icon { + opacity: 0.2; + } + + sp-icon { + transform: scaleX(1); + transition: transform 0.3s ease-in-out; + } + + sp-icon.flipped { + transform: scaleX(-1); + } + } + `, +]; diff --git a/studio/src/common/components/mas-item-selector.js b/studio/src/common/components/mas-item-selector.js new file mode 100644 index 000000000..f8e744171 --- /dev/null +++ b/studio/src/common/components/mas-item-selector.js @@ -0,0 +1,264 @@ +import { LitElement, html, nothing } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { TABLE_TYPE } from '../../constants.js'; +import { toggleSidebarIcon } from '../../icons.js'; +import './mas-items-table.js'; +import './mas-items-selected-panel.js'; +import './mas-items-search-filters.js'; +import { styles } from './mas-item-selector.css.js'; + +export const TABS = [ + { value: TABLE_TYPE.CARDS, label: 'Fragments' }, + { value: TABLE_TYPE.COLLECTIONS, label: 'Collections' }, + { value: TABLE_TYPE.PLACEHOLDERS, label: 'Placeholders' }, +]; + +/** + * Item selector with tabs (Fragments / Collections / Placeholders). + * Props-driven: receives all data as props, emits events for changes. + * + * @fires selection-changed - detail: { path: string, selected: boolean } + * @fires load-variations - detail: { cardPath: string, variationPaths: string[] } + * + * @property {Array} cards - All card items + * @property {Array} collections - All collection items + * @property {Array} placeholders - All placeholder items + * @property {Set} selectedCardPaths - Selected card paths + * @property {Set} selectedCollectionPaths - Selected collection paths + * @property {Set} selectedPlaceholderPaths - Selected placeholder paths + * @property {Map} variationsByParent - Map of cardPath -> Map of variationPath -> variation + * @property {boolean} viewOnly - Read-only mode + * @property {boolean} loadingCards - Whether cards are loading + * @property {boolean} loadingCollections - Whether collections are loading + * @property {boolean} loadingPlaceholders - Whether placeholders are loading + */ +class MasItemSelector extends LitElement { + static styles = styles; + + static properties = { + cards: { type: Array }, + collections: { type: Array }, + placeholders: { type: Array }, + selectedCardPaths: { type: Object }, + selectedCollectionPaths: { type: Object }, + selectedPlaceholderPaths: { type: Object }, + variationsByParent: { type: Object }, + viewOnly: { type: Boolean }, + loadingCards: { type: Boolean }, + loadingCollections: { type: Boolean }, + loadingPlaceholders: { type: Boolean }, + showSelected: { type: Boolean, state: true }, + filteredCards: { type: Array, state: true }, + filteredCollections: { type: Array, state: true }, + filteredPlaceholders: { type: Array, state: true }, + }; + + constructor() { + super(); + this.cards = []; + this.collections = []; + this.placeholders = []; + this.selectedCardPaths = new Set(); + this.selectedCollectionPaths = new Set(); + this.selectedPlaceholderPaths = new Set(); + this.variationsByParent = new Map(); + this.viewOnly = false; + this.loadingCards = false; + this.loadingCollections = false; + this.loadingPlaceholders = false; + this.showSelected = false; + this.filteredCards = []; + this.filteredCollections = []; + this.filteredPlaceholders = []; + } + + get selectedCount() { + return this.selectedCardPaths.size + this.selectedCollectionPaths.size + this.selectedPlaceholderPaths.size; + } + + get selectedItems() { + const items = []; + const addFromList = (list, paths) => { + for (const path of paths) { + const item = list.find((i) => i.path === path); + if (item) items.push(item); + } + }; + addFromList(this.cards, this.selectedCardPaths); + addFromList(this.collections, this.selectedCollectionPaths); + addFromList(this.placeholders, this.selectedPlaceholderPaths); + return items; + } + + #getItemsForType(type) { + switch (type) { + case TABLE_TYPE.CARDS: + return this.filteredCards.length ? this.filteredCards : this.cards; + case TABLE_TYPE.COLLECTIONS: + return this.filteredCollections.length ? this.filteredCollections : this.collections; + case TABLE_TYPE.PLACEHOLDERS: + return this.filteredPlaceholders.length ? this.filteredPlaceholders : this.placeholders; + default: + return []; + } + } + + #getSelectedPathsForType(type) { + switch (type) { + case TABLE_TYPE.CARDS: + return this.selectedCardPaths; + case TABLE_TYPE.COLLECTIONS: + return this.selectedCollectionPaths; + case TABLE_TYPE.PLACEHOLDERS: + return this.selectedPlaceholderPaths; + default: + return new Set(); + } + } + + #getLoadingForType(type) { + switch (type) { + case TABLE_TYPE.CARDS: + return this.loadingCards; + case TABLE_TYPE.COLLECTIONS: + return this.loadingCollections; + case TABLE_TYPE.PLACEHOLDERS: + return this.loadingPlaceholders; + default: + return false; + } + } + + #getAllItemsForType(type) { + switch (type) { + case TABLE_TYPE.CARDS: + return this.cards; + case TABLE_TYPE.COLLECTIONS: + return this.collections; + case TABLE_TYPE.PLACEHOLDERS: + return this.placeholders; + default: + return []; + } + } + + #handleFiltered(type, e) { + switch (type) { + case TABLE_TYPE.CARDS: + this.filteredCards = e.detail.items; + break; + case TABLE_TYPE.COLLECTIONS: + this.filteredCollections = e.detail.items; + break; + case TABLE_TYPE.PLACEHOLDERS: + this.filteredPlaceholders = e.detail.items; + break; + } + } + + #handleRemoveItem(e) { + const { path } = e.detail; + this.dispatchEvent( + new CustomEvent('selection-changed', { + detail: { path, selected: false }, + bubbles: true, + composed: true, + }), + ); + } + + #toggleShowSelected() { + this.showSelected = !this.showSelected; + } + + #showToast({ detail: { text, variant } }) { + const toast = this.shadowRoot?.querySelector('sp-toast'); + if (toast) { + toast.textContent = text; + toast.variant = variant; + toast.open = true; + } + } + + #getTabLabel(tab) { + if (this.viewOnly) { + return `${tab.label} (${this.#getSelectedPathsForType(tab.value).size})`; + } + return tab.label; + } + + render() { + return html` + + ${repeat( + TABS, + (tab) => tab.value, + (tab) => html`${this.#getTabLabel(tab)}`, + )} + ${repeat( + TABS, + (tab) => tab.value, + (tab) => html` + + ${this.viewOnly + ? nothing + : html` + this.#handleFiltered(tab.value, e)} + > + `} +
+ + ${this.viewOnly + ? nothing + : html``} +
+ e.stopPropagation()}> +
+ `, + )} +
+ + ${this.viewOnly + ? nothing + : html` +
+ + + ${toggleSidebarIcon} + + ${this.showSelected && this.selectedCount ? 'Hide selection' : 'Selected items'} + (${this.selectedCount}) + +
+ `} + `; + } +} + +customElements.define('mas-item-selector', MasItemSelector); diff --git a/studio/src/common/components/mas-items-search-filters.css.js b/studio/src/common/components/mas-items-search-filters.css.js new file mode 100644 index 000000000..f2a277f76 --- /dev/null +++ b/studio/src/common/components/mas-items-search-filters.css.js @@ -0,0 +1,74 @@ +import { css } from 'lit'; + +export const styles = css` + :host { + display: block; + } + + .search { + display: flex; + align-items: center; + gap: 8px; + margin: 32px 0 20px 0; + } + + .search sp-search { + flex: 1; + max-width: 400px; + } + + .result-count { + color: var(--spectrum-gray-700); + font-size: 14px; + white-space: nowrap; + } + + .filters { + display: flex; + gap: 12px; + margin-bottom: 8px; + flex-wrap: wrap; + } + + .filter-trigger { + border: 1px solid var(--spectrum-gray-300); + border-radius: 12px; + justify-content: start; + sp-icon-chevron-down { + order: 2; + } + } + + .filter-popover { + padding: 12px; + } + + .checkbox-list { + display: flex; + flex-direction: column; + gap: 12px; + max-height: 300px; + overflow-y: auto; + min-width: 150px; + padding-inline-start: 4px; + } + + .checkbox-list sp-checkbox { + display: flex; + white-space: nowrap; + } + + .applied-filters { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + flex-wrap: wrap; + } + + .applied-filters sp-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + } +`; diff --git a/studio/src/common/components/mas-items-search-filters.js b/studio/src/common/components/mas-items-search-filters.js new file mode 100644 index 000000000..76fc6b804 --- /dev/null +++ b/studio/src/common/components/mas-items-search-filters.js @@ -0,0 +1,308 @@ +import { LitElement, html, nothing } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { VARIANTS } from '../../editors/variant-picker.js'; +import { styles } from './mas-items-search-filters.css.js'; +import { FILTER_TYPE, TABLE_TYPE } from '../../constants.js'; + +/** + * Search and filter bar for items selection. + * Props-driven: receives items, emits filtered results via events. + * + * @fires items-filtered - Dispatched when filters/search change. detail: { items: Array } + * + * @property {string} type - 'cards' | 'collections' | 'placeholders' + * @property {Array} items - All items to filter + * @property {boolean} searchOnly - If true, only show search bar (no filter pickers) + * @property {boolean} loading - Whether data is still loading + */ +class MasItemsSearchFilters extends LitElement { + static styles = styles; + + static properties = { + type: { type: String }, + items: { type: Array }, + searchOnly: { type: Boolean }, + loading: { type: Boolean }, + searchQuery: { type: String, state: true }, + templateFilter: { type: Array, state: true }, + marketSegmentFilter: { type: Array, state: true }, + customerSegmentFilter: { type: Array, state: true }, + productFilter: { type: Array, state: true }, + }; + + constructor() { + super(); + this.type = TABLE_TYPE.CARDS; + this.items = []; + this.searchOnly = false; + this.loading = false; + this.searchQuery = ''; + this.templateFilter = []; + this.marketSegmentFilter = []; + this.customerSegmentFilter = []; + this.productFilter = []; + } + + updated(changedProperties) { + super.updated(changedProperties); + if (changedProperties.has('items')) { + if (!this.searchOnly) this.#extractFilterOptions(); + this.#applyFilters(); + } + } + + get templateOptions() { + return VARIANTS.filter((v) => v.label.toLowerCase() !== 'all').map((v) => ({ id: v.value, title: v.label })); + } + + get marketSegmentOptions() { + return this.#extractTagOptions('mas:market_segment/', 'mas:market_segments/'); + } + + get customerSegmentOptions() { + return this.#extractTagOptions('mas:customer_segment/'); + } + + get productOptions() { + return this.#extractTagOptions('mas:product_code/'); + } + + get filteredCount() { + return this._filteredItems?.length ?? this.items.length; + } + + get appliedFilters() { + const filters = []; + const addFilters = (filterValues, options, filterType) => { + const optionsMap = new Map(options.map((opt) => [opt.id, opt])); + for (const id of filterValues) { + const option = optionsMap.get(id); + if (option) filters.push({ type: filterType, id, label: option.title }); + } + }; + addFilters(this.templateFilter, this.templateOptions, FILTER_TYPE.TEMPLATE); + addFilters(this.marketSegmentFilter, this.marketSegmentOptions, FILTER_TYPE.MARKET_SEGMENT); + addFilters(this.customerSegmentFilter, this.customerSegmentOptions, FILTER_TYPE.CUSTOMER_SEGMENT); + addFilters(this.productFilter, this.productOptions, FILTER_TYPE.PRODUCT); + return filters; + } + + #extractTagOptions(...prefixes) { + const tags = new Map(); + for (const item of this.items) { + if (!item.tags) continue; + for (const tag of item.tags) { + const tagId = tag.id || ''; + if (prefixes.some((prefix) => tagId.startsWith(prefix))) { + tags.set(tagId, { id: tagId, title: tag.title || tagId.split('/').pop() || '' }); + } + } + } + return Array.from(tags.values()).sort((a, b) => a.title.localeCompare(b.title)); + } + + #extractFilterOptions() { + // Filter options are derived from items reactively via getters + // This method is kept as a hook for future enhancements + } + + #handleSearchInput({ target: { value: query } }) { + this.searchQuery = query; + this.#applyFilters(); + } + + #handleSearchSubmit(e) { + e.preventDefault(); + this.#applyFilters(); + } + + #handleCheckboxChange(filterType, optionId, e) { + const filterMap = { + [FILTER_TYPE.TEMPLATE]: 'templateFilter', + [FILTER_TYPE.MARKET_SEGMENT]: 'marketSegmentFilter', + [FILTER_TYPE.CUSTOMER_SEGMENT]: 'customerSegmentFilter', + [FILTER_TYPE.PRODUCT]: 'productFilter', + }; + const prop = filterMap[filterType]; + if (!prop) return; + + const current = [...this[prop]]; + if (e.target.checked) { + if (!current.includes(optionId)) current.push(optionId); + } else { + const idx = current.indexOf(optionId); + if (idx !== -1) current.splice(idx, 1); + } + this[prop] = current; + this.#applyFilters(); + } + + #handleTagDelete({ + target: { + value: { type, id }, + }, + }) { + const filterMap = { + [FILTER_TYPE.TEMPLATE]: 'templateFilter', + [FILTER_TYPE.MARKET_SEGMENT]: 'marketSegmentFilter', + [FILTER_TYPE.CUSTOMER_SEGMENT]: 'customerSegmentFilter', + [FILTER_TYPE.PRODUCT]: 'productFilter', + }; + const prop = filterMap[type]; + if (prop) { + this[prop] = this[prop].filter((filterId) => filterId !== id); + this.#applyFilters(); + } + } + + #clearAllFilters() { + this.templateFilter = []; + this.marketSegmentFilter = []; + this.customerSegmentFilter = []; + this.productFilter = []; + this.#applyFilters(); + } + + #applyFilters() { + const query = this.searchQuery?.toLowerCase(); + const hasTemplate = this.templateFilter.length > 0; + const hasMarket = this.marketSegmentFilter.length > 0; + const hasCustomer = this.customerSegmentFilter.length > 0; + const hasProduct = this.productFilter.length > 0; + + const result = (this.items || []).filter((item) => { + if (query) { + if (this.type === TABLE_TYPE.PLACEHOLDERS) { + const key = item.key?.toLowerCase() || ''; + const value = item.value?.toLowerCase() || ''; + if (!key.includes(query) && !value.includes(query)) return false; + } else { + const title = (item.title || '').toLowerCase(); + const productTag = item.tags?.find(({ id }) => id?.startsWith('mas:product_code/'))?.title || ''; + const offerId = item.offerData?.offerId || ''; + if ( + !title.includes(query) && + !productTag.toLowerCase().includes(query) && + !offerId.toLowerCase().includes(query) + ) + return false; + } + } + if (hasTemplate) { + const variantField = item.fields?.find((f) => f.name === 'variant'); + if (!variantField?.values?.some((v) => this.templateFilter.includes(v))) return false; + } + if (hasMarket && !item.tags?.some((tag) => this.marketSegmentFilter.includes(tag.id))) return false; + if (hasCustomer && !item.tags?.some((tag) => this.customerSegmentFilter.includes(tag.id))) return false; + if (hasProduct && !item.tags?.some((tag) => this.productFilter.includes(tag.id))) return false; + return true; + }); + + if (this.type === TABLE_TYPE.CARDS) { + result.sort((a, b) => (b.groupedVariations?.length > 0 ? 1 : 0) - (a.groupedVariations?.length > 0 ? 1 : 0)); + } + + this._filteredItems = result; + this.dispatchEvent(new CustomEvent('items-filtered', { detail: { items: result }, bubbles: true, composed: true })); + } + + #renderFilterPicker(label, options, selectedValues, filterType) { + const count = selectedValues.length; + const displayLabel = count > 0 ? `${label} (${count})` : label; + + return html` + e.stopPropagation()}> + + ${displayLabel} + + + +
+ ${options.map((option) => { + const optionId = option.id; + return html` + this.#handleCheckboxChange(filterType, optionId, e)} + > + ${option.title} + + `; + })} +
+
+
+ `; + } + + #renderAppliedFilters() { + if (this.appliedFilters.length === 0) return nothing; + + return html` +
+ + ${repeat( + this.appliedFilters, + (f) => `${f.type}-${f.id}`, + (f) => html` + + ${f.label} + + `, + )} + + Clear all +
+ `; + } + + render() { + return html` + + + ${this.searchOnly + ? nothing + : html` +
+ ${this.#renderFilterPicker( + 'Template', + this.templateOptions, + this.templateFilter, + FILTER_TYPE.TEMPLATE, + )} + ${this.#renderFilterPicker( + 'Market Segment', + this.marketSegmentOptions, + this.marketSegmentFilter, + FILTER_TYPE.MARKET_SEGMENT, + )} + ${this.#renderFilterPicker( + 'Customer Segment', + this.customerSegmentOptions, + this.customerSegmentFilter, + FILTER_TYPE.CUSTOMER_SEGMENT, + )} + ${this.#renderFilterPicker('Product', this.productOptions, this.productFilter, FILTER_TYPE.PRODUCT)} +
+ ${this.#renderAppliedFilters()} + `} + `; + } +} + +customElements.define('mas-items-search-filters', MasItemsSearchFilters); diff --git a/studio/src/common/components/mas-items-selected-panel.css.js b/studio/src/common/components/mas-items-selected-panel.css.js new file mode 100644 index 000000000..9277e0010 --- /dev/null +++ b/studio/src/common/components/mas-items-selected-panel.css.js @@ -0,0 +1,50 @@ +import { css } from 'lit'; +import { ghostButtonStyles } from './common-table-styles.css.js'; + +export const styles = [ + ghostButtonStyles, + css` + :host { + display: flex; + } + + .selected-items { + display: flex; + flex-direction: column; + padding: 12px; + margin: 0; + gap: 12px; + border: 1px solid var(--spectrum-gray-300); + border-radius: 12px; + background: var(--spectrum-gray-50); + + .item { + display: grid; + grid-template-columns: 160px auto; + grid-template-rows: max-content max-content; + padding: 12px; + gap: 8px; + border: 1px solid var(--spectrum-gray-300); + border-radius: 12px; + background: var(--spectrum-white); + + .title { + grid-column: 1; + grid-row: 1; + margin: 0; + overflow-wrap: break-word; + } + + .type { + color: var(--spectrum-orange-800); + } + + .remove-button { + grid-column: 2; + grid-row: 1 / 3; + align-self: center; + } + } + } + `, +]; diff --git a/studio/src/common/components/mas-items-selected-panel.js b/studio/src/common/components/mas-items-selected-panel.js new file mode 100644 index 000000000..48afbb3c5 --- /dev/null +++ b/studio/src/common/components/mas-items-selected-panel.js @@ -0,0 +1,72 @@ +import { LitElement, html, nothing } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { styles } from './mas-items-selected-panel.css.js'; +import { getItemTypeLabel, getItemTitle } from '../utils/render-utils.js'; + +/** + * Panel showing currently selected items with remove buttons. + * Props-driven: receives items, emits events for removal. + * + * @fires remove-item - detail: { path: string, item: Object } + * + * @property {Array} items - Selected items to display + * @property {boolean} visible - Whether the panel is visible + * @property {boolean} disabled - Whether remove buttons are disabled + */ +class MasItemsSelectedPanel extends LitElement { + static styles = styles; + + static properties = { + items: { type: Array }, + visible: { type: Boolean }, + disabled: { type: Boolean }, + }; + + constructor() { + super(); + this.items = []; + this.visible = false; + this.disabled = false; + } + + #removeItem(item) { + this.dispatchEvent( + new CustomEvent('remove-item', { + detail: { path: item.path, item }, + bubbles: true, + composed: true, + }), + ); + } + + render() { + if (!this.visible || !this.items.length) return nothing; + + return html` +
    + ${repeat( + this.items, + (item) => item.path, + (item) => html` +
  • +

    ${getItemTitle(item)}

    +
    ${getItemTypeLabel(item)}
    + this.#removeItem(item)} + ?disabled=${this.disabled} + > + + +
  • + `, + )} +
+ `; + } +} + +customElements.define('mas-items-selected-panel', MasItemsSelectedPanel); diff --git a/studio/src/common/components/mas-items-table.css.js b/studio/src/common/components/mas-items-table.css.js new file mode 100644 index 000000000..5a7060c48 --- /dev/null +++ b/studio/src/common/components/mas-items-table.css.js @@ -0,0 +1,49 @@ +import { css } from 'lit'; +import { + tableHeaderStyles, + tableCellStyles, + tableIconCellStyles, + tableSelectedRowStyles, + loadingContainerStyles, +} from './common-table-styles.css.js'; + +export const styles = [ + tableHeaderStyles, + tableCellStyles, + tableIconCellStyles, + tableSelectedRowStyles, + loadingContainerStyles, + css` + :host { + width: 100%; + } + + .items-table { + sp-table-head sp-table-checkbox-cell:first-of-type { + border-top-left-radius: 12px; + } + + sp-table-cell { + word-break: break-word; + } + } + + .loading-container--flex { + padding: 80px; + } + + .scroll-sentinel { + height: 1px; + } + + .loading-more { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px; + color: var(--spectrum-gray-700); + font-size: var(--spectrum-font-size-75); + } + `, +]; diff --git a/studio/src/common/components/mas-items-table.js b/studio/src/common/components/mas-items-table.js new file mode 100644 index 000000000..2657eb956 --- /dev/null +++ b/studio/src/common/components/mas-items-table.js @@ -0,0 +1,224 @@ +import { LitElement, html, nothing } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { styles } from './mas-items-table.css.js'; +import { TABLE_TYPE } from '../../constants.js'; +import { renderFragmentStatusCell } from '../utils/render-utils.js'; +import './mas-expandable-card-row.js'; + +/** + * Table component for displaying and selecting items (cards, collections, placeholders). + * Props-driven: receives items and selected state, emits events for selection changes. + * + * @fires selection-changed - detail: { path: string, selected: boolean } + * @fires load-variations - detail: { cardPath: string, variationPaths: string[] } + * @fires show-toast - detail: { text: string, variant: string } + * + * @property {string} type - 'cards' | 'collections' | 'placeholders' + * @property {Array} items - Items to display + * @property {Set} selectedPaths - Currently selected paths + * @property {Map} variationsByParent - Map of cardPath -> Map of variationPath -> variation + * @property {boolean} viewOnly - Read-only mode + * @property {boolean} loading - Whether data is loading + * @property {boolean} hasMore - Whether more pages are available + * @property {boolean} loadingMore - Whether loading additional pages + */ +class MasItemsTable extends LitElement { + static styles = styles; + + static properties = { + type: { type: String }, + items: { type: Array }, + selectedPaths: { type: Object }, + variationsByParent: { type: Object }, + viewOnly: { type: Boolean }, + loading: { type: Boolean }, + hasMore: { type: Boolean }, + loadingMore: { type: Boolean }, + }; + + constructor() { + super(); + this.type = TABLE_TYPE.CARDS; + this.items = []; + this.selectedPaths = new Set(); + this.variationsByParent = new Map(); + this.viewOnly = false; + this.loading = false; + this.hasMore = false; + this.loadingMore = false; + } + + get tableColumns() { + const COLUMNS = { + cards: { + selectable: [ + { label: '', key: 'chevron', class: 'icon-cell icon-cell--chevron' }, + { label: '', key: 'checkbox', class: 'icon-cell icon-cell--checkbox' }, + { label: 'Offer', key: 'offer' }, + { label: 'Fragment title', key: 'fragmentTitle' }, + { label: 'Offer ID', key: 'offerId' }, + { label: 'Path', key: 'path' }, + { label: 'Status', key: 'status' }, + ], + viewOnly: [ + { label: '', key: 'chevron', class: 'icon-cell icon-cell--chevron' }, + { label: 'Offer', key: 'offer' }, + { label: 'Fragment title', key: 'fragmentTitle' }, + { label: 'Offer ID', key: 'offerId' }, + { label: 'Path', key: 'path' }, + { label: 'Item type', key: 'itemType' }, + { label: 'Status', key: 'status' }, + ], + }, + collections: { + selectable: [ + { label: '', key: 'checkbox', class: 'icon-cell icon-cell--checkbox' }, + { label: 'Collection title', key: 'collectionTitle' }, + { label: 'Path', key: 'path' }, + { label: 'Status', key: 'status' }, + ], + viewOnly: [ + { label: 'Collection title', key: 'collectionTitle' }, + { label: 'Path', key: 'path' }, + { label: 'Status', key: 'status' }, + ], + }, + placeholders: { + selectable: [ + { label: '', key: 'checkbox', class: 'icon-cell icon-cell--checkbox' }, + { label: 'Key', key: 'key' }, + { label: 'Value', key: 'value' }, + { label: 'Status', key: 'status' }, + ], + viewOnly: [ + { label: 'Key', key: 'key' }, + { label: 'Value', key: 'value' }, + { label: 'Status', key: 'status' }, + ], + }, + }; + return COLUMNS[this.type]?.[this.viewOnly ? 'viewOnly' : 'selectable'] || []; + } + + #toggleSelected(e, path) { + e.stopPropagation(); + const isSelected = this.selectedPaths.has(path); + this.dispatchEvent( + new CustomEvent('selection-changed', { + detail: { path, selected: !isSelected }, + bubbles: true, + composed: true, + }), + ); + } + + #renderCardsBody() { + return html`${repeat( + this.items, + (card) => card.path, + (card) => html` + + `, + )}`; + } + + #renderCollectionsBody() { + return html`${repeat( + this.items, + (item) => item.path, + (item) => html` + + ${!this.viewOnly + ? html` + this.#toggleSelected(e, item.path)} + > + ` + : nothing} + ${item.title || '-'} + ${item.studioPath} + ${renderFragmentStatusCell(item.status)} + + `, + )}`; + } + + #renderPlaceholdersBody() { + return html`${repeat( + this.items, + (item) => item.path, + (item) => html` + + ${!this.viewOnly + ? html` + this.#toggleSelected(e, item.path)} + > + ` + : nothing} + ${item.key || '-'} + + ${item.value?.length > 100 ? `${item.value.slice(0, 100)}...` : item.value || '-'} + + ${renderFragmentStatusCell(item.status)} + + `, + )}`; + } + + #renderTableBody() { + switch (this.type) { + case TABLE_TYPE.CARDS: + return this.#renderCardsBody(); + case TABLE_TYPE.COLLECTIONS: + return this.#renderCollectionsBody(); + case TABLE_TYPE.PLACEHOLDERS: + return this.#renderPlaceholdersBody(); + default: + return nothing; + } + } + + render() { + if (this.loading) { + return html`
+ +
`; + } + + if (!this.items.length) { + return html`

No items found.

`; + } + + return html` + + + ${repeat( + this.tableColumns, + (col) => col.key, + (col) => html`${col.label}`, + )} + + ${this.#renderTableBody()} + + ${this.hasMore ? html`
` : nothing} + ${this.loadingMore + ? html`
+ + Loading more items… +
` + : nothing} + `; + } +} + +customElements.define('mas-items-table', MasItemsTable); diff --git a/studio/src/common/utils/item-loading.js b/studio/src/common/utils/item-loading.js index 7b8ba6e03..b01cfa26d 100644 --- a/studio/src/common/utils/item-loading.js +++ b/studio/src/common/utils/item-loading.js @@ -377,6 +377,7 @@ export async function loadCardVariationsByPath( ]), ); } + /** * Parses placeholder stores into a flat array of placeholders * @param {Array<{ value?: Object, get?: Function }>} placeholderStores diff --git a/studio/src/common/utils/render-utils.js b/studio/src/common/utils/render-utils.js new file mode 100644 index 000000000..4c8c26b5b --- /dev/null +++ b/studio/src/common/utils/render-utils.js @@ -0,0 +1,51 @@ +import { html, nothing } from 'lit'; +import { FRAGMENT_STATUS, CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../../constants.js'; +import { Fragment } from '../../aem/fragment.js'; + +/** + * Renders a fragment status cell with a colored dot and label. + * @param {string} [status] + * @returns {import('lit').TemplateResult|typeof nothing} + */ +export function renderFragmentStatusCell(status) { + if (!status) return nothing; + let statusClass = ''; + if (status === FRAGMENT_STATUS.PUBLISHED) { + statusClass = 'green'; + } else if (status === FRAGMENT_STATUS.MODIFIED) { + statusClass = 'blue'; + } + return html` +
+ ${status.charAt(0).toUpperCase()}${status.slice(1).toLowerCase()} +
`; +} + +/** + * Returns a human-readable item type label. + * @param {Object} item + * @returns {string} + */ +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 === COLLECTION_MODEL_PATH) return 'Collection'; + if (item.model?.path === CARD_MODEL_PATH) return 'Default'; + return 'Unknown'; +} + +/** + * Returns a display title for an item (card, collection, or placeholder). + * @param {Object} item + * @param {number} [maxLength=54] + * @returns {string} + */ +export function getItemTitle(item, maxLength = 54) { + if (!item) return '-'; + if (item.model?.path === CARD_MODEL_PATH || item.model?.path === COLLECTION_MODEL_PATH) { + const title = item.title || '-'; + return title.length > maxLength ? `${title.slice(0, maxLength)}...` : title; + } + return item.key || item.getFieldValue?.('key') || '-'; +} diff --git a/studio/test/common/components/mas-expandable-card-row.test.js b/studio/test/common/components/mas-expandable-card-row.test.js new file mode 100644 index 000000000..e11e5c676 --- /dev/null +++ b/studio/test/common/components/mas-expandable-card-row.test.js @@ -0,0 +1,63 @@ +import { expect } from '@esm-bundle/chai'; +import { html } from 'lit'; +import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; +import sinon from 'sinon'; +import { CARD_MODEL_PATH } from '../../../src/constants.js'; +import '../../../src/swc.js'; +import '../../../src/common/components/mas-expandable-card-row.js'; + +describe('MasExpandableCardRow', () => { + afterEach(() => { + fixtureCleanup(); + }); + + const cardWithVariation = { + path: '/content/dam/mas/card-base', + title: 'Parent', + studioPath: 'studio/parent', + model: { path: CARD_MODEL_PATH }, + tags: [{ id: 'mas:product_code/x', title: 'Offer' }], + offerData: { offerId: 'OID' }, + fields: [{ name: 'variations', values: ['/content/dam/mas/card-base/pzn/g1/var1'] }], + status: 'PUBLISHED', + }; + + it('renders nothing when card is missing', async () => { + const el = await fixture(html``); + expect(el.shadowRoot.textContent.trim()).to.equal(''); + expect(el.shadowRoot.querySelector('sp-table-row')).to.be.null; + }); + + it('dispatches selection-changed when main checkbox toggles', async () => { + const handler = sinon.spy(); + const el = await fixture(html` + + `); + const cb = el.shadowRoot.querySelector('sp-checkbox'); + cb.click(); + expect(handler.calledOnce).to.be.true; + expect(handler.firstCall.args[0].detail).to.deep.include({ path: cardWithVariation.path, selected: true }); + }); + + it('dispatches load-variations when expanding with empty variations map', async () => { + const handler = sinon.spy(); + const el = await fixture(html` + + `); + const expandBtn = el.shadowRoot.querySelector('.expand-button'); + expandBtn.click(); + await el.updateComplete; + expect(handler.calledOnce).to.be.true; + expect(handler.firstCall.args[0].detail.cardPath).to.equal(cardWithVariation.path); + expect(handler.firstCall.args[0].detail.variationPaths.length).to.be.greaterThan(0); + }); +}); diff --git a/studio/test/common/components/mas-item-selector.test.js b/studio/test/common/components/mas-item-selector.test.js new file mode 100644 index 000000000..dddc9b21d --- /dev/null +++ b/studio/test/common/components/mas-item-selector.test.js @@ -0,0 +1,95 @@ +import { expect } from '@esm-bundle/chai'; +import { html } from 'lit'; +import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; +import sinon from 'sinon'; +import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, TABLE_TYPE } from '../../../src/constants.js'; +import '../../../src/swc.js'; +import '../../../src/common/components/mas-item-selector.js'; +import { TABS } from '../../../src/common/components/mas-item-selector.js'; + +describe('MasItemSelector', () => { + afterEach(() => { + fixtureCleanup(); + }); + + const card = { + path: '/c/card1', + title: 'C1', + studioPath: 's', + model: { path: CARD_MODEL_PATH }, + }; + const collection = { + path: '/c/col1', + title: 'Col', + studioPath: 'sc', + model: { path: COLLECTION_MODEL_PATH }, + }; + const placeholder = { path: '/c/ph1', key: 'k', value: 'v' }; + + describe('TABS', () => { + it('exports three tabs matching TABLE_TYPE', () => { + expect(TABS).to.have.lengthOf(3); + expect(TABS.map((t) => t.value)).to.deep.equal([ + TABLE_TYPE.CARDS, + TABLE_TYPE.COLLECTIONS, + TABLE_TYPE.PLACEHOLDERS, + ]); + }); + }); + + describe('selectedCount and selectedItems', () => { + it('counts selections across types', async () => { + const el = await fixture(html` + + `); + expect(el.selectedCount).to.equal(2); + expect(el.selectedItems.map((i) => i.path)).to.deep.equal([card.path, collection.path]); + }); + }); + + describe('selection-changed from selected panel', () => { + it('re-emits selection-changed with selected false when panel removes item', async () => { + const handler = sinon.spy(); + const el = await fixture(html` + + `); + el.showSelected = true; + await el.updateComplete; + const panel = el.shadowRoot.querySelector('mas-items-selected-panel'); + expect(panel).to.exist; + panel.dispatchEvent( + new CustomEvent('remove-item', { + detail: { path: card.path, item: card }, + bubbles: true, + composed: true, + }), + ); + expect(handler.calledOnce).to.be.true; + expect(handler.firstCall.args[0].detail).to.deep.equal({ path: card.path, selected: false }); + }); + }); + + describe('viewOnly', () => { + it('hides search filters and selected panel', async () => { + const el = await fixture(html``); + expect(el.shadowRoot.querySelector('mas-items-search-filters')).to.be.null; + expect(el.shadowRoot.querySelector('mas-items-selected-panel')).to.be.null; + }); + }); +}); diff --git a/studio/test/common/components/mas-items-search-filters.test.js b/studio/test/common/components/mas-items-search-filters.test.js new file mode 100644 index 000000000..ce00eea6d --- /dev/null +++ b/studio/test/common/components/mas-items-search-filters.test.js @@ -0,0 +1,104 @@ +import { expect } from '@esm-bundle/chai'; +import { html } from 'lit'; +import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; +import sinon from 'sinon'; +import { TABLE_TYPE, FILTER_TYPE, CARD_MODEL_PATH } from '../../../src/constants.js'; +import '../../../src/swc.js'; +import '../../../src/common/components/mas-items-search-filters.js'; + +describe('MasItemsSearchFilters', () => { + let sandbox; + + const mockCard = (overrides = {}) => ({ + title: 'Alpha Card', + path: '/content/dam/mas/cards/alpha', + tags: [{ id: 'mas:product_code/123', title: 'Prod' }], + fields: [{ name: 'variant', values: ['standard'] }], + offerData: { offerId: 'OFF-1' }, + model: { path: CARD_MODEL_PATH }, + ...overrides, + }); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + fixtureCleanup(); + sandbox.restore(); + }); + + describe('initialization', () => { + it('defaults search and filter state', async () => { + const el = await fixture(html``); + expect(el.searchQuery).to.equal(''); + expect(el.templateFilter).to.deep.equal([]); + expect(el.marketSegmentFilter).to.deep.equal([]); + expect(el.customerSegmentFilter).to.deep.equal([]); + expect(el.productFilter).to.deep.equal([]); + }); + + it('exposes templateOptions derived from VARIANTS', async () => { + const el = await fixture(html``); + expect(el.templateOptions.length).to.be.greaterThan(0); + expect(el.templateOptions.every((o) => o.id && o.title)).to.be.true; + }); + }); + + describe('items-filtered', () => { + it('dispatches items-filtered when items are set', async () => { + const onFiltered = sinon.spy(); + const items = [mockCard()]; + await fixture(html` + + `); + expect(onFiltered.called).to.be.true; + const evt = onFiltered.firstCall.args[0]; + expect(evt.detail.items).to.deep.equal(items); + }); + + it('filters when search input changes', async () => { + const onFiltered = sinon.spy(); + const items = [mockCard({ title: 'FindMe' }), mockCard({ title: 'Other' })]; + const el = await fixture(html` + + `); + onFiltered.resetHistory(); + const search = el.shadowRoot.querySelector('sp-search'); + search.value = 'findme'; + search.dispatchEvent(new Event('input', { bubbles: true })); + await el.updateComplete; + const evt = onFiltered.lastCall.args[0]; + expect(evt.detail.items.length).to.equal(1); + expect(evt.detail.items[0].title).to.equal('FindMe'); + }); + }); + + describe('appliedFilters', () => { + it('builds labels from template filter ids', async () => { + const el = await fixture(html``); + const firstTemplateId = el.templateOptions[0]?.id; + if (!firstTemplateId) return; + el.templateFilter = [firstTemplateId]; + await el.updateComplete; + expect(el.appliedFilters.some((f) => f.type === FILTER_TYPE.TEMPLATE && f.id === firstTemplateId)).to.be.true; + }); + }); + + describe('tag options', () => { + it('extracts market segment options from item tags', async () => { + const items = [ + mockCard({ + tags: [{ id: 'mas:market_segment/us', title: 'US' }], + }), + ]; + const el = await fixture(html``); + await el.updateComplete; + expect(el.marketSegmentOptions.some((o) => o.id === 'mas:market_segment/us')).to.be.true; + }); + }); +}); diff --git a/studio/test/common/components/mas-items-selected-panel.test.js b/studio/test/common/components/mas-items-selected-panel.test.js new file mode 100644 index 000000000..e00d79f69 --- /dev/null +++ b/studio/test/common/components/mas-items-selected-panel.test.js @@ -0,0 +1,48 @@ +import { expect } from '@esm-bundle/chai'; +import { html } from 'lit'; +import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; +import sinon from 'sinon'; +import { CARD_MODEL_PATH } from '../../../src/constants.js'; +import '../../../src/swc.js'; +import '../../../src/common/components/mas-items-selected-panel.js'; + +describe('MasItemsSelectedPanel', () => { + afterEach(() => { + fixtureCleanup(); + }); + + const item = { + path: '/content/item-1', + title: 'My card', + model: { path: CARD_MODEL_PATH }, + }; + + it('renders nothing when not visible', async () => { + const el = await fixture(html``); + expect(el.shadowRoot.textContent.trim()).to.equal(''); + expect(el.shadowRoot.querySelector('ul.selected-items')).to.be.null; + }); + + it('renders nothing when visible but no items', async () => { + const el = await fixture(html``); + expect(el.shadowRoot.textContent.trim()).to.equal(''); + expect(el.shadowRoot.querySelector('ul.selected-items')).to.be.null; + }); + + it('renders titles when visible with items', async () => { + const el = await fixture(html``); + expect(el.shadowRoot.textContent).to.include('My card'); + }); + + it('dispatches remove-item when remove is clicked', async () => { + const onRemove = sinon.spy(); + const el = await fixture(html` + + `); + const btn = el.shadowRoot.querySelector('sp-button.remove-button'); + btn.click(); + expect(onRemove.calledOnce).to.be.true; + expect(onRemove.firstCall.args[0].detail.path).to.equal(item.path); + expect(onRemove.firstCall.args[0].detail.item).to.equal(item); + }); +}); diff --git a/studio/test/common/components/mas-items-table.test.js b/studio/test/common/components/mas-items-table.test.js new file mode 100644 index 000000000..24ea85707 --- /dev/null +++ b/studio/test/common/components/mas-items-table.test.js @@ -0,0 +1,94 @@ +import { expect } from '@esm-bundle/chai'; +import { html } from 'lit'; +import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; +import sinon from 'sinon'; +import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, TABLE_TYPE, FRAGMENT_STATUS } from '../../../src/constants.js'; +import '../../../src/swc.js'; +import '../../../src/common/components/mas-items-table.js'; + +describe('MasItemsTable', () => { + afterEach(() => { + fixtureCleanup(); + }); + + const mockCard = { + path: '/content/card-1', + title: 'Card', + studioPath: 'studio/card', + model: { path: CARD_MODEL_PATH }, + tags: [], + status: FRAGMENT_STATUS.PUBLISHED, + }; + + const mockCollection = { + path: '/content/col-1', + title: 'Col', + studioPath: 'studio/col', + model: { path: COLLECTION_MODEL_PATH }, + status: FRAGMENT_STATUS.PUBLISHED, + }; + + const mockPlaceholder = { + path: '/content/ph-1', + key: 'k', + value: 'v', + status: FRAGMENT_STATUS.PUBLISHED, + }; + + describe('tableColumns', () => { + it('returns selectable columns for cards', async () => { + const el = await fixture(html``); + expect(el.tableColumns.some((c) => c.key === 'checkbox')).to.be.true; + expect(el.tableColumns.some((c) => c.key === 'fragmentTitle')).to.be.true; + }); + + it('returns view-only columns for cards when viewOnly', async () => { + const el = await fixture(html``); + expect(el.tableColumns.some((c) => c.key === 'itemType')).to.be.true; + expect(el.tableColumns.some((c) => c.key === 'checkbox')).to.be.false; + }); + + it('returns columns for placeholders', async () => { + const el = await fixture(html``); + expect(el.tableColumns.some((c) => c.key === 'key')).to.be.true; + }); + }); + + describe('render states', () => { + it('shows loading indicator when loading', async () => { + const el = await fixture(html``); + expect(el.shadowRoot.querySelector('sp-progress-circle')).to.exist; + }); + + it('shows empty message when not loading and no items', async () => { + const el = await fixture(html``); + expect(el.shadowRoot.textContent).to.include('No items found'); + }); + + it('renders table for collections', async () => { + const el = await fixture(html` + + `); + expect(el.shadowRoot.querySelector('sp-table')).to.exist; + }); + }); + + describe('selection-changed', () => { + it('dispatches selection-changed when toggling collection row', async () => { + const handler = sinon.spy(); + const el = await fixture(html` + + `); + const row = el.shadowRoot.querySelector('sp-table-row'); + const cb = row.querySelector('sp-checkbox'); + cb.click(); + expect(handler.calledOnce).to.be.true; + expect(handler.firstCall.args[0].detail).to.deep.include({ path: mockCollection.path, selected: true }); + }); + }); +}); diff --git a/studio/test/common/utils/render-utils.test.js b/studio/test/common/utils/render-utils.test.js new file mode 100644 index 000000000..5fdd2b80a --- /dev/null +++ b/studio/test/common/utils/render-utils.test.js @@ -0,0 +1,80 @@ +import { expect } from '@esm-bundle/chai'; +import { nothing, render } from 'lit'; +import { + renderFragmentStatusCell, + getItemTypeLabel, + getItemTitle, +} from '../../../src/common/utils/render-utils.js'; +import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, FRAGMENT_STATUS } from '../../../src/constants.js'; + +describe('render-utils', () => { + describe('renderFragmentStatusCell', () => { + it('returns nothing when status is missing', () => { + expect(renderFragmentStatusCell()).to.equal(nothing); + expect(renderFragmentStatusCell('')).to.equal(nothing); + }); + + it('renders published status with green class', () => { + const container = document.createElement('div'); + render(renderFragmentStatusCell(FRAGMENT_STATUS.PUBLISHED), container); + const dot = container.querySelector('.status-dot'); + expect(dot?.classList.contains('green')).to.be.true; + expect(container.textContent).to.include('Published'); + }); + + it('renders modified status with blue class', () => { + const container = document.createElement('div'); + render(renderFragmentStatusCell(FRAGMENT_STATUS.MODIFIED), container); + const dot = container.querySelector('.status-dot'); + expect(dot?.classList.contains('blue')).to.be.true; + expect(container.textContent).to.include('Modified'); + }); + }); + + describe('getItemTypeLabel', () => { + it('returns Unknown for falsy item', () => { + expect(getItemTypeLabel(null)).to.equal('Unknown'); + expect(getItemTypeLabel(undefined)).to.equal('Unknown'); + }); + + it('returns Grouped variation when path is a grouped variation path', () => { + expect(getItemTypeLabel({ path: '/content/x/pzn/y/var' })).to.equal('Grouped variation'); + }); + + it('returns Placeholder for dictionary model', () => { + expect(getItemTypeLabel({ model: { path: '/conf/.../dictionary/foo' } })).to.equal('Placeholder'); + }); + + it('returns Collection for collection model', () => { + expect(getItemTypeLabel({ model: { path: COLLECTION_MODEL_PATH } })).to.equal('Collection'); + }); + + it('returns Default for card model', () => { + expect(getItemTypeLabel({ model: { path: CARD_MODEL_PATH } })).to.equal('Default'); + }); + }); + + describe('getItemTitle', () => { + it('returns dash for falsy item', () => { + expect(getItemTitle(null)).to.equal('-'); + }); + + it('truncates long card titles', () => { + const long = 'a'.repeat(60); + expect(getItemTitle({ model: { path: CARD_MODEL_PATH }, title: long }).length).to.be.lessThan(long.length); + expect(getItemTitle({ model: { path: CARD_MODEL_PATH }, title: long })).to.include('...'); + }); + + it('uses key for placeholder-like items', () => { + expect(getItemTitle({ key: 'my-key' })).to.equal('my-key'); + }); + + it('uses getFieldValue when present', () => { + expect( + getItemTitle({ + getFieldValue: (f) => (f === 'key' ? 'from-field' : ''), + }), + ).to.equal('from-field'); + }); + }); +}); From 0da50f86fb5dac54d6414ca667a3c71325c86f21 Mon Sep 17 00:00:00 2001 From: Andrei Tanasa Date: Tue, 14 Apr 2026 15:58:17 +0300 Subject: [PATCH 3/9] MWPW-192158: Fix prettier check --- .../common/components/mas-item-selector.test.js | 6 +----- .../components/mas-items-search-filters.test.js | 14 +++++++++++--- .../components/mas-items-selected-panel.test.js | 4 +++- .../test/common/components/mas-items-table.test.js | 6 +++++- studio/test/common/utils/render-utils.test.js | 6 +----- 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/studio/test/common/components/mas-item-selector.test.js b/studio/test/common/components/mas-item-selector.test.js index dddc9b21d..1c35da718 100644 --- a/studio/test/common/components/mas-item-selector.test.js +++ b/studio/test/common/components/mas-item-selector.test.js @@ -29,11 +29,7 @@ describe('MasItemSelector', () => { describe('TABS', () => { it('exports three tabs matching TABLE_TYPE', () => { expect(TABS).to.have.lengthOf(3); - expect(TABS.map((t) => t.value)).to.deep.equal([ - TABLE_TYPE.CARDS, - TABLE_TYPE.COLLECTIONS, - TABLE_TYPE.PLACEHOLDERS, - ]); + expect(TABS.map((t) => t.value)).to.deep.equal([TABLE_TYPE.CARDS, TABLE_TYPE.COLLECTIONS, TABLE_TYPE.PLACEHOLDERS]); }); }); diff --git a/studio/test/common/components/mas-items-search-filters.test.js b/studio/test/common/components/mas-items-search-filters.test.js index ce00eea6d..2ce4ee92e 100644 --- a/studio/test/common/components/mas-items-search-filters.test.js +++ b/studio/test/common/components/mas-items-search-filters.test.js @@ -65,7 +65,11 @@ describe('MasItemsSearchFilters', () => { const onFiltered = sinon.spy(); const items = [mockCard({ title: 'FindMe' }), mockCard({ title: 'Other' })]; const el = await fixture(html` - + `); onFiltered.resetHistory(); const search = el.shadowRoot.querySelector('sp-search'); @@ -80,7 +84,9 @@ describe('MasItemsSearchFilters', () => { describe('appliedFilters', () => { it('builds labels from template filter ids', async () => { - const el = await fixture(html``); + const el = await fixture( + html``, + ); const firstTemplateId = el.templateOptions[0]?.id; if (!firstTemplateId) return; el.templateFilter = [firstTemplateId]; @@ -96,7 +102,9 @@ describe('MasItemsSearchFilters', () => { tags: [{ id: 'mas:market_segment/us', title: 'US' }], }), ]; - const el = await fixture(html``); + const el = await fixture( + html``, + ); await el.updateComplete; expect(el.marketSegmentOptions.some((o) => o.id === 'mas:market_segment/us')).to.be.true; }); diff --git a/studio/test/common/components/mas-items-selected-panel.test.js b/studio/test/common/components/mas-items-selected-panel.test.js index e00d79f69..6f1b78fc2 100644 --- a/studio/test/common/components/mas-items-selected-panel.test.js +++ b/studio/test/common/components/mas-items-selected-panel.test.js @@ -18,7 +18,9 @@ describe('MasItemsSelectedPanel', () => { }; it('renders nothing when not visible', async () => { - const el = await fixture(html``); + const el = await fixture( + html``, + ); expect(el.shadowRoot.textContent.trim()).to.equal(''); expect(el.shadowRoot.querySelector('ul.selected-items')).to.be.null; }); diff --git a/studio/test/common/components/mas-items-table.test.js b/studio/test/common/components/mas-items-table.test.js index 24ea85707..8829e7c66 100644 --- a/studio/test/common/components/mas-items-table.test.js +++ b/studio/test/common/components/mas-items-table.test.js @@ -67,7 +67,11 @@ describe('MasItemsTable', () => { it('renders table for collections', async () => { const el = await fixture(html` - + `); expect(el.shadowRoot.querySelector('sp-table')).to.exist; }); diff --git a/studio/test/common/utils/render-utils.test.js b/studio/test/common/utils/render-utils.test.js index 5fdd2b80a..af955daf8 100644 --- a/studio/test/common/utils/render-utils.test.js +++ b/studio/test/common/utils/render-utils.test.js @@ -1,10 +1,6 @@ import { expect } from '@esm-bundle/chai'; import { nothing, render } from 'lit'; -import { - renderFragmentStatusCell, - getItemTypeLabel, - getItemTitle, -} from '../../../src/common/utils/render-utils.js'; +import { renderFragmentStatusCell, getItemTypeLabel, getItemTitle } from '../../../src/common/utils/render-utils.js'; import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, FRAGMENT_STATUS } from '../../../src/constants.js'; describe('render-utils', () => { From 3ef7ffc3e600df68cef74995062825a637ab39a6 Mon Sep 17 00:00:00 2001 From: Andrei Tanasa Date: Thu, 16 Apr 2026 15:26:58 +0300 Subject: [PATCH 4/9] MWPW-192158: Refactor the translation area to implement the components from the common folder --- .../components/mas-expandable-card-row.css.js | 144 -------- .../components/mas-expandable-card-row.js | 330 ------------------ .../components/mas-item-selector.css.js | 62 ---- .../common/components/mas-item-selector.js | 264 -------------- .../mas-items-search-filters.css.js | 74 ---- .../components/mas-items-search-filters.js | 308 ---------------- .../mas-items-selected-panel.css.js | 50 --- .../components/mas-items-selected-panel.js | 72 ---- .../components}/mas-items-selector.css.js | 2 +- .../components}/mas-items-selector.js | 32 +- .../common/components/mas-items-table.css.js | 49 --- .../src/common/components/mas-items-table.js | 224 ------------ .../components}/mas-search-and-filters.css.js | 0 .../components}/mas-search-and-filters.js | 31 +- .../components}/mas-select-items-table.css.js | 2 +- .../components}/mas-select-items-table.js | 33 +- .../components}/mas-selected-items.css.js | 2 +- .../components}/mas-selected-items.js | 55 +-- studio/src/common/items-selection-store.js | 14 + .../translation-common-styles.css.js} | 64 ++-- .../utils}/translation-items-loader.js | 54 +-- .../translation/mas-collapsible-table-row.js | 26 +- .../src/translation/mas-translation-editor.js | 4 +- .../mas-expandable-card-row.test.js | 63 ---- .../components/mas-item-selector.test.js | 91 ----- .../mas-items-search-filters.test.js | 112 ------ .../mas-items-selected-panel.test.js | 50 --- .../common/components/mas-items-table.test.js | 98 ------ .../mas-collapsible-table-row.test.js | 2 +- .../translation/mas-items-selector.test.js | 4 +- .../mas-search-and-filters.test.js | 2 +- .../mas-select-items-table.test.js | 2 +- .../translation/mas-selected-items.test.js | 4 +- .../translation-items-loader.test.js | 2 +- 34 files changed, 177 insertions(+), 2149 deletions(-) delete mode 100644 studio/src/common/components/mas-expandable-card-row.css.js delete mode 100644 studio/src/common/components/mas-expandable-card-row.js delete mode 100644 studio/src/common/components/mas-item-selector.css.js delete mode 100644 studio/src/common/components/mas-item-selector.js delete mode 100644 studio/src/common/components/mas-items-search-filters.css.js delete mode 100644 studio/src/common/components/mas-items-search-filters.js delete mode 100644 studio/src/common/components/mas-items-selected-panel.css.js delete mode 100644 studio/src/common/components/mas-items-selected-panel.js rename studio/src/{translation => common/components}/mas-items-selector.css.js (94%) rename studio/src/{translation => common/components}/mas-items-selector.js (82%) delete mode 100644 studio/src/common/components/mas-items-table.css.js delete mode 100644 studio/src/common/components/mas-items-table.js rename studio/src/{translation => common/components}/mas-search-and-filters.css.js (100%) rename studio/src/{translation => common/components}/mas-search-and-filters.js (92%) rename studio/src/{translation => common/components}/mas-select-items-table.css.js (96%) rename studio/src/{translation => common/components}/mas-select-items-table.js (91%) rename studio/src/{translation => common/components}/mas-selected-items.css.js (94%) rename studio/src/{translation => common/components}/mas-selected-items.js (70%) create mode 100644 studio/src/common/items-selection-store.js rename studio/src/common/{components/common-table-styles.css.js => styles/translation-common-styles.css.js} (75%) rename studio/src/{translation => common/utils}/translation-items-loader.js (91%) delete mode 100644 studio/test/common/components/mas-expandable-card-row.test.js delete mode 100644 studio/test/common/components/mas-item-selector.test.js delete mode 100644 studio/test/common/components/mas-items-search-filters.test.js delete mode 100644 studio/test/common/components/mas-items-selected-panel.test.js delete mode 100644 studio/test/common/components/mas-items-table.test.js diff --git a/studio/src/common/components/mas-expandable-card-row.css.js b/studio/src/common/components/mas-expandable-card-row.css.js deleted file mode 100644 index 3680248f1..000000000 --- a/studio/src/common/components/mas-expandable-card-row.css.js +++ /dev/null @@ -1,144 +0,0 @@ -import { css } from 'lit'; -import { - tableIconCellStyles, - tableCellStyles, - tableSelectedRowStyles, - loadingContainerStyles, -} from './common-table-styles.css.js'; - -export const styles = [ - tableIconCellStyles, - tableCellStyles, - tableSelectedRowStyles, - loadingContainerStyles, - css` - .loading-container--flex { - padding: 10px; - width: 100%; - } - - .offer-id { - color: var(--spectrum-blue-900); - - div { - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - margin-right: 4px; - word-break: break-all; - text-overflow: ellipsis; - } - - div:hover { - text-decoration: underline; - color: var(--spectrum-blue-1000); - } - - sp-action-button { - --mod-actionbutton-content-color-default: var(--spectrum-blue-900); - } - - sp-tooltip { - word-break: break-all; - } - } - - .tags-cell { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 4px; - } - - .tags-label { - margin-left: 6px; - } - - .expand-button { - background: none; - border: none; - } - - sp-tabs { - padding: 0 20px 16px 0; - background-color: var(--spectrum-gray-50); - } - - sp-tab-panel { - padding-top: 16px; - } - - sp-tab-panel sp-table-body { - border: none; - } - - .nested-content-container { - background-color: var(--spectrum-gray-50); - } - - .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); - } - - .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); - } - - .variation-details-row { - sp-table-cell { - background-color: var(--spectrum-gray-50); - } - - sp-table-cell:first-of-type { - padding: 25px; - flex: 0; - } - - sp-table-cell:nth-of-type(2) { - padding: 22px; - flex: 0; - } - - sp-tag { - --mod-tag-background-color: var(--spectrum-gray-100); - --mod-tag-border-color: transparent; - } - } - `, -]; diff --git a/studio/src/common/components/mas-expandable-card-row.js b/studio/src/common/components/mas-expandable-card-row.js deleted file mode 100644 index 25dc0689e..000000000 --- a/studio/src/common/components/mas-expandable-card-row.js +++ /dev/null @@ -1,330 +0,0 @@ -import { LitElement, html, nothing } from 'lit'; -import { repeat } from 'lit/directives/repeat.js'; -import { styles } from './mas-expandable-card-row.css.js'; -import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../../constants.js'; -import { renderFragmentStatusCell } from '../utils/render-utils.js'; -import { Fragment } from '../../aem/fragment.js'; - -/** - * An expandable table row for cards with grouped variations and promotions. - * Props-driven: receives card data and variations, emits events for interactions. - * - * @fires selection-changed - detail: { path: string, selected: boolean } - * @fires load-variations - detail: { cardPath: string, variationPaths: string[] } - * @fires show-toast - detail: { text: string, variant: string } - * - * @property {Object} card - The card fragment data - * @property {Map} variationsByPath - Map of variationPath -> variation data for this card - * @property {Set} selectedPaths - Set of currently selected paths - * @property {boolean} viewOnly - Whether in read-only mode - * @property {boolean} loadingVariations - Whether variations are being loaded - */ -export class MasExpandableCardRow extends LitElement { - static styles = styles; - - static properties = { - card: { type: Object }, - variationsByPath: { type: Object }, - selectedPaths: { type: Object }, - viewOnly: { type: Boolean }, - loadingVariations: { type: Boolean }, - isExpanded: { type: Boolean, state: true }, - expandedVariationsPaths: { type: Object, state: true }, - }; - - constructor() { - super(); - this.card = null; - this.variationsByPath = new Map(); - this.selectedPaths = new Set(); - this.viewOnly = false; - this.loadingVariations = false; - this.isExpanded = false; - this.expandedVariationsPaths = new Set(); - this.tabs = [ - { label: 'Promotion', key: 'promotion', disabled: true }, - { label: 'Grouped variation', key: 'groupedVariation', selected: true }, - ]; - } - - connectedCallback() { - super.connectedCallback(); - this.setAttribute('value', this.card?.path ?? ''); - } - - get variationPaths() { - return new Fragment(this.card).getVariations() || []; - } - - get cells() { - return this.viewOnly - ? ['OfferName', 'Title', 'OfferId', 'StudioPath', 'ItemType', 'Status'] - : ['OfferName', 'Title', 'OfferId', 'StudioPath', 'Status']; - } - - get isGroupedVariation() { - return Fragment.isGroupedVariationPath(this.card?.path); - } - - #toggleSelect(e, path) { - e.stopPropagation(); - const isSelected = this.selectedPaths.has(path); - this.dispatchEvent( - new CustomEvent('selection-changed', { - detail: { path, selected: !isSelected }, - bubbles: true, - composed: true, - }), - ); - } - - #toggleExpand(e) { - e.stopPropagation(); - this.isExpanded = !this.isExpanded; - if (this.isExpanded && this.variationPaths.length > 0 && this.variationsByPath.size === 0) { - this.dispatchEvent( - new CustomEvent('load-variations', { - detail: { cardPath: this.card.path, variationPaths: this.variationPaths }, - bubbles: true, - composed: true, - }), - ); - } - } - - #toggleExpandVariation(e, path) { - e.stopPropagation(); - const newSet = new Set(this.expandedVariationsPaths); - if (newSet.has(path)) newSet.delete(path); - else newSet.add(path); - this.expandedVariationsPaths = newSet; - } - - async #copyToClipboard(e, text) { - e.stopPropagation(); - try { - await navigator.clipboard.writeText(text); - this.dispatchEvent( - new CustomEvent('show-toast', { - detail: { text: 'Offer ID copied to clipboard', variant: 'positive' }, - bubbles: true, - composed: true, - }), - ); - } catch (err) { - console.error('Failed to copy:', err); - } - } - - renderTitle(item) { - return html`${item?.title || 'no title'}`; - } - - renderOfferName(item) { - return html` - ${item?.tags?.find(({ id }) => id.startsWith('mas:product_code/'))?.title || 'no offer name'} - `; - } - - renderStudioPath(item) { - return html`${item?.studioPath || 'no path'}`; - } - - renderOfferId(item) { - const { offerId } = item?.offerData || {}; - return html` - - ${offerId - ? html` -
${offerId}
- ${offerId} -
- this.#copyToClipboard(e, offerId)} - > - - ` - : 'no offer data'} -
- `; - } - - renderStatus(item) { - return renderFragmentStatusCell(item?.status); - } - - renderItemType(item) { - if (Fragment.isGroupedVariationPath(item?.path)) return html`Grouped variation`; - if (item?.model?.path?.includes('/dictionary/')) return html`Placeholder`; - if (item?.model?.path === COLLECTION_MODEL_PATH) return html`Collection`; - if (item?.model?.path === CARD_MODEL_PATH) return html`Default`; - return html`no type`; - } - - renderTags(item) { - const tagNames = item?.fieldTags?.map(({ name }) => name) || []; - if (!tagNames.length) return html`no tags`; - return html` -
Grouped variation tags
- ${repeat(tagNames, (name) => html`${name}`)} -
`; - } - - renderPromoCode(item) { - const code = item?.fields?.find((f) => f.name === 'promoCode')?.values[0] || 'no promo code'; - return html`${code}`; - } - - get groupedVariationTabTemplate() { - if (this.loadingVariations) { - return html`
- -
`; - } - - const filteredPaths = this.variationPaths.filter( - (path) => Fragment.isGroupedVariationPath(path) && this.variationsByPath.has(path), - ); - - if (filteredPaths.length === 0) { - return html`
No grouped variations found
`; - } - - return html` - - ${repeat(filteredPaths, (variationPath) => { - const variation = this.variationsByPath.get(variationPath); - const isSelected = this.selectedPaths.has(variationPath); - const isExpanded = this.expandedVariationsPaths.has(variationPath); - return html` - - - this.#toggleExpandVariation(e, variationPath)} - > - ${isExpanded - ? html`` - : html``} - - - - this.#toggleSelect(e, variationPath)} - > - - ${repeat(this.cells, (cell) => this[`render${cell}`](variation) ?? nothing)} - - ${isExpanded ? this.#renderVariationDetailsRow(variationPath) : nothing} - `; - })} - - `; - } - - get promotionTabTemplate() { - return html`
To be implemented
`; - } - - #renderVariationDetailsRow(variationPath) { - const variation = this.variationsByPath.get(variationPath); - if (this.loadingVariations) { - return html` - - - -
- -
-
-
`; - } - return html` - - - ${this.renderPromoCode(variation)} - - ${this.renderTags(variation)} - - - `; - } - - render() { - if (!this.card) return nothing; - - const isSelected = this.selectedPaths.has(this.card.path); - const selectedTab = this.tabs.find((t) => t.selected); - - if (this.viewOnly) { - return html` - - ${this.isGroupedVariation - ? html` - - ${this.isExpanded - ? html`` - : html``} - - ` - : html``} - ${repeat(this.cells, (cell) => this[`render${cell}`](this.card) ?? nothing)} - - ${this.isExpanded ? this.#renderVariationDetailsRow(this.card.path) : nothing} - `; - } - - return html` - - - - ${this.isExpanded - ? html`` - : html``} - - - - this.#toggleSelect(e, this.card.path)} - > - - ${repeat(this.cells, (cell) => this[`render${cell}`](this.card) ?? nothing)} - - - ${this.isExpanded - ? html`
-
- - ${repeat( - this.tabs, - (tab) => - html`${tab.label}`, - )} - ${repeat( - this.tabs, - (tab) => - html`${this[`${tab.key}TabTemplate`] ?? nothing}`, - )} - -
-
` - : nothing} - `; - } -} - -customElements.define('mas-expandable-card-row', MasExpandableCardRow); diff --git a/studio/src/common/components/mas-item-selector.css.js b/studio/src/common/components/mas-item-selector.css.js deleted file mode 100644 index ee91ecd95..000000000 --- a/studio/src/common/components/mas-item-selector.css.js +++ /dev/null @@ -1,62 +0,0 @@ -import { css } from 'lit'; -import { ghostButtonStyles } from './common-table-styles.css.js'; - -export const styles = [ - ghostButtonStyles, - css` - sp-tab-panel[selected] { - display: flex; - flex-direction: column; - gap: 12px; - } - - .container { - display: flex; - width: 80vw; - } - - .container.view-only { - width: 100%; - } - - sp-tab-panel.view-only { - padding: 20px 0 0 0; - } - - sp-toast { - position: fixed; - bottom: 40px; - left: 50%; - transform: translateX(-50%); - z-index: 1000; - } - - .selected-items-count { - position: fixed; - bottom: 98px; - right: 22px; - display: flex; - justify-content: flex-end; - align-items: center; - gap: 6px; - - sp-button { - min-width: 156px; - font-weight: 500; - } - - sp-button[disabled] sp-icon { - opacity: 0.2; - } - - sp-icon { - transform: scaleX(1); - transition: transform 0.3s ease-in-out; - } - - sp-icon.flipped { - transform: scaleX(-1); - } - } - `, -]; diff --git a/studio/src/common/components/mas-item-selector.js b/studio/src/common/components/mas-item-selector.js deleted file mode 100644 index f8e744171..000000000 --- a/studio/src/common/components/mas-item-selector.js +++ /dev/null @@ -1,264 +0,0 @@ -import { LitElement, html, nothing } from 'lit'; -import { repeat } from 'lit/directives/repeat.js'; -import { TABLE_TYPE } from '../../constants.js'; -import { toggleSidebarIcon } from '../../icons.js'; -import './mas-items-table.js'; -import './mas-items-selected-panel.js'; -import './mas-items-search-filters.js'; -import { styles } from './mas-item-selector.css.js'; - -export const TABS = [ - { value: TABLE_TYPE.CARDS, label: 'Fragments' }, - { value: TABLE_TYPE.COLLECTIONS, label: 'Collections' }, - { value: TABLE_TYPE.PLACEHOLDERS, label: 'Placeholders' }, -]; - -/** - * Item selector with tabs (Fragments / Collections / Placeholders). - * Props-driven: receives all data as props, emits events for changes. - * - * @fires selection-changed - detail: { path: string, selected: boolean } - * @fires load-variations - detail: { cardPath: string, variationPaths: string[] } - * - * @property {Array} cards - All card items - * @property {Array} collections - All collection items - * @property {Array} placeholders - All placeholder items - * @property {Set} selectedCardPaths - Selected card paths - * @property {Set} selectedCollectionPaths - Selected collection paths - * @property {Set} selectedPlaceholderPaths - Selected placeholder paths - * @property {Map} variationsByParent - Map of cardPath -> Map of variationPath -> variation - * @property {boolean} viewOnly - Read-only mode - * @property {boolean} loadingCards - Whether cards are loading - * @property {boolean} loadingCollections - Whether collections are loading - * @property {boolean} loadingPlaceholders - Whether placeholders are loading - */ -class MasItemSelector extends LitElement { - static styles = styles; - - static properties = { - cards: { type: Array }, - collections: { type: Array }, - placeholders: { type: Array }, - selectedCardPaths: { type: Object }, - selectedCollectionPaths: { type: Object }, - selectedPlaceholderPaths: { type: Object }, - variationsByParent: { type: Object }, - viewOnly: { type: Boolean }, - loadingCards: { type: Boolean }, - loadingCollections: { type: Boolean }, - loadingPlaceholders: { type: Boolean }, - showSelected: { type: Boolean, state: true }, - filteredCards: { type: Array, state: true }, - filteredCollections: { type: Array, state: true }, - filteredPlaceholders: { type: Array, state: true }, - }; - - constructor() { - super(); - this.cards = []; - this.collections = []; - this.placeholders = []; - this.selectedCardPaths = new Set(); - this.selectedCollectionPaths = new Set(); - this.selectedPlaceholderPaths = new Set(); - this.variationsByParent = new Map(); - this.viewOnly = false; - this.loadingCards = false; - this.loadingCollections = false; - this.loadingPlaceholders = false; - this.showSelected = false; - this.filteredCards = []; - this.filteredCollections = []; - this.filteredPlaceholders = []; - } - - get selectedCount() { - return this.selectedCardPaths.size + this.selectedCollectionPaths.size + this.selectedPlaceholderPaths.size; - } - - get selectedItems() { - const items = []; - const addFromList = (list, paths) => { - for (const path of paths) { - const item = list.find((i) => i.path === path); - if (item) items.push(item); - } - }; - addFromList(this.cards, this.selectedCardPaths); - addFromList(this.collections, this.selectedCollectionPaths); - addFromList(this.placeholders, this.selectedPlaceholderPaths); - return items; - } - - #getItemsForType(type) { - switch (type) { - case TABLE_TYPE.CARDS: - return this.filteredCards.length ? this.filteredCards : this.cards; - case TABLE_TYPE.COLLECTIONS: - return this.filteredCollections.length ? this.filteredCollections : this.collections; - case TABLE_TYPE.PLACEHOLDERS: - return this.filteredPlaceholders.length ? this.filteredPlaceholders : this.placeholders; - default: - return []; - } - } - - #getSelectedPathsForType(type) { - switch (type) { - case TABLE_TYPE.CARDS: - return this.selectedCardPaths; - case TABLE_TYPE.COLLECTIONS: - return this.selectedCollectionPaths; - case TABLE_TYPE.PLACEHOLDERS: - return this.selectedPlaceholderPaths; - default: - return new Set(); - } - } - - #getLoadingForType(type) { - switch (type) { - case TABLE_TYPE.CARDS: - return this.loadingCards; - case TABLE_TYPE.COLLECTIONS: - return this.loadingCollections; - case TABLE_TYPE.PLACEHOLDERS: - return this.loadingPlaceholders; - default: - return false; - } - } - - #getAllItemsForType(type) { - switch (type) { - case TABLE_TYPE.CARDS: - return this.cards; - case TABLE_TYPE.COLLECTIONS: - return this.collections; - case TABLE_TYPE.PLACEHOLDERS: - return this.placeholders; - default: - return []; - } - } - - #handleFiltered(type, e) { - switch (type) { - case TABLE_TYPE.CARDS: - this.filteredCards = e.detail.items; - break; - case TABLE_TYPE.COLLECTIONS: - this.filteredCollections = e.detail.items; - break; - case TABLE_TYPE.PLACEHOLDERS: - this.filteredPlaceholders = e.detail.items; - break; - } - } - - #handleRemoveItem(e) { - const { path } = e.detail; - this.dispatchEvent( - new CustomEvent('selection-changed', { - detail: { path, selected: false }, - bubbles: true, - composed: true, - }), - ); - } - - #toggleShowSelected() { - this.showSelected = !this.showSelected; - } - - #showToast({ detail: { text, variant } }) { - const toast = this.shadowRoot?.querySelector('sp-toast'); - if (toast) { - toast.textContent = text; - toast.variant = variant; - toast.open = true; - } - } - - #getTabLabel(tab) { - if (this.viewOnly) { - return `${tab.label} (${this.#getSelectedPathsForType(tab.value).size})`; - } - return tab.label; - } - - render() { - return html` - - ${repeat( - TABS, - (tab) => tab.value, - (tab) => html`${this.#getTabLabel(tab)}`, - )} - ${repeat( - TABS, - (tab) => tab.value, - (tab) => html` - - ${this.viewOnly - ? nothing - : html` - this.#handleFiltered(tab.value, e)} - > - `} -
- - ${this.viewOnly - ? nothing - : html``} -
- e.stopPropagation()}> -
- `, - )} -
- - ${this.viewOnly - ? nothing - : html` -
- - - ${toggleSidebarIcon} - - ${this.showSelected && this.selectedCount ? 'Hide selection' : 'Selected items'} - (${this.selectedCount}) - -
- `} - `; - } -} - -customElements.define('mas-item-selector', MasItemSelector); diff --git a/studio/src/common/components/mas-items-search-filters.css.js b/studio/src/common/components/mas-items-search-filters.css.js deleted file mode 100644 index f2a277f76..000000000 --- a/studio/src/common/components/mas-items-search-filters.css.js +++ /dev/null @@ -1,74 +0,0 @@ -import { css } from 'lit'; - -export const styles = css` - :host { - display: block; - } - - .search { - display: flex; - align-items: center; - gap: 8px; - margin: 32px 0 20px 0; - } - - .search sp-search { - flex: 1; - max-width: 400px; - } - - .result-count { - color: var(--spectrum-gray-700); - font-size: 14px; - white-space: nowrap; - } - - .filters { - display: flex; - gap: 12px; - margin-bottom: 8px; - flex-wrap: wrap; - } - - .filter-trigger { - border: 1px solid var(--spectrum-gray-300); - border-radius: 12px; - justify-content: start; - sp-icon-chevron-down { - order: 2; - } - } - - .filter-popover { - padding: 12px; - } - - .checkbox-list { - display: flex; - flex-direction: column; - gap: 12px; - max-height: 300px; - overflow-y: auto; - min-width: 150px; - padding-inline-start: 4px; - } - - .checkbox-list sp-checkbox { - display: flex; - white-space: nowrap; - } - - .applied-filters { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 8px; - flex-wrap: wrap; - } - - .applied-filters sp-tags { - display: flex; - flex-wrap: wrap; - gap: 8px; - } -`; diff --git a/studio/src/common/components/mas-items-search-filters.js b/studio/src/common/components/mas-items-search-filters.js deleted file mode 100644 index 76fc6b804..000000000 --- a/studio/src/common/components/mas-items-search-filters.js +++ /dev/null @@ -1,308 +0,0 @@ -import { LitElement, html, nothing } from 'lit'; -import { repeat } from 'lit/directives/repeat.js'; -import { VARIANTS } from '../../editors/variant-picker.js'; -import { styles } from './mas-items-search-filters.css.js'; -import { FILTER_TYPE, TABLE_TYPE } from '../../constants.js'; - -/** - * Search and filter bar for items selection. - * Props-driven: receives items, emits filtered results via events. - * - * @fires items-filtered - Dispatched when filters/search change. detail: { items: Array } - * - * @property {string} type - 'cards' | 'collections' | 'placeholders' - * @property {Array} items - All items to filter - * @property {boolean} searchOnly - If true, only show search bar (no filter pickers) - * @property {boolean} loading - Whether data is still loading - */ -class MasItemsSearchFilters extends LitElement { - static styles = styles; - - static properties = { - type: { type: String }, - items: { type: Array }, - searchOnly: { type: Boolean }, - loading: { type: Boolean }, - searchQuery: { type: String, state: true }, - templateFilter: { type: Array, state: true }, - marketSegmentFilter: { type: Array, state: true }, - customerSegmentFilter: { type: Array, state: true }, - productFilter: { type: Array, state: true }, - }; - - constructor() { - super(); - this.type = TABLE_TYPE.CARDS; - this.items = []; - this.searchOnly = false; - this.loading = false; - this.searchQuery = ''; - this.templateFilter = []; - this.marketSegmentFilter = []; - this.customerSegmentFilter = []; - this.productFilter = []; - } - - updated(changedProperties) { - super.updated(changedProperties); - if (changedProperties.has('items')) { - if (!this.searchOnly) this.#extractFilterOptions(); - this.#applyFilters(); - } - } - - get templateOptions() { - return VARIANTS.filter((v) => v.label.toLowerCase() !== 'all').map((v) => ({ id: v.value, title: v.label })); - } - - get marketSegmentOptions() { - return this.#extractTagOptions('mas:market_segment/', 'mas:market_segments/'); - } - - get customerSegmentOptions() { - return this.#extractTagOptions('mas:customer_segment/'); - } - - get productOptions() { - return this.#extractTagOptions('mas:product_code/'); - } - - get filteredCount() { - return this._filteredItems?.length ?? this.items.length; - } - - get appliedFilters() { - const filters = []; - const addFilters = (filterValues, options, filterType) => { - const optionsMap = new Map(options.map((opt) => [opt.id, opt])); - for (const id of filterValues) { - const option = optionsMap.get(id); - if (option) filters.push({ type: filterType, id, label: option.title }); - } - }; - addFilters(this.templateFilter, this.templateOptions, FILTER_TYPE.TEMPLATE); - addFilters(this.marketSegmentFilter, this.marketSegmentOptions, FILTER_TYPE.MARKET_SEGMENT); - addFilters(this.customerSegmentFilter, this.customerSegmentOptions, FILTER_TYPE.CUSTOMER_SEGMENT); - addFilters(this.productFilter, this.productOptions, FILTER_TYPE.PRODUCT); - return filters; - } - - #extractTagOptions(...prefixes) { - const tags = new Map(); - for (const item of this.items) { - if (!item.tags) continue; - for (const tag of item.tags) { - const tagId = tag.id || ''; - if (prefixes.some((prefix) => tagId.startsWith(prefix))) { - tags.set(tagId, { id: tagId, title: tag.title || tagId.split('/').pop() || '' }); - } - } - } - return Array.from(tags.values()).sort((a, b) => a.title.localeCompare(b.title)); - } - - #extractFilterOptions() { - // Filter options are derived from items reactively via getters - // This method is kept as a hook for future enhancements - } - - #handleSearchInput({ target: { value: query } }) { - this.searchQuery = query; - this.#applyFilters(); - } - - #handleSearchSubmit(e) { - e.preventDefault(); - this.#applyFilters(); - } - - #handleCheckboxChange(filterType, optionId, e) { - const filterMap = { - [FILTER_TYPE.TEMPLATE]: 'templateFilter', - [FILTER_TYPE.MARKET_SEGMENT]: 'marketSegmentFilter', - [FILTER_TYPE.CUSTOMER_SEGMENT]: 'customerSegmentFilter', - [FILTER_TYPE.PRODUCT]: 'productFilter', - }; - const prop = filterMap[filterType]; - if (!prop) return; - - const current = [...this[prop]]; - if (e.target.checked) { - if (!current.includes(optionId)) current.push(optionId); - } else { - const idx = current.indexOf(optionId); - if (idx !== -1) current.splice(idx, 1); - } - this[prop] = current; - this.#applyFilters(); - } - - #handleTagDelete({ - target: { - value: { type, id }, - }, - }) { - const filterMap = { - [FILTER_TYPE.TEMPLATE]: 'templateFilter', - [FILTER_TYPE.MARKET_SEGMENT]: 'marketSegmentFilter', - [FILTER_TYPE.CUSTOMER_SEGMENT]: 'customerSegmentFilter', - [FILTER_TYPE.PRODUCT]: 'productFilter', - }; - const prop = filterMap[type]; - if (prop) { - this[prop] = this[prop].filter((filterId) => filterId !== id); - this.#applyFilters(); - } - } - - #clearAllFilters() { - this.templateFilter = []; - this.marketSegmentFilter = []; - this.customerSegmentFilter = []; - this.productFilter = []; - this.#applyFilters(); - } - - #applyFilters() { - const query = this.searchQuery?.toLowerCase(); - const hasTemplate = this.templateFilter.length > 0; - const hasMarket = this.marketSegmentFilter.length > 0; - const hasCustomer = this.customerSegmentFilter.length > 0; - const hasProduct = this.productFilter.length > 0; - - const result = (this.items || []).filter((item) => { - if (query) { - if (this.type === TABLE_TYPE.PLACEHOLDERS) { - const key = item.key?.toLowerCase() || ''; - const value = item.value?.toLowerCase() || ''; - if (!key.includes(query) && !value.includes(query)) return false; - } else { - const title = (item.title || '').toLowerCase(); - const productTag = item.tags?.find(({ id }) => id?.startsWith('mas:product_code/'))?.title || ''; - const offerId = item.offerData?.offerId || ''; - if ( - !title.includes(query) && - !productTag.toLowerCase().includes(query) && - !offerId.toLowerCase().includes(query) - ) - return false; - } - } - if (hasTemplate) { - const variantField = item.fields?.find((f) => f.name === 'variant'); - if (!variantField?.values?.some((v) => this.templateFilter.includes(v))) return false; - } - if (hasMarket && !item.tags?.some((tag) => this.marketSegmentFilter.includes(tag.id))) return false; - if (hasCustomer && !item.tags?.some((tag) => this.customerSegmentFilter.includes(tag.id))) return false; - if (hasProduct && !item.tags?.some((tag) => this.productFilter.includes(tag.id))) return false; - return true; - }); - - if (this.type === TABLE_TYPE.CARDS) { - result.sort((a, b) => (b.groupedVariations?.length > 0 ? 1 : 0) - (a.groupedVariations?.length > 0 ? 1 : 0)); - } - - this._filteredItems = result; - this.dispatchEvent(new CustomEvent('items-filtered', { detail: { items: result }, bubbles: true, composed: true })); - } - - #renderFilterPicker(label, options, selectedValues, filterType) { - const count = selectedValues.length; - const displayLabel = count > 0 ? `${label} (${count})` : label; - - return html` - e.stopPropagation()}> - - ${displayLabel} - - - -
- ${options.map((option) => { - const optionId = option.id; - return html` - this.#handleCheckboxChange(filterType, optionId, e)} - > - ${option.title} - - `; - })} -
-
-
- `; - } - - #renderAppliedFilters() { - if (this.appliedFilters.length === 0) return nothing; - - return html` -
- - ${repeat( - this.appliedFilters, - (f) => `${f.type}-${f.id}`, - (f) => html` - - ${f.label} - - `, - )} - - Clear all -
- `; - } - - render() { - return html` - - - ${this.searchOnly - ? nothing - : html` -
- ${this.#renderFilterPicker( - 'Template', - this.templateOptions, - this.templateFilter, - FILTER_TYPE.TEMPLATE, - )} - ${this.#renderFilterPicker( - 'Market Segment', - this.marketSegmentOptions, - this.marketSegmentFilter, - FILTER_TYPE.MARKET_SEGMENT, - )} - ${this.#renderFilterPicker( - 'Customer Segment', - this.customerSegmentOptions, - this.customerSegmentFilter, - FILTER_TYPE.CUSTOMER_SEGMENT, - )} - ${this.#renderFilterPicker('Product', this.productOptions, this.productFilter, FILTER_TYPE.PRODUCT)} -
- ${this.#renderAppliedFilters()} - `} - `; - } -} - -customElements.define('mas-items-search-filters', MasItemsSearchFilters); diff --git a/studio/src/common/components/mas-items-selected-panel.css.js b/studio/src/common/components/mas-items-selected-panel.css.js deleted file mode 100644 index 9277e0010..000000000 --- a/studio/src/common/components/mas-items-selected-panel.css.js +++ /dev/null @@ -1,50 +0,0 @@ -import { css } from 'lit'; -import { ghostButtonStyles } from './common-table-styles.css.js'; - -export const styles = [ - ghostButtonStyles, - css` - :host { - display: flex; - } - - .selected-items { - display: flex; - flex-direction: column; - padding: 12px; - margin: 0; - gap: 12px; - border: 1px solid var(--spectrum-gray-300); - border-radius: 12px; - background: var(--spectrum-gray-50); - - .item { - display: grid; - grid-template-columns: 160px auto; - grid-template-rows: max-content max-content; - padding: 12px; - gap: 8px; - border: 1px solid var(--spectrum-gray-300); - border-radius: 12px; - background: var(--spectrum-white); - - .title { - grid-column: 1; - grid-row: 1; - margin: 0; - overflow-wrap: break-word; - } - - .type { - color: var(--spectrum-orange-800); - } - - .remove-button { - grid-column: 2; - grid-row: 1 / 3; - align-self: center; - } - } - } - `, -]; diff --git a/studio/src/common/components/mas-items-selected-panel.js b/studio/src/common/components/mas-items-selected-panel.js deleted file mode 100644 index 48afbb3c5..000000000 --- a/studio/src/common/components/mas-items-selected-panel.js +++ /dev/null @@ -1,72 +0,0 @@ -import { LitElement, html, nothing } from 'lit'; -import { repeat } from 'lit/directives/repeat.js'; -import { styles } from './mas-items-selected-panel.css.js'; -import { getItemTypeLabel, getItemTitle } from '../utils/render-utils.js'; - -/** - * Panel showing currently selected items with remove buttons. - * Props-driven: receives items, emits events for removal. - * - * @fires remove-item - detail: { path: string, item: Object } - * - * @property {Array} items - Selected items to display - * @property {boolean} visible - Whether the panel is visible - * @property {boolean} disabled - Whether remove buttons are disabled - */ -class MasItemsSelectedPanel extends LitElement { - static styles = styles; - - static properties = { - items: { type: Array }, - visible: { type: Boolean }, - disabled: { type: Boolean }, - }; - - constructor() { - super(); - this.items = []; - this.visible = false; - this.disabled = false; - } - - #removeItem(item) { - this.dispatchEvent( - new CustomEvent('remove-item', { - detail: { path: item.path, item }, - bubbles: true, - composed: true, - }), - ); - } - - render() { - if (!this.visible || !this.items.length) return nothing; - - return html` -
    - ${repeat( - this.items, - (item) => item.path, - (item) => html` -
  • -

    ${getItemTitle(item)}

    -
    ${getItemTypeLabel(item)}
    - this.#removeItem(item)} - ?disabled=${this.disabled} - > - - -
  • - `, - )} -
- `; - } -} - -customElements.define('mas-items-selected-panel', MasItemsSelectedPanel); diff --git a/studio/src/translation/mas-items-selector.css.js b/studio/src/common/components/mas-items-selector.css.js similarity index 94% rename from studio/src/translation/mas-items-selector.css.js rename to studio/src/common/components/mas-items-selector.css.js index 1b7f11512..857c10afa 100644 --- a/studio/src/translation/mas-items-selector.css.js +++ b/studio/src/common/components/mas-items-selector.css.js @@ -1,5 +1,5 @@ import { css } from 'lit'; -import { ghostButtonStyles } from './translation-common-styles.css.js'; +import { ghostButtonStyles } from '../styles/translation-common-styles.css.js'; export const styles = [ ghostButtonStyles, diff --git a/studio/src/translation/mas-items-selector.js b/studio/src/common/components/mas-items-selector.js similarity index 82% rename from studio/src/translation/mas-items-selector.js rename to studio/src/common/components/mas-items-selector.js index 176a6b7d6..344355851 100644 --- a/studio/src/translation/mas-items-selector.js +++ b/studio/src/common/components/mas-items-selector.js @@ -1,9 +1,9 @@ import { LitElement, html, nothing } from 'lit'; import { repeat } from 'lit/directives/repeat.js'; -import Store from '../store.js'; -import ReactiveController from '../reactivity/reactive-controller.js'; -import { TABLE_TYPE } from '../constants.js'; -import { toggleSidebarIcon } from '../icons.js'; +import ReactiveController from '../../reactivity/reactive-controller.js'; +import { getItemsSelectionStore } from '../items-selection-store.js'; +import { TABLE_TYPE } from '../../constants.js'; +import { toggleSidebarIcon } from '../../icons.js'; import './mas-select-items-table.js'; import './mas-selected-items.js'; import './mas-search-and-filters.js'; @@ -29,35 +29,33 @@ class MasItemsSelector extends LitElement { connectedCallback() { super.connectedCallback(); + const s = getItemsSelectionStore(); this.storeController = new ReactiveController(this, [ - Store.translationProjects.inEdit, - Store.translationProjects.showSelected, - Store.translationProjects.selectedCards, - Store.translationProjects.selectedCollections, - Store.translationProjects.selectedPlaceholders, + s.inEdit, + s.showSelected, + s.selectedCards, + s.selectedCollections, + s.selectedPlaceholders, ]); } get showSelected() { - return Store.translationProjects.showSelected.value; + return getItemsSelectionStore().showSelected.value; } get selectedCount() { - return [ - ...Store.translationProjects.selectedCards.value, - ...Store.translationProjects.selectedPlaceholders.value, - ...Store.translationProjects.selectedCollections.value, - ].length; + const s = getItemsSelectionStore(); + return [...s.selectedCards.value, ...s.selectedPlaceholders.value, ...s.selectedCollections.value].length; } #toggleShowSelected() { - Store.translationProjects.showSelected.set(!this.showSelected); + getItemsSelectionStore().showSelected.set(!this.showSelected); } #getTabLabel(tab) { if (this.viewOnly) { const valueUppercase = tab.value.charAt(0).toUpperCase() + tab.value.slice(1); - return `${tab.label} (${Store.translationProjects[`selected${valueUppercase}`].value.length})`; + return `${tab.label} (${getItemsSelectionStore()[`selected${valueUppercase}`].value.length})`; } return tab.label; } diff --git a/studio/src/common/components/mas-items-table.css.js b/studio/src/common/components/mas-items-table.css.js deleted file mode 100644 index 5a7060c48..000000000 --- a/studio/src/common/components/mas-items-table.css.js +++ /dev/null @@ -1,49 +0,0 @@ -import { css } from 'lit'; -import { - tableHeaderStyles, - tableCellStyles, - tableIconCellStyles, - tableSelectedRowStyles, - loadingContainerStyles, -} from './common-table-styles.css.js'; - -export const styles = [ - tableHeaderStyles, - tableCellStyles, - tableIconCellStyles, - tableSelectedRowStyles, - loadingContainerStyles, - css` - :host { - width: 100%; - } - - .items-table { - sp-table-head sp-table-checkbox-cell:first-of-type { - border-top-left-radius: 12px; - } - - sp-table-cell { - word-break: break-word; - } - } - - .loading-container--flex { - padding: 80px; - } - - .scroll-sentinel { - height: 1px; - } - - .loading-more { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 12px; - color: var(--spectrum-gray-700); - font-size: var(--spectrum-font-size-75); - } - `, -]; diff --git a/studio/src/common/components/mas-items-table.js b/studio/src/common/components/mas-items-table.js deleted file mode 100644 index 2657eb956..000000000 --- a/studio/src/common/components/mas-items-table.js +++ /dev/null @@ -1,224 +0,0 @@ -import { LitElement, html, nothing } from 'lit'; -import { repeat } from 'lit/directives/repeat.js'; -import { styles } from './mas-items-table.css.js'; -import { TABLE_TYPE } from '../../constants.js'; -import { renderFragmentStatusCell } from '../utils/render-utils.js'; -import './mas-expandable-card-row.js'; - -/** - * Table component for displaying and selecting items (cards, collections, placeholders). - * Props-driven: receives items and selected state, emits events for selection changes. - * - * @fires selection-changed - detail: { path: string, selected: boolean } - * @fires load-variations - detail: { cardPath: string, variationPaths: string[] } - * @fires show-toast - detail: { text: string, variant: string } - * - * @property {string} type - 'cards' | 'collections' | 'placeholders' - * @property {Array} items - Items to display - * @property {Set} selectedPaths - Currently selected paths - * @property {Map} variationsByParent - Map of cardPath -> Map of variationPath -> variation - * @property {boolean} viewOnly - Read-only mode - * @property {boolean} loading - Whether data is loading - * @property {boolean} hasMore - Whether more pages are available - * @property {boolean} loadingMore - Whether loading additional pages - */ -class MasItemsTable extends LitElement { - static styles = styles; - - static properties = { - type: { type: String }, - items: { type: Array }, - selectedPaths: { type: Object }, - variationsByParent: { type: Object }, - viewOnly: { type: Boolean }, - loading: { type: Boolean }, - hasMore: { type: Boolean }, - loadingMore: { type: Boolean }, - }; - - constructor() { - super(); - this.type = TABLE_TYPE.CARDS; - this.items = []; - this.selectedPaths = new Set(); - this.variationsByParent = new Map(); - this.viewOnly = false; - this.loading = false; - this.hasMore = false; - this.loadingMore = false; - } - - get tableColumns() { - const COLUMNS = { - cards: { - selectable: [ - { label: '', key: 'chevron', class: 'icon-cell icon-cell--chevron' }, - { label: '', key: 'checkbox', class: 'icon-cell icon-cell--checkbox' }, - { label: 'Offer', key: 'offer' }, - { label: 'Fragment title', key: 'fragmentTitle' }, - { label: 'Offer ID', key: 'offerId' }, - { label: 'Path', key: 'path' }, - { label: 'Status', key: 'status' }, - ], - viewOnly: [ - { label: '', key: 'chevron', class: 'icon-cell icon-cell--chevron' }, - { label: 'Offer', key: 'offer' }, - { label: 'Fragment title', key: 'fragmentTitle' }, - { label: 'Offer ID', key: 'offerId' }, - { label: 'Path', key: 'path' }, - { label: 'Item type', key: 'itemType' }, - { label: 'Status', key: 'status' }, - ], - }, - collections: { - selectable: [ - { label: '', key: 'checkbox', class: 'icon-cell icon-cell--checkbox' }, - { label: 'Collection title', key: 'collectionTitle' }, - { label: 'Path', key: 'path' }, - { label: 'Status', key: 'status' }, - ], - viewOnly: [ - { label: 'Collection title', key: 'collectionTitle' }, - { label: 'Path', key: 'path' }, - { label: 'Status', key: 'status' }, - ], - }, - placeholders: { - selectable: [ - { label: '', key: 'checkbox', class: 'icon-cell icon-cell--checkbox' }, - { label: 'Key', key: 'key' }, - { label: 'Value', key: 'value' }, - { label: 'Status', key: 'status' }, - ], - viewOnly: [ - { label: 'Key', key: 'key' }, - { label: 'Value', key: 'value' }, - { label: 'Status', key: 'status' }, - ], - }, - }; - return COLUMNS[this.type]?.[this.viewOnly ? 'viewOnly' : 'selectable'] || []; - } - - #toggleSelected(e, path) { - e.stopPropagation(); - const isSelected = this.selectedPaths.has(path); - this.dispatchEvent( - new CustomEvent('selection-changed', { - detail: { path, selected: !isSelected }, - bubbles: true, - composed: true, - }), - ); - } - - #renderCardsBody() { - return html`${repeat( - this.items, - (card) => card.path, - (card) => html` - - `, - )}`; - } - - #renderCollectionsBody() { - return html`${repeat( - this.items, - (item) => item.path, - (item) => html` - - ${!this.viewOnly - ? html` - this.#toggleSelected(e, item.path)} - > - ` - : nothing} - ${item.title || '-'} - ${item.studioPath} - ${renderFragmentStatusCell(item.status)} - - `, - )}`; - } - - #renderPlaceholdersBody() { - return html`${repeat( - this.items, - (item) => item.path, - (item) => html` - - ${!this.viewOnly - ? html` - this.#toggleSelected(e, item.path)} - > - ` - : nothing} - ${item.key || '-'} - - ${item.value?.length > 100 ? `${item.value.slice(0, 100)}...` : item.value || '-'} - - ${renderFragmentStatusCell(item.status)} - - `, - )}`; - } - - #renderTableBody() { - switch (this.type) { - case TABLE_TYPE.CARDS: - return this.#renderCardsBody(); - case TABLE_TYPE.COLLECTIONS: - return this.#renderCollectionsBody(); - case TABLE_TYPE.PLACEHOLDERS: - return this.#renderPlaceholdersBody(); - default: - return nothing; - } - } - - render() { - if (this.loading) { - return html`
- -
`; - } - - if (!this.items.length) { - return html`

No items found.

`; - } - - return html` - - - ${repeat( - this.tableColumns, - (col) => col.key, - (col) => html`${col.label}`, - )} - - ${this.#renderTableBody()} - - ${this.hasMore ? html`
` : nothing} - ${this.loadingMore - ? html`
- - Loading more items… -
` - : nothing} - `; - } -} - -customElements.define('mas-items-table', MasItemsTable); diff --git a/studio/src/translation/mas-search-and-filters.css.js b/studio/src/common/components/mas-search-and-filters.css.js similarity index 100% rename from studio/src/translation/mas-search-and-filters.css.js rename to studio/src/common/components/mas-search-and-filters.css.js diff --git a/studio/src/translation/mas-search-and-filters.js b/studio/src/common/components/mas-search-and-filters.js similarity index 92% rename from studio/src/translation/mas-search-and-filters.js rename to studio/src/common/components/mas-search-and-filters.js index fe7a7535a..737eb9744 100644 --- a/studio/src/translation/mas-search-and-filters.js +++ b/studio/src/common/components/mas-search-and-filters.js @@ -1,10 +1,11 @@ import { LitElement, html, nothing } from 'lit'; import { repeat } from 'lit/directives/repeat.js'; -import { VARIANTS } from '../editors/variant-picker.js'; +import { VARIANTS } from '../../editors/variant-picker.js'; import { styles } from './mas-search-and-filters.css.js'; -import Store from '../store.js'; -import { FILTER_TYPE, TABLE_TYPE } from '../constants.js'; -import ReactiveController from '../reactivity/reactive-controller.js'; +import Store from '../../store.js'; +import { getItemsSelectionStore } from '../items-selection-store.js'; +import { FILTER_TYPE, TABLE_TYPE } from '../../constants.js'; +import ReactiveController from '../../reactivity/reactive-controller.js'; class MasSearchAndFilters extends LitElement { static styles = styles; @@ -40,8 +41,8 @@ class MasSearchAndFilters extends LitElement { connectedCallback() { super.connectedCallback(); this.commonDataController = new ReactiveController(this, [ - Store.translationProjects[`all${this.typeUppercased}`], - Store.translationProjects[`display${this.typeUppercased}`], + getItemsSelectionStore()[`all${this.typeUppercased}`], + getItemsSelectionStore()[`display${this.typeUppercased}`], Store[this.type === TABLE_TYPE.PLACEHOLDERS ? 'placeholders' : 'fragments'].list.loading, ]); const dataCallback = () => { @@ -51,16 +52,16 @@ class MasSearchAndFilters extends LitElement { this.#applyFilters(); this.requestUpdate(); }; - Store.translationProjects[`all${this.typeUppercased}`].subscribe(dataCallback); + getItemsSelectionStore()[`all${this.typeUppercased}`].subscribe(dataCallback); this.dataSubscription = { - unsubscribe: () => Store.translationProjects[`all${this.typeUppercased}`].unsubscribe(dataCallback), + unsubscribe: () => getItemsSelectionStore()[`all${this.typeUppercased}`].unsubscribe(dataCallback), }; } disconnectedCallback() { super.disconnectedCallback(); - Store.translationProjects[`display${this.typeUppercased}`].set( - Store.translationProjects[`all${this.typeUppercased}`].value, + getItemsSelectionStore()[`display${this.typeUppercased}`].set( + getItemsSelectionStore()[`all${this.typeUppercased}`].value, ); this.dataSubscription?.unsubscribe(); } @@ -105,7 +106,7 @@ class MasSearchAndFilters extends LitElement { const marketSegments = new Map(); const customerSegments = new Map(); const products = new Map(); - for (const fragment of Store.translationProjects[`all${this.typeUppercased}`].value) { + for (const fragment of getItemsSelectionStore()[`all${this.typeUppercased}`].value) { if (!fragment.tags) continue; for (const tag of fragment.tags) { @@ -271,7 +272,7 @@ class MasSearchAndFilters extends LitElement { } #applyFilters() { - const source = Store.translationProjects[`all${this.typeUppercased}`].value || []; + const source = getItemsSelectionStore()[`all${this.typeUppercased}`].value || []; const query = this.searchQuery?.toLowerCase(); const hasTemplate = this.templateFilter?.length > 0; const hasMarket = this.marketSegmentFilter?.length > 0; @@ -319,15 +320,15 @@ class MasSearchAndFilters extends LitElement { if (this.type === TABLE_TYPE.CARDS) { result.sort((a, b) => (b.groupedVariations?.length > 0 ? 1 : 0) - (a.groupedVariations?.length > 0 ? 1 : 0)); } - Store.translationProjects[`display${this.typeUppercased}`].set(result); + getItemsSelectionStore()[`display${this.typeUppercased}`].set(result); } renderCount() { return html`
${this.isLoading ? html`` - : html`${Store.translationProjects[`display${this.typeUppercased}`].value.length} - result${Store.translationProjects[`display${this.typeUppercased}`].value.length !== 1 ? 's' : ''}`} + : html`${getItemsSelectionStore()[`display${this.typeUppercased}`].value.length} + result${getItemsSelectionStore()[`display${this.typeUppercased}`].value.length !== 1 ? 's' : ''}`}
`; } diff --git a/studio/src/translation/mas-select-items-table.css.js b/studio/src/common/components/mas-select-items-table.css.js similarity index 96% rename from studio/src/translation/mas-select-items-table.css.js rename to studio/src/common/components/mas-select-items-table.css.js index 197a1674d..2b8d9a9ad 100644 --- a/studio/src/translation/mas-select-items-table.css.js +++ b/studio/src/common/components/mas-select-items-table.css.js @@ -5,7 +5,7 @@ import { tableColumnIconStyles, tableSelectedRowStyles, loadingContainerFlexStyles, -} from './translation-common-styles.css.js'; +} from '../styles/translation-common-styles.css.js'; export const styles = [ tableHeaderBaseStyles, diff --git a/studio/src/translation/mas-select-items-table.js b/studio/src/common/components/mas-select-items-table.js similarity index 91% rename from studio/src/translation/mas-select-items-table.js rename to studio/src/common/components/mas-select-items-table.js index 9a3c5a063..ca6851756 100644 --- a/studio/src/translation/mas-select-items-table.js +++ b/studio/src/common/components/mas-select-items-table.js @@ -1,18 +1,19 @@ import { LitElement, html, nothing } from 'lit'; import { repeat } from 'lit/directives/repeat.js'; import { styles } from './mas-select-items-table.css.js'; -import Store from '../store.js'; -import StoreController from '../reactivity/store-controller.js'; -import { TABLE_TYPE } from '../constants.js'; -import { renderFragmentStatusCell } from './translation-utils.js'; -import ReactiveController from '../reactivity/reactive-controller.js'; -import { MasCollapsibleTableRow } from './mas-collapsible-table-row.js'; +import Store from '../../store.js'; +import { getItemsSelectionStore } from '../items-selection-store.js'; +import StoreController from '../../reactivity/store-controller.js'; +import { TABLE_TYPE } from '../../constants.js'; +import { renderFragmentStatusCell } from '../../translation/translation-utils.js'; +import ReactiveController from '../../reactivity/reactive-controller.js'; import { loadAllPlaceholders, loadAllFragments, loadSelectedPlaceholders, loadSelectedFragments, -} from './translation-items-loader.js'; +} from '../utils/translation-items-loader.js'; +import '../../translation/mas-collapsible-table-row.js'; class MasSelectItemsTable extends LitElement { static styles = styles; @@ -49,9 +50,9 @@ class MasSelectItemsTable extends LitElement { super.connectedCallback(); if (this.viewOnly) { if (this.type === TABLE_TYPE.PLACEHOLDERS) { - this.viewOnlyLoading = !!Store.translationProjects.selectedPlaceholders.value?.length; + this.viewOnlyLoading = !!getItemsSelectionStore().selectedPlaceholders.value?.length; this.dataSubscription = loadSelectedPlaceholders( - Store.translationProjects.selectedPlaceholders.value, + getItemsSelectionStore().selectedPlaceholders.value, (items) => { this.viewOnlyFragments = items; if (!Store.placeholders.list.loading.get()) { @@ -60,10 +61,10 @@ class MasSelectItemsTable extends LitElement { }, ); } else { - this.viewOnlyLoading = !!Store.translationProjects[`selected${this.typeUppercased}`].value?.length; + this.viewOnlyLoading = !!getItemsSelectionStore()[`selected${this.typeUppercased}`].value?.length; this.processAbortController = new AbortController(); loadSelectedFragments( - Store.translationProjects[`selected${this.typeUppercased}`].value, + getItemsSelectionStore()[`selected${this.typeUppercased}`].value, this.type, this.repository, { @@ -97,10 +98,10 @@ class MasSelectItemsTable extends LitElement { this[`selected${this.typeUppercased}StoreController`] = new ReactiveController(this, [ Store.fragments.list.loading, Store.placeholders.list.loading, - Store.translationProjects[`selected${this.typeUppercased}`], + getItemsSelectionStore()[`selected${this.typeUppercased}`], ]); this[`display${this.typeUppercased}StoreController`] = new ReactiveController(this, [ - Store.translationProjects[`display${this.typeUppercased}`], + getItemsSelectionStore()[`display${this.typeUppercased}`], ]); } @@ -165,11 +166,11 @@ class MasSelectItemsTable extends LitElement { if (this.viewOnly) { return this.viewOnlyFragments; } - return Store.translationProjects[`display${this.typeUppercased}`].value; + return getItemsSelectionStore()[`display${this.typeUppercased}`].value; } get selectedInTable() { - return new Set(Store.translationProjects[`selected${this.typeUppercased}`].value); + return new Set(getItemsSelectionStore()[`selected${this.typeUppercased}`].value); } get tableColumns() { @@ -229,7 +230,7 @@ class MasSelectItemsTable extends LitElement { const newSelected = this.selectedInTable.has(path) ? [...this.selectedInTable].filter((p) => p !== path) : [...this.selectedInTable, path]; - Store.translationProjects[`selected${this.typeUppercased}`].set(newSelected); + getItemsSelectionStore()[`selected${this.typeUppercased}`].set(newSelected); } #renderTableBody() { diff --git a/studio/src/translation/mas-selected-items.css.js b/studio/src/common/components/mas-selected-items.css.js similarity index 94% rename from studio/src/translation/mas-selected-items.css.js rename to studio/src/common/components/mas-selected-items.css.js index 3911ebc82..9e836e1d4 100644 --- a/studio/src/translation/mas-selected-items.css.js +++ b/studio/src/common/components/mas-selected-items.css.js @@ -1,5 +1,5 @@ import { css } from 'lit'; -import { ghostButtonStyles } from './translation-common-styles.css.js'; +import { ghostButtonStyles } from '../styles/translation-common-styles.css.js'; export const styles = [ ghostButtonStyles, diff --git a/studio/src/translation/mas-selected-items.js b/studio/src/common/components/mas-selected-items.js similarity index 70% rename from studio/src/translation/mas-selected-items.js rename to studio/src/common/components/mas-selected-items.js index b745d67b7..6f5aaa9b8 100644 --- a/studio/src/translation/mas-selected-items.js +++ b/studio/src/common/components/mas-selected-items.js @@ -1,11 +1,12 @@ import { LitElement, html, nothing } from 'lit'; import { repeat } from 'lit/directives/repeat.js'; import { styles } from './mas-selected-items.css.js'; -import Store from '../store.js'; -import ReactiveController from '../reactivity/reactive-controller.js'; -import { Fragment } from '../aem/fragment.js'; -import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../constants.js'; -import { fetchUnresolvedVariations } from './translation-items-loader.js'; +import Store from '../../store.js'; +import { getItemsSelectionStore } from '../items-selection-store.js'; +import ReactiveController from '../../reactivity/reactive-controller.js'; +import { Fragment } from '../../aem/fragment.js'; +import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../../constants.js'; +import { fetchUnresolvedVariations } from '../utils/translation-items-loader.js'; class MasSelectedItems extends LitElement { static styles = styles; @@ -15,17 +16,17 @@ class MasSelectedItems extends LitElement { constructor() { super(); this.storeController = new ReactiveController(this, [ - Store.translationProjects.showSelected, - Store.translationProjects.selectedCards, - Store.translationProjects.selectedCollections, - Store.translationProjects.selectedPlaceholders, - Store.translationProjects.groupedVariationsByParent, + getItemsSelectionStore().showSelected, + getItemsSelectionStore().selectedCards, + getItemsSelectionStore().selectedCollections, + getItemsSelectionStore().selectedPlaceholders, + getItemsSelectionStore().groupedVariationsByParent, Store.fragments.list.loading, Store.placeholders.list.loading, ]); this.fetchController = new ReactiveController( this, - [Store.translationProjects.showSelected, Store.translationProjects.selectedCards], + [getItemsSelectionStore().showSelected, getItemsSelectionStore().selectedCards], this.maybeFetchUnresolvedVariations.bind(this), ); } @@ -35,15 +36,15 @@ class MasSelectedItems extends LitElement { maybeFetchUnresolvedVariations() { if (!this.showSelected || !this.repository) return; - const selectedCards = Store.translationProjects.selectedCards.value || []; + const selectedCards = getItemsSelectionStore().selectedCards.value || []; const selectedCardsKey = [...selectedCards].sort().join('\0'); if (selectedCardsKey === this.#lastFetchedSelectedCardsKey) return; this.#lastFetchedSelectedCardsKey = selectedCardsKey; fetchUnresolvedVariations( selectedCards, - Store.translationProjects.cardsByPaths.value, - Store.translationProjects.groupedVariationsByParent.value, + getItemsSelectionStore().cardsByPaths.value, + getItemsSelectionStore().groupedVariationsByParent.value, this.repository, ); } @@ -54,28 +55,28 @@ class MasSelectedItems extends LitElement { } get selectedItems() { - const cards = Store.translationProjects.selectedCards.value - ?.map( + const cards = getItemsSelectionStore() + .selectedCards.value?.map( (path) => - Store.translationProjects.cardsByPaths.value?.get(path) ?? - Store.translationProjects.groupedVariationsData.value?.get(path), + getItemsSelectionStore().cardsByPaths.value?.get(path) ?? + getItemsSelectionStore().groupedVariationsData.value?.get(path), ) .filter(Boolean); - const collections = Store.translationProjects.selectedCollections.value - ?.map((path) => { - return Store.translationProjects.collectionsByPaths.value.get(path); + const collections = getItemsSelectionStore() + .selectedCollections.value?.map((path) => { + return getItemsSelectionStore().collectionsByPaths.value.get(path); }) .filter(Boolean); - const placeholders = Store.translationProjects.selectedPlaceholders.value - ?.map((path) => { - return Store.translationProjects.placeholdersByPaths.value.get(path); + const placeholders = getItemsSelectionStore() + .selectedPlaceholders.value?.map((path) => { + return getItemsSelectionStore().placeholdersByPaths.value.get(path); }) .filter(Boolean); return [...cards, ...collections, ...placeholders]; } get showSelected() { - return Store.translationProjects.showSelected.value; + return getItemsSelectionStore().showSelected.value; } get isLoadingItems() { @@ -120,8 +121,8 @@ class MasSelectedItems extends LitElement { type = 'Placeholders'; break; } - Store.translationProjects[`selected${type}`].set( - Store.translationProjects[`selected${type}`].value?.filter((selectedPath) => selectedPath !== item.path), + getItemsSelectionStore()[`selected${type}`].set( + getItemsSelectionStore()[`selected${type}`].value?.filter((selectedPath) => selectedPath !== item.path), ); } diff --git a/studio/src/common/items-selection-store.js b/studio/src/common/items-selection-store.js new file mode 100644 index 000000000..da4a7cf47 --- /dev/null +++ b/studio/src/common/items-selection-store.js @@ -0,0 +1,14 @@ +import Store from '../store.js'; + +let activeItemsSelectionStore = Store.translationProjects; + +export function getItemsSelectionStore() { + return activeItemsSelectionStore; +} + +/** + * @param {typeof Store.translationProjects} slice + */ +export function setItemsSelectionStore(slice) { + activeItemsSelectionStore = slice; +} diff --git a/studio/src/common/components/common-table-styles.css.js b/studio/src/common/styles/translation-common-styles.css.js similarity index 75% rename from studio/src/common/components/common-table-styles.css.js rename to studio/src/common/styles/translation-common-styles.css.js index 053506eed..8457fa3cb 100644 --- a/studio/src/common/components/common-table-styles.css.js +++ b/studio/src/common/styles/translation-common-styles.css.js @@ -7,13 +7,7 @@ export const ghostButtonStyles = css` } `; -export const loadingContainerStyles = css` - .loading-container--flex { - display: flex; - justify-content: center; - align-items: center; - } - +export const loadingContainerCenteredStyles = css` .loading-container--absolute { position: absolute; top: 50%; @@ -22,34 +16,58 @@ export const loadingContainerStyles = css` } `; -export const tableHeaderStyles = css` - .items-table { +export const loadingContainerFlexStyles = css` + .loading-container--flex { + display: flex; + justify-content: center; + align-items: center; + } +`; + +export const tableHeaderBaseStyles = css` + .translation-table { --mod-table-header-background-color: var(--spectrum-gray-50); --mod-table-border-radius: 0; } - .items-table sp-table-head { + .translation-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; } - .items-table sp-table-head-cell { + .translation-table sp-table-head-cell { align-content: center; } - .items-table sp-table-head-cell:first-of-type { + .translation-table sp-table-head-cell:first-of-type { border-top-left-radius: 12px; } - .items-table sp-table-head-cell:last-of-type { + .translation-table sp-table-head-cell:last-of-type { border-top-right-radius: 12px; } `; -export const tableCellStyles = css` - .items-table sp-table-cell, +export const tableColumnIconStyles = css` + .translation-table-icon-cell { + display: flex; + align-items: center; + flex: 0; + } + + .translation-table-icon-cell--chevron { + padding: 29px; + } + + .translation-table-icon-cell--checkbox { + padding: 22px; + } +`; + +export const tableCellBaseStyles = css` + .translation-table sp-table-cell, sp-table-cell { display: flex; align-items: center; @@ -77,22 +95,6 @@ export const tableCellStyles = css` } `; -export const tableIconCellStyles = css` - .icon-cell { - display: flex; - align-items: center; - flex: 0; - } - - .icon-cell--chevron { - padding: 29px; - } - - .icon-cell--checkbox { - padding: 22px; - } -`; - export const tableSelectedRowStyles = css` sp-table-row[selected] { --mod-table-row-background-color: var(--spectrum-blue-200); diff --git a/studio/src/translation/translation-items-loader.js b/studio/src/common/utils/translation-items-loader.js similarity index 91% rename from studio/src/translation/translation-items-loader.js rename to studio/src/common/utils/translation-items-loader.js index 5ee3a1a62..81c05273f 100644 --- a/studio/src/translation/translation-items-loader.js +++ b/studio/src/common/utils/translation-items-loader.js @@ -1,8 +1,9 @@ -import Store from '../store.js'; -import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, TABLE_TYPE } from '../constants.js'; -import { Fragment } from '../aem/fragment.js'; -import { getFragmentName } from './translation-utils.js'; -import { getService } from '../utils.js'; +import Store from '../../store.js'; +import { getItemsSelectionStore } from '../items-selection-store.js'; +import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, TABLE_TYPE } from '../../constants.js'; +import { Fragment } from '../../aem/fragment.js'; +import { getFragmentName } from '../../translation/translation-utils.js'; +import { getService } from '../../utils.js'; const OFFER_DATA_CONCURRENCY_LIMIT = 5; const VARIATIONS_CONCURRENCY_LIMIT = 5; @@ -58,11 +59,11 @@ async function processConcurrently(items, asyncFn, concurrencyLimit, batchSize = * Loads offer data for a fragment using its OSI field * @param {Object} fragment - Fragment object with fields * @param {AbortSignal} signal - Optional abort signal for cancellation - * @param {Number} timeoutMs - Timeout in milliseconds (default: 10000) + * @param {Number} timeoutMs - Timeout in milliseconds * @returns {Promise} Offer data or null if not found/failed */ async function loadOfferData(fragment, signal, timeoutMs = 10000) { - const cache = Store.translationProjects.offerDataCache; + const cache = getItemsSelectionStore().offerDataCache; const wcsOsi = fragment?.fields?.find(({ name }) => name === 'osi')?.values?.[0]; if (!wcsOsi) return null; @@ -166,7 +167,7 @@ export async function fetchVariationByPath(variationPath, repository) { offerData, }; - const existing = Store.translationProjects.groupedVariationsByParent.value || new Map(); + const existing = getItemsSelectionStore().groupedVariationsByParent.value || new Map(); const innerMap = new Map(existing.get(parentCardPath) || []); innerMap.set(variationPath, enriched); const merged = new Map(existing); @@ -180,19 +181,18 @@ export async function fetchVariationByPath(variationPath, repository) { } /** - * Updates groupedVariationsByParent and keeps groupedVariationsData (flattened map) in sync. - * Call this instead of Store.translationProjects.groupedVariationsByParent.set() to avoid rebuilding the flattened map on every render. + * Updates groupedVariationsByParent. * @param {Map} groupedVariationsByParentValue - Map of cardPath -> Map of variationPath -> variation */ export function setCardVariationsByPaths(groupedVariationsByParentValue) { - Store.translationProjects.groupedVariationsByParent.set(groupedVariationsByParentValue); + getItemsSelectionStore().groupedVariationsByParent.set(groupedVariationsByParentValue); const flattened = new Map(); for (const variationsMap of groupedVariationsByParentValue.values()) { for (const [path, variation] of variationsMap) { flattened.set(path, variation); } } - Store.translationProjects.groupedVariationsData.set(flattened); + getItemsSelectionStore().groupedVariationsData.set(flattened); } /** @@ -234,7 +234,7 @@ async function processCardsData(allCards, repository, state) { const { signal: currentSignal } = state.processAbortController; try { - const existingCards = Store.translationProjects.allCards.get() || []; + const existingCards = getItemsSelectionStore().allCards.get() || []; const existingOfferDataByPath = new Map( existingCards.filter((card) => card.offerData !== undefined).map((card) => [card.path, card.offerData]), ); @@ -292,16 +292,16 @@ async function processCardsData(allCards, repository, state) { .map((card) => [card.path, new Map(card.groupedVariations.map((v) => [v.path, v]))]), ); if (prefetchedVariations.size > 0) { - const existing = Store.translationProjects.groupedVariationsByParent.value || new Map(); + const existing = getItemsSelectionStore().groupedVariationsByParent.value || new Map(); const merged = new Map(existing); for (const [cardPath, varMap] of prefetchedVariations) { merged.set(cardPath, varMap); } setCardVariationsByPaths(merged); } - Store.translationProjects.displayCards.set(enrichedCards); - Store.translationProjects.allCards.set(enrichedCards); - Store.translationProjects.cardsByPaths.set(cardsByPaths); + getItemsSelectionStore().displayCards.set(enrichedCards); + getItemsSelectionStore().allCards.set(enrichedCards); + getItemsSelectionStore().cardsByPaths.set(cardsByPaths); } finally { state.isProcessingCards = false; } @@ -312,10 +312,10 @@ async function processCardsData(allCards, repository, state) { * @param {Array} allCollections - Array of collection objects */ function processCollectionsData(allCollections) { - Store.translationProjects.displayCollections.set(allCollections); - Store.translationProjects.allCollections.set(allCollections); + getItemsSelectionStore().displayCollections.set(allCollections); + getItemsSelectionStore().allCollections.set(allCollections); const collectionsByPaths = new Map(allCollections.map((f) => [f.path, f])); - Store.translationProjects.collectionsByPaths.set(collectionsByPaths); + getItemsSelectionStore().collectionsByPaths.set(collectionsByPaths); } /** @@ -323,15 +323,15 @@ function processCollectionsData(allCollections) { * @returns {{ unsubscribe: () => void }} */ export function loadAllPlaceholders() { - if (Store.translationProjects.allPlaceholders.get()?.length) { + if (getItemsSelectionStore().allPlaceholders.get()?.length) { return { unsubscribe: () => {} }; } const callback = () => { const placeholderValues = Store.placeholders.list.data.get().map((placeholder) => placeholder.value); const placeholdersByPaths = new Map(placeholderValues.map((p) => [p.path, p])); - Store.translationProjects.displayPlaceholders.set(placeholderValues); - Store.translationProjects.allPlaceholders.set(placeholderValues); - Store.translationProjects.placeholdersByPaths.set(placeholdersByPaths); + getItemsSelectionStore().displayPlaceholders.set(placeholderValues); + getItemsSelectionStore().allPlaceholders.set(placeholderValues); + getItemsSelectionStore().placeholdersByPaths.set(placeholdersByPaths); }; Store.placeholders.list.data.subscribe(callback); return { unsubscribe: () => Store.placeholders.list.data.unsubscribe(callback) }; @@ -346,7 +346,7 @@ export function loadAllPlaceholders() { */ export function loadAllFragments(type, repository, state = {}) { const typeUppercased = type.charAt(0).toUpperCase() + type.slice(1); - if (Store.translationProjects[`all${typeUppercased}`].get()?.length) { + if (getItemsSelectionStore()[`all${typeUppercased}`].get()?.length) { return { unsubscribe: () => {} }; } const callback = async () => { @@ -498,7 +498,7 @@ export async function fetchUnresolvedVariations(selectedCards, cardsByPaths, gro * @returns {Promise} */ export async function loadCardVariations(cardPath, variationPaths, repository) { - const hadPath = Store.translationProjects.groupedVariationsByParent.value?.has(cardPath); + const hadPath = getItemsSelectionStore().groupedVariationsByParent.value?.has(cardPath); if (!variationPaths?.length || hadPath || !repository) return; try { @@ -536,7 +536,7 @@ export async function loadCardVariations(cardPath, variationPaths, repository) { ]), ); - const existing = Store.translationProjects.groupedVariationsByParent.value || new Map(); + const existing = getItemsSelectionStore().groupedVariationsByParent.value || new Map(); const merged = new Map(existing); merged.set(cardPath, variationsByPaths); setCardVariationsByPaths(merged); diff --git a/studio/src/translation/mas-collapsible-table-row.js b/studio/src/translation/mas-collapsible-table-row.js index 46b1e9667..d8cdedfb3 100644 --- a/studio/src/translation/mas-collapsible-table-row.js +++ b/studio/src/translation/mas-collapsible-table-row.js @@ -4,8 +4,8 @@ import { styles } from './mas-collapsible-table-row.css.js'; import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../constants.js'; import { renderFragmentStatusCell } from './translation-utils.js'; import { Fragment } from '../aem/fragment.js'; -import Store from '../store.js'; -import { loadCardVariations, fetchVariationByPath } from './translation-items-loader.js'; +import { getItemsSelectionStore } from '../common/items-selection-store.js'; +import { loadCardVariations, fetchVariationByPath } from '../common/utils/translation-items-loader.js'; import ReactiveController from '../reactivity/reactive-controller.js'; export class MasCollapsibleTableRow extends LitElement { @@ -41,8 +41,8 @@ export class MasCollapsibleTableRow extends LitElement { this.isTopLevelExpanded = false; this.expandedVariationsPaths = new Set(); this.resizeObserver = null; - this.variationsController = new ReactiveController(this, [Store.translationProjects.groupedVariationsByParent]); - this.selectedCardsController = new ReactiveController(this, [Store.translationProjects.selectedCards]); + this.variationsController = new ReactiveController(this, [getItemsSelectionStore().groupedVariationsByParent]); + this.selectedCardsController = new ReactiveController(this, [getItemsSelectionStore().selectedCards]); } connectedCallback() { @@ -77,11 +77,11 @@ export class MasCollapsibleTableRow extends LitElement { } get topLevelCardVariationsByPaths() { - return Store.translationProjects.groupedVariationsByParent.value.get(this.topLevelCard.path) || new Map(); + return getItemsSelectionStore().groupedVariationsByParent.value.get(this.topLevelCard.path) || new Map(); } get selectedCards() { - return Store.translationProjects.selectedCards.value || []; + return getItemsSelectionStore().selectedCards.value || []; } get cells() { @@ -303,11 +303,11 @@ export class MasCollapsibleTableRow extends LitElement { #toggleSelect(e, path) { e.stopPropagation(); - const current = Store.translationProjects.selectedCards.value || []; + const current = getItemsSelectionStore().selectedCards.value || []; if (current.includes(path)) { - Store.translationProjects.selectedCards.set(current.filter((p) => p !== path)); + getItemsSelectionStore().selectedCards.set(current.filter((p) => p !== path)); } else { - Store.translationProjects.selectedCards.set([...current, path]); + getItemsSelectionStore().selectedCards.set([...current, path]); } } @@ -315,14 +315,14 @@ export class MasCollapsibleTableRow extends LitElement { e.stopPropagation(); this.isTopLevelExpanded = !this.isTopLevelExpanded; if (this.isGroupedVariation) { - if (Store.translationProjects.groupedVariationsData.value?.get(this.topLevelCard.path)) return; + if (getItemsSelectionStore().groupedVariationsData.value?.get(this.topLevelCard.path)) return; this.isLoadingVariations = true; fetchVariationByPath(this.topLevelCard.path, this.repository).finally(() => { this.isLoadingVariations = false; }); } else { if ( - Store.translationProjects.groupedVariationsByParent.value?.has(this.topLevelCard.path) || + getItemsSelectionStore().groupedVariationsByParent.value?.has(this.topLevelCard.path) || !this.variationPaths.length ) return; @@ -359,9 +359,9 @@ export class MasCollapsibleTableRow extends LitElement { : html` - ${this.renderPromoCode(Store.translationProjects.groupedVariationsData.value?.get(variationPath))} + ${this.renderPromoCode(getItemsSelectionStore().groupedVariationsData.value?.get(variationPath))} - ${this.renderTags(Store.translationProjects.groupedVariationsData.value?.get(variationPath))} + ${this.renderTags(getItemsSelectionStore().groupedVariationsData.value?.get(variationPath))} `; diff --git a/studio/src/translation/mas-translation-editor.js b/studio/src/translation/mas-translation-editor.js index 6098d7527..9847a6514 100644 --- a/studio/src/translation/mas-translation-editor.js +++ b/studio/src/translation/mas-translation-editor.js @@ -6,12 +6,13 @@ import { FragmentStore } from '../reactivity/fragment-store.js'; import { Fragment } from '../aem/fragment.js'; import { MasRepository, getFromFragmentCache } from '../mas-repository.js'; import { styles } from './mas-translation-editor.css.js'; -import './mas-items-selector.js'; +import '../common/components/mas-items-selector.js'; import '../mas-quick-actions.js'; import './mas-translation-languages.js'; import router from '../router.js'; import { normalizeKey, showToast } from '../utils.js'; import { PAGE_NAMES, TRANSLATION_PROJECT_MODEL_ID, QUICK_ACTION } from '../constants.js'; +import { setItemsSelectionStore } from '../common/items-selection-store.js'; class MasTranslationEditor extends LitElement { static styles = styles; @@ -61,6 +62,7 @@ class MasTranslationEditor extends LitElement { async connectedCallback() { super.connectedCallback(); + setItemsSelectionStore(Store.translationProjects); if (this.repository?.searchFragments) { this.repository.searchFragments(); diff --git a/studio/test/common/components/mas-expandable-card-row.test.js b/studio/test/common/components/mas-expandable-card-row.test.js deleted file mode 100644 index e11e5c676..000000000 --- a/studio/test/common/components/mas-expandable-card-row.test.js +++ /dev/null @@ -1,63 +0,0 @@ -import { expect } from '@esm-bundle/chai'; -import { html } from 'lit'; -import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; -import sinon from 'sinon'; -import { CARD_MODEL_PATH } from '../../../src/constants.js'; -import '../../../src/swc.js'; -import '../../../src/common/components/mas-expandable-card-row.js'; - -describe('MasExpandableCardRow', () => { - afterEach(() => { - fixtureCleanup(); - }); - - const cardWithVariation = { - path: '/content/dam/mas/card-base', - title: 'Parent', - studioPath: 'studio/parent', - model: { path: CARD_MODEL_PATH }, - tags: [{ id: 'mas:product_code/x', title: 'Offer' }], - offerData: { offerId: 'OID' }, - fields: [{ name: 'variations', values: ['/content/dam/mas/card-base/pzn/g1/var1'] }], - status: 'PUBLISHED', - }; - - it('renders nothing when card is missing', async () => { - const el = await fixture(html``); - expect(el.shadowRoot.textContent.trim()).to.equal(''); - expect(el.shadowRoot.querySelector('sp-table-row')).to.be.null; - }); - - it('dispatches selection-changed when main checkbox toggles', async () => { - const handler = sinon.spy(); - const el = await fixture(html` - - `); - const cb = el.shadowRoot.querySelector('sp-checkbox'); - cb.click(); - expect(handler.calledOnce).to.be.true; - expect(handler.firstCall.args[0].detail).to.deep.include({ path: cardWithVariation.path, selected: true }); - }); - - it('dispatches load-variations when expanding with empty variations map', async () => { - const handler = sinon.spy(); - const el = await fixture(html` - - `); - const expandBtn = el.shadowRoot.querySelector('.expand-button'); - expandBtn.click(); - await el.updateComplete; - expect(handler.calledOnce).to.be.true; - expect(handler.firstCall.args[0].detail.cardPath).to.equal(cardWithVariation.path); - expect(handler.firstCall.args[0].detail.variationPaths.length).to.be.greaterThan(0); - }); -}); diff --git a/studio/test/common/components/mas-item-selector.test.js b/studio/test/common/components/mas-item-selector.test.js deleted file mode 100644 index 1c35da718..000000000 --- a/studio/test/common/components/mas-item-selector.test.js +++ /dev/null @@ -1,91 +0,0 @@ -import { expect } from '@esm-bundle/chai'; -import { html } from 'lit'; -import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; -import sinon from 'sinon'; -import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, TABLE_TYPE } from '../../../src/constants.js'; -import '../../../src/swc.js'; -import '../../../src/common/components/mas-item-selector.js'; -import { TABS } from '../../../src/common/components/mas-item-selector.js'; - -describe('MasItemSelector', () => { - afterEach(() => { - fixtureCleanup(); - }); - - const card = { - path: '/c/card1', - title: 'C1', - studioPath: 's', - model: { path: CARD_MODEL_PATH }, - }; - const collection = { - path: '/c/col1', - title: 'Col', - studioPath: 'sc', - model: { path: COLLECTION_MODEL_PATH }, - }; - const placeholder = { path: '/c/ph1', key: 'k', value: 'v' }; - - describe('TABS', () => { - it('exports three tabs matching TABLE_TYPE', () => { - expect(TABS).to.have.lengthOf(3); - expect(TABS.map((t) => t.value)).to.deep.equal([TABLE_TYPE.CARDS, TABLE_TYPE.COLLECTIONS, TABLE_TYPE.PLACEHOLDERS]); - }); - }); - - describe('selectedCount and selectedItems', () => { - it('counts selections across types', async () => { - const el = await fixture(html` - - `); - expect(el.selectedCount).to.equal(2); - expect(el.selectedItems.map((i) => i.path)).to.deep.equal([card.path, collection.path]); - }); - }); - - describe('selection-changed from selected panel', () => { - it('re-emits selection-changed with selected false when panel removes item', async () => { - const handler = sinon.spy(); - const el = await fixture(html` - - `); - el.showSelected = true; - await el.updateComplete; - const panel = el.shadowRoot.querySelector('mas-items-selected-panel'); - expect(panel).to.exist; - panel.dispatchEvent( - new CustomEvent('remove-item', { - detail: { path: card.path, item: card }, - bubbles: true, - composed: true, - }), - ); - expect(handler.calledOnce).to.be.true; - expect(handler.firstCall.args[0].detail).to.deep.equal({ path: card.path, selected: false }); - }); - }); - - describe('viewOnly', () => { - it('hides search filters and selected panel', async () => { - const el = await fixture(html``); - expect(el.shadowRoot.querySelector('mas-items-search-filters')).to.be.null; - expect(el.shadowRoot.querySelector('mas-items-selected-panel')).to.be.null; - }); - }); -}); diff --git a/studio/test/common/components/mas-items-search-filters.test.js b/studio/test/common/components/mas-items-search-filters.test.js deleted file mode 100644 index 2ce4ee92e..000000000 --- a/studio/test/common/components/mas-items-search-filters.test.js +++ /dev/null @@ -1,112 +0,0 @@ -import { expect } from '@esm-bundle/chai'; -import { html } from 'lit'; -import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; -import sinon from 'sinon'; -import { TABLE_TYPE, FILTER_TYPE, CARD_MODEL_PATH } from '../../../src/constants.js'; -import '../../../src/swc.js'; -import '../../../src/common/components/mas-items-search-filters.js'; - -describe('MasItemsSearchFilters', () => { - let sandbox; - - const mockCard = (overrides = {}) => ({ - title: 'Alpha Card', - path: '/content/dam/mas/cards/alpha', - tags: [{ id: 'mas:product_code/123', title: 'Prod' }], - fields: [{ name: 'variant', values: ['standard'] }], - offerData: { offerId: 'OFF-1' }, - model: { path: CARD_MODEL_PATH }, - ...overrides, - }); - - beforeEach(() => { - sandbox = sinon.createSandbox(); - }); - - afterEach(() => { - fixtureCleanup(); - sandbox.restore(); - }); - - describe('initialization', () => { - it('defaults search and filter state', async () => { - const el = await fixture(html``); - expect(el.searchQuery).to.equal(''); - expect(el.templateFilter).to.deep.equal([]); - expect(el.marketSegmentFilter).to.deep.equal([]); - expect(el.customerSegmentFilter).to.deep.equal([]); - expect(el.productFilter).to.deep.equal([]); - }); - - it('exposes templateOptions derived from VARIANTS', async () => { - const el = await fixture(html``); - expect(el.templateOptions.length).to.be.greaterThan(0); - expect(el.templateOptions.every((o) => o.id && o.title)).to.be.true; - }); - }); - - describe('items-filtered', () => { - it('dispatches items-filtered when items are set', async () => { - const onFiltered = sinon.spy(); - const items = [mockCard()]; - await fixture(html` - - `); - expect(onFiltered.called).to.be.true; - const evt = onFiltered.firstCall.args[0]; - expect(evt.detail.items).to.deep.equal(items); - }); - - it('filters when search input changes', async () => { - const onFiltered = sinon.spy(); - const items = [mockCard({ title: 'FindMe' }), mockCard({ title: 'Other' })]; - const el = await fixture(html` - - `); - onFiltered.resetHistory(); - const search = el.shadowRoot.querySelector('sp-search'); - search.value = 'findme'; - search.dispatchEvent(new Event('input', { bubbles: true })); - await el.updateComplete; - const evt = onFiltered.lastCall.args[0]; - expect(evt.detail.items.length).to.equal(1); - expect(evt.detail.items[0].title).to.equal('FindMe'); - }); - }); - - describe('appliedFilters', () => { - it('builds labels from template filter ids', async () => { - const el = await fixture( - html``, - ); - const firstTemplateId = el.templateOptions[0]?.id; - if (!firstTemplateId) return; - el.templateFilter = [firstTemplateId]; - await el.updateComplete; - expect(el.appliedFilters.some((f) => f.type === FILTER_TYPE.TEMPLATE && f.id === firstTemplateId)).to.be.true; - }); - }); - - describe('tag options', () => { - it('extracts market segment options from item tags', async () => { - const items = [ - mockCard({ - tags: [{ id: 'mas:market_segment/us', title: 'US' }], - }), - ]; - const el = await fixture( - html``, - ); - await el.updateComplete; - expect(el.marketSegmentOptions.some((o) => o.id === 'mas:market_segment/us')).to.be.true; - }); - }); -}); diff --git a/studio/test/common/components/mas-items-selected-panel.test.js b/studio/test/common/components/mas-items-selected-panel.test.js deleted file mode 100644 index 6f1b78fc2..000000000 --- a/studio/test/common/components/mas-items-selected-panel.test.js +++ /dev/null @@ -1,50 +0,0 @@ -import { expect } from '@esm-bundle/chai'; -import { html } from 'lit'; -import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; -import sinon from 'sinon'; -import { CARD_MODEL_PATH } from '../../../src/constants.js'; -import '../../../src/swc.js'; -import '../../../src/common/components/mas-items-selected-panel.js'; - -describe('MasItemsSelectedPanel', () => { - afterEach(() => { - fixtureCleanup(); - }); - - const item = { - path: '/content/item-1', - title: 'My card', - model: { path: CARD_MODEL_PATH }, - }; - - it('renders nothing when not visible', async () => { - const el = await fixture( - html``, - ); - expect(el.shadowRoot.textContent.trim()).to.equal(''); - expect(el.shadowRoot.querySelector('ul.selected-items')).to.be.null; - }); - - it('renders nothing when visible but no items', async () => { - const el = await fixture(html``); - expect(el.shadowRoot.textContent.trim()).to.equal(''); - expect(el.shadowRoot.querySelector('ul.selected-items')).to.be.null; - }); - - it('renders titles when visible with items', async () => { - const el = await fixture(html``); - expect(el.shadowRoot.textContent).to.include('My card'); - }); - - it('dispatches remove-item when remove is clicked', async () => { - const onRemove = sinon.spy(); - const el = await fixture(html` - - `); - const btn = el.shadowRoot.querySelector('sp-button.remove-button'); - btn.click(); - expect(onRemove.calledOnce).to.be.true; - expect(onRemove.firstCall.args[0].detail.path).to.equal(item.path); - expect(onRemove.firstCall.args[0].detail.item).to.equal(item); - }); -}); diff --git a/studio/test/common/components/mas-items-table.test.js b/studio/test/common/components/mas-items-table.test.js deleted file mode 100644 index 8829e7c66..000000000 --- a/studio/test/common/components/mas-items-table.test.js +++ /dev/null @@ -1,98 +0,0 @@ -import { expect } from '@esm-bundle/chai'; -import { html } from 'lit'; -import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; -import sinon from 'sinon'; -import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, TABLE_TYPE, FRAGMENT_STATUS } from '../../../src/constants.js'; -import '../../../src/swc.js'; -import '../../../src/common/components/mas-items-table.js'; - -describe('MasItemsTable', () => { - afterEach(() => { - fixtureCleanup(); - }); - - const mockCard = { - path: '/content/card-1', - title: 'Card', - studioPath: 'studio/card', - model: { path: CARD_MODEL_PATH }, - tags: [], - status: FRAGMENT_STATUS.PUBLISHED, - }; - - const mockCollection = { - path: '/content/col-1', - title: 'Col', - studioPath: 'studio/col', - model: { path: COLLECTION_MODEL_PATH }, - status: FRAGMENT_STATUS.PUBLISHED, - }; - - const mockPlaceholder = { - path: '/content/ph-1', - key: 'k', - value: 'v', - status: FRAGMENT_STATUS.PUBLISHED, - }; - - describe('tableColumns', () => { - it('returns selectable columns for cards', async () => { - const el = await fixture(html``); - expect(el.tableColumns.some((c) => c.key === 'checkbox')).to.be.true; - expect(el.tableColumns.some((c) => c.key === 'fragmentTitle')).to.be.true; - }); - - it('returns view-only columns for cards when viewOnly', async () => { - const el = await fixture(html``); - expect(el.tableColumns.some((c) => c.key === 'itemType')).to.be.true; - expect(el.tableColumns.some((c) => c.key === 'checkbox')).to.be.false; - }); - - it('returns columns for placeholders', async () => { - const el = await fixture(html``); - expect(el.tableColumns.some((c) => c.key === 'key')).to.be.true; - }); - }); - - describe('render states', () => { - it('shows loading indicator when loading', async () => { - const el = await fixture(html``); - expect(el.shadowRoot.querySelector('sp-progress-circle')).to.exist; - }); - - it('shows empty message when not loading and no items', async () => { - const el = await fixture(html``); - expect(el.shadowRoot.textContent).to.include('No items found'); - }); - - it('renders table for collections', async () => { - const el = await fixture(html` - - `); - expect(el.shadowRoot.querySelector('sp-table')).to.exist; - }); - }); - - describe('selection-changed', () => { - it('dispatches selection-changed when toggling collection row', async () => { - const handler = sinon.spy(); - const el = await fixture(html` - - `); - const row = el.shadowRoot.querySelector('sp-table-row'); - const cb = row.querySelector('sp-checkbox'); - cb.click(); - expect(handler.calledOnce).to.be.true; - expect(handler.firstCall.args[0].detail).to.deep.include({ path: mockCollection.path, selected: true }); - }); - }); -}); diff --git a/studio/test/translation/mas-collapsible-table-row.test.js b/studio/test/translation/mas-collapsible-table-row.test.js index 3e7f941cc..92ece0840 100644 --- a/studio/test/translation/mas-collapsible-table-row.test.js +++ b/studio/test/translation/mas-collapsible-table-row.test.js @@ -3,7 +3,7 @@ import { html } from 'lit'; import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; import sinon from 'sinon'; import Store from '../../src/store.js'; -import { setCardVariationsByPaths } from '../../src/translation/translation-items-loader.js'; +import { setCardVariationsByPaths } from '../../src/common/utils/translation-items-loader.js'; import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, FRAGMENT_STATUS } from '../../src/constants.js'; import '../../src/swc.js'; import '../../src/translation/mas-collapsible-table-row.js'; diff --git a/studio/test/translation/mas-items-selector.test.js b/studio/test/translation/mas-items-selector.test.js index 2adbbb364..2f20fb995 100644 --- a/studio/test/translation/mas-items-selector.test.js +++ b/studio/test/translation/mas-items-selector.test.js @@ -5,8 +5,8 @@ import sinon from 'sinon'; import Store from '../../src/store.js'; import { TABLE_TYPE } from '../../src/constants.js'; import '../../src/swc.js'; -import '../../src/translation/mas-items-selector.js'; -import { TABS } from '../../src/translation/mas-items-selector.js'; +import '../../src/common/components/mas-items-selector.js'; +import { TABS } from '../../src/common/components/mas-items-selector.js'; describe('MasItemsSelector', () => { let sandbox; diff --git a/studio/test/translation/mas-search-and-filters.test.js b/studio/test/translation/mas-search-and-filters.test.js index 5d306467e..07175d07f 100644 --- a/studio/test/translation/mas-search-and-filters.test.js +++ b/studio/test/translation/mas-search-and-filters.test.js @@ -5,7 +5,7 @@ import sinon from 'sinon'; import Store from '../../src/store.js'; import { TABLE_TYPE, FILTER_TYPE } from '../../src/constants.js'; import '../../src/swc.js'; -import '../../src/translation/mas-search-and-filters.js'; +import '../../src/common/components/mas-search-and-filters.js'; describe('MasSearchAndFilters', () => { let sandbox; diff --git a/studio/test/translation/mas-select-items-table.test.js b/studio/test/translation/mas-select-items-table.test.js index e199cb1bd..c4a9b34ce 100644 --- a/studio/test/translation/mas-select-items-table.test.js +++ b/studio/test/translation/mas-select-items-table.test.js @@ -5,7 +5,7 @@ import sinon from 'sinon'; import Store from '../../src/store.js'; import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, TABLE_TYPE, FRAGMENT_STATUS } from '../../src/constants.js'; import '../../src/swc.js'; -import '../../src/translation/mas-select-items-table.js'; +import '../../src/common/components/mas-select-items-table.js'; describe('MasSelectItemsTable', () => { let sandbox; diff --git a/studio/test/translation/mas-selected-items.test.js b/studio/test/translation/mas-selected-items.test.js index e4072df33..65ffa20e9 100644 --- a/studio/test/translation/mas-selected-items.test.js +++ b/studio/test/translation/mas-selected-items.test.js @@ -3,10 +3,10 @@ import { html } from 'lit'; import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; import sinon from 'sinon'; import Store from '../../src/store.js'; -import { setCardVariationsByPaths } from '../../src/translation/translation-items-loader.js'; +import { setCardVariationsByPaths } from '../../src/common/utils/translation-items-loader.js'; import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../../src/constants.js'; import '../../src/swc.js'; -import '../../src/translation/mas-selected-items.js'; +import '../../src/common/components/mas-selected-items.js'; describe('MasSelectedItems', () => { let sandbox; diff --git a/studio/test/translation/translation-items-loader.test.js b/studio/test/translation/translation-items-loader.test.js index 0cd857a2e..8fbf60c2f 100644 --- a/studio/test/translation/translation-items-loader.test.js +++ b/studio/test/translation/translation-items-loader.test.js @@ -12,7 +12,7 @@ import { fetchUnresolvedVariations, fetchVariationByPath, setCardVariationsByPaths, -} from '../../src/translation/translation-items-loader.js'; +} from '../../src/common/utils/translation-items-loader.js'; describe('translation-items-loader', () => { let sandbox; From cd487ca048e2a6c1195ed8c0c3178ad5939abb84 Mon Sep 17 00:00:00 2001 From: Andrei Tanasa Date: Fri, 17 Apr 2026 12:14:52 +0300 Subject: [PATCH 5/9] MWPW-192158: Decouple common layer from translation and restore store on disconnect --- .../common/components/mas-items-selector.js | 11 +- .../components/mas-select-items-table.js | 17 ++- .../common/components/mas-selected-items.js | 5 + .../common/utils/translation-items-loader.js | 120 ++++++------------ .../translation/mas-collapsible-table-row.js | 15 ++- .../src/translation/mas-translation-editor.js | 25 +++- .../mas-collapsible-table-row.test.js | 11 +- .../mas-select-items-table.test.js | 23 +++- .../translation-items-loader.test.js | 92 +++++++++----- 9 files changed, 189 insertions(+), 130 deletions(-) diff --git a/studio/src/common/components/mas-items-selector.js b/studio/src/common/components/mas-items-selector.js index 344355851..a84c500fc 100644 --- a/studio/src/common/components/mas-items-selector.js +++ b/studio/src/common/components/mas-items-selector.js @@ -20,11 +20,16 @@ class MasItemsSelector extends LitElement { static properties = { viewOnly: { type: Boolean, state: true }, + /** @type {(fragmentData: object) => string} */ + getDisplayName: { type: Function }, + renderFragmentStatusCell: { type: Function }, }; constructor() { super(); this.viewOnly = false; + this.getDisplayName = (fragmentData) => fragmentData?.path ?? ''; + this.renderFragmentStatusCell = () => nothing; } connectedCallback() { @@ -94,9 +99,13 @@ class MasItemsSelector extends LitElement { - ${this.viewOnly ? nothing : html``} + ${this.viewOnly + ? nothing + : html``} event.stopPropagation()}> diff --git a/studio/src/common/components/mas-select-items-table.js b/studio/src/common/components/mas-select-items-table.js index ca6851756..5eb7c8104 100644 --- a/studio/src/common/components/mas-select-items-table.js +++ b/studio/src/common/components/mas-select-items-table.js @@ -5,7 +5,6 @@ import Store from '../../store.js'; import { getItemsSelectionStore } from '../items-selection-store.js'; import StoreController from '../../reactivity/store-controller.js'; import { TABLE_TYPE } from '../../constants.js'; -import { renderFragmentStatusCell } from '../../translation/translation-utils.js'; import ReactiveController from '../../reactivity/reactive-controller.js'; import { loadAllPlaceholders, @@ -13,7 +12,6 @@ import { loadSelectedPlaceholders, loadSelectedFragments, } from '../utils/translation-items-loader.js'; -import '../../translation/mas-collapsible-table-row.js'; class MasSelectItemsTable extends LitElement { static styles = styles; @@ -23,6 +21,8 @@ class MasSelectItemsTable extends LitElement { viewOnly: { type: Boolean }, viewOnlyLoading: { type: Boolean, state: true }, viewOnlyFragments: { type: Array, state: true }, + getDisplayName: { type: Function }, + renderFragmentStatusCell: { type: Function }, }; hasMore = new StoreController(this, Store.fragments.list.hasMore); @@ -44,6 +44,8 @@ class MasSelectItemsTable extends LitElement { this.selectedPlaceholdersStoreController = null; this.observedSentinel = null; this.wasLoading = false; + this.getDisplayName = (fragmentData) => fragmentData?.path ?? ''; + this.renderFragmentStatusCell = () => nothing; } connectedCallback() { @@ -72,6 +74,7 @@ class MasSelectItemsTable extends LitElement { onItems: (items) => { this.viewOnlyFragments = items; }, + getDisplayName: this.getDisplayName, }, ).finally(() => { this.viewOnlyLoading = false; @@ -81,7 +84,9 @@ class MasSelectItemsTable extends LitElement { if (this.type === TABLE_TYPE.PLACEHOLDERS) { this.dataSubscription = loadAllPlaceholders(); } else { - this.dataSubscription = loadAllFragments(this.type, this.repository, this.dataState); + this.dataSubscription = loadAllFragments(this.type, this.repository, this.dataState, { + getDisplayName: this.getDisplayName, + }); } } if (!this.viewOnly && this.type !== TABLE_TYPE.PLACEHOLDERS) { @@ -243,6 +248,8 @@ class MasSelectItemsTable extends LitElement { html``, )}`; case TABLE_TYPE.COLLECTIONS: @@ -268,7 +275,7 @@ class MasSelectItemsTable extends LitElement { : nothing} ${fragment.title || '-'} ${fragment.studioPath} - ${renderFragmentStatusCell(fragment.status)} + ${this.renderFragmentStatusCell(fragment.status)} `, )}`; case TABLE_TYPE.PLACEHOLDERS: @@ -294,7 +301,7 @@ class MasSelectItemsTable extends LitElement { ${fragment.value?.length > 100 ? `${fragment.value.slice(0, 100)}...` : fragment.value || '-'} - ${renderFragmentStatusCell(fragment.status)} + ${this.renderFragmentStatusCell(fragment.status)} `, )}`; diff --git a/studio/src/common/components/mas-selected-items.js b/studio/src/common/components/mas-selected-items.js index 6f5aaa9b8..559aa4175 100644 --- a/studio/src/common/components/mas-selected-items.js +++ b/studio/src/common/components/mas-selected-items.js @@ -10,11 +10,15 @@ import { fetchUnresolvedVariations } from '../utils/translation-items-loader.js' class MasSelectedItems extends LitElement { static styles = styles; + static properties = { + getDisplayName: { type: Function }, + }; #lastFetchedSelectedCardsKey = null; constructor() { super(); + this.getDisplayName = (fragmentData) => fragmentData?.path ?? ''; this.storeController = new ReactiveController(this, [ getItemsSelectionStore().showSelected, getItemsSelectionStore().selectedCards, @@ -46,6 +50,7 @@ class MasSelectedItems extends LitElement { getItemsSelectionStore().cardsByPaths.value, getItemsSelectionStore().groupedVariationsByParent.value, this.repository, + { getDisplayName: this.getDisplayName }, ); } diff --git a/studio/src/common/utils/translation-items-loader.js b/studio/src/common/utils/translation-items-loader.js index 81c05273f..a05cc682d 100644 --- a/studio/src/common/utils/translation-items-loader.js +++ b/studio/src/common/utils/translation-items-loader.js @@ -2,64 +2,20 @@ import Store from '../../store.js'; import { getItemsSelectionStore } from '../items-selection-store.js'; import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, TABLE_TYPE } from '../../constants.js'; import { Fragment } from '../../aem/fragment.js'; -import { getFragmentName } from '../../translation/translation-utils.js'; import { getService } from '../../utils.js'; - -const OFFER_DATA_CONCURRENCY_LIMIT = 5; -const VARIATIONS_CONCURRENCY_LIMIT = 5; - -/** - * Yields control to the browser event loop - * @returns {Promise} - */ -async function yieldToMain() { - return new Promise((resolve) => { - setTimeout(resolve, 0); - }); -} - -/** - * Process an array of async tasks with concurrency limiting and periodic UI updates - * @param {Array} items - Items to process - * @param {Function} asyncFn - Async function to apply to each item - * @param {Number} concurrencyLimit - Maximum number of concurrent operations - * @param {Number} batchSize - Number of items to process before yielding to UI - * @returns {Promise} Results in the same order as input items - */ -async function processConcurrently(items, asyncFn, concurrencyLimit, batchSize = 20) { - const results = new Array(items.length); - const executing = []; - let processedCount = 0; - - for (let i = 0; i < items.length; i++) { - const promise = Promise.resolve().then(() => asyncFn(items[i], i)); - results[i] = promise; - - if (concurrencyLimit <= items.length) { - const e = promise.then(() => { - executing.splice(executing.indexOf(e), 1); - processedCount++; - }); - executing.push(e); - if (executing.length >= concurrencyLimit) { - await Promise.race(executing); - } - - if (processedCount % batchSize === 0) { - await yieldToMain(); - } - } - } - - await Promise.all(executing); - return Promise.all(results); -} +import { + processConcurrently, + yieldToMain, + OFFER_DATA_CONCURRENCY_LIMIT, + VARIATIONS_CONCURRENCY_LIMIT, + flattenGroupedVariationsByParent, +} from './item-loading.js'; /** * Loads offer data for a fragment using its OSI field * @param {Object} fragment - Fragment object with fields * @param {AbortSignal} signal - Optional abort signal for cancellation - * @param {Number} timeoutMs - Timeout in milliseconds + * @param {Number} timeoutMs - Timeout in milliseconds (default: 10000) * @returns {Promise} Offer data or null if not found/failed */ async function loadOfferData(fragment, signal, timeoutMs = 10000) { @@ -107,7 +63,7 @@ async function loadOfferData(fragment, signal, timeoutMs = 10000) { * @param {AbortSignal} signal - Optional abort signal for cancellation * @returns {Promise>} Array of variation objects with studioPath and offerData */ -async function loadGroupedVariations(card, repository, signal) { +async function loadGroupedVariations(card, repository, signal, getDisplayName) { if (!repository?.aem?.getFragmentByPath) return []; const fragment = new Fragment(card); const groupedRefs = fragment.listGroupedVariations(); @@ -139,7 +95,7 @@ async function loadGroupedVariations(card, repository, signal) { return validVariations.map((variation, i) => ({ ...variation, - studioPath: getFragmentName(new Fragment(variation)), + studioPath: getDisplayName(new Fragment(variation)), offerData: offerDataResults[i] ?? null, })); } @@ -148,9 +104,10 @@ async function loadGroupedVariations(card, repository, signal) { * Fetches a single variation by path and merges it into groupedVariationsByParent * @param {string} variationPath - Full path to the variation fragment * @param {Object} repository - MasRepository instance with aem.getFragmentByPath + * @param {Function} options.getDisplayName - Display label for a Fragment * @returns {Promise} True if fetch and merge succeeded */ -export async function fetchVariationByPath(variationPath, repository) { +export async function fetchVariationByPath(variationPath, repository, { getDisplayName } = {}) { if (!repository?.aem?.getFragmentByPath || !Fragment.isGroupedVariationPath(variationPath)) return false; const pznIdx = variationPath.indexOf('/pzn/'); if (pznIdx === -1) return false; @@ -163,7 +120,7 @@ export async function fetchVariationByPath(variationPath, repository) { const offerData = await loadOfferData(variation); const enriched = { ...variation, - studioPath: getFragmentName(new Fragment(variation)), + studioPath: getDisplayName(new Fragment(variation)), offerData, }; @@ -186,13 +143,7 @@ export async function fetchVariationByPath(variationPath, repository) { */ export function setCardVariationsByPaths(groupedVariationsByParentValue) { getItemsSelectionStore().groupedVariationsByParent.set(groupedVariationsByParentValue); - const flattened = new Map(); - for (const variationsMap of groupedVariationsByParentValue.values()) { - for (const [path, variation] of variationsMap) { - flattened.set(path, variation); - } - } - getItemsSelectionStore().groupedVariationsData.set(flattened); + getItemsSelectionStore().groupedVariationsData.set(flattenGroupedVariationsByParent(groupedVariationsByParentValue)); } /** @@ -200,12 +151,12 @@ export function setCardVariationsByPaths(groupedVariationsByParentValue) { * @param {Array} allFragments - Array of fragment store objects * @returns {{ allCards: Array, allCollections: Array }} */ -function parseFragmentsFromStore(allFragments) { +function parseFragmentsFromStore(allFragments, getDisplayName) { return (allFragments || []).reduce( (acc, fragment) => { const withPath = { ...fragment.value, - studioPath: getFragmentName(fragment.value), + studioPath: getDisplayName(fragment.value), }; if (fragment.value.model.path === CARD_MODEL_PATH) { acc.allCards.push(withPath); @@ -225,7 +176,7 @@ function parseFragmentsFromStore(allFragments) { * @param {AbortSignal} signal - Abort signal for cancellation * @param {Object} state - Mutable state { isProcessingCards, processAbortController } */ -async function processCardsData(allCards, repository, state) { +async function processCardsData(allCards, repository, state, getDisplayName) { if (state.isProcessingCards) { state.processAbortController?.abort(); } @@ -262,7 +213,7 @@ async function processCardsData(allCards, repository, state) { if (cardsNeedingGroupedVariations.length > 0 && repository) { const groupedVariationsResults = await processConcurrently( cardsNeedingGroupedVariations, - (card) => loadGroupedVariations(card, repository, currentSignal), + (card) => loadGroupedVariations(card, repository, currentSignal, getDisplayName), OFFER_DATA_CONCURRENCY_LIMIT, ); if (currentSignal.aborted) return; @@ -342,17 +293,18 @@ export function loadAllPlaceholders() { * @param {string} type - TABLE_TYPE.CARDS or TABLE_TYPE.COLLECTIONS * @param {Object} repository - MasRepository instance * @param {Object} state - Mutable state for process cancellation + * @param {Function} options.getDisplayName - Display label for raw fragment data * @returns {{ unsubscribe: () => void }} */ -export function loadAllFragments(type, repository, state = {}) { +export function loadAllFragments(type, repository, state = {}, { getDisplayName } = {}) { const typeUppercased = type.charAt(0).toUpperCase() + type.slice(1); if (getItemsSelectionStore()[`all${typeUppercased}`].get()?.length) { return { unsubscribe: () => {} }; } const callback = async () => { - const { allCards, allCollections } = parseFragmentsFromStore(Store.fragments.list.data.get() || []); + const { allCards, allCollections } = parseFragmentsFromStore(Store.fragments.list.data.get() || [], getDisplayName); if (type === TABLE_TYPE.CARDS) { - await processCardsData(allCards, repository, state); + await processCardsData(allCards, repository, state, getDisplayName); } else { processCollectionsData(allCollections); } @@ -387,11 +339,11 @@ export function loadSelectedPlaceholders(selectedPaths, onItems) { * @param {Array} selectedPaths - Paths of selected fragments * @param {string} type - TABLE_TYPE.CARDS or TABLE_TYPE.COLLECTIONS * @param {Object} repository - MasRepository instance - * @param {Object} options - { signal: AbortSignal, onItems: (items) => void } + * @param {Object} options - { signal: AbortSignal, onItems: (items) => void, getDisplayName } * @returns {Promise} */ export async function loadSelectedFragments(selectedPaths, type, repository, options = {}) { - const { signal, onItems } = options; + const { signal, onItems, getDisplayName } = options; if (!repository || !selectedPaths?.length) { if (onItems) onItems([]); return; @@ -406,7 +358,7 @@ export async function loadSelectedFragments(selectedPaths, type, repository, opt const fragment = new Fragment(fragmentData); return { ...fragmentData, - studioPath: getFragmentName(fragment), + studioPath: getDisplayName(fragment), }; } catch (err) { console.warn(`Failed to fetch fragment at ${path}:`, err.message); @@ -419,7 +371,7 @@ export async function loadSelectedFragments(selectedPaths, type, repository, opt const validFragments = fragments.filter(Boolean); if (type === TABLE_TYPE.CARDS) { - const enriched = await enrichCardsForViewOnly(validFragments, repository, signal); + const enriched = await enrichCardsForViewOnly(validFragments, repository, signal, getDisplayName); if (!signal?.aborted && onItems) onItems(enriched); } else if (onItems) { onItems(validFragments); @@ -437,7 +389,7 @@ export async function loadSelectedFragments(selectedPaths, type, repository, opt * @param {AbortSignal} signal - Abort signal * @returns {Promise>} Enriched cards */ -async function enrichCardsForViewOnly(cards, repository, signal) { +async function enrichCardsForViewOnly(cards, repository, signal, getDisplayName) { const offerDataResults = await processConcurrently( cards, (card) => loadOfferData(card, signal), @@ -448,7 +400,7 @@ async function enrichCardsForViewOnly(cards, repository, signal) { const groupedVariationsResults = repository ? await processConcurrently( cards, - (card) => loadGroupedVariations(card, repository, signal), + (card) => loadGroupedVariations(card, repository, signal, getDisplayName), OFFER_DATA_CONCURRENCY_LIMIT, ) : cards.map(() => []); @@ -469,9 +421,16 @@ async function enrichCardsForViewOnly(cards, repository, signal) { * @param {Map} cardsByPaths - Map of path -> card from Store * @param {Map} groupedVariationsByParent - Map of cardPath -> Map of variationPath -> variation * @param {Object} repository - MasRepository instance + * @param {Function} options.getDisplayName - Display label for a Fragment * @returns {Promise} */ -export async function fetchUnresolvedVariations(selectedCards, cardsByPaths, groupedVariationsByParent, repository) { +export async function fetchUnresolvedVariations( + selectedCards, + cardsByPaths, + groupedVariationsByParent, + repository, + { getDisplayName } = {}, +) { const unresolvedPathsFetched = new Set(); const unresolved = (selectedCards || []).filter((path) => { if (!Fragment.isGroupedVariationPath(path)) return false; @@ -484,7 +443,7 @@ export async function fetchUnresolvedVariations(selectedCards, cardsByPaths, gro for (const path of unresolved) { unresolvedPathsFetched.add(path); - const fetchedSuccessfully = await fetchVariationByPath(path, repository); + const fetchedSuccessfully = await fetchVariationByPath(path, repository, { getDisplayName }); if (!fetchedSuccessfully) unresolvedPathsFetched.delete(path); } } @@ -495,9 +454,10 @@ export async function fetchUnresolvedVariations(selectedCards, cardsByPaths, gro * @param {string} cardPath - Path of the parent card * @param {Array} variationPaths - Paths of variation fragments to fetch * @param {Object} repository - MasRepository instance + * @param {Function} options.getDisplayName - Display label for a Fragment * @returns {Promise} */ -export async function loadCardVariations(cardPath, variationPaths, repository) { +export async function loadCardVariations(cardPath, variationPaths, repository, { getDisplayName } = {}) { const hadPath = getItemsSelectionStore().groupedVariationsByParent.value?.has(cardPath); if (!variationPaths?.length || hadPath || !repository) return; @@ -530,7 +490,7 @@ export async function loadCardVariations(cardPath, variationPaths, repository) { variation.path, { ...variation, - studioPath: getFragmentName(new Fragment(variation)), + studioPath: getDisplayName(new Fragment(variation)), offerData: offerDataResults[index] ?? null, }, ]), diff --git a/studio/src/translation/mas-collapsible-table-row.js b/studio/src/translation/mas-collapsible-table-row.js index d8cdedfb3..94a351e96 100644 --- a/studio/src/translation/mas-collapsible-table-row.js +++ b/studio/src/translation/mas-collapsible-table-row.js @@ -2,7 +2,6 @@ import { LitElement, html, nothing } from 'lit'; import { repeat } from 'lit/directives/repeat.js'; import { styles } from './mas-collapsible-table-row.css.js'; import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../constants.js'; -import { renderFragmentStatusCell } from './translation-utils.js'; import { Fragment } from '../aem/fragment.js'; import { getItemsSelectionStore } from '../common/items-selection-store.js'; import { loadCardVariations, fetchVariationByPath } from '../common/utils/translation-items-loader.js'; @@ -20,10 +19,14 @@ export class MasCollapsibleTableRow extends LitElement { isLoadingVariations: { type: Boolean, state: true }, resizeObserver: { type: Object }, repository: { type: Object, state: true }, + getDisplayName: { type: Function }, + renderFragmentStatusCell: { type: Function }, }; constructor() { super(); + this.getDisplayName = (fragmentData) => fragmentData?.path ?? ''; + this.renderFragmentStatusCell = () => nothing; if (!this.tabs) { this.tabs = [ { @@ -226,7 +229,7 @@ export class MasCollapsibleTableRow extends LitElement { } renderStatus(item) { - return renderFragmentStatusCell(item?.status); + return this.renderFragmentStatusCell(item?.status); } renderItemType(item) { @@ -317,7 +320,9 @@ export class MasCollapsibleTableRow extends LitElement { if (this.isGroupedVariation) { if (getItemsSelectionStore().groupedVariationsData.value?.get(this.topLevelCard.path)) return; this.isLoadingVariations = true; - fetchVariationByPath(this.topLevelCard.path, this.repository).finally(() => { + fetchVariationByPath(this.topLevelCard.path, this.repository, { + getDisplayName: this.getDisplayName, + }).finally(() => { this.isLoadingVariations = false; }); } else { @@ -327,7 +332,9 @@ export class MasCollapsibleTableRow extends LitElement { ) return; this.isLoadingVariations = true; - loadCardVariations(this.topLevelCard.path, this.variationPaths, this.repository).finally(() => { + loadCardVariations(this.topLevelCard.path, this.variationPaths, this.repository, { + getDisplayName: this.getDisplayName, + }).finally(() => { this.isLoadingVariations = false; }); } diff --git a/studio/src/translation/mas-translation-editor.js b/studio/src/translation/mas-translation-editor.js index 9847a6514..55a65d4be 100644 --- a/studio/src/translation/mas-translation-editor.js +++ b/studio/src/translation/mas-translation-editor.js @@ -12,7 +12,9 @@ import './mas-translation-languages.js'; import router from '../router.js'; import { normalizeKey, showToast } from '../utils.js'; import { PAGE_NAMES, TRANSLATION_PROJECT_MODEL_ID, QUICK_ACTION } from '../constants.js'; -import { setItemsSelectionStore } from '../common/items-selection-store.js'; +import { getItemsSelectionStore, setItemsSelectionStore } from '../common/items-selection-store.js'; +import { getFragmentName, renderFragmentStatusCell } from './translation-utils.js'; +import './mas-collapsible-table-row.js'; class MasTranslationEditor extends LitElement { static styles = styles; @@ -35,6 +37,7 @@ class MasTranslationEditor extends LitElement { #collectionsSnapshot = []; #placeholdersSnapshot = []; #targetLocalesSnapshot = []; + #itemsSelectionStoreSnapshot = null; constructor() { super(); @@ -62,6 +65,7 @@ class MasTranslationEditor extends LitElement { async connectedCallback() { super.connectedCallback(); + this.#itemsSelectionStoreSnapshot = getItemsSelectionStore(); setItemsSelectionStore(Store.translationProjects); if (this.repository?.searchFragments) { @@ -105,6 +109,14 @@ class MasTranslationEditor extends LitElement { } } + disconnectedCallback() { + super.disconnectedCallback(); + if (this.#itemsSelectionStoreSnapshot != null) { + setItemsSelectionStore(this.#itemsSelectionStoreSnapshot); + this.#itemsSelectionStoreSnapshot = null; + } + } + /** @type {MasRepository} */ get repository() { return document.querySelector('mas-repository'); @@ -522,7 +534,10 @@ class MasTranslationEditor extends LitElement { @confirm=${this.#confirmItemSelection} @cancel=${this.#cancelItemSelection} > - + `; } @@ -779,7 +794,11 @@ class MasTranslationEditor extends LitElement { ${this.isSelectedItemsOpen - ? html`` + ? html`` : nothing} ` } diff --git a/studio/test/translation/mas-collapsible-table-row.test.js b/studio/test/translation/mas-collapsible-table-row.test.js index 92ece0840..49ef2340d 100644 --- a/studio/test/translation/mas-collapsible-table-row.test.js +++ b/studio/test/translation/mas-collapsible-table-row.test.js @@ -5,6 +5,7 @@ import sinon from 'sinon'; import Store from '../../src/store.js'; import { setCardVariationsByPaths } from '../../src/common/utils/translation-items-loader.js'; import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, FRAGMENT_STATUS } from '../../src/constants.js'; +import { renderFragmentStatusCell } from '../../src/translation/translation-utils.js'; import '../../src/swc.js'; import '../../src/translation/mas-collapsible-table-row.js'; @@ -424,7 +425,10 @@ describe('MasCollapsibleTableRow', () => { it('should render published status with green class', async () => { const topLevelCard = createMockTopLevelCard({ status: FRAGMENT_STATUS.PUBLISHED }); const el = await fixture( - html``, + html``, ); const statusDot = el.shadowRoot.querySelector('.status-dot.green'); expect(statusDot).to.exist; @@ -433,7 +437,10 @@ describe('MasCollapsibleTableRow', () => { it('should render modified status with blue class', async () => { const topLevelCard = createMockTopLevelCard({ status: FRAGMENT_STATUS.MODIFIED }); const el = await fixture( - html``, + html``, ); const statusDot = el.shadowRoot.querySelector('.status-dot.blue'); expect(statusDot).to.exist; diff --git a/studio/test/translation/mas-select-items-table.test.js b/studio/test/translation/mas-select-items-table.test.js index c4a9b34ce..2c472c9a2 100644 --- a/studio/test/translation/mas-select-items-table.test.js +++ b/studio/test/translation/mas-select-items-table.test.js @@ -4,7 +4,9 @@ import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; import sinon from 'sinon'; import Store from '../../src/store.js'; import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, TABLE_TYPE, FRAGMENT_STATUS } from '../../src/constants.js'; +import { renderFragmentStatusCell } from '../../src/translation/translation-utils.js'; import '../../src/swc.js'; +import '../../src/translation/mas-collapsible-table-row.js'; import '../../src/common/components/mas-select-items-table.js'; describe('MasSelectItemsTable', () => { @@ -472,7 +474,12 @@ describe('MasSelectItemsTable', () => { it('should render Published status with green dot', async () => { const cards = [createMockCard('/path/card1', 'Card 1', { status: FRAGMENT_STATUS.PUBLISHED })]; setupCardsInStore(cards); - const el = await fixture(html``); + const el = await fixture( + html``, + ); const collapsibleRow = el.shadowRoot.querySelector('mas-collapsible-table-row'); const statusCell = collapsibleRow.shadowRoot.querySelector('.status-cell'); const statusDot = statusCell.querySelector('.status-dot'); @@ -483,7 +490,12 @@ describe('MasSelectItemsTable', () => { it('should render Modified status with blue dot', async () => { const cards = [createMockCard('/path/card1', 'Card 1', { status: FRAGMENT_STATUS.MODIFIED })]; setupCardsInStore(cards); - const el = await fixture(html``); + const el = await fixture( + html``, + ); const collapsibleRow = el.shadowRoot.querySelector('mas-collapsible-table-row'); const statusCell = collapsibleRow.shadowRoot.querySelector('.status-cell'); const statusDot = statusCell.querySelector('.status-dot'); @@ -494,7 +506,12 @@ describe('MasSelectItemsTable', () => { it('should render Draft status without color class', async () => { const cards = [createMockCard('/path/card1', 'Card 1', { status: FRAGMENT_STATUS.DRAFT })]; setupCardsInStore(cards); - const el = await fixture(html``); + const el = await fixture( + html``, + ); const collapsibleRow = el.shadowRoot.querySelector('mas-collapsible-table-row'); const statusCell = collapsibleRow.shadowRoot.querySelector('.status-cell'); const statusDot = statusCell.querySelector('.status-dot'); diff --git a/studio/test/translation/translation-items-loader.test.js b/studio/test/translation/translation-items-loader.test.js index 8fbf60c2f..6c2920ce6 100644 --- a/studio/test/translation/translation-items-loader.test.js +++ b/studio/test/translation/translation-items-loader.test.js @@ -17,6 +17,7 @@ import { describe('translation-items-loader', () => { let sandbox; + const mockGetDisplayName = () => 'mock-display-name'; const resetStore = () => { Store.translationProjects.allCards.set([]); Store.translationProjects.cardsByPaths.set(new Map()); @@ -117,20 +118,20 @@ describe('translation-items-loader', () => { describe('loadAllFragments', () => { it('should return no-op subscription when allCards already has data', () => { Store.translationProjects.allCards.set([{ path: '/card1', title: 'Card 1' }]); - const result = loadAllFragments(TABLE_TYPE.CARDS, null, {}); + const result = loadAllFragments(TABLE_TYPE.CARDS, null, {}, { getDisplayName: mockGetDisplayName }); expect(result.unsubscribe).to.be.a('function'); expect(Store.translationProjects.allCards.get()).to.have.lengthOf(1); }); it('should return no-op subscription when allCollections already has data', () => { Store.translationProjects.allCollections.set([{ path: '/col1', title: 'Col 1' }]); - const result = loadAllFragments(TABLE_TYPE.COLLECTIONS, null, {}); + const result = loadAllFragments(TABLE_TYPE.COLLECTIONS, null, {}, { getDisplayName: mockGetDisplayName }); expect(result.unsubscribe).to.be.a('function'); expect(Store.translationProjects.allCollections.get()).to.have.lengthOf(1); }); it('should subscribe and process collections when fragments list updates', async () => { - const result = loadAllFragments(TABLE_TYPE.COLLECTIONS, null, {}); + const result = loadAllFragments(TABLE_TYPE.COLLECTIONS, null, {}, { getDisplayName: mockGetDisplayName }); const mockCollection = { value: { @@ -162,7 +163,7 @@ describe('translation-items-loader', () => { }, }; - const result = loadAllFragments(TABLE_TYPE.CARDS, repo, state); + const result = loadAllFragments(TABLE_TYPE.CARDS, repo, state, { getDisplayName: mockGetDisplayName }); const mockCardData = { path: '/content/dam/mas/acom/en_US/cards/card1', @@ -198,7 +199,7 @@ describe('translation-items-loader', () => { const state = {}; const repo = { aem: { getFragmentByPath: sinon.stub() } }; - const result = loadAllFragments(TABLE_TYPE.CARDS, repo, state); + const result = loadAllFragments(TABLE_TYPE.CARDS, repo, state, { getDisplayName: mockGetDisplayName }); const mockCardStore = { value: new Fragment({ @@ -261,14 +262,17 @@ describe('translation-items-loader', () => { describe('loadSelectedFragments', () => { it('should call onItems with empty array when repository is null', async () => { const onItems = sinon.stub(); - await loadSelectedFragments(['/path/1'], TABLE_TYPE.CARDS, null, { onItems }); + await loadSelectedFragments(['/path/1'], TABLE_TYPE.CARDS, null, { + onItems, + getDisplayName: mockGetDisplayName, + }); expect(onItems.calledWith([])).to.be.true; }); it('should call onItems with empty array when selectedPaths is empty', async () => { const onItems = sinon.stub(); const repo = { aem: { getFragmentByPath: sinon.stub() } }; - await loadSelectedFragments([], TABLE_TYPE.CARDS, repo, { onItems }); + await loadSelectedFragments([], TABLE_TYPE.CARDS, repo, { onItems, getDisplayName: mockGetDisplayName }); expect(onItems.calledWith([])).to.be.true; }); @@ -285,7 +289,10 @@ describe('translation-items-loader', () => { }; const onItems = sinon.stub(); - await loadSelectedFragments([mockFragment.path], TABLE_TYPE.COLLECTIONS, repo, { onItems }); + await loadSelectedFragments([mockFragment.path], TABLE_TYPE.COLLECTIONS, repo, { + onItems, + getDisplayName: mockGetDisplayName, + }); expect(repo.aem.getFragmentByPath.calledWith(mockFragment.path)).to.be.true; expect(onItems.called).to.be.true; @@ -315,7 +322,10 @@ describe('translation-items-loader', () => { }; const onItems = sinon.stub(); - await loadSelectedFragments([cardPath], TABLE_TYPE.CARDS, repo, { onItems }); + await loadSelectedFragments([cardPath], TABLE_TYPE.CARDS, repo, { + onItems, + getDisplayName: mockGetDisplayName, + }); expect(repo.aem.getFragmentByPath.calledWith(cardPath)).to.be.true; expect(onItems.called).to.be.true; @@ -334,7 +344,10 @@ describe('translation-items-loader', () => { aem: { getFragmentByPath: sinon.stub().rejects(new Error('Fetch failed')) }, }; - await loadSelectedFragments(['/invalid/path'], TABLE_TYPE.CARDS, repo, { onItems }); + await loadSelectedFragments(['/invalid/path'], TABLE_TYPE.CARDS, repo, { + onItems, + getDisplayName: mockGetDisplayName, + }); expect(onItems.calledWith([])).to.be.true; }); @@ -349,7 +362,9 @@ describe('translation-items-loader', () => { }, }; - await loadSelectedFragments(['/path'], TABLE_TYPE.COLLECTIONS, repo, {}); + await loadSelectedFragments(['/path'], TABLE_TYPE.COLLECTIONS, repo, { + getDisplayName: mockGetDisplayName, + }); expect(repo.aem.getFragmentByPath.called).to.be.true; }); @@ -373,6 +388,7 @@ describe('translation-items-loader', () => { await loadSelectedFragments([cardPath], TABLE_TYPE.CARDS, repo, { signal: abortedController.signal, onItems, + getDisplayName: mockGetDisplayName, }); expect(onItems.called).to.be.false; @@ -382,7 +398,7 @@ describe('translation-items-loader', () => { describe('loadCardVariations', () => { it('should return early when variationPaths is empty', async () => { const repo = { aem: { getFragmentByPath: sinon.stub() } }; - await loadCardVariations('/card/path', [], repo); + await loadCardVariations('/card/path', [], repo, { getDisplayName: mockGetDisplayName }); expect(repo.aem.getFragmentByPath.called).to.be.false; }); @@ -392,13 +408,13 @@ describe('translation-items-loader', () => { setCardVariationsByPaths(existingMap); const repo = { aem: { getFragmentByPath: sinon.stub() } }; - await loadCardVariations('/card/path', ['/var/path'], repo); + await loadCardVariations('/card/path', ['/var/path'], repo, { getDisplayName: mockGetDisplayName }); expect(repo.aem.getFragmentByPath.called).to.be.false; }); it('should return early when repository is null', async () => { - await loadCardVariations('/card/path', ['/var/path'], null); + await loadCardVariations('/card/path', ['/var/path'], null, { getDisplayName: mockGetDisplayName }); expect(Store.translationProjects.groupedVariationsByParent.value?.has('/card/path')).to.be.false; }); @@ -417,7 +433,7 @@ describe('translation-items-loader', () => { }, }; - await loadCardVariations(cardPath, [variationPath], repo); + await loadCardVariations(cardPath, [variationPath], repo, { getDisplayName: mockGetDisplayName }); expect(repo.aem.getFragmentByPath.calledWith(variationPath)).to.be.true; const variationsByPaths = Store.translationProjects.groupedVariationsByParent.value?.get(cardPath); @@ -445,7 +461,7 @@ describe('translation-items-loader', () => { }, }; - await loadCardVariations(cardPath, [invalidPath], repo); + await loadCardVariations(cardPath, [invalidPath], repo, { getDisplayName: mockGetDisplayName }); const variationsByPaths = Store.translationProjects.groupedVariationsByParent.value?.get(cardPath); expect(variationsByPaths).to.exist; @@ -459,7 +475,7 @@ describe('translation-items-loader', () => { aem: { getFragmentByPath: sinon.stub().rejects(new Error('Network error')) }, }; - await loadCardVariations(cardPath, [variationPath], repo); + await loadCardVariations(cardPath, [variationPath], repo, { getDisplayName: mockGetDisplayName }); expect(repo.aem.getFragmentByPath.calledWith(variationPath)).to.be.true; const variationsMap = Store.translationProjects.groupedVariationsByParent.value?.get(cardPath); @@ -487,7 +503,7 @@ describe('translation-items-loader', () => { aem: { getFragmentByPath: sinon.stub().resolves(mockVar) }, }; - await loadCardVariations(cardPath2, [varPath2], repo); + await loadCardVariations(cardPath2, [varPath2], repo, { getDisplayName: mockGetDisplayName }); const result = Store.translationProjects.groupedVariationsByParent.value; expect(result.has(cardPath1)).to.be.true; @@ -501,19 +517,19 @@ describe('translation-items-loader', () => { const variationPath = '/content/dam/mas/acom/en_US/cards/parent/pzn/var1'; it('should return false when repository is null', async () => { - const result = await fetchVariationByPath(variationPath, null); + const result = await fetchVariationByPath(variationPath, null, { getDisplayName: mockGetDisplayName }); expect(result).to.be.false; }); it('should return false when repository has no getFragmentByPath', async () => { - const result = await fetchVariationByPath(variationPath, {}); + const result = await fetchVariationByPath(variationPath, {}, { getDisplayName: mockGetDisplayName }); expect(result).to.be.false; }); it('should return false when path is not a grouped variation path', async () => { const cardPath = '/content/dam/mas/acom/en_US/cards/card1'; const repo = { aem: { getFragmentByPath: sinon.stub() } }; - const result = await fetchVariationByPath(cardPath, repo); + const result = await fetchVariationByPath(cardPath, repo, { getDisplayName: mockGetDisplayName }); expect(result).to.be.false; expect(repo.aem.getFragmentByPath.called).to.be.false; }); @@ -521,7 +537,7 @@ describe('translation-items-loader', () => { it('should return false when path has no /pzn/ segment', async () => { const invalidPath = '/content/dam/mas/acom/en_US/cards/parent/invalid/var1'; const repo = { aem: { getFragmentByPath: sinon.stub() } }; - const result = await fetchVariationByPath(invalidPath, repo); + const result = await fetchVariationByPath(invalidPath, repo, { getDisplayName: mockGetDisplayName }); expect(result).to.be.false; expect(repo.aem.getFragmentByPath.called).to.be.false; }); @@ -530,7 +546,7 @@ describe('translation-items-loader', () => { const repo = { aem: { getFragmentByPath: sinon.stub().rejects(new Error('Network error')) }, }; - const result = await fetchVariationByPath(variationPath, repo); + const result = await fetchVariationByPath(variationPath, repo, { getDisplayName: mockGetDisplayName }); expect(result).to.be.false; }); @@ -544,7 +560,7 @@ describe('translation-items-loader', () => { aem: { getFragmentByPath: sinon.stub().resolves(mockVariation) }, }; - const result = await fetchVariationByPath(variationPath, repo); + const result = await fetchVariationByPath(variationPath, repo, { getDisplayName: mockGetDisplayName }); expect(result).to.be.true; const cardPath = '/content/dam/mas/acom/en_US/cards/parent'; @@ -561,20 +577,22 @@ describe('translation-items-loader', () => { it('should not fetch when selectedCards is empty', async () => { const repo = { aem: { getFragmentByPath: sinon.stub() } }; - await fetchUnresolvedVariations([], new Map(), new Map(), repo); + await fetchUnresolvedVariations([], new Map(), new Map(), repo, { getDisplayName: mockGetDisplayName }); expect(repo.aem.getFragmentByPath.called).to.be.false; }); it('should not fetch when selectedCards is null', async () => { const repo = { aem: { getFragmentByPath: sinon.stub() } }; - await fetchUnresolvedVariations(null, new Map(), new Map(), repo); + await fetchUnresolvedVariations(null, new Map(), new Map(), repo, { getDisplayName: mockGetDisplayName }); expect(repo.aem.getFragmentByPath.called).to.be.false; }); it('should skip non-grouped-variation paths', async () => { const defaultCardPath = '/content/dam/mas/acom/en_US/cards/card1'; const repo = { aem: { getFragmentByPath: sinon.stub() } }; - await fetchUnresolvedVariations([defaultCardPath], new Map(), new Map(), repo); + await fetchUnresolvedVariations([defaultCardPath], new Map(), new Map(), repo, { + getDisplayName: mockGetDisplayName, + }); expect(repo.aem.getFragmentByPath.called).to.be.false; }); @@ -582,7 +600,9 @@ describe('translation-items-loader', () => { const cardsByPaths = new Map(); cardsByPaths.set(variationPath, { path: variationPath }); const repo = { aem: { getFragmentByPath: sinon.stub() } }; - await fetchUnresolvedVariations([variationPath], cardsByPaths, new Map(), repo); + await fetchUnresolvedVariations([variationPath], cardsByPaths, new Map(), repo, { + getDisplayName: mockGetDisplayName, + }); expect(repo.aem.getFragmentByPath.called).to.be.false; }); @@ -593,7 +613,9 @@ describe('translation-items-loader', () => { const groupedVariationsByParent = new Map(); groupedVariationsByParent.set(cardPath, variationsMap); const repo = { aem: { getFragmentByPath: sinon.stub() } }; - await fetchUnresolvedVariations([variationPath], new Map(), groupedVariationsByParent, repo); + await fetchUnresolvedVariations([variationPath], new Map(), groupedVariationsByParent, repo, { + getDisplayName: mockGetDisplayName, + }); expect(repo.aem.getFragmentByPath.called).to.be.false; }); @@ -607,7 +629,9 @@ describe('translation-items-loader', () => { aem: { getFragmentByPath: sinon.stub().resolves(mockVariation) }, }; - await fetchUnresolvedVariations([variationPath], new Map(), new Map(), repo); + await fetchUnresolvedVariations([variationPath], new Map(), new Map(), repo, { + getDisplayName: mockGetDisplayName, + }); expect(repo.aem.getFragmentByPath.calledWith(variationPath)).to.be.true; const cardPath = '/content/dam/mas/acom/en_US/cards/parent'; @@ -621,7 +645,9 @@ describe('translation-items-loader', () => { aem: { getFragmentByPath: sinon.stub().rejects(new Error('Network error')) }, }; - await fetchUnresolvedVariations([variationPath], new Map(), new Map(), repo); + await fetchUnresolvedVariations([variationPath], new Map(), new Map(), repo, { + getDisplayName: mockGetDisplayName, + }); expect(repo.aem.getFragmentByPath.calledWith(variationPath)).to.be.true; const cardPath = '/content/dam/mas/acom/en_US/cards/parent'; @@ -639,7 +665,9 @@ describe('translation-items-loader', () => { aem: { getFragmentByPath: sinon.stub().resolves(mockInvalidVariation) }, }; - await fetchUnresolvedVariations([variationPath], new Map(), new Map(), repo); + await fetchUnresolvedVariations([variationPath], new Map(), new Map(), repo, { + getDisplayName: mockGetDisplayName, + }); expect(repo.aem.getFragmentByPath.calledWith(variationPath)).to.be.true; const cardPath = '/content/dam/mas/acom/en_US/cards/parent'; From b27aeedec2cec45806551c649f0eaed82c6fb171 Mon Sep 17 00:00:00 2001 From: Andrei Tanasa Date: Mon, 27 Apr 2026 18:43:38 +0300 Subject: [PATCH 6/9] MWPW-192158: Deduplicate item-loading utilities --- .../common/components/mas-selected-items.js | 12 +- studio/src/common/items-selection-store.js | 18 ++- studio/src/common/utils/item-loading.js | 8 +- .../common/utils/translation-items-loader.js | 131 +++--------------- .../translation/mas-collapsible-table-row.js | 16 +-- .../src/translation/mas-translation-editor.js | 8 +- .../mas-collapsible-table-row.test.js | 7 +- .../translation/mas-items-selector.test.js | 3 + .../mas-search-and-filters.test.js | 3 + .../mas-select-items-table.test.js | 3 + .../translation/mas-selected-items.test.js | 7 +- .../mas-translation-editor.test.js | 3 + .../translation-items-loader.test.js | 3 + 13 files changed, 73 insertions(+), 149 deletions(-) diff --git a/studio/src/common/components/mas-selected-items.js b/studio/src/common/components/mas-selected-items.js index 559aa4175..d230412c8 100644 --- a/studio/src/common/components/mas-selected-items.js +++ b/studio/src/common/components/mas-selected-items.js @@ -4,8 +4,8 @@ import { styles } from './mas-selected-items.css.js'; import Store from '../../store.js'; import { getItemsSelectionStore } from '../items-selection-store.js'; import ReactiveController from '../../reactivity/reactive-controller.js'; -import { Fragment } from '../../aem/fragment.js'; import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../../constants.js'; +import { getItemTypeLabel } from '../utils/render-utils.js'; import { fetchUnresolvedVariations } from '../utils/translation-items-loader.js'; class MasSelectedItems extends LitElement { @@ -89,15 +89,7 @@ class MasSelectedItems extends LitElement { } getType(item) { - if (!item) return 'Unknown type'; - switch (item.model.path) { - case CARD_MODEL_PATH: - return Fragment.isGroupedVariationPath(item.path) ? 'Grouped variation' : 'Default card'; - case COLLECTION_MODEL_PATH: - return 'Collection'; - default: - return 'Placeholder'; - } + return getItemTypeLabel(item); } getTitle(item) { diff --git a/studio/src/common/items-selection-store.js b/studio/src/common/items-selection-store.js index da4a7cf47..8a28ee66e 100644 --- a/studio/src/common/items-selection-store.js +++ b/studio/src/common/items-selection-store.js @@ -1,13 +1,21 @@ -import Store from '../store.js'; +let activeItemsSelectionStore = null; -let activeItemsSelectionStore = Store.translationProjects; - -export function getItemsSelectionStore() { +/** + * @param {{ allowUnset?: boolean }} [options] If "allowUnset" is true, returns null when no slice is bound instead of throwing. + * @returns {object|null} + */ +export function getItemsSelectionStore(options) { + if (activeItemsSelectionStore == null) { + if (options?.allowUnset) { + return null; + } + throw new Error('Items selection store not set.'); + } return activeItemsSelectionStore; } /** - * @param {typeof Store.translationProjects} slice + * @param {object|null} slice */ export function setItemsSelectionStore(slice) { activeItemsSelectionStore = slice; diff --git a/studio/src/common/utils/item-loading.js b/studio/src/common/utils/item-loading.js index b01cfa26d..be3a2ebf9 100644 --- a/studio/src/common/utils/item-loading.js +++ b/studio/src/common/utils/item-loading.js @@ -3,6 +3,7 @@ import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../../constants.js'; export const OFFER_DATA_CONCURRENCY_LIMIT = 5; export const VARIATIONS_CONCURRENCY_LIMIT = 5; +export const LARGE_BATCH_YIELD_THRESHOLD = 50; /** * Yields control to the event loop. @@ -16,6 +17,11 @@ export async function yieldToMain() { /** * Processes async tasks with a concurrency limit and periodic yielding. + * + * NOTE: `Promise.resolve().then(...)` schedules all tasks into the microtask queue + * immediately. The concurrency throttle applies only to async work; any synchronous + * work inside `asyncFn` runs in parallel. + * * @param {Array} items * @param {Function} asyncFn * @param {number} concurrencyLimit @@ -277,7 +283,7 @@ export async function enrichCards( groupedVariations: groupedVariationsByPath.get(card.path) ?? [], })); - if (enrichedCards.length > 50) { + if (enrichedCards.length > LARGE_BATCH_YIELD_THRESHOLD) { await yieldToMain(); } diff --git a/studio/src/common/utils/translation-items-loader.js b/studio/src/common/utils/translation-items-loader.js index b70c3070c..3296e52fb 100644 --- a/studio/src/common/utils/translation-items-loader.js +++ b/studio/src/common/utils/translation-items-loader.js @@ -1,61 +1,19 @@ import Store from '../../store.js'; import { getItemsSelectionStore } from '../items-selection-store.js'; -import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, TABLE_TYPE } from '../../constants.js'; +import { TABLE_TYPE } from '../../constants.js'; import { Fragment } from '../../aem/fragment.js'; -import { getService } from '../../utils.js'; +import { loadOfferData } from './item-loading-browser.js'; import { processConcurrently, yieldToMain, OFFER_DATA_CONCURRENCY_LIMIT, VARIATIONS_CONCURRENCY_LIMIT, + LARGE_BATCH_YIELD_THRESHOLD, flattenGroupedVariationsByParent, + parseFragmentsFromStore, + enrichCards, } from './item-loading.js'; -/** - * Loads offer data for a fragment using its OSI field - * @param {Object} fragment - Fragment object with fields - * @param {AbortSignal} signal - Optional abort signal for cancellation - * @param {Number} timeoutMs - Timeout in milliseconds (default: 10000) - * @returns {Promise} Offer data or null if not found/failed - */ -async function loadOfferData(fragment, signal, timeoutMs = 10000) { - const cache = getItemsSelectionStore().offerDataCache; - const wcsOsi = fragment?.fields?.find(({ name }) => name === 'osi')?.values?.[0]; - if (!wcsOsi) return null; - - try { - if (cache.has(wcsOsi)) { - return cache.get(wcsOsi); - } - if (signal?.aborted) return null; - - const service = getService(); - const priceOptions = service.collectPriceOptions({ wcsOsi }); - const [offersPromise] = service.resolveOfferSelectors(priceOptions); - if (!offersPromise) return null; - let timeoutId; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error('Request timeout')), timeoutMs); - }); - try { - const [offer] = await Promise.race([offersPromise, timeoutPromise]); - clearTimeout(timeoutId); - if (signal?.aborted) return null; - cache.set(wcsOsi, offer); - return offer; - } catch (err) { - clearTimeout(timeoutId); - throw err; - } - } catch (err) { - console.warn(`Failed to load offer data for fragment ${fragment.id}:`, err.message); - if (!signal?.aborted) { - cache.set(wcsOsi, null); - } - return null; - } -} - /** * Loads grouped variations for a card fragment * @param {Object} card - Card object with path, references, fields @@ -89,7 +47,8 @@ async function loadGroupedVariations(card, repository, signal, getDisplayName) { const offerDataResults = await processConcurrently( validVariations, - (variation) => loadOfferData(variation, signal), + (variation) => + loadOfferData(variation, { cache: getItemsSelectionStore().offerDataCache, signal }), VARIATIONS_CONCURRENCY_LIMIT, ); @@ -117,7 +76,7 @@ export async function fetchVariationByPath(variationPath, repository, { getDispl const variation = await repository.aem.getFragmentByPath(variationPath); if (!variation || !Array.isArray(variation.fieldTags) || variation.fieldTags.length === 0) return false; - const offerData = await loadOfferData(variation); + const offerData = await loadOfferData(variation, { cache: getItemsSelectionStore().offerDataCache }); const enriched = { ...variation, studioPath: getDisplayName(new Fragment(variation)), @@ -146,30 +105,6 @@ export function setCardVariationsByPaths(groupedVariationsByParentValue) { getItemsSelectionStore().groupedVariationsData.set(flattenGroupedVariationsByParent(groupedVariationsByParentValue)); } -/** - * Extracts card fragments from the shared fragment store, decorating each with studioPath. - * Collections come from repository.loadAllCollections() — not this stream. - * @param {Array} allFragments - Array of fragment store objects - * @returns {Array} Array of card objects - */ -function parseFragmentsFromStore(allFragments, getDisplayName) { - return (allFragments || []).reduce( - (acc, fragment) => { - const withPath = { - ...fragment.value, - studioPath: getDisplayName(fragment.value), - }; - if (fragment.value.model.path === CARD_MODEL_PATH) { - acc.allCards.push(withPath); - } else if (fragment.value.model.path === COLLECTION_MODEL_PATH) { - acc.allCollections.push(withPath); - } - return acc; - }, - { allCards: [], allCollections: [] }, - ); -} - /** * Processes and enriches cards with offer data and grouped variations, writes to store. * Re-entrant: if a call arrives while one is already in flight, the new payload is @@ -202,7 +137,7 @@ async function processCardsData(allCards, repository, state, getDisplayName) { if (cardsNeedingOfferData.length > 0) { const offerDataResults = await processConcurrently( cardsNeedingOfferData, - (card) => loadOfferData(card, signal), + (card) => loadOfferData(card, { cache: getItemsSelectionStore().offerDataCache, signal }), OFFER_DATA_CONCURRENCY_LIMIT, ); if (signal?.aborted) return; @@ -234,7 +169,7 @@ async function processCardsData(allCards, repository, state, getDisplayName) { groupedVariations: existingGroupedVariationsByPath.get(card.path) ?? [], })); - if (enrichedCards.length > 50) { + if (enrichedCards.length > LARGE_BATCH_YIELD_THRESHOLD) { await yieldToMain(); } if (signal?.aborted) return; @@ -310,7 +245,7 @@ export function loadAllFragments(type, repository, state = {}, { getDisplayName } state.subscribed = true; const callback = async () => { - const { allCards } = parseFragmentsFromStore(Store.fragments.list.data.get() || [], getDisplayName); + const { allCards } = parseFragmentsFromStore(Store.fragments.list.data.get() || [], { getDisplayName }); await processCardsData(allCards, repository, state, getDisplayName); }; Store.fragments.list.data.subscribe(callback); @@ -380,7 +315,15 @@ export async function loadSelectedFragments(selectedPaths, type, repository, opt const validFragments = fragments.filter(Boolean); if (type === TABLE_TYPE.CARDS) { - const enriched = await enrichCardsForViewOnly(validFragments, repository, signal, getDisplayName); + const enriched = await enrichCards(validFragments, { + getByPath: repository.aem.getFragmentByPath, + getOfferData: loadOfferData, + signal, + getDisplayName, + offerDataCache: getItemsSelectionStore().offerDataCache, + existingOfferDataByPath: new Map(), + existingGroupedVariationsByPath: new Map(), + }); if (!signal?.aborted && onItems) onItems(enriched); } else if (onItems) { onItems(validFragments); @@ -391,38 +334,6 @@ export async function loadSelectedFragments(selectedPaths, type, repository, opt } } -/** - * Enriches cards with offer data and grouped variations (for view-only) - * @param {Array} cards - Card objects - * @param {Object} repository - MasRepository instance - * @param {AbortSignal} signal - Abort signal - * @returns {Promise>} Enriched cards - */ -async function enrichCardsForViewOnly(cards, repository, signal, getDisplayName) { - const offerDataResults = await processConcurrently( - cards, - (card) => loadOfferData(card, signal), - OFFER_DATA_CONCURRENCY_LIMIT, - ); - if (signal?.aborted) return []; - - const groupedVariationsResults = repository - ? await processConcurrently( - cards, - (card) => loadGroupedVariations(card, repository, signal, getDisplayName), - OFFER_DATA_CONCURRENCY_LIMIT, - ) - : cards.map(() => []); - - if (signal?.aborted) return []; - - return cards.map((card, i) => ({ - ...card, - offerData: offerDataResults[i] ?? null, - groupedVariations: groupedVariationsResults[i] ?? [], - })); -} - /** * Fetches unresolved grouped variation paths for selected cards. * Skips paths already in cardsByPaths or groupedVariationsByParent; uses unresolvedPathsFetched to avoid re-fetching. @@ -490,7 +401,7 @@ export async function loadCardVariations(cardPath, variationPaths, repository, { const offerDataResults = await processConcurrently( validVariations, - (variation) => loadOfferData(variation), + (variation) => loadOfferData(variation, { cache: getItemsSelectionStore().offerDataCache }), VARIATIONS_CONCURRENCY_LIMIT, ); diff --git a/studio/src/translation/mas-collapsible-table-row.js b/studio/src/translation/mas-collapsible-table-row.js index 94a351e96..ef5cf4be5 100644 --- a/studio/src/translation/mas-collapsible-table-row.js +++ b/studio/src/translation/mas-collapsible-table-row.js @@ -1,8 +1,8 @@ import { LitElement, html, nothing } from 'lit'; import { repeat } from 'lit/directives/repeat.js'; import { styles } from './mas-collapsible-table-row.css.js'; -import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../constants.js'; import { Fragment } from '../aem/fragment.js'; +import { getItemTypeLabel } from '../common/utils/render-utils.js'; import { getItemsSelectionStore } from '../common/items-selection-store.js'; import { loadCardVariations, fetchVariationByPath } from '../common/utils/translation-items-loader.js'; import ReactiveController from '../reactivity/reactive-controller.js'; @@ -233,19 +233,7 @@ export class MasCollapsibleTableRow extends LitElement { } renderItemType(item) { - if (Fragment.isGroupedVariationPath(item?.path)) { - return html`Grouped variation`; - } - if (item?.model?.path.includes('/dictionary/')) { - return html`Placeholder`; - } - if (item?.model?.path === COLLECTION_MODEL_PATH) { - return html`Collection`; - } - if (item?.model?.path === CARD_MODEL_PATH) { - return html`Default`; - } - return html`no type`; + return html`${getItemTypeLabel(item)}`; } async #copyToClipboard(e, text) { diff --git a/studio/src/translation/mas-translation-editor.js b/studio/src/translation/mas-translation-editor.js index ec40ed05e..5a7362586 100644 --- a/studio/src/translation/mas-translation-editor.js +++ b/studio/src/translation/mas-translation-editor.js @@ -66,7 +66,7 @@ class MasTranslationEditor extends LitElement { async connectedCallback() { super.connectedCallback(); - this.#itemsSelectionStoreSnapshot = getItemsSelectionStore(); + this.#itemsSelectionStoreSnapshot = getItemsSelectionStore({ allowUnset: true }); setItemsSelectionStore(Store.translationProjects); if (this.repository?.searchFragments) { @@ -116,10 +116,8 @@ class MasTranslationEditor extends LitElement { disconnectedCallback() { super.disconnectedCallback(); - if (this.#itemsSelectionStoreSnapshot != null) { - setItemsSelectionStore(this.#itemsSelectionStoreSnapshot); - this.#itemsSelectionStoreSnapshot = null; - } + setItemsSelectionStore(this.#itemsSelectionStoreSnapshot); + this.#itemsSelectionStoreSnapshot = null; } /** @type {MasRepository} */ diff --git a/studio/test/translation/mas-collapsible-table-row.test.js b/studio/test/translation/mas-collapsible-table-row.test.js index 49ef2340d..5d5dbbba4 100644 --- a/studio/test/translation/mas-collapsible-table-row.test.js +++ b/studio/test/translation/mas-collapsible-table-row.test.js @@ -3,6 +3,7 @@ import { html } from 'lit'; import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; import sinon from 'sinon'; import Store from '../../src/store.js'; +import { setItemsSelectionStore } from '../../src/common/items-selection-store.js'; import { setCardVariationsByPaths } from '../../src/common/utils/translation-items-loader.js'; import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, FRAGMENT_STATUS } from '../../src/constants.js'; import { renderFragmentStatusCell } from '../../src/translation/translation-utils.js'; @@ -49,6 +50,7 @@ describe('MasCollapsibleTableRow', () => { beforeEach(() => { sandbox = sinon.createSandbox(); + setItemsSelectionStore(Store.translationProjects); resetStore(); createMockRepository(); }); @@ -58,6 +60,7 @@ describe('MasCollapsibleTableRow', () => { sandbox.restore(); resetStore(); removeMockRepository(); + setItemsSelectionStore(null); }); describe('initialization', () => { @@ -495,7 +498,7 @@ describe('MasCollapsibleTableRow', () => { expect(groupedCell).to.exist; }); - it('should render "no type" for unknown model path', async () => { + it('should render "Unknown" for unknown model path', async () => { const topLevelCard = createMockTopLevelCard({ modelPath: '/conf/mas/settings/dam/cfm/models/unknown', }); @@ -503,7 +506,7 @@ describe('MasCollapsibleTableRow', () => { html``, ); const shadowText = el.shadowRoot?.textContent || ''; - expect(shadowText).to.include('no type'); + expect(shadowText).to.include('Unknown'); }); }); diff --git a/studio/test/translation/mas-items-selector.test.js b/studio/test/translation/mas-items-selector.test.js index 2f20fb995..843e7e5e2 100644 --- a/studio/test/translation/mas-items-selector.test.js +++ b/studio/test/translation/mas-items-selector.test.js @@ -3,6 +3,7 @@ import { html } from 'lit'; import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; import sinon from 'sinon'; import Store from '../../src/store.js'; +import { setItemsSelectionStore } from '../../src/common/items-selection-store.js'; import { TABLE_TYPE } from '../../src/constants.js'; import '../../src/swc.js'; import '../../src/common/components/mas-items-selector.js'; @@ -13,6 +14,7 @@ describe('MasItemsSelector', () => { beforeEach(() => { sandbox = sinon.createSandbox(); + setItemsSelectionStore(Store.translationProjects); Store.translationProjects.inEdit.set(null); Store.translationProjects.showSelected.set(false); Store.translationProjects.selectedCards.set([]); @@ -28,6 +30,7 @@ describe('MasItemsSelector', () => { Store.translationProjects.selectedCards.set([]); Store.translationProjects.selectedCollections.set([]); Store.translationProjects.selectedPlaceholders.set([]); + setItemsSelectionStore(null); }); describe('TABS constant', () => { diff --git a/studio/test/translation/mas-search-and-filters.test.js b/studio/test/translation/mas-search-and-filters.test.js index aff4680ff..440b3f399 100644 --- a/studio/test/translation/mas-search-and-filters.test.js +++ b/studio/test/translation/mas-search-and-filters.test.js @@ -3,6 +3,7 @@ import { html } from 'lit'; import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; import sinon from 'sinon'; import Store from '../../src/store.js'; +import { setItemsSelectionStore } from '../../src/common/items-selection-store.js'; import { TABLE_TYPE, FILTER_TYPE } from '../../src/constants.js'; import '../../src/swc.js'; import '../../src/common/components/mas-search-and-filters.js'; @@ -27,6 +28,7 @@ describe('MasSearchAndFilters', () => { beforeEach(() => { sandbox = sinon.createSandbox(); + setItemsSelectionStore(Store.translationProjects); Store.translationProjects.allCards.set([]); Store.translationProjects.displayCards.set([]); Store.translationProjects.allCollections.set([]); @@ -50,6 +52,7 @@ describe('MasSearchAndFilters', () => { Store.fragments.list.loading.set(false); Store.placeholders.list.loading.set(false); Store.placeholders.list.data.set([]); + setItemsSelectionStore(null); }); describe('initialization', () => { diff --git a/studio/test/translation/mas-select-items-table.test.js b/studio/test/translation/mas-select-items-table.test.js index 294da1c34..38a459e1d 100644 --- a/studio/test/translation/mas-select-items-table.test.js +++ b/studio/test/translation/mas-select-items-table.test.js @@ -3,6 +3,7 @@ import { html } from 'lit'; import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; import sinon from 'sinon'; import Store from '../../src/store.js'; +import { setItemsSelectionStore } from '../../src/common/items-selection-store.js'; import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, TABLE_TYPE, FRAGMENT_STATUS } from '../../src/constants.js'; import { renderFragmentStatusCell } from '../../src/translation/translation-utils.js'; import '../../src/swc.js'; @@ -111,6 +112,7 @@ describe('MasSelectItemsTable', () => { beforeEach(() => { sandbox = sinon.createSandbox(); + setItemsSelectionStore(Store.translationProjects); resetStore(); mockCommerceService = createMockCommerceService(); }); @@ -120,6 +122,7 @@ describe('MasSelectItemsTable', () => { sandbox.restore(); resetStore(); removeMockCommerceService(); + setItemsSelectionStore(null); }); describe('initialization', () => { diff --git a/studio/test/translation/mas-selected-items.test.js b/studio/test/translation/mas-selected-items.test.js index 65ffa20e9..494d2997e 100644 --- a/studio/test/translation/mas-selected-items.test.js +++ b/studio/test/translation/mas-selected-items.test.js @@ -3,6 +3,7 @@ import { html } from 'lit'; import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; import sinon from 'sinon'; import Store from '../../src/store.js'; +import { setItemsSelectionStore } from '../../src/common/items-selection-store.js'; import { setCardVariationsByPaths } from '../../src/common/utils/translation-items-loader.js'; import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../../src/constants.js'; import '../../src/swc.js'; @@ -58,6 +59,7 @@ describe('MasSelectedItems', () => { beforeEach(() => { sandbox = sinon.createSandbox(); + setItemsSelectionStore(Store.translationProjects); Store.translationProjects.showSelected.set(false); Store.translationProjects.selectedCards.set([]); Store.translationProjects.selectedCollections.set([]); @@ -73,6 +75,7 @@ describe('MasSelectedItems', () => { Store.translationProjects.selectedCollections.set([]); Store.translationProjects.selectedPlaceholders.set([]); resetMaps(); + setItemsSelectionStore(null); }); describe('initialization', () => { @@ -333,7 +336,7 @@ describe('MasSelectedItems', () => { const el = await fixture(html``); const typeEl = el.shadowRoot.querySelector('.type'); expect(typeEl).to.exist; - expect(typeEl.textContent.trim()).to.equal('Default card'); + expect(typeEl.textContent.trim()).to.equal('Default'); }); it('should render remove button for each item', async () => { @@ -519,7 +522,7 @@ describe('MasSelectedItems', () => { it('should handle undefined item gracefully in getType', async () => { const el = await fixture(html``); - expect(el.getType(undefined)).to.equal('Unknown type'); + expect(el.getType(undefined)).to.equal('Unknown'); }); }); }); diff --git a/studio/test/translation/mas-translation-editor.test.js b/studio/test/translation/mas-translation-editor.test.js index c479316e6..3e71477b9 100644 --- a/studio/test/translation/mas-translation-editor.test.js +++ b/studio/test/translation/mas-translation-editor.test.js @@ -4,6 +4,7 @@ import { html } from 'lit'; import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; import { PAGE_NAMES, QUICK_ACTION, TRANSLATION_PROJECT_MODEL_ID } from '../../src/constants.js'; import Store from '../../src/store.js'; +import { setItemsSelectionStore } from '../../src/common/items-selection-store.js'; import router from '../../src/router.js'; import Events from '../../src/events.js'; import { Fragment } from '../../src/aem/fragment.js'; @@ -88,6 +89,7 @@ describe('MasTranslationEditor', () => { beforeEach(() => { sandbox = sinon.createSandbox(); + setItemsSelectionStore(Store.translationProjects); toastEmitStub = sandbox.stub(Events.toast, 'emit'); originalQuerySelector = document.querySelector.bind(document); defaultMockRepository = { @@ -119,6 +121,7 @@ describe('MasTranslationEditor', () => { fixtureCleanup(); sandbox.restore(); resetStores(); + setItemsSelectionStore(null); }); describe('initialization', () => { diff --git a/studio/test/translation/translation-items-loader.test.js b/studio/test/translation/translation-items-loader.test.js index b9b98cbc1..684452304 100644 --- a/studio/test/translation/translation-items-loader.test.js +++ b/studio/test/translation/translation-items-loader.test.js @@ -1,6 +1,7 @@ import { expect } from '@esm-bundle/chai'; import sinon from 'sinon'; import Store from '../../src/store.js'; +import { setItemsSelectionStore } from '../../src/common/items-selection-store.js'; import { Fragment } from '../../src/aem/fragment.js'; import { TABLE_TYPE, COLLECTION_MODEL_PATH, CARD_MODEL_PATH } from '../../src/constants.js'; import { @@ -51,6 +52,7 @@ describe('translation-items-loader', () => { beforeEach(() => { sandbox = sinon.createSandbox(); + setItemsSelectionStore(Store.translationProjects); resetStore(); createMockCommerceService(); }); @@ -59,6 +61,7 @@ describe('translation-items-loader', () => { sandbox.restore(); resetStore(); removeMockCommerceService(); + setItemsSelectionStore(null); }); describe('setCardVariationsByPaths', () => { From b786b324c1fc5300b8fc199cb4c234137533ec1d Mon Sep 17 00:00:00 2001 From: Andrei Tanasa Date: Mon, 27 Apr 2026 19:08:28 +0300 Subject: [PATCH 7/9] MWPW-192158: Fix prettier check --- studio/src/common/utils/translation-items-loader.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/studio/src/common/utils/translation-items-loader.js b/studio/src/common/utils/translation-items-loader.js index 3296e52fb..fd02b9f05 100644 --- a/studio/src/common/utils/translation-items-loader.js +++ b/studio/src/common/utils/translation-items-loader.js @@ -47,8 +47,7 @@ async function loadGroupedVariations(card, repository, signal, getDisplayName) { const offerDataResults = await processConcurrently( validVariations, - (variation) => - loadOfferData(variation, { cache: getItemsSelectionStore().offerDataCache, signal }), + (variation) => loadOfferData(variation, { cache: getItemsSelectionStore().offerDataCache, signal }), VARIATIONS_CONCURRENCY_LIMIT, ); From c25b6330a067d8175e7e9b8485459a7063d8971c Mon Sep 17 00:00:00 2001 From: Andrei Tanasa Date: Tue, 28 Apr 2026 13:52:20 +0300 Subject: [PATCH 8/9] MWPW-192158: Rename the translation-items-loader file to items-loader --- studio/src/common/components/mas-select-items-table.js | 2 +- studio/src/common/components/mas-selected-items.js | 2 +- .../utils/{translation-items-loader.js => items-loader.js} | 0 studio/src/translation/mas-collapsible-table-row.js | 2 +- studio/test/translation/mas-collapsible-table-row.test.js | 2 +- studio/test/translation/mas-selected-items.test.js | 2 +- studio/test/translation/translation-items-loader.test.js | 2 +- 7 files changed, 6 insertions(+), 6 deletions(-) rename studio/src/common/utils/{translation-items-loader.js => items-loader.js} (100%) diff --git a/studio/src/common/components/mas-select-items-table.js b/studio/src/common/components/mas-select-items-table.js index b74c8f053..21e983548 100644 --- a/studio/src/common/components/mas-select-items-table.js +++ b/studio/src/common/components/mas-select-items-table.js @@ -11,7 +11,7 @@ import { loadAllFragments, loadSelectedPlaceholders, loadSelectedFragments, -} from '../utils/translation-items-loader.js'; +} from '../utils/items-loader.js'; class MasSelectItemsTable extends LitElement { static styles = styles; diff --git a/studio/src/common/components/mas-selected-items.js b/studio/src/common/components/mas-selected-items.js index d230412c8..8894c609d 100644 --- a/studio/src/common/components/mas-selected-items.js +++ b/studio/src/common/components/mas-selected-items.js @@ -6,7 +6,7 @@ import { getItemsSelectionStore } from '../items-selection-store.js'; import ReactiveController from '../../reactivity/reactive-controller.js'; import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../../constants.js'; import { getItemTypeLabel } from '../utils/render-utils.js'; -import { fetchUnresolvedVariations } from '../utils/translation-items-loader.js'; +import { fetchUnresolvedVariations } from '../utils/items-loader.js'; class MasSelectedItems extends LitElement { static styles = styles; diff --git a/studio/src/common/utils/translation-items-loader.js b/studio/src/common/utils/items-loader.js similarity index 100% rename from studio/src/common/utils/translation-items-loader.js rename to studio/src/common/utils/items-loader.js diff --git a/studio/src/translation/mas-collapsible-table-row.js b/studio/src/translation/mas-collapsible-table-row.js index ef5cf4be5..40029daa6 100644 --- a/studio/src/translation/mas-collapsible-table-row.js +++ b/studio/src/translation/mas-collapsible-table-row.js @@ -4,7 +4,7 @@ import { styles } from './mas-collapsible-table-row.css.js'; import { Fragment } from '../aem/fragment.js'; import { getItemTypeLabel } from '../common/utils/render-utils.js'; import { getItemsSelectionStore } from '../common/items-selection-store.js'; -import { loadCardVariations, fetchVariationByPath } from '../common/utils/translation-items-loader.js'; +import { loadCardVariations, fetchVariationByPath } from '../common/utils/items-loader.js'; import ReactiveController from '../reactivity/reactive-controller.js'; export class MasCollapsibleTableRow extends LitElement { diff --git a/studio/test/translation/mas-collapsible-table-row.test.js b/studio/test/translation/mas-collapsible-table-row.test.js index 5d5dbbba4..da84f18ac 100644 --- a/studio/test/translation/mas-collapsible-table-row.test.js +++ b/studio/test/translation/mas-collapsible-table-row.test.js @@ -4,7 +4,7 @@ import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; import sinon from 'sinon'; import Store from '../../src/store.js'; import { setItemsSelectionStore } from '../../src/common/items-selection-store.js'; -import { setCardVariationsByPaths } from '../../src/common/utils/translation-items-loader.js'; +import { setCardVariationsByPaths } from '../../src/common/utils/items-loader.js'; import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, FRAGMENT_STATUS } from '../../src/constants.js'; import { renderFragmentStatusCell } from '../../src/translation/translation-utils.js'; import '../../src/swc.js'; diff --git a/studio/test/translation/mas-selected-items.test.js b/studio/test/translation/mas-selected-items.test.js index 494d2997e..9ec59851b 100644 --- a/studio/test/translation/mas-selected-items.test.js +++ b/studio/test/translation/mas-selected-items.test.js @@ -4,7 +4,7 @@ import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; import sinon from 'sinon'; import Store from '../../src/store.js'; import { setItemsSelectionStore } from '../../src/common/items-selection-store.js'; -import { setCardVariationsByPaths } from '../../src/common/utils/translation-items-loader.js'; +import { setCardVariationsByPaths } from '../../src/common/utils/items-loader.js'; import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../../src/constants.js'; import '../../src/swc.js'; import '../../src/common/components/mas-selected-items.js'; diff --git a/studio/test/translation/translation-items-loader.test.js b/studio/test/translation/translation-items-loader.test.js index 684452304..086f89c09 100644 --- a/studio/test/translation/translation-items-loader.test.js +++ b/studio/test/translation/translation-items-loader.test.js @@ -13,7 +13,7 @@ import { fetchUnresolvedVariations, fetchVariationByPath, setCardVariationsByPaths, -} from '../../src/common/utils/translation-items-loader.js'; +} from '../../src/common/utils/items-loader.js'; describe('translation-items-loader', () => { let sandbox; From fad2fc498b72fce9b31388ab568004012acd7ff5 Mon Sep 17 00:00:00 2001 From: Andrei Tanasa Date: Wed, 29 Apr 2026 14:44:35 +0300 Subject: [PATCH 9/9] MWPW-192158: Split the style components between common and translation --- nala/studio/translations/translations.page.js | 6 +- .../components/mas-items-selector.css.js | 2 +- .../components/mas-select-items-table.css.js | 2 +- .../components/mas-select-items-table.js | 16 ++-- .../components/mas-selected-items.css.js | 2 +- ...mmon-styles.css.js => table-styles.css.js} | 85 +++++++++++++---- .../mas-collapsible-table-row.css.js | 2 +- .../translation/mas-collapsible-table-row.js | 22 ++--- .../translation/mas-translation-editor.css.js | 36 ++++--- studio/src/translation/mas-translation.css.js | 2 +- studio/src/translation/mas-translation.js | 4 +- .../translation-common-styles.css.js | 93 +------------------ .../mas-collapsible-table-row.test.js | 2 +- .../test/translation/mas-translation.test.js | 2 +- 14 files changed, 115 insertions(+), 161 deletions(-) rename studio/src/common/styles/{translation-common-styles.css.js => table-styles.css.js} (53%) diff --git a/nala/studio/translations/translations.page.js b/nala/studio/translations/translations.page.js index 2e2edd5d4..8312d3636 100644 --- a/nala/studio/translations/translations.page.js +++ b/nala/studio/translations/translations.page.js @@ -5,7 +5,7 @@ export default class TranslationsPage { const translationHost = page.locator('mas-translation'); this.loadingIndicator = translationHost.locator('.loading-container sp-progress-circle'); - this.translationTable = translationHost.locator('sp-table.translation-table'); + this.translationTable = translationHost.locator('sp-table.item-table'); this.tableHeaders = { translationProject: translationHost.locator('sp-table-head-cell:has-text("Translation Project")'), lastUpdatedBy: translationHost.locator('sp-table-head-cell:has-text("Last updated by")'), @@ -14,9 +14,9 @@ export default class TranslationsPage { }; this.emptyState = translationHost.locator('.translation-empty-state'); - this.tableRows = translationHost.locator('sp-table.translation-table sp-table-row'); + this.tableRows = translationHost.locator('sp-table.item-table sp-table-row'); - this.firstRow = translationHost.locator('sp-table.translation-table sp-table-row').first(); + this.firstRow = translationHost.locator('sp-table.item-table sp-table-row').first(); this.firstRowTitleCell = this.firstRow.locator('sp-table-cell').nth(0); this.firstRowActionMenu = this.firstRow.locator('sp-action-menu'); diff --git a/studio/src/common/components/mas-items-selector.css.js b/studio/src/common/components/mas-items-selector.css.js index 72612201a..c288828e7 100644 --- a/studio/src/common/components/mas-items-selector.css.js +++ b/studio/src/common/components/mas-items-selector.css.js @@ -1,5 +1,5 @@ import { css } from 'lit'; -import { ghostButtonStyles } from '../styles/translation-common-styles.css.js'; +import { ghostButtonStyles } from '../styles/table-styles.css.js'; export const styles = [ ghostButtonStyles, 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 7f19ebdc2..9a36fc6b6 100644 --- a/studio/src/common/components/mas-select-items-table.css.js +++ b/studio/src/common/components/mas-select-items-table.css.js @@ -5,7 +5,7 @@ import { tableColumnIconStyles, tableSelectedRowStyles, loadingContainerFlexStyles, -} from '../styles/translation-common-styles.css.js'; +} from '../styles/table-styles.css.js'; export const styles = [ tableHeaderBaseStyles, diff --git a/studio/src/common/components/mas-select-items-table.js b/studio/src/common/components/mas-select-items-table.js index 21e983548..d56596850 100644 --- a/studio/src/common/components/mas-select-items-table.js +++ b/studio/src/common/components/mas-select-items-table.js @@ -186,8 +186,8 @@ class MasSelectItemsTable extends LitElement { const TABLE_COLUMNS = { cards: { selectable: [ - { label: '', key: 'chevron', class: 'translation-table-icon-cell translation-table-icon-cell--chevron' }, - { label: '', key: 'checkbox', class: 'translation-table-icon-cell translation-table-icon-cell--checkbox' }, + { label: '', key: 'chevron', class: 'table-icon-cell table-icon-cell--chevron' }, + { label: '', key: 'checkbox', class: 'table-icon-cell table-icon-cell--checkbox' }, { label: 'Offer', key: 'offer', sortable: true }, { label: 'Fragment title', key: 'fragmentTitle' }, { label: 'Offer ID', key: 'offerId' }, @@ -195,7 +195,7 @@ class MasSelectItemsTable extends LitElement { { label: 'Status', key: 'status' }, ], viewOnly: [ - { label: '', key: 'chevron', class: 'translation-table-icon-cell translation-table-icon-cell--chevron' }, + { label: '', key: 'chevron', class: 'table-icon-cell table-icon-cell--chevron' }, { label: 'Offer', key: 'offer', sortable: true }, { label: 'Fragment title', key: 'fragmentTitle' }, { label: 'Offer ID', key: 'offerId' }, @@ -206,7 +206,7 @@ class MasSelectItemsTable extends LitElement { }, collections: { selectable: [ - { label: '', key: 'checkbox', class: 'translation-table-icon-cell translation-table-icon-cell--checkbox' }, + { label: '', key: 'checkbox', class: 'table-icon-cell table-icon-cell--checkbox' }, { label: 'Collection title', key: 'collectionTitle' }, { label: 'Path', key: 'path' }, { label: 'Status', key: 'status' }, @@ -219,7 +219,7 @@ class MasSelectItemsTable extends LitElement { }, placeholders: { selectable: [ - { label: '', key: 'checkbox', class: 'translation-table-icon-cell translation-table-icon-cell--checkbox' }, + { label: '', key: 'checkbox', class: 'table-icon-cell table-icon-cell--checkbox' }, { label: 'Key', key: 'key' }, { label: 'Value', key: 'value' }, { label: 'Status', key: 'status' }, @@ -268,7 +268,7 @@ class MasSelectItemsTable extends LitElement { > ${!this.viewOnly ? html` - + ${!this.viewOnly - ? html` + ? html` ` : html`${this.itemsToDisplay.length > 0 - ? html` + ? html` ${repeat( this.tableColumns, diff --git a/studio/src/common/components/mas-selected-items.css.js b/studio/src/common/components/mas-selected-items.css.js index 8af878aac..2b02b4fec 100644 --- a/studio/src/common/components/mas-selected-items.css.js +++ b/studio/src/common/components/mas-selected-items.css.js @@ -1,5 +1,5 @@ import { css } from 'lit'; -import { ghostButtonStyles } from '../styles/translation-common-styles.css.js'; +import { ghostButtonStyles } from '../styles/table-styles.css.js'; export const styles = [ ghostButtonStyles, diff --git a/studio/src/common/styles/translation-common-styles.css.js b/studio/src/common/styles/table-styles.css.js similarity index 53% rename from studio/src/common/styles/translation-common-styles.css.js rename to studio/src/common/styles/table-styles.css.js index 8457fa3cb..3dd655b15 100644 --- a/studio/src/common/styles/translation-common-styles.css.js +++ b/studio/src/common/styles/table-styles.css.js @@ -7,15 +7,6 @@ export const ghostButtonStyles = css` } `; -export const loadingContainerCenteredStyles = css` - .loading-container--absolute { - position: absolute; - top: 50%; - right: 50%; - transform: translate(-50%, -50%); - } -`; - export const loadingContainerFlexStyles = css` .loading-container--flex { display: flex; @@ -25,49 +16,49 @@ export const loadingContainerFlexStyles = css` `; export const tableHeaderBaseStyles = css` - .translation-table { + .item-table { --mod-table-header-background-color: var(--spectrum-gray-50); --mod-table-border-radius: 0; } - .translation-table sp-table-head { + .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; } - .translation-table sp-table-head-cell { + .item-table sp-table-head-cell { align-content: center; } - .translation-table sp-table-head-cell:first-of-type { + .item-table sp-table-head-cell:first-of-type { border-top-left-radius: 12px; } - .translation-table sp-table-head-cell:last-of-type { + .item-table sp-table-head-cell:last-of-type { border-top-right-radius: 12px; } `; export const tableColumnIconStyles = css` - .translation-table-icon-cell { + .table-icon-cell { display: flex; align-items: center; flex: 0; } - .translation-table-icon-cell--chevron { + .table-icon-cell--chevron { padding: 29px; } - .translation-table-icon-cell--checkbox { + .table-icon-cell--checkbox { padding: 22px; } `; export const tableCellBaseStyles = css` - .translation-table sp-table-cell, + .item-table sp-table-cell, sp-table-cell { display: flex; align-items: center; @@ -101,3 +92,61 @@ export const tableSelectedRowStyles = css` --spectrum-table-cell-background-color: var(--spectrum-blue-200); } `; + +export const selectItemsFormSectionStyles = css` + .select-items { + sp-button { + --mod-button-background-color-default: transparent; + --mod-button-background-color-hover: var(--spectrum-gray-200); + } + + sp-icon-add { + width: 48px; + height: 48px; + } + + .label { + align-content: center; + } + } + + .items-empty-state { + display: flex; + flex-direction: row; + gap: 12px; + padding: 12px 24px; + border: 1px dashed var(--spectrum-gray-800); + border-radius: 10px; + } + + .selected-items { + display: flex; + flex-direction: column; + gap: 20px; + + .selected-items-header { + display: flex; + justify-content: space-between; + align-items: center; + + h2 { + margin: 0; + + span { + font-weight: 500; + } + } + + .toggle-btn { + --mod-button-background-color-down: var(--spectrum-gray-300); + --mod-button-content-color-default: var(--spectrum-gray-800); + --mod-button-content-color-hover: var(--spectrum-gray-900); + } + } + + h2 sp-icon-asterisk100 { + width: 10px; + height: 10px; + } + } +`; diff --git a/studio/src/translation/mas-collapsible-table-row.css.js b/studio/src/translation/mas-collapsible-table-row.css.js index 84193d959..c1d54f4e2 100644 --- a/studio/src/translation/mas-collapsible-table-row.css.js +++ b/studio/src/translation/mas-collapsible-table-row.css.js @@ -4,7 +4,7 @@ import { tableCellBaseStyles, tableSelectedRowStyles, loadingContainerFlexStyles, -} from './translation-common-styles.css.js'; +} from '../common/styles/table-styles.css.js'; export const styles = [ tableColumnIconStyles, diff --git a/studio/src/translation/mas-collapsible-table-row.js b/studio/src/translation/mas-collapsible-table-row.js index 40029daa6..be3b6aad2 100644 --- a/studio/src/translation/mas-collapsible-table-row.js +++ b/studio/src/translation/mas-collapsible-table-row.js @@ -120,7 +120,7 @@ export class MasCollapsibleTableRow extends LitElement { ?selected=${isSelected} aria-selected=${isSelected ? 'true' : 'false'} > - + `} - + ${this.isGroupedVariation - ? html` + ? html` `} ` - : html``} + : html``} ${repeat(this.cells, (cell) => this[`render${cell}`](this.topLevelCard) ?? nothing)} @@ -343,8 +341,8 @@ export class MasCollapsibleTableRow extends LitElement { renderGroupedVariationDetailsRow(variationPath) { return this.isLoadingVariations ? html` - - + +
@@ -352,8 +350,8 @@ export class MasCollapsibleTableRow extends LitElement { ` : html` - - + + ${this.renderPromoCode(getItemsSelectionStore().groupedVariationsData.value?.get(variationPath))} ${this.renderTags(getItemsSelectionStore().groupedVariationsData.value?.get(variationPath))} @@ -372,14 +370,14 @@ export class MasCollapsibleTableRow extends LitElement { ?selected=${isSelected} aria-selected=${isSelected ? 'true' : 'false'} > - + ${this.isTopLevelExpanded ? html`` : html``} - + + return html` ${this.translationProjectsTableHead} ${Array.from({ length: 5 }, translationSkeletonRow)} `; } if (this.translationProjectsData.length) { - return html` + return html` ${this.translationProjectsTableHead} ${repeat( diff --git a/studio/src/translation/translation-common-styles.css.js b/studio/src/translation/translation-common-styles.css.js index 8457fa3cb..e3d213c44 100644 --- a/studio/src/translation/translation-common-styles.css.js +++ b/studio/src/translation/translation-common-styles.css.js @@ -1,11 +1,6 @@ import { css } from 'lit'; -export const ghostButtonStyles = css` - .ghost-button { - --mod-button-background-color-default: transparent; - --mod-button-background-color-hover: var(--spectrum-gray-200); - } -`; +export { ghostButtonStyles, tableHeaderBaseStyles, tableCellBaseStyles } from '../common/styles/table-styles.css.js'; export const loadingContainerCenteredStyles = css` .loading-container--absolute { @@ -15,89 +10,3 @@ export const loadingContainerCenteredStyles = css` transform: translate(-50%, -50%); } `; - -export const loadingContainerFlexStyles = css` - .loading-container--flex { - display: flex; - justify-content: center; - align-items: center; - } -`; - -export const tableHeaderBaseStyles = css` - .translation-table { - --mod-table-header-background-color: var(--spectrum-gray-50); - --mod-table-border-radius: 0; - } - - .translation-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; - } - - .translation-table sp-table-head-cell { - align-content: center; - } - - .translation-table sp-table-head-cell:first-of-type { - border-top-left-radius: 12px; - } - - .translation-table sp-table-head-cell:last-of-type { - border-top-right-radius: 12px; - } -`; - -export const tableColumnIconStyles = css` - .translation-table-icon-cell { - display: flex; - align-items: center; - flex: 0; - } - - .translation-table-icon-cell--chevron { - padding: 29px; - } - - .translation-table-icon-cell--checkbox { - padding: 22px; - } -`; - -export const tableCellBaseStyles = css` - .translation-table sp-table-cell, - sp-table-cell { - display: flex; - align-items: center; - } - - .status-cell { - display: flex; - align-items: center; - gap: 6px; - - .status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background-color: var(--spectrum-gray-500); - } - - .status-dot.green { - background-color: var(--spectrum-green-700); - } - - .status-dot.blue { - background-color: var(--spectrum-blue-800); - } - } -`; - -export const tableSelectedRowStyles = css` - sp-table-row[selected] { - --mod-table-row-background-color: var(--spectrum-blue-200); - --spectrum-table-cell-background-color: var(--spectrum-blue-200); - } -`; diff --git a/studio/test/translation/mas-collapsible-table-row.test.js b/studio/test/translation/mas-collapsible-table-row.test.js index da84f18ac..3f6a1ab69 100644 --- a/studio/test/translation/mas-collapsible-table-row.test.js +++ b/studio/test/translation/mas-collapsible-table-row.test.js @@ -804,7 +804,7 @@ describe('MasCollapsibleTableRow', () => { const el = await fixture( html``, ); - const chevronCell = el.shadowRoot.querySelector('.translation-table-icon-cell--chevron'); + const chevronCell = el.shadowRoot.querySelector('.table-icon-cell--chevron'); expect(chevronCell).to.exist; }); diff --git a/studio/test/translation/mas-translation.test.js b/studio/test/translation/mas-translation.test.js index f63048522..c8b08a9fb 100644 --- a/studio/test/translation/mas-translation.test.js +++ b/studio/test/translation/mas-translation.test.js @@ -162,7 +162,7 @@ describe('MasTranslation', () => { const mockProjects = [createMockTranslationProject('1', 'Project 1')]; Store.translationProjects.list.data.value = mockProjects; const el = await fixture(html``); - const table = el.shadowRoot.querySelector('.translation-table'); + const table = el.shadowRoot.querySelector('.item-table'); expect(table).to.exist; });