From b78588d3ab5e3b6943ba4438e0071f9effdd89b4 Mon Sep 17 00:00:00 2001 From: Axel Cureno Basurto Date: Fri, 3 Apr 2026 14:42:16 -0700 Subject: [PATCH 01/38] chore: add docs/ to .gitignore (per-ticket documentation not committed) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index a9b2f90c7..a34307f1b 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ masStudio-*.json .cursor .superpowers/ +# Worktree documentation (per-ticket, not committed) +docs/ + # web-components specific !web-components/dist web-components/commerce.json From 5c78d3b55719456f02e40d3a171c4d6e28c6f4dd Mon Sep 17 00:00:00 2001 From: Axel Cureno Basurto Date: Fri, 3 Apr 2026 12:01:31 -0700 Subject: [PATCH 02/38] fix(translation): guard sentinel on non-empty items list to prevent cascade --- .../src/translation/mas-select-items-table.js | 7 ++-- .../mas-select-items-table.test.js | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/studio/src/translation/mas-select-items-table.js b/studio/src/translation/mas-select-items-table.js index 9a3c5a063..6d64395da 100644 --- a/studio/src/translation/mas-select-items-table.js +++ b/studio/src/translation/mas-select-items-table.js @@ -32,7 +32,7 @@ class MasSelectItemsTable extends LitElement { super(); this.dataSubscription = null; this.processAbortController = null; - this.dataState = { isProcessingCards: false, processAbortController: null }; + this.dataState = { isProcessingCards: false }; this.viewOnlyLoading = false; this.viewOnlyFragments = []; this.displayCardsStoreController = null; @@ -127,7 +127,7 @@ class MasSelectItemsTable extends LitElement { this.observedSentinel = null; } else if (loadingJustCompleted && this.hasMore.value) { this.scrollObserver?.unobserve(sentinel); - this.scrollObserver?.observe(sentinel); + requestAnimationFrame(() => this.scrollObserver?.observe(sentinel)); } } @@ -331,7 +331,8 @@ class MasSelectItemsTable extends LitElement { ${this.#renderTableBody()} ` : html`

No items found.

`} - ${this.hasMore.value ? html`
` : nothing} ${this.loadingMoreIndicator}`} + ${this.itemsToDisplay.length > 0 && this.hasMore.value ? html`
` : nothing} + ${this.loadingMoreIndicator}`} `; } } diff --git a/studio/test/translation/mas-select-items-table.test.js b/studio/test/translation/mas-select-items-table.test.js index e199cb1bd..36f67efca 100644 --- a/studio/test/translation/mas-select-items-table.test.js +++ b/studio/test/translation/mas-select-items-table.test.js @@ -1135,4 +1135,42 @@ describe('MasSelectItemsTable', () => { expect(el.isLoading).to.equal(el.viewOnlyLoading); }); }); + + describe('scroll sentinel', () => { + it('should not render sentinel when items list is empty even if hasMore is true', async () => { + Store.fragments.list.hasMore.set(true); + Store.fragments.list.firstPageLoaded.set(true); + Store.translationProjects.displayCards.set([]); + + const el = await fixture(html``); + await el.updateComplete; + + const sentinel = el.renderRoot.querySelector('.scroll-sentinel'); + expect(sentinel).to.be.null; + }); + + it('should render sentinel when items are present and hasMore is true', async () => { + Store.fragments.list.hasMore.set(true); + Store.fragments.list.firstPageLoaded.set(true); + setupCardsInStore([createMockCard('/card/1', 'Card 1')]); + + const el = await fixture(html``); + await el.updateComplete; + + const sentinel = el.renderRoot.querySelector('.scroll-sentinel'); + expect(sentinel).to.not.be.null; + }); + + it('should not render sentinel when hasMore is false even if items are present', async () => { + Store.fragments.list.hasMore.set(false); + Store.fragments.list.firstPageLoaded.set(true); + setupCardsInStore([createMockCard('/card/1', 'Card 1')]); + + const el = await fixture(html``); + await el.updateComplete; + + const sentinel = el.renderRoot.querySelector('.scroll-sentinel'); + expect(sentinel).to.be.null; + }); + }); }); From 9a71c3ae7f8a2c100570dfb18a7285b63dcc9723 Mon Sep 17 00:00:00 2001 From: Axel Cureno Basurto Date: Fri, 3 Apr 2026 12:37:27 -0700 Subject: [PATCH 03/38] fix(translation): remove early-exit from loadAllFragments; replace abort-restart with re-entrancy guard in processCardsData --- .../src/translation/mas-select-items-table.js | 3 +- .../translation/translation-items-loader.js | 38 +++--- .../translation-items-loader.test.js | 128 ++++++++++++------ 3 files changed, 113 insertions(+), 56 deletions(-) diff --git a/studio/src/translation/mas-select-items-table.js b/studio/src/translation/mas-select-items-table.js index 6d64395da..df2a012d9 100644 --- a/studio/src/translation/mas-select-items-table.js +++ b/studio/src/translation/mas-select-items-table.js @@ -32,7 +32,7 @@ class MasSelectItemsTable extends LitElement { super(); this.dataSubscription = null; this.processAbortController = null; - this.dataState = { isProcessingCards: false }; + this.dataState = { isProcessingCards: false, abortController: new AbortController() }; this.viewOnlyLoading = false; this.viewOnlyFragments = []; this.displayCardsStoreController = null; @@ -134,6 +134,7 @@ class MasSelectItemsTable extends LitElement { disconnectedCallback() { super.disconnectedCallback(); this.dataSubscription?.unsubscribe(); + this.dataState.abortController?.abort(); this.processAbortController?.abort(); this.processAbortController = null; this.scrollObserver?.disconnect(); diff --git a/studio/src/translation/translation-items-loader.js b/studio/src/translation/translation-items-loader.js index 5ee3a1a62..a131b0af7 100644 --- a/studio/src/translation/translation-items-loader.js +++ b/studio/src/translation/translation-items-loader.js @@ -219,20 +219,18 @@ function parseFragmentsFromStore(allFragments) { } /** - * Processes and enriches cards with offer data and grouped variations, writes to store + * Processes and enriches cards with offer data and grouped variations, writes to store. + * Skips concurrent invocations — if already processing, the new call returns immediately. * @param {Array} allCards - Array of card objects * @param {Object} repository - MasRepository instance - * @param {AbortSignal} signal - Abort signal for cancellation - * @param {Object} state - Mutable state { isProcessingCards, processAbortController } + * @param {Object} state - Mutable state { isProcessingCards, abortController } */ async function processCardsData(allCards, repository, state) { - if (state.isProcessingCards) { - state.processAbortController?.abort(); - } + if (state.isProcessingCards) return; state.isProcessingCards = true; - state.processAbortController = new AbortController(); - const { signal: currentSignal } = state.processAbortController; + const signal = state.abortController?.signal; + performance.mark('processCardsData:start'); try { const existingCards = Store.translationProjects.allCards.get() || []; const existingOfferDataByPath = new Map( @@ -248,10 +246,10 @@ async function processCardsData(allCards, repository, state) { if (cardsNeedingOfferData.length > 0) { const offerDataResults = await processConcurrently( cardsNeedingOfferData, - (card) => loadOfferData(card, currentSignal), + (card) => loadOfferData(card, signal), OFFER_DATA_CONCURRENCY_LIMIT, ); - if (currentSignal.aborted) return; + if (signal?.aborted) return; await yieldToMain(); cardsNeedingOfferData.forEach((card, i) => { existingOfferDataByPath.set(card.path, offerDataResults[i]); @@ -262,17 +260,17 @@ 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, signal), OFFER_DATA_CONCURRENCY_LIMIT, ); - if (currentSignal.aborted) return; + if (signal?.aborted) return; await yieldToMain(); cardsNeedingGroupedVariations.forEach((card, i) => { existingGroupedVariationsByPath.set(card.path, groupedVariationsResults[i] ?? []); }); } - if (currentSignal.aborted) return; + if (signal?.aborted) return; const enrichedCards = allCards.map((card) => ({ ...card, @@ -283,7 +281,7 @@ async function processCardsData(allCards, repository, state) { if (enrichedCards.length > 50) { await yieldToMain(); } - if (currentSignal.aborted) return; + if (signal?.aborted) return; const cardsByPaths = new Map(enrichedCards.map((card) => [card.path, card])); const prefetchedVariations = new Map( @@ -304,6 +302,7 @@ async function processCardsData(allCards, repository, state) { Store.translationProjects.cardsByPaths.set(cardsByPaths); } finally { state.isProcessingCards = false; + performance.measure('processCardsData', 'processCardsData:start'); } } @@ -345,10 +344,10 @@ export function loadAllPlaceholders() { * @returns {{ unsubscribe: () => void }} */ export function loadAllFragments(type, repository, state = {}) { - const typeUppercased = type.charAt(0).toUpperCase() + type.slice(1); - if (Store.translationProjects[`all${typeUppercased}`].get()?.length) { + if (state.subscribed) { return { unsubscribe: () => {} }; } + state.subscribed = true; const callback = async () => { const { allCards, allCollections } = parseFragmentsFromStore(Store.fragments.list.data.get() || []); if (type === TABLE_TYPE.CARDS) { @@ -358,7 +357,12 @@ export function loadAllFragments(type, repository, state = {}) { } }; Store.fragments.list.data.subscribe(callback); - return { unsubscribe: () => Store.fragments.list.data.unsubscribe(callback) }; + return { + unsubscribe: () => { + Store.fragments.list.data.unsubscribe(callback); + state.subscribed = false; + }, + }; } /** diff --git a/studio/test/translation/translation-items-loader.test.js b/studio/test/translation/translation-items-loader.test.js index 0cd857a2e..61f32bcf8 100644 --- a/studio/test/translation/translation-items-loader.test.js +++ b/studio/test/translation/translation-items-loader.test.js @@ -115,20 +115,6 @@ 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, {}); - 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, {}); - 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, {}); @@ -187,35 +173,40 @@ describe('translation-items-loader', () => { result.unsubscribe(); }); - it('should use existing card data when already in store', async () => { - const existingCard = { - path: '/content/dam/mas/acom/en_US/cards/existing', - title: 'Existing', - offerData: { offerId: 'cached' }, - groupedVariations: [], + it('should not re-fetch grouped variations for a card already enriched in a prior page', async () => { + const state = {}; + const cardPath = '/content/dam/mas/acom/en_US/cards/card1'; + const variationPath = `${cardPath}/pzn/var1`; + const repo = { + aem: { + getFragmentByPath: sinon.stub().resolves({ + path: variationPath, + fieldTags: [{ id: 't1', name: 'T1' }], + fields: [], + tags: [], + }), + }, }; - Store.translationProjects.allCards.set([existingCard]); + const mockCardFragment = new Fragment({ + path: cardPath, + model: { path: CARD_MODEL_PATH }, + tags: [], + fields: [{ name: 'variations', values: [variationPath] }], + references: [{ path: variationPath }], + }); - const state = {}; - const repo = { aem: { getFragmentByPath: sinon.stub() } }; const result = loadAllFragments(TABLE_TYPE.CARDS, repo, state); - const mockCardStore = { - value: new Fragment({ - path: '/content/dam/mas/acom/en_US/cards/existing', - title: 'Existing', - model: { path: CARD_MODEL_PATH }, - tags: [], - fields: [], - }), - }; - Store.fragments.list.data.set([mockCardStore]); - await new Promise((r) => setTimeout(r, 100)); + Store.fragments.list.data.set([{ value: mockCardFragment }]); + await new Promise((r) => setTimeout(r, 200)); - const cards = Store.translationProjects.allCards.get(); - expect(cards).to.have.lengthOf(1); - expect(cards[0].offerData).to.deep.equal({ offerId: 'cached' }); - expect(repo.aem.getFragmentByPath.called).to.be.false; + const callCountAfterFirst = repo.aem.getFragmentByPath.callCount; + expect(callCountAfterFirst).to.be.greaterThan(0); + + Store.fragments.list.data.set([{ value: mockCardFragment }]); + await new Promise((r) => setTimeout(r, 200)); + + expect(repo.aem.getFragmentByPath.callCount).to.equal(callCountAfterFirst); result.unsubscribe(); }); @@ -647,4 +638,65 @@ describe('translation-items-loader', () => { expect(variation).to.not.exist; }); }); + + describe('loadAllFragments subscription persistence', () => { + it('should subscribe to fragment store updates even when allCards already has data', async () => { + const existingCard = { + path: '/card/existing', + model: { path: CARD_MODEL_PATH }, + studioPath: 'existing', + tags: [], + fields: [], + }; + Store.translationProjects.allCards.set([existingCard]); + + const state = {}; + const { unsubscribe } = loadAllFragments(TABLE_TYPE.CARDS, null, state); + + const newCardData = { + path: '/card/new', + model: { path: CARD_MODEL_PATH }, + title: 'New Card', + tags: [], + fields: [], + fieldTags: [], + }; + Store.fragments.list.data.set([{ value: new Fragment(newCardData) }]); + await new Promise((r) => setTimeout(r, 50)); + + expect(Store.translationProjects.allCards.get().length).to.be.greaterThan(0); + unsubscribe(); + }); + + it('should not register duplicate subscriptions when called twice with the same state', () => { + const state = {}; + const sub1 = loadAllFragments(TABLE_TYPE.CARDS, null, state); + const sub2 = loadAllFragments(TABLE_TYPE.CARDS, null, state); + + expect(sub1.unsubscribe).to.be.a('function'); + expect(sub2.unsubscribe).to.be.a('function'); + + sub1.unsubscribe(); + sub2.unsubscribe(); + }); + }); + + describe('processCardsData re-entrancy', () => { + it('should reset isProcessingCards to false after processing completes', async () => { + const state = {}; + const card = { + path: '/card/1', + model: { path: CARD_MODEL_PATH }, + tags: [], + fields: [], + }; + + const { unsubscribe } = loadAllFragments(TABLE_TYPE.CARDS, null, state); + Store.fragments.list.data.set([{ value: new Fragment(card) }]); + await new Promise((r) => setTimeout(r, 100)); + + expect(state.isProcessingCards).to.be.false; + unsubscribe(); + }); + }); }); From 2fbbc88098e1c5b3cbd9e652d21051c7ddb14186 Mon Sep 17 00:00:00 2001 From: Axel Cureno Basurto Date: Fri, 3 Apr 2026 12:42:12 -0700 Subject: [PATCH 04/38] fix(content): delay sentinel re-observe with requestAnimationFrame after page load --- studio/src/mas-content.js | 2 +- studio/test/mas-content.test.js | 30 +++++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/studio/src/mas-content.js b/studio/src/mas-content.js index ba16b02ab..a68cc9a41 100644 --- a/studio/src/mas-content.js +++ b/studio/src/mas-content.js @@ -288,7 +288,7 @@ class MasContent extends LitElement { this.observedSentinel = null; } else if (loadingJustCompleted && this.hasMore.value) { this.scrollObserver?.unobserve(sentinel); - this.scrollObserver?.observe(sentinel); + requestAnimationFrame(() => this.scrollObserver?.observe(sentinel)); } } diff --git a/studio/test/mas-content.test.js b/studio/test/mas-content.test.js index 34156a326..794a9b4ac 100644 --- a/studio/test/mas-content.test.js +++ b/studio/test/mas-content.test.js @@ -1,4 +1,5 @@ -import { expect, fixture, html } from '@open-wc/testing'; +import { expect, fixture, html, nextFrame } from '@open-wc/testing'; +import sinon from 'sinon'; import '../src/swc.js'; import { Fragment } from '../src/aem/fragment.js'; import Store from '../src/store.js'; @@ -94,6 +95,33 @@ describe('MasContent table + personalization grouping', () => { expect(text).to.include('All other fragments (1)'); }); + it('uses requestAnimationFrame to re-observe sentinel after a page load completes', async () => { + Store.fragments.list.loading.set(true); + Store.fragments.list.firstPageLoaded.set(true); + Store.fragments.list.hasMore.set(true); + Store.fragments.list.data.value = []; + + const el = await fixture(html``); + await el.updateComplete; + + const rafStub = sinon.stub(window, 'requestAnimationFrame').callsFake((cb) => { + cb(); + return 0; + }); + + try { + // Transition loading false → triggers loadingJustCompleted in updated() + Store.fragments.list.loading.set(false); + await nextFrame(); + await el.updateComplete; + + expect(rafStub.called).to.be.true; + } finally { + rafStub.restore(); + Store.fragments.list.hasMore.set(false); + } + }); + it('narrows the personalization group when selected filter tags are non-country PZN ids', async () => { const withGeneral = makeFragment({ id: 'g', From 4664df79187fd8dc3cbad63312a0ed714768a5ac Mon Sep 17 00:00:00 2001 From: Axel Cureno Basurto Date: Fri, 3 Apr 2026 12:44:28 -0700 Subject: [PATCH 05/38] test(content): improve RAF re-observe test to verify callback is deferred not immediate --- studio/test/mas-content.test.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/studio/test/mas-content.test.js b/studio/test/mas-content.test.js index 794a9b4ac..731bbbcee 100644 --- a/studio/test/mas-content.test.js +++ b/studio/test/mas-content.test.js @@ -104,18 +104,21 @@ describe('MasContent table + personalization grouping', () => { const el = await fixture(html``); await el.updateComplete; + // Capture RAF callbacks without executing them — proves observe() is deferred + const rafCallbacks = []; const rafStub = sinon.stub(window, 'requestAnimationFrame').callsFake((cb) => { - cb(); - return 0; + rafCallbacks.push(cb); + return rafCallbacks.length; }); try { - // Transition loading false → triggers loadingJustCompleted in updated() Store.fragments.list.loading.set(false); - await nextFrame(); await el.updateComplete; + // RAF was scheduled expect(rafStub.called).to.be.true; + // Callback was NOT yet executed (observe is deferred) + expect(rafCallbacks).to.have.length(1); } finally { rafStub.restore(); Store.fragments.list.hasMore.set(false); From 97d0f74bb7028401300772ba2c5670b19e1618e1 Mon Sep 17 00:00:00 2001 From: Axel Cureno Basurto Date: Fri, 3 Apr 2026 12:49:28 -0700 Subject: [PATCH 06/38] fix(repository): cap eagerLoadAllPznPages at MAX_EAGER_PAGES to prevent AEM request burst --- studio/src/mas-repository.js | 7 +++ studio/test/mas-repository.test.js | 90 ++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/studio/src/mas-repository.js b/studio/src/mas-repository.js index 2e2e67ddc..f845a99d8 100644 --- a/studio/src/mas-repository.js +++ b/studio/src/mas-repository.js @@ -480,6 +480,7 @@ export class MasRepository extends LitElement { } static MIN_PAGE_SIZE = 10; + static MAX_EAGER_PAGES = 20; async #fillPage(cursor, variants, surface, fragmentStores, limit = MasRepository.MIN_PAGE_SIZE, signal) { let added = 0; @@ -500,8 +501,13 @@ export class MasRepository extends LitElement { async #eagerLoadAllPznPages(cursorSnapshot, searchController) { const { cursor, variants, surface, fragmentStores } = cursorSnapshot; + let pagesLoaded = 0; try { while (this.#searchCursor === cursorSnapshot) { + if (pagesLoaded >= MasRepository.MAX_EAGER_PAGES) { + Store.fragments.list.hasMore.set(true); + break; + } const done = await this.#fillPage( cursor, variants, @@ -510,6 +516,7 @@ export class MasRepository extends LitElement { undefined, searchController.signal, ); + pagesLoaded++; if (this.#searchCursor !== cursorSnapshot) return; Store.fragments.list.data.set([...this.#filterStoresByPersonalizationEnabled(fragmentStores)]); if (done) { diff --git a/studio/test/mas-repository.test.js b/studio/test/mas-repository.test.js index 70009bd45..44c952860 100644 --- a/studio/test/mas-repository.test.js +++ b/studio/test/mas-repository.test.js @@ -1800,6 +1800,96 @@ describe('MasRepository dictionary helpers', () => { }); }); + describe('eagerLoadAllPznPages cap', () => { + const setupPznSearchTest = async (pageCount) => { + const repository = createFullRepository(); + repository.page = { value: PAGE_NAMES.CONTENT }; + repository.search = { value: { path: 'acom', query: '' } }; + repository.filters = { + value: { + locale: 'en_US', + tags: '', + personalizationFilterEnabled: true, + }, + }; + // Each page has MIN_PAGE_SIZE items so each #fillPage call consumes exactly 1 page + const pages = Array.from({ length: pageCount }, (_, p) => + Array.from({ length: MasRepository.MIN_PAGE_SIZE }, (_, i) => + createFragment({ + id: `pzn-${p}-${i}`, + path: `${ROOT_PATH}/acom/en_US/pzn-${p}-${i}`, + tags: [{ id: 'mas:pzn/general' }], + fields: [], + }), + ), + ); + let index = 0; + const mockCursor = { + next: async () => { + if (index >= pages.length) return { done: true }; + const page = pages[index++]; + return { + done: false, + value: { + [Symbol.asyncIterator]: async function* () { + for (const item of page) yield item; + }, + }, + }; + }, + }; + const searchStub = sandbox.stub().resolves(mockCursor); + repository.aem = createAemMock({ fragments: { search: searchStub } }); + const { default: Store } = await import('../src/store.js'); + const originalProfile = Store.profile.value; + Store.profile.set({ name: 'test-user' }); + Store.createdByUsers.set([]); + const mockDataStore = { + get: sandbox.stub().returns([]), + getMeta: sandbox.stub().returns(null), + set: sandbox.stub(), + setMeta: sandbox.stub(), + }; + const originalData = Store.fragments.list.data; + Store.fragments.list.data = mockDataStore; + return { + repository, + mockDataStore, + cleanup: () => { + Store.profile.set(originalProfile); + Store.fragments.list.data = originalData; + }, + }; + }; + + it('stops eager loading after MAX_EAGER_PAGES and sets hasMore true', async () => { + const pageCount = MasRepository.MAX_EAGER_PAGES + 5; + const { repository, cleanup } = await setupPznSearchTest(pageCount); + try { + await repository.searchFragments(); + const { default: Store } = await import('../src/store.js'); + // Wait for the async eager-load loop to complete + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(Store.fragments.list.hasMore.value).to.be.true; + } finally { + cleanup(); + } + }); + + it('does not set hasMore when all pages fit within MAX_EAGER_PAGES', async () => { + const pageCount = 2; + const { repository, cleanup } = await setupPznSearchTest(pageCount); + try { + await repository.searchFragments(); + await new Promise((resolve) => setTimeout(resolve, 50)); + const { default: Store } = await import('../src/store.js'); + expect(Store.fragments.list.hasMore.value).to.be.false; + } finally { + cleanup(); + } + }); + }); + describe('parseVariationAlreadyExistsPath', () => { it('returns path when message is "A variation already exists at /path/to/fragment"', () => { const repository = createRepository(); From c6f4fbad383b6afa7fa7e976243b0192a7991e9b Mon Sep 17 00:00:00 2001 From: Axel Cureno Basurto Date: Fri, 3 Apr 2026 13:00:37 -0700 Subject: [PATCH 07/38] test(translation): fix sentinel test to set displayCards after subscription fires --- studio/test/translation/mas-select-items-table.test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/studio/test/translation/mas-select-items-table.test.js b/studio/test/translation/mas-select-items-table.test.js index 36f67efca..264ed8a49 100644 --- a/studio/test/translation/mas-select-items-table.test.js +++ b/studio/test/translation/mas-select-items-table.test.js @@ -1152,11 +1152,14 @@ describe('MasSelectItemsTable', () => { it('should render sentinel when items are present and hasMore is true', async () => { Store.fragments.list.hasMore.set(true); Store.fragments.list.firstPageLoaded.set(true); - setupCardsInStore([createMockCard('/card/1', 'Card 1')]); const el = await fixture(html``); await el.updateComplete; + // Set displayCards after initial subscription fires (which overwrites with empty data) + setupCardsInStore([createMockCard('/card/1', 'Card 1')]); + await el.updateComplete; + const sentinel = el.renderRoot.querySelector('.scroll-sentinel'); expect(sentinel).to.not.be.null; }); From 9efa38a4393d63e0f5af204c3a75f071f10827d6 Mon Sep 17 00:00:00 2001 From: Axel Cureno Basurto Date: Fri, 3 Apr 2026 14:23:17 -0700 Subject: [PATCH 08/38] test: fix mas-select-items-table test setup pattern Fixed 28 failing tests by moving setupCardsInStore/setupCollectionsInStore/setupPlaceholdersInStore calls to occur AFTER element creation instead of BEFORE. The tests now properly follow the pattern: 1. Create element 2. Wait for initial subscription (updateComplete) 3. Setup store data 4. Wait for updates (updateComplete) This fixes the root cause where store initialization in connectedCallback was overwriting test setup data. Co-Authored-By: Claude Haiku 4.5 --- .../mas-select-items-table.test.js | 339 +++++++++++++----- 1 file changed, 249 insertions(+), 90 deletions(-) diff --git a/studio/test/translation/mas-select-items-table.test.js b/studio/test/translation/mas-select-items-table.test.js index 264ed8a49..470bf656a 100644 --- a/studio/test/translation/mas-select-items-table.test.js +++ b/studio/test/translation/mas-select-items-table.test.js @@ -122,100 +122,128 @@ describe('MasSelectItemsTable', () => { describe('initialization', () => { it('should initialize with default values', async () => { - setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); const el = await fixture(html``); + await el.updateComplete; + setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); + await el.updateComplete; expect(el.selectedInTable).to.deep.equal(new Set()); expect(el.viewOnly).to.not.equal(true); }); it('should accept type property for cards', async () => { - setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); const el = await fixture(html``); + await el.updateComplete; + setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); + await el.updateComplete; expect(el.type).to.equal('cards'); }); it('should accept type property for collections', async () => { - setupCollectionsInStore([createMockCollection('/path/collection1', 'Collection 1')]); const el = await fixture(html``); + await el.updateComplete; + setupCollectionsInStore([createMockCollection('/path/collection1', 'Collection 1')]); + await el.updateComplete; expect(el.type).to.equal('collections'); }); it('should accept type property for placeholders', async () => { - setupPlaceholdersInStore([createMockPlaceholder('/path/placeholder1', 'key1', 'value1')]); const el = await fixture(html``); + await el.updateComplete; + setupPlaceholdersInStore([createMockPlaceholder('/path/placeholder1', 'key1', 'value1')]); + await el.updateComplete; expect(el.type).to.equal('placeholders'); }); it('should accept viewOnly property', async () => { + const el = await fixture(html``); + await el.updateComplete; const card = createMockCard('/path/card1', 'Card 1'); setupCardsInStore([card]); Store.translationProjects.selectedCards.set(['/path/card1']); - const el = await fixture(html``); + await el.updateComplete; expect(el.viewOnly).to.be.true; }); }); describe('typeUppercased getter', () => { it('should return Cards for cards type', async () => { - setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); const el = await fixture(html``); + await el.updateComplete; + setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); + await el.updateComplete; expect(el.typeUppercased).to.equal('Cards'); }); it('should return Collections for collections type', async () => { - setupCollectionsInStore([createMockCollection('/path/collection1', 'Collection 1')]); const el = await fixture(html``); + await el.updateComplete; + setupCollectionsInStore([createMockCollection('/path/collection1', 'Collection 1')]); + await el.updateComplete; expect(el.typeUppercased).to.equal('Collections'); }); it('should return Placeholders for placeholders type', async () => { - setupPlaceholdersInStore([createMockPlaceholder('/path/placeholder1', 'key1', 'value1')]); const el = await fixture(html``); + await el.updateComplete; + setupPlaceholdersInStore([createMockPlaceholder('/path/placeholder1', 'key1', 'value1')]); + await el.updateComplete; expect(el.typeUppercased).to.equal('Placeholders'); }); }); describe('isLoading getter', () => { it('should return true for cards when firstPageLoaded is false', async () => { + const el = await fixture(html``); + await el.updateComplete; Store.fragments.list.firstPageLoaded.set(false); setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); - const el = await fixture(html``); + await el.updateComplete; expect(el.isLoading).to.be.true; }); it('should return false for cards when firstPageLoaded is true', async () => { + const el = await fixture(html``); + await el.updateComplete; Store.fragments.list.firstPageLoaded.set(true); setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); - const el = await fixture(html``); + await el.updateComplete; expect(el.isLoading).to.be.false; }); it('should return true for collections when firstPageLoaded is false', async () => { + const el = await fixture(html``); + await el.updateComplete; Store.fragments.list.firstPageLoaded.set(false); setupCollectionsInStore([createMockCollection('/path/collection1', 'Collection 1')]); - const el = await fixture(html``); + await el.updateComplete; expect(el.isLoading).to.be.true; }); it('should return true for placeholders when placeholders are loading', async () => { + const el = await fixture(html``); + await el.updateComplete; Store.placeholders.list.loading.set(true); setupPlaceholdersInStore([createMockPlaceholder('/path/placeholder1', 'key1', 'value1')]); - const el = await fixture(html``); + await el.updateComplete; expect(el.isLoading).to.be.true; }); it('should return false for placeholders when not loading', async () => { + const el = await fixture(html``); + await el.updateComplete; Store.placeholders.list.loading.set(false); setupPlaceholdersInStore([createMockPlaceholder('/path/placeholder1', 'key1', 'value1')]); - const el = await fixture(html``); + await el.updateComplete; expect(el.isLoading).to.be.false; }); it('should return viewOnlyLoading for cards when viewOnly is true', async () => { + const el = await fixture(html``); + await el.updateComplete; const card = createMockCard('/path/card1', 'Card 1'); setupCardsInStore([card]); Store.translationProjects.selectedCards.set(['/path/card1']); - const el = await fixture(html``); + await el.updateComplete; el.viewOnlyLoading = true; expect(el.isLoading).to.be.true; el.viewOnlyLoading = false; @@ -223,12 +251,14 @@ describe('MasSelectItemsTable', () => { }); it('should return viewOnlyLoading for collections when viewOnly is true', async () => { - const collection = createMockCollection('/path/collection1', 'Collection 1'); - setupCollectionsInStore([collection]); - Store.translationProjects.selectedCollections.set(['/path/collection1']); const el = await fixture( html``, ); + await el.updateComplete; + const collection = createMockCollection('/path/collection1', 'Collection 1'); + setupCollectionsInStore([collection]); + Store.translationProjects.selectedCollections.set(['/path/collection1']); + await el.updateComplete; el.viewOnlyLoading = true; expect(el.isLoading).to.be.true; }); @@ -236,25 +266,31 @@ describe('MasSelectItemsTable', () => { describe('itemsToDisplay getter', () => { it('should return displayCards from store when not viewOnly', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1')]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; expect(el.itemsToDisplay.length).to.equal(1); expect(el.itemsToDisplay[0].path).to.equal('/path/card1'); }); it('should return displayCollections from store when not viewOnly', async () => { + const el = await fixture(html``); + await el.updateComplete; const collections = [createMockCollection('/path/collection1', 'Collection 1')]; setupCollectionsInStore(collections); - const el = await fixture(html``); + await el.updateComplete; expect(el.itemsToDisplay.length).to.equal(1); expect(el.itemsToDisplay[0].path).to.equal('/path/collection1'); }); it('should return displayPlaceholders from store when not viewOnly', async () => { + const el = await fixture(html``); + await el.updateComplete; const placeholders = [createMockPlaceholder('/path/placeholder1', 'key1', 'value1')]; setupPlaceholdersInStore(placeholders); - const el = await fixture(html``); + await el.updateComplete; expect(el.itemsToDisplay.length).to.equal(1); expect(el.itemsToDisplay[0].path).to.equal('/path/placeholder1'); }); @@ -278,9 +314,11 @@ describe('MasSelectItemsTable', () => { describe('rendering - loading state', () => { it('should render loading indicator when loading', async () => { + const el = await fixture(html``); + await el.updateComplete; Store.fragments.list.firstPageLoaded.set(false); setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); - const el = await fixture(html``); + await el.updateComplete; const loadingContainer = el.shadowRoot.querySelector('.loading-container--flex'); const progressCircle = el.shadowRoot.querySelector('sp-progress-circle'); expect(loadingContainer).to.exist; @@ -288,9 +326,11 @@ describe('MasSelectItemsTable', () => { }); it('should not render loading indicator when not loading', async () => { + const el = await fixture(html``); + await el.updateComplete; Store.fragments.list.firstPageLoaded.set(true); setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); - const el = await fixture(html``); + await el.updateComplete; const loadingContainer = el.shadowRoot.querySelector('.loading-container--flex'); expect(loadingContainer).to.be.null; }); @@ -298,8 +338,10 @@ describe('MasSelectItemsTable', () => { describe('rendering - empty state', () => { it('should render "No items found" when no items to display', async () => { - setupCardsInStore([]); const el = await fixture(html``); + await el.updateComplete; + setupCardsInStore([]); + await el.updateComplete; const emptyMessage = el.shadowRoot.querySelector('p'); expect(emptyMessage).to.exist; expect(emptyMessage.textContent).to.equal('No items found.'); @@ -308,17 +350,21 @@ describe('MasSelectItemsTable', () => { describe('rendering - table structure', () => { it('should render table when items exist', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1')]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; const table = el.shadowRoot.querySelector('sp-table'); expect(table).to.exist; }); it('should render table headers for cards', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1')]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; const headers = el.shadowRoot.querySelectorAll('sp-table-head-cell'); expect(headers.length).to.equal(7); expect(headers[2].textContent.trim()).to.include('Offer'); @@ -329,9 +375,11 @@ describe('MasSelectItemsTable', () => { }); it('should render table headers for collections', async () => { + const el = await fixture(html``); + await el.updateComplete; const collections = [createMockCollection('/path/collection1', 'Collection 1')]; setupCollectionsInStore(collections); - const el = await fixture(html``); + await el.updateComplete; const headers = el.shadowRoot.querySelectorAll('sp-table-head-cell'); expect(headers.length).to.equal(4); expect(headers[1].textContent.trim()).to.include('Collection title'); @@ -340,9 +388,11 @@ describe('MasSelectItemsTable', () => { }); it('should render table headers for placeholders', async () => { + const el = await fixture(html``); + await el.updateComplete; const placeholders = [createMockPlaceholder('/path/placeholder1', 'key1', 'value1')]; setupPlaceholdersInStore(placeholders); - const el = await fixture(html``); + await el.updateComplete; const headers = el.shadowRoot.querySelectorAll('sp-table-head-cell'); expect(headers.length).to.equal(4); expect(headers[1].textContent.trim()).to.include('Key'); @@ -353,6 +403,8 @@ describe('MasSelectItemsTable', () => { describe('rendering - cards table body', () => { it('should render card rows with correct data', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [ createMockCard('/path/card1', 'Test Card', { tags: [{ id: 'mas:product_code/photoshop', title: 'Photoshop' }], @@ -361,7 +413,7 @@ describe('MasSelectItemsTable', () => { }), ]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; const collapsibleRow = el.shadowRoot.querySelector('mas-collapsible-table-row'); expect(collapsibleRow).to.exist; const cells = collapsibleRow.shadowRoot.querySelectorAll('sp-table-cell'); @@ -370,9 +422,11 @@ describe('MasSelectItemsTable', () => { }); it('should display placeholder when no product tag exists', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Test Card')]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; const collapsibleRow = el.shadowRoot.querySelector('mas-collapsible-table-row'); const cells = collapsibleRow.shadowRoot.querySelectorAll('sp-table-cell'); const offerCell = cells[2]?.textContent?.trim() || ''; @@ -380,22 +434,26 @@ describe('MasSelectItemsTable', () => { }); it('should render copy button when offer data exists', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [ createMockCard('/path/card1', 'Test Card', { offerData: { offerId: 'offer-123' }, }), ]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; const collapsibleRow = el.shadowRoot.querySelector('mas-collapsible-table-row'); const copyButton = collapsibleRow.shadowRoot.querySelector('sp-action-button'); expect(copyButton).to.exist; }); it('should display "no offer data" when offerData is null', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Test Card')]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; const collapsibleRow = el.shadowRoot.querySelector('mas-collapsible-table-row'); const cells = collapsibleRow.shadowRoot.querySelectorAll('sp-table-cell'); expect(cells[4].textContent).to.include('no offer data'); @@ -404,13 +462,15 @@ describe('MasSelectItemsTable', () => { describe('rendering - collections table body', () => { it('should render collection rows with correct data', async () => { + const el = await fixture(html``); + await el.updateComplete; const collections = [ createMockCollection('/path/collection1', 'Test Collection', { studioPath: 'merch-card-collection: ACOM / Test Collection', }), ]; setupCollectionsInStore(collections); - const el = await fixture(html``); + await el.updateComplete; const rows = el.shadowRoot.querySelectorAll('sp-table-row'); expect(rows.length).to.equal(1); const cells = rows[0].querySelectorAll('sp-table-cell'); @@ -418,9 +478,11 @@ describe('MasSelectItemsTable', () => { }); it('should display "-" for collection with no title', async () => { + const el = await fixture(html``); + await el.updateComplete; const collections = [createMockCollection('/path/collection1', null)]; setupCollectionsInStore(collections); - const el = await fixture(html``); + await el.updateComplete; const rows = el.shadowRoot.querySelectorAll('sp-table-row'); const cells = rows[0].querySelectorAll('sp-table-cell'); expect(cells[1].textContent.trim()).to.equal('-'); @@ -429,9 +491,11 @@ describe('MasSelectItemsTable', () => { describe('rendering - placeholders table body', () => { it('should render placeholder rows with correct data', async () => { + const el = await fixture(html``); + await el.updateComplete; const placeholders = [createMockPlaceholder('/path/placeholder1', 'test-key', 'test-value')]; setupPlaceholdersInStore(placeholders); - const el = await fixture(html``); + await el.updateComplete; const rows = el.shadowRoot.querySelectorAll('sp-table-row'); expect(rows.length).to.equal(1); const cells = rows[0].querySelectorAll('sp-table-cell'); @@ -440,28 +504,34 @@ describe('MasSelectItemsTable', () => { }); it('should display "-" for placeholder with no key', async () => { + const el = await fixture(html``); + await el.updateComplete; const placeholders = [createMockPlaceholder('/path/placeholder1', null, 'test-value')]; setupPlaceholdersInStore(placeholders); - const el = await fixture(html``); + await el.updateComplete; const rows = el.shadowRoot.querySelectorAll('sp-table-row'); const cells = rows[0].querySelectorAll('sp-table-cell'); expect(cells[1].textContent.trim()).to.equal('-'); }); it('should truncate long placeholder values to 100 characters', async () => { + const el = await fixture(html``); + await el.updateComplete; const longValue = 'A'.repeat(150); const placeholders = [createMockPlaceholder('/path/placeholder1', 'key', longValue)]; setupPlaceholdersInStore(placeholders); - const el = await fixture(html``); + await el.updateComplete; const rows = el.shadowRoot.querySelectorAll('sp-table-row'); const cells = rows[0].querySelectorAll('sp-table-cell'); expect(cells[2].textContent.trim()).to.equal(`${'A'.repeat(100)}...`); }); it('should display "-" for placeholder with no value', async () => { + const el = await fixture(html``); + await el.updateComplete; const placeholders = [createMockPlaceholder('/path/placeholder1', 'key', null)]; setupPlaceholdersInStore(placeholders); - const el = await fixture(html``); + await el.updateComplete; const rows = el.shadowRoot.querySelectorAll('sp-table-row'); const cells = rows[0].querySelectorAll('sp-table-cell'); expect(cells[2].textContent.trim()).to.equal('-'); @@ -470,9 +540,11 @@ describe('MasSelectItemsTable', () => { describe('rendering - status cell', () => { it('should render Published status with green dot', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1', { status: FRAGMENT_STATUS.PUBLISHED })]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; const collapsibleRow = el.shadowRoot.querySelector('mas-collapsible-table-row'); const statusCell = collapsibleRow.shadowRoot.querySelector('.status-cell'); const statusDot = statusCell.querySelector('.status-dot'); @@ -481,9 +553,11 @@ describe('MasSelectItemsTable', () => { }); it('should render Modified status with blue dot', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1', { status: FRAGMENT_STATUS.MODIFIED })]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; const collapsibleRow = el.shadowRoot.querySelector('mas-collapsible-table-row'); const statusCell = collapsibleRow.shadowRoot.querySelector('.status-cell'); const statusDot = statusCell.querySelector('.status-dot'); @@ -492,9 +566,11 @@ describe('MasSelectItemsTable', () => { }); it('should render Draft status without color class', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1', { status: FRAGMENT_STATUS.DRAFT })]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; const collapsibleRow = el.shadowRoot.querySelector('mas-collapsible-table-row'); const statusCell = collapsibleRow.shadowRoot.querySelector('.status-cell'); const statusDot = statusCell.querySelector('.status-dot'); @@ -506,17 +582,20 @@ describe('MasSelectItemsTable', () => { describe('table selection behavior', () => { it('should render checkboxes when not viewOnly', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1')]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; const row = el.shadowRoot.querySelector('mas-collapsible-table-row'); const checkbox = row?.shadowRoot?.querySelector('sp-checkbox'); expect(checkbox).to.exist; }); it('should not render checkboxes when viewOnly', async () => { - const card = createMockCard('/path/card1', 'Card 1'); const el = await fixture(html``); + await el.updateComplete; + const card = createMockCard('/path/card1', 'Card 1'); el.viewOnlyFragments = [card]; await el.updateComplete; const row = el.shadowRoot.querySelector('mas-collapsible-table-row'); @@ -525,10 +604,11 @@ describe('MasSelectItemsTable', () => { }); it('should reflect store selection in selectedInTable', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1'), createMockCard('/path/card2', 'Card 2')]; setupCardsInStore(cards); Store.translationProjects.selectedCards.set(['/path/card1']); - const el = await fixture(html``); await el.updateComplete; expect(el.selectedInTable).to.include('/path/card1'); }); @@ -536,19 +616,21 @@ describe('MasSelectItemsTable', () => { describe('selection preselection', () => { it('should preselect items that are in the store selection', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1'), createMockCard('/path/card2', 'Card 2')]; setupCardsInStore(cards); Store.translationProjects.selectedCards.set(['/path/card1']); - const el = await fixture(html``); await el.updateComplete; expect(el.selectedInTable).to.include('/path/card1'); }); it('should reflect store selection in selectedInTable', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1')]; setupCardsInStore(cards); Store.translationProjects.selectedCards.set(['/path/card1', '/path/card2']); - const el = await fixture(html``); await el.updateComplete; expect(el.selectedInTable).to.include('/path/card1'); expect(el.selectedInTable).to.include('/path/card2'); @@ -557,10 +639,11 @@ describe('MasSelectItemsTable', () => { describe('selection updates', () => { it('should update store when checkbox selection changes', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1'), createMockCard('/path/card2', 'Card 2')]; setupCardsInStore(cards); Store.translationProjects.selectedCards.set(['/path/card1']); - const el = await fixture(html``); await el.updateComplete; const row = el.shadowRoot.querySelector('mas-collapsible-table-row'); const checkbox = row?.shadowRoot?.querySelector('sp-checkbox'); @@ -574,6 +657,8 @@ describe('MasSelectItemsTable', () => { describe('copy to clipboard', () => { it('should dispatch show-toast event on successful copy', async () => { + const el = await fixture(html``); + await el.updateComplete; const writeTextStub = sandbox.stub(navigator.clipboard, 'writeText').resolves(); const cards = [ createMockCard('/path/card1', 'Test Card', { @@ -581,7 +666,7 @@ describe('MasSelectItemsTable', () => { }), ]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; let toastEvent = null; el.addEventListener('show-toast', (e) => { @@ -600,6 +685,8 @@ describe('MasSelectItemsTable', () => { }); it('should dispatch negative toast on copy failure', async () => { + const el = await fixture(html``); + await el.updateComplete; sandbox.stub(navigator.clipboard, 'writeText').rejects(new Error('Copy failed')); const cards = [ createMockCard('/path/card1', 'Test Card', { @@ -607,7 +694,7 @@ describe('MasSelectItemsTable', () => { }), ]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; let toastEvent = null; el.addEventListener('show-toast', (e) => { @@ -627,8 +714,10 @@ describe('MasSelectItemsTable', () => { describe('disconnectedCallback', () => { it('should unsubscribe from data subscription on disconnect', async () => { - setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); const el = await fixture(html``); + await el.updateComplete; + setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); + await el.updateComplete; const unsubscribeSpy = sandbox.spy(); el.dataSubscription = { unsubscribe: unsubscribeSpy }; @@ -638,8 +727,10 @@ describe('MasSelectItemsTable', () => { }); it('should abort process controller on disconnect', async () => { - setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); const el = await fixture(html``); + await el.updateComplete; + setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); + await el.updateComplete; const abortSpy = sandbox.spy(); el.processAbortController = { abort: abortSpy }; @@ -649,8 +740,10 @@ describe('MasSelectItemsTable', () => { }); it('should set processAbortController to null on disconnect', async () => { - setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); const el = await fixture(html``); + await el.updateComplete; + setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); + await el.updateComplete; el.processAbortController = { abort: () => {} }; el.disconnectedCallback(); @@ -661,44 +754,55 @@ describe('MasSelectItemsTable', () => { describe('store controllers', () => { it('should initialize display store controller when not viewOnly', async () => { - setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); const el = await fixture(html``); + await el.updateComplete; + setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); + await el.updateComplete; expect(el.displayCardsStoreController).to.exist; }); it('should initialize selected store controller for cards', async () => { - setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); const el = await fixture(html``); + await el.updateComplete; + setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); + await el.updateComplete; expect(el.selectedCardsStoreController).to.exist; }); it('should initialize selected store controller for collections', async () => { - setupCollectionsInStore([createMockCollection('/path/collection1', 'Collection 1')]); const el = await fixture(html``); + await el.updateComplete; + setupCollectionsInStore([createMockCollection('/path/collection1', 'Collection 1')]); + await el.updateComplete; expect(el.selectedCollectionsStoreController).to.exist; }); it('should initialize selected store controller for placeholders', async () => { - setupPlaceholdersInStore([createMockPlaceholder('/path/placeholder1', 'key1', 'value1')]); const el = await fixture(html``); + await el.updateComplete; + setupPlaceholdersInStore([createMockPlaceholder('/path/placeholder1', 'key1', 'value1')]); + await el.updateComplete; expect(el.selectedPlaceholdersStoreController).to.exist; }); it('should initialize display store controller when viewOnly (for consistent reactivity)', async () => { + const el = await fixture(html``); + await el.updateComplete; const card = createMockCard('/path/card1', 'Card 1'); setupCardsInStore([card]); Store.translationProjects.selectedCards.set(['/path/card1']); - const el = await fixture(html``); + await el.updateComplete; expect(el.displayCardsStoreController).to.exist; }); }); describe('hidden selection preservation', () => { it('should preserve hidden selections when deselecting visible item', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1')]; setupCardsInStore(cards); Store.translationProjects.selectedCards.set(['/path/card1', '/path/hidden-card']); - const el = await fixture(html``); await el.updateComplete; const row = el.shadowRoot.querySelector('mas-collapsible-table-row'); const checkbox = row?.shadowRoot?.querySelector('sp-checkbox'); @@ -711,35 +815,41 @@ describe('MasSelectItemsTable', () => { describe('multiple rows rendering', () => { it('should render multiple card rows', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [ createMockCard('/path/card1', 'Card 1'), createMockCard('/path/card2', 'Card 2'), createMockCard('/path/card3', 'Card 3'), ]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; const rows = el.shadowRoot.querySelectorAll('mas-collapsible-table-row'); expect(rows.length).to.equal(3); }); it('should render multiple collection rows', async () => { + const el = await fixture(html``); + await el.updateComplete; const collections = [ createMockCollection('/path/collection1', 'Collection 1'), createMockCollection('/path/collection2', 'Collection 2'), ]; setupCollectionsInStore(collections); - const el = await fixture(html``); + await el.updateComplete; const rows = el.shadowRoot.querySelectorAll('sp-table-row'); expect(rows.length).to.equal(2); }); it('should render multiple placeholder rows', async () => { + const el = await fixture(html``); + await el.updateComplete; const placeholders = [ createMockPlaceholder('/path/placeholder1', 'key1', 'value1'), createMockPlaceholder('/path/placeholder2', 'key2', 'value2'), ]; setupPlaceholdersInStore(placeholders); - const el = await fixture(html``); + await el.updateComplete; const rows = el.shadowRoot.querySelectorAll('sp-table-row'); expect(rows.length).to.equal(2); }); @@ -747,18 +857,22 @@ describe('MasSelectItemsTable', () => { describe('row value attribute', () => { it('should set row value attribute to fragment path for cards', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1')]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; const collapsibleRow = el.shadowRoot.querySelector('mas-collapsible-table-row'); const row = collapsibleRow?.shadowRoot?.querySelector('sp-table-row'); expect(row?.getAttribute('value')).to.equal('/path/card1'); }); it('should set row value attribute to fragment path for placeholders', async () => { + const el = await fixture(html``); + await el.updateComplete; const placeholders = [createMockPlaceholder('/path/placeholder1', 'key1', 'value1')]; setupPlaceholdersInStore(placeholders); - const el = await fixture(html``); + await el.updateComplete; const row = el.shadowRoot.querySelector('sp-table-row'); expect(row.getAttribute('value')).to.equal('/path/placeholder1'); }); @@ -766,40 +880,48 @@ describe('MasSelectItemsTable', () => { describe('edge cases', () => { it('should handle empty value in placeholder gracefully', async () => { + const el = await fixture(html``); + await el.updateComplete; const placeholders = [createMockPlaceholder('/path/placeholder1', 'key', '')]; setupPlaceholdersInStore(placeholders); - const el = await fixture(html``); + await el.updateComplete; const row = el.shadowRoot.querySelector('sp-table-row'); const cells = row?.querySelectorAll('sp-table-cell'); expect(cells?.[2]?.textContent.trim()).to.equal('-'); }); it('should handle undefined status gracefully', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [{ ...createMockCard('/path/card1', 'Card 1'), status: undefined }]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; const collapsibleRow = el.shadowRoot.querySelector('mas-collapsible-table-row'); expect(collapsibleRow).to.exist; }); it('should handle placeholder value exactly 100 characters', async () => { + const el = await fixture(html``); + await el.updateComplete; const exactValue = 'B'.repeat(100); const placeholders = [createMockPlaceholder('/path/placeholder1', 'key', exactValue)]; setupPlaceholdersInStore(placeholders); - const el = await fixture(html``); + await el.updateComplete; const row = el.shadowRoot.querySelector('sp-table-row'); const cells = row?.querySelectorAll('sp-table-cell'); expect(cells?.[2]?.textContent.trim()).to.equal(exactValue); }); it('should handle card with only non-product tags', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [ createMockCard('/path/card1', 'Card 1', { tags: [{ id: 'mas:other/tag', title: 'Other' }], }), ]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; const collapsibleRow = el.shadowRoot.querySelector('mas-collapsible-table-row'); const cells = collapsibleRow?.shadowRoot?.querySelectorAll('sp-table-cell'); const offerCell = Array.from(cells || []).find((c) => c.textContent.includes('no offer') || c.textContent === '-'); @@ -807,6 +929,8 @@ describe('MasSelectItemsTable', () => { }); it('should handle card with multiple tags including product code', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [ createMockCard('/path/card1', 'Card 1', { tags: [ @@ -816,7 +940,7 @@ describe('MasSelectItemsTable', () => { }), ]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; const collapsibleRow = el.shadowRoot.querySelector('mas-collapsible-table-row'); const cells = collapsibleRow?.shadowRoot?.querySelectorAll('sp-table-cell'); expect(cells?.[2]?.textContent.trim()).to.equal('Illustrator'); @@ -858,13 +982,15 @@ describe('MasSelectItemsTable', () => { describe('studioPath display', () => { it('should display studioPath for cards', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [ createMockCard('/path/card1', 'Card 1', { studioPath: 'merch-card: ACOM / Plans / Consumer', }), ]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; const row = el.shadowRoot.querySelector('mas-collapsible-table-row'); const cells = row?.shadowRoot?.querySelectorAll('sp-table-cell'); const pathCell = Array.from(cells || []).find((c) => c.textContent.includes('merch-card: ACOM')); @@ -872,13 +998,15 @@ describe('MasSelectItemsTable', () => { }); it('should display studioPath for collections', async () => { + const el = await fixture(html``); + await el.updateComplete; const collections = [ createMockCollection('/path/collection1', 'Collection 1', { studioPath: 'merch-card-collection: ACOM / Collection 1', }), ]; setupCollectionsInStore(collections); - const el = await fixture(html``); + await el.updateComplete; const row = el.shadowRoot.querySelector('sp-table-row'); const cells = row?.querySelectorAll('sp-table-cell'); expect(cells?.[2]?.textContent.trim()).to.equal('merch-card-collection: ACOM / Collection 1'); @@ -887,25 +1015,31 @@ describe('MasSelectItemsTable', () => { describe('data loading early returns', () => { it('should not create real subscription when allPlaceholders already has data', async () => { + const el = await fixture(html``); + await el.updateComplete; const placeholders = [createMockPlaceholder('/path/placeholder1', 'key1', 'value1')]; setupPlaceholdersInStore(placeholders); - const el = await fixture(html``); + await el.updateComplete; expect(el.dataSubscription).to.exist; expect(el.dataSubscription.unsubscribe).to.be.a('function'); }); it('should not create real subscription when allCards already has data', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1')]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; expect(el.dataSubscription).to.exist; expect(el.dataSubscription.unsubscribe).to.be.a('function'); }); it('should not create real subscription when allCollections already has data', async () => { + const el = await fixture(html``); + await el.updateComplete; const collections = [createMockCollection('/path/collection1', 'Collection 1')]; setupCollectionsInStore(collections); - const el = await fixture(html``); + await el.updateComplete; expect(el.dataSubscription).to.exist; expect(el.dataSubscription.unsubscribe).to.be.a('function'); }); @@ -933,10 +1067,11 @@ describe('MasSelectItemsTable', () => { describe('preselection edge cases', () => { it('should handle preselection when selectedInTable equals visible selections', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1')]; setupCardsInStore(cards); Store.translationProjects.selectedCards.set(['/path/card1']); - const el = await fixture(html``); await el.updateComplete; Store.translationProjects.displayCards.set([...cards]); await el.updateComplete; @@ -944,10 +1079,11 @@ describe('MasSelectItemsTable', () => { }); it('should not update selectedInTable when already equal', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1')]; setupCardsInStore(cards); Store.translationProjects.selectedCards.set(['/path/card1']); - const el = await fixture(html``); await el.updateComplete; const initialSelected = el.selectedInTable; Store.translationProjects.displayCards.set([...cards]); @@ -989,38 +1125,48 @@ describe('MasSelectItemsTable', () => { describe('display store controller', () => { it('should initialize displayCardsStoreController when not viewOnly for cards', async () => { - setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); const el = await fixture(html``); + await el.updateComplete; + setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); + await el.updateComplete; expect(el.displayCardsStoreController).to.not.be.null; }); it('should initialize displayCollectionsStoreController when not viewOnly for collections', async () => { - setupCollectionsInStore([createMockCollection('/path/collection1', 'Collection 1')]); const el = await fixture(html``); + await el.updateComplete; + setupCollectionsInStore([createMockCollection('/path/collection1', 'Collection 1')]); + await el.updateComplete; expect(el.displayCollectionsStoreController).to.not.be.null; }); it('should initialize displayPlaceholdersStoreController when not viewOnly for placeholders', async () => { - setupPlaceholdersInStore([createMockPlaceholder('/path/placeholder1', 'key1', 'value1')]); const el = await fixture(html``); + await el.updateComplete; + setupPlaceholdersInStore([createMockPlaceholder('/path/placeholder1', 'key1', 'value1')]); + await el.updateComplete; expect(el.displayPlaceholdersStoreController).to.not.be.null; }); }); describe('dataState', () => { it('should have dataState with isProcessingCards false by default', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1')]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; expect(el.dataState?.isProcessingCards).to.be.false; }); }); describe('rendering with different statuses', () => { it('should render null status gracefully', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [{ ...createMockCard('/path/card1', 'Card 1'), status: null }]; setupCardsInStore(cards); - const el = await fixture(html``); + await el.updateComplete; const statusCell = el.shadowRoot.querySelector('.status-cell'); expect(statusCell).to.be.null; }); @@ -1028,10 +1174,11 @@ describe('MasSelectItemsTable', () => { describe('fallback value handling', () => { it('should render without error when selectedCards is empty array', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1')]; setupCardsInStore(cards); Store.translationProjects.selectedCards.set([]); - const el = await fixture(html``); await el.updateComplete; expect(el.selectedInTable).to.deep.equal(new Set()); }); @@ -1039,8 +1186,10 @@ describe('MasSelectItemsTable', () => { describe('constructor default values', () => { it('should initialize with correct default values', async () => { - setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); const el = await fixture(html``); + await el.updateComplete; + setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); + await el.updateComplete; expect(el.selectedInTable).to.be.an('Set'); expect(el.dataState).to.exist; }); @@ -1048,9 +1197,10 @@ describe('MasSelectItemsTable', () => { describe('multiple selection scenarios', () => { it('should add item to selection when checkbox is clicked', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1'), createMockCard('/path/card2', 'Card 2')]; setupCardsInStore(cards); - const el = await fixture(html``); await el.updateComplete; const row = el.shadowRoot.querySelector('mas-collapsible-table-row'); const checkbox = row?.shadowRoot?.querySelector('sp-checkbox'); @@ -1062,10 +1212,11 @@ describe('MasSelectItemsTable', () => { }); it('should remove item from selection when checkbox is clicked', async () => { + const el = await fixture(html``); + await el.updateComplete; const cards = [createMockCard('/path/card1', 'Card 1'), createMockCard('/path/card2', 'Card 2')]; setupCardsInStore(cards); Store.translationProjects.selectedCards.set(['/path/card1', '/path/card2']); - const el = await fixture(html``); await el.updateComplete; const rows = el.shadowRoot.querySelectorAll('mas-collapsible-table-row'); const card2Row = Array.from(rows).find((row) => row.getAttribute('value') === '/path/card2'); @@ -1090,31 +1241,35 @@ describe('MasSelectItemsTable', () => { describe('repository getter', () => { it('should return null when mas-repository does not exist', async () => { - setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); const el = await fixture(html``); + await el.updateComplete; + setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); + await el.updateComplete; expect(el.repository).to.be.null; }); }); describe('fetchSelectedFragments early returns', () => { it('should return early when repository is not available', async () => { + const el = await fixture(html``); + await el.updateComplete; const card = createMockCard('/path/card1', 'Card 1'); setupCardsInStore([card]); Store.translationProjects.selectedCards.set(['/path/card1']); - const el = await fixture(html``); await el.updateComplete; // Since repository is null, it returns early and viewOnlyLoading stays false expect(el.viewOnlyLoading).to.be.false; }); it('should not fetch for placeholders in viewOnly mode', async () => { - const placeholder = createMockPlaceholder('/path/placeholder1', 'key1', 'value1'); - setupPlaceholdersInStore([placeholder]); - Store.translationProjects.selectedPlaceholders.set(['/path/placeholder1']); const el = await fixture( html``, ); await el.updateComplete; + const placeholder = createMockPlaceholder('/path/placeholder1', 'key1', 'value1'); + setupPlaceholdersInStore([placeholder]); + Store.translationProjects.selectedPlaceholders.set(['/path/placeholder1']); + await el.updateComplete; // Placeholders don't trigger fetchSelectedFragments expect(el.viewOnlyLoading).to.be.false; }); @@ -1122,16 +1277,20 @@ describe('MasSelectItemsTable', () => { describe('viewOnlyLoading property', () => { it('should initialize viewOnlyLoading to false', async () => { - setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); const el = await fixture(html``); + await el.updateComplete; + setupCardsInStore([createMockCard('/path/card1', 'Card 1')]); + await el.updateComplete; expect(el.viewOnlyLoading).to.be.false; }); it('should affect isLoading in viewOnly mode', async () => { + const el = await fixture(html``); + await el.updateComplete; const card = createMockCard('/path/card1', 'Card 1'); setupCardsInStore([card]); Store.translationProjects.selectedCards.set(['/path/card1']); - const el = await fixture(html``); + await el.updateComplete; expect(el.isLoading).to.equal(el.viewOnlyLoading); }); }); From fd4c6cc50724be7064f46f04220678e696ad60d8 Mon Sep 17 00:00:00 2001 From: Axel Cureno Basurto Date: Fri, 3 Apr 2026 16:44:01 -0700 Subject: [PATCH 09/38] MWPW-191570: Add skeleton shimmer to translations and placeholders tables Replace sp-progress-circle spinners with skeleton shimmer rows that match table column structure for better UX and loading state visibility. Changes: - Create studio/src/common/skeleton-styles.css.js with shared shimmer animation - Add skeleton rows to translations table (4 columns, 5 rows) - Add skeleton rows to placeholders table (7 columns, 5 rows) - Skeleton cells inherit widths from table column CSS classes - Remove loadingIndicator() from placeholders component - Fixes animation duplication in mas-fragment-editor.js and merch-card-editor.js Co-Authored-By: Claude Haiku 4.5 --- studio/src/common/skeleton-styles.css.js | 34 + .../src/placeholders/mas-placeholders.css.js | 771 +++++++++--------- studio/src/placeholders/mas-placeholders.js | 58 +- studio/src/translation/mas-translation.css.js | 5 +- studio/src/translation/mas-translation.js | 33 +- 5 files changed, 480 insertions(+), 421 deletions(-) create mode 100644 studio/src/common/skeleton-styles.css.js diff --git a/studio/src/common/skeleton-styles.css.js b/studio/src/common/skeleton-styles.css.js new file mode 100644 index 000000000..dbd2827e7 --- /dev/null +++ b/studio/src/common/skeleton-styles.css.js @@ -0,0 +1,34 @@ +import { css } from 'lit'; + +export const skeletonStyles = css` + .skeleton-element { + background: linear-gradient( + 90deg, + var(--spectrum-gray-200) 25%, + var(--spectrum-gray-100) 50%, + var(--spectrum-gray-200) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: 4px; + } + + .skeleton-table-cell { + height: 18px; + width: 80%; + border-radius: 4px; + } + + .skeleton-row sp-table-cell { + padding: 16px 20px; + } + + @keyframes shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } + } +`; diff --git a/studio/src/placeholders/mas-placeholders.css.js b/studio/src/placeholders/mas-placeholders.css.js index d1299a07e..252c619d3 100644 --- a/studio/src/placeholders/mas-placeholders.css.js +++ b/studio/src/placeholders/mas-placeholders.css.js @@ -1,397 +1,388 @@ import { css } from 'lit'; +import { skeletonStyles } from '../common/skeleton-styles.css.js'; + +export default [ + skeletonStyles, + css` + .placeholders-container { + height: 100%; + min-height: 200px; + border-radius: 8px; + padding: 24px; + background-color: var(--spectrum-white); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + box-sizing: border-box; + position: relative; + } + + .placeholders-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + } + + .header-left { + display: flex; + align-items: center; + } + + .search-filters-container { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + gap: 14px; + } + + .placeholders-title { + display: flex; + flex-direction: column; + gap: 4px; + } + + .placeholders-title h2 { + margin: 0; + font-size: 14px; + font-weight: 500; + } + + .filters-container { + display: flex; + gap: 14px; + align-items: center; + } + + .placeholders-content { + flex: 1; + position: relative; + } + + .error-message { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background-color: var(--spectrum-semantic-negative-color-background); + color: var(--spectrum-semantic-negative-color-text); + border-radius: 4px; + margin-bottom: 16px; + } + + .error-message sp-icon-alert { + color: var(--spectrum-semantic-negative-color-icon); + } + + .placeholders-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + border-radius: 8px; + border: 1px solid var(--spectrum-gray-200); + table-layout: fixed; + } + + .no-placeholders-label { + text-align: center; + } + + .placeholders-table sp-table-head { + background-color: var(--spectrum-gray-100); + border-bottom: 1px solid var(--spectrum-gray-200); + } + + .placeholders-table sp-table-head-cell { + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: flex-start; + color: var(--spectrum-gray-700); + font-size: 12px; + font-weight: 700; + } + + .placeholders-table sp-table-head-cell:last-child, + .placeholders-table sp-table-cell:last-child { + max-width: 100px; + justify-content: flex-end; + } -export const styles = css` - .placeholders-container { - height: 100%; - min-height: 200px; - border-radius: 8px; - padding: 24px; - background-color: var(--spectrum-white); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - box-sizing: border-box; - position: relative; - } - - .placeholders-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 24px; - } - - .header-left { - display: flex; - align-items: center; - } - - .search-filters-container { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 16px; - gap: 14px; - } - - .placeholders-title { - display: flex; - flex-direction: column; - gap: 4px; - } - - .placeholders-title h2 { - margin: 0; - font-size: 14px; - font-weight: 500; - } - - .filters-container { - display: flex; - gap: 14px; - align-items: center; - } - - .placeholders-content { - flex: 1; - position: relative; - } - - .error-message { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 16px; - background-color: var(--spectrum-semantic-negative-color-background); - color: var(--spectrum-semantic-negative-color-text); - border-radius: 4px; - margin-bottom: 16px; - } - - .error-message sp-icon-alert { - color: var(--spectrum-semantic-negative-color-icon); - } - - sp-progress-circle { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - } - - sp-progress-circle.loading-indicator { - top: -60px; - } - - .placeholders-table { - width: 100%; - border-collapse: separate; - border-spacing: 0; - border-radius: 8px; - border: 1px solid var(--spectrum-gray-200); - table-layout: fixed; - } - - .no-placeholders-label { - text-align: center; - } - - .placeholders-table sp-table-head { - background-color: var(--spectrum-gray-100); - border-bottom: 1px solid var(--spectrum-gray-200); - } - - .placeholders-table sp-table-head-cell { - font-weight: 600; - cursor: pointer; - display: flex; - align-items: center; - justify-content: flex-start; - color: var(--spectrum-gray-700); - font-size: 12px; - font-weight: 700; - } - - .placeholders-table sp-table-head-cell:last-child, - .placeholders-table sp-table-cell:last-child { - max-width: 100px; - justify-content: flex-end; - } - - .placeholders-table sp-table-head-cell.align-right { - text-align: right; - } - - .placeholders-table sp-table-cell { - display: flex; - align-items: center; - justify-content: flex-start; - } - - .placeholders-table sp-table-cell, - .placeholders-table sp-table-checkbox-cell:not([head-cell]) { - border-block-start: var(--mod-table-border-width, var(--spectrum-table-border-width)) solid - var(--highcontrast-table-divider-color, var(--mod-table-divider-color, var(--spectrum-table-divider-color))); - border-radius: 0; - } - - .placeholders-table sp-table-cell.editing-cell { - box-sizing: border-box; - display: inline-flex; - padding: 0 30px 0 0; - } - - .placeholders-table sp-table-cell.updated-by { - overflow: hidden; - - & overlay-trigger { + .placeholders-table sp-table-head-cell.align-right { + text-align: right; + } + + .placeholders-table sp-table-cell { + display: flex; + align-items: center; + justify-content: flex-start; + } + + .placeholders-table sp-table-cell, + .placeholders-table sp-table-checkbox-cell:not([head-cell]) { + border-block-start: var(--mod-table-border-width, var(--spectrum-table-border-width)) solid + var(--highcontrast-table-divider-color, var(--mod-table-divider-color, var(--spectrum-table-divider-color))); + border-radius: 0; + } + + .placeholders-table sp-table-cell.editing-cell { + box-sizing: border-box; + display: inline-flex; + padding: 0 30px 0 0; + } + + .placeholders-table sp-table-cell.updated-by { overflow: hidden; + + & overlay-trigger { + overflow: hidden; + } + + & .cell-content { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .placeholders-table sp-table-body { + overflow: visible; + } + + .action-cell { + position: relative; + box-sizing: border-box; + } + + .action-buttons { + display: flex; + align-items: center; + gap: 8px; + justify-content: flex-end; + width: 100%; + } + + .action-button { + background: none; + border: none; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + flex: 0 0 auto; + + &:disabled { + filter: grayscale(1); + opacity: 0.6; + } + } + + .action-button:hover { + background-color: var(--spectrum-gray-200); + } + + .dropdown-menu-container { + position: relative; + } + + .dropdown-menu { + position: absolute; + right: 0; + top: 100%; + background: white; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + z-index: 100; + width: 160px; + padding: 8px 0; + display: flex; + flex-direction: column; + } + + .dropdown-item { + flex: 1; + align-items: center; + padding: 8px 16px; + cursor: pointer; + gap: 8px; + justify-self: flex-start; + display: flex; + } + + .dropdown-item:hover { + background-color: var(--spectrum-gray-100); } - & .cell-content { + .dropdown-item.disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .dropdown-item.disabled:hover { + background-color: transparent; + } + + .dropdown-item span { + flex: 1; + display: inline-flex; + } + + .status-cell { + display: flex; + align-items: center; + justify-content: flex-start; + min-width: 120px; + padding: 8px 0; + } + + .status-cell mas-fragment-status { + width: auto; + display: inline-flex; + font-weight: 500; + } + + .edit-field-container { + width: 100%; + padding: 8px; + display: flex; + } + + .edit-field-container sp-textfield { + width: 100%; + flex: 1; + } + + .edit-field-container rte-field { + width: 100%; + min-height: 80px; + margin-bottom: 8px; + } + + .rte-container { + position: relative; + display: block; + width: 100%; + min-height: 120px; + margin: 5px 0; + } + + sp-switch { + display: inline-flex; + } + + rte-field { + display: block; + min-height: 120px; + border-radius: 4px; + width: 100%; + } + + .rich-text-cell { overflow: hidden; + padding: 4px 0; + font-size: var(--spectrum-global-font-size-100); + line-height: var(--spectrum-global-font-line-height-medium); + position: relative; text-overflow: ellipsis; - white-space: nowrap; - } - } - - .placeholders-table sp-table-body { - overflow: visible; - } - - .action-cell { - position: relative; - box-sizing: border-box; - } - - .action-buttons { - display: flex; - align-items: center; - gap: 8px; - justify-content: flex-end; - width: 100%; - } - - .action-button { - background: none; - border: none; - cursor: pointer; - padding: 4px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - flex: 0 0 auto; - - &:disabled { - filter: grayscale(1); - opacity: 0.6; - } - } - - .action-button:hover { - background-color: var(--spectrum-gray-200); - } - - .dropdown-menu-container { - position: relative; - } - - .dropdown-menu { - position: absolute; - right: 0; - top: 100%; - background: white; - border-radius: 8px; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); - z-index: 100; - width: 160px; - padding: 8px 0; - display: flex; - flex-direction: column; - } - - .dropdown-item { - flex: 1; - align-items: center; - padding: 8px 16px; - cursor: pointer; - gap: 8px; - justify-self: flex-start; - display: flex; - } - - .dropdown-item:hover { - background-color: var(--spectrum-gray-100); - } - - .dropdown-item.disabled { - opacity: 0.5; - cursor: not-allowed; - } - - .dropdown-item.disabled:hover { - background-color: transparent; - } - - .dropdown-item span { - flex: 1; - display: inline-flex; - } - - .status-cell { - display: flex; - align-items: center; - justify-content: flex-start; - min-width: 120px; - padding: 8px 0; - } - - .status-cell mas-fragment-status { - width: auto; - display: inline-flex; - font-weight: 500; - } - - .edit-field-container { - width: 100%; - padding: 8px; - display: flex; - } - - .edit-field-container sp-textfield { - width: 100%; - flex: 1; - } - - .edit-field-container rte-field { - width: 100%; - min-height: 80px; - margin-bottom: 8px; - } - - .rte-container { - position: relative; - display: block; - width: 100%; - min-height: 120px; - margin: 5px 0; - } - - sp-switch { - display: inline-flex; - } - - rte-field { - display: block; - min-height: 120px; - border-radius: 4px; - width: 100%; - } - - .rich-text-cell { - overflow: hidden; - padding: 4px 0; - font-size: var(--spectrum-global-font-size-100); - line-height: var(--spectrum-global-font-line-height-medium); - position: relative; - text-overflow: ellipsis; - } - - .rich-text-cell p { - margin: 0 0 8px 0; - } - - .rich-text-cell p:last-child { - margin-bottom: 0; - } - - .rich-text-cell a { - color: var(--spectrum-blue-600); - text-decoration: none; - } - - .rich-text-cell a:hover { - text-decoration: underline; - } - - .bulk-action-container { - position: fixed; - bottom: 24px; - left: 50%; - transform: translateX(-50%); - z-index: 1000; - opacity: 0; - visibility: hidden; - transition: - opacity 0.3s ease, - visibility 0.3s ease; - border-radius: 4px; - background-color: var(--spectrum-semantic-negative-color-background); - padding: 6px; - } - - .bulk-action-container.visible { - opacity: 1; - visibility: visible; - } - - .bulk-action-container sp-action-button { - color: var(--spectrum-white); - background-color: var(--spectrum-red-800); - box-shadow: 1px 2px 6px rgba(0, 0, 0, 0.2); - } - - .approve-button sp-icon-checkmark { - color: var(--spectrum-semantic-positive-color-default, green); - } - - .reject-button sp-icon-close { - color: var(--spectrum-semantic-negative-color-default, red); - } - - /* Dialog styles */ - .dialog-content { - display: flex; - flex-direction: column; - gap: 0; - padding: var(calc(var(--swc-scale-factor) * 16px)); - width: 80vw; - max-width: 900px; - box-sizing: border-box; - } - - .form-field { - margin-bottom: var(calc(var(--swc-scale-factor) * 16px)); - display: flex; - flex-direction: column; - gap: 4px; - } - - .form-field:last-child { - margin-bottom: 0; - } - - .form-field sp-field-label { - display: block; - margin-bottom: var(calc(var(--swc-scale-factor) * 6px)); - } - - .form-field sp-picker, - .form-field sp-textfield { - width: 100%; - min-width: 0; - box-sizing: border-box; - } - - .form-field .rte-container { - width: 100%; - } - - sp-table .key { - flex: 2; - } - sp-table .value { - flex: 3; - } -`; - -export default styles; + } + + .rich-text-cell p { + margin: 0 0 8px 0; + } + + .rich-text-cell p:last-child { + margin-bottom: 0; + } + + .rich-text-cell a { + color: var(--spectrum-blue-600); + text-decoration: none; + } + + .rich-text-cell a:hover { + text-decoration: underline; + } + + .bulk-action-container { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: + opacity 0.3s ease, + visibility 0.3s ease; + border-radius: 4px; + background-color: var(--spectrum-semantic-negative-color-background); + padding: 6px; + } + + .bulk-action-container.visible { + opacity: 1; + visibility: visible; + } + + .bulk-action-container sp-action-button { + color: var(--spectrum-white); + background-color: var(--spectrum-red-800); + box-shadow: 1px 2px 6px rgba(0, 0, 0, 0.2); + } + + .approve-button sp-icon-checkmark { + color: var(--spectrum-semantic-positive-color-default, green); + } + + .reject-button sp-icon-close { + color: var(--spectrum-semantic-negative-color-default, red); + } + + /* Dialog styles */ + .dialog-content { + display: flex; + flex-direction: column; + gap: 0; + padding: var(calc(var(--swc-scale-factor) * 16px)); + width: 80vw; + max-width: 900px; + box-sizing: border-box; + } + + .form-field { + margin-bottom: var(calc(var(--swc-scale-factor) * 16px)); + display: flex; + flex-direction: column; + gap: 4px; + } + + .form-field:last-child { + margin-bottom: 0; + } + + .form-field sp-field-label { + display: block; + margin-bottom: var(calc(var(--swc-scale-factor) * 6px)); + } + + .form-field sp-picker, + .form-field sp-textfield { + width: 100%; + min-width: 0; + box-sizing: border-box; + } + + .form-field .rte-container { + width: 100%; + } + + sp-table .key { + flex: 2; + } + sp-table .value { + flex: 3; + } + `, +]; diff --git a/studio/src/placeholders/mas-placeholders.js b/studio/src/placeholders/mas-placeholders.js index f93e19655..781889f44 100644 --- a/studio/src/placeholders/mas-placeholders.js +++ b/studio/src/placeholders/mas-placeholders.js @@ -13,6 +13,17 @@ import { confirmation } from '../mas-confirm-dialog.js'; import { FragmentStore } from '../reactivity/fragment-store.js'; import { clearCaches } from '../../libs/fragment-client.js'; +const placeholdersSkeletonRow = () => + html` +
+
+
+
+
+
+ +
`; + class MasPlaceholders extends LitElement { static styles = styles; @@ -324,7 +335,7 @@ class MasPlaceholders extends LitElement { -
${this.loadingIndicator()}${this.renderTable()}
+
${this.renderTable()}
${this.showCreationModal ? html``; - } - // #region Table renderTable() { @@ -399,25 +405,27 @@ class MasPlaceholders extends LitElement { )} - ${repeat( - this.internalPlaceholders, - (placeholderStore) => placeholderStore.get().key, - (placeholderStore) => { - const placeholder = placeholderStore.get(); - return html` - - `; - }, - )} + ${this.loading && this.internalPlaceholders.length === 0 + ? Array.from({ length: 5 }, placeholdersSkeletonRow) + : repeat( + this.internalPlaceholders, + (placeholderStore) => placeholderStore.get().key, + (placeholderStore) => { + const placeholder = placeholderStore.get(); + return html` + + `; + }, + )} ${this.internalPlaceholders.length === 0 && !this.loading ? html`

No placeholders found

` : nothing} diff --git a/studio/src/translation/mas-translation.css.js b/studio/src/translation/mas-translation.css.js index 23f2146c8..ad62eec3b 100644 --- a/studio/src/translation/mas-translation.css.js +++ b/studio/src/translation/mas-translation.css.js @@ -1,8 +1,9 @@ import { css } from 'lit'; -import { loadingContainerCenteredStyles, tableHeaderBaseStyles, tableCellBaseStyles } from './translation-common-styles.css.js'; +import { tableHeaderBaseStyles, tableCellBaseStyles } from './translation-common-styles.css.js'; +import { skeletonStyles } from '../common/skeleton-styles.css.js'; export const styles = [ - loadingContainerCenteredStyles, + skeletonStyles, tableHeaderBaseStyles, tableCellBaseStyles, css` diff --git a/studio/src/translation/mas-translation.js b/studio/src/translation/mas-translation.js index 07f2c3bad..1f741bfeb 100644 --- a/studio/src/translation/mas-translation.js +++ b/studio/src/translation/mas-translation.js @@ -7,6 +7,14 @@ import ReactiveController from '../reactivity/reactive-controller.js'; import { PAGE_NAMES, TRANSLATIONS_ALLOWED_SURFACES } from '../constants.js'; import { showToast } from '../utils.js'; +const translationSkeletonRow = () => + html` +
+
+
+ +
`; + class MasTranslation extends LitElement { static styles = styles; @@ -77,10 +85,27 @@ class MasTranslation extends LitElement { } get translationsProjectsContent() { - if (Store.translationProjects?.list?.loading?.get()) { - return html`
- -
`; + const isLoading = Store.translationProjects?.list?.loading?.get(); + if (isLoading && !this.translationProjectsData.length) { + return html` + + ${[...this.columns].map( + ({ key, label, align }) => html` + + ${label} + + `, + )} + + ${Array.from({ length: 5 }, translationSkeletonRow)} + `; } if (this.translationProjectsData.length) { return html` From dfac0ea3c0c9e8b1fb88a266e6abf4b0d57cbdcd Mon Sep 17 00:00:00 2001 From: Axel Cureno Basurto Date: Fri, 3 Apr 2026 16:49:39 -0700 Subject: [PATCH 10/38] MWPW-191570: Fix placeholders skeleton disappearing prematurely Change skeleton condition from 'loading && internalPlaceholders.length === 0' to 'loading' only. The previous condition caused skeleton to disappear and briefly show "No placeholders found" before new data arrived, due to: 1. Race condition with loadPreviewPlaceholders() clearing loading state 2. internalPlaceholders retaining old data from previous locale Now: - Skeleton shows for entire loading duration regardless of data state - "No placeholders found" only appears after loading completes with no results - No flash of zero results between skeleton and data appearance Co-Authored-By: Claude Haiku 4.5 --- studio/src/placeholders/mas-placeholders.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/studio/src/placeholders/mas-placeholders.js b/studio/src/placeholders/mas-placeholders.js index 781889f44..9b2c39fe6 100644 --- a/studio/src/placeholders/mas-placeholders.js +++ b/studio/src/placeholders/mas-placeholders.js @@ -405,7 +405,7 @@ class MasPlaceholders extends LitElement { )} - ${this.loading && this.internalPlaceholders.length === 0 + ${this.loading ? Array.from({ length: 5 }, placeholdersSkeletonRow) : repeat( this.internalPlaceholders, @@ -426,7 +426,7 @@ class MasPlaceholders extends LitElement { `; }, )} - ${this.internalPlaceholders.length === 0 && !this.loading + ${!this.loading && this.internalPlaceholders.length === 0 ? html`

No placeholders found

` : nothing}
From 0d0740eec5679a544f0478466d0f4d442971c887 Mon Sep 17 00:00:00 2001 From: Axel Cureno Basurto Date: Fri, 10 Apr 2026 11:08:00 -0700 Subject: [PATCH 11/38] refactor(translation): DRY table-head template, fix re-entrancy in processCardsData Extract duplicated sp-table-head markup into a translationProjectsTableHead getter. Replace inline style with CSS class for align-right. Fix processCardsData to stash pending payloads instead of silently dropping them during concurrent invocations. Update skeleton-row test assertions to match the shimmer UI shipped in fd4c6cc50. --- studio.html | 19 ++++++- .../src/translation/mas-select-items-table.js | 5 +- studio/src/translation/mas-translation.css.js | 4 ++ studio/src/translation/mas-translation.js | 52 +++++++------------ .../translation/translation-items-loader.js | 16 ++++-- studio/test/settings/settings-store.test.js | 2 +- .../test/translation/mas-translation.test.js | 16 +++--- 7 files changed, 67 insertions(+), 47 deletions(-) diff --git a/studio.html b/studio.html index c8cdf399a..74406bafb 100644 --- a/studio.html +++ b/studio.html @@ -8,6 +8,17 @@ +