Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
b78588d
chore: add docs/ to .gitignore (per-ticket documentation not committed)
Axelcureno Apr 3, 2026
5c78d3b
fix(translation): guard sentinel on non-empty items list to prevent c…
Axelcureno Apr 3, 2026
9a71c3a
fix(translation): remove early-exit from loadAllFragments; replace ab…
Axelcureno Apr 3, 2026
2fbbc88
fix(content): delay sentinel re-observe with requestAnimationFrame af…
Axelcureno Apr 3, 2026
4664df7
test(content): improve RAF re-observe test to verify callback is defe…
Axelcureno Apr 3, 2026
97d0f74
fix(repository): cap eagerLoadAllPznPages at MAX_EAGER_PAGES to preve…
Axelcureno Apr 3, 2026
c6f4fba
test(translation): fix sentinel test to set displayCards after subscr…
Axelcureno Apr 3, 2026
9efa38a
test: fix mas-select-items-table test setup pattern
Axelcureno Apr 3, 2026
fd4c6cc
MWPW-191570: Add skeleton shimmer to translations and placeholders ta…
Axelcureno Apr 3, 2026
dfac0ea
MWPW-191570: Fix placeholders skeleton disappearing prematurely
Axelcureno Apr 3, 2026
0d0740e
refactor(translation): DRY table-head template, fix re-entrancy in pr…
Axelcureno Apr 10, 2026
2d7498b
feat(repository): auto-refill search pages when filtered count below 25
Axelcureno Apr 10, 2026
dd366d8
test(repository): cover refill-below-threshold loop and fix paginatio…
Axelcureno Apr 10, 2026
1b21157
feat(translation): improve Select items dialog UX
Axelcureno Apr 10, 2026
55d9965
fix(translation): add spacing between dialog header, tabs, and filters
Axelcureno Apr 10, 2026
5f524e1
fix: resolve all CI failures — prettier studio.html, fix 5 pre-existi…
Axelcureno Apr 10, 2026
37a8630
fix(translation): restore dialog footer buttons with headline-visibil…
Axelcureno Apr 10, 2026
84c4af5
Merge remote-tracking branch 'origin/main' into MWPW-191570
Axelcureno Apr 10, 2026
08349ce
fix(repository): cap refill rounds and guard against sentinel race
Axelcureno Apr 10, 2026
98f6d19
chore: revert studio.html worktree dev changes (out of PR scope)
Axelcureno Apr 10, 2026
f7899a4
Merge branch 'main' into MWPW-191570
mirafedas Apr 13, 2026
fd1b1c5
MWPW-191570 - address PR review feedback from yesil
Axelcureno Apr 14, 2026
5d6ee64
Merge branch 'main' into MWPW-191570
Axelcureno Apr 14, 2026
576165e
MWPW-191570 - fix stuck loading flag and hidden tab filter clobber
Axelcureno Apr 14, 2026
fc7377f
Merge branch 'main' into MWPW-191570
Axelcureno Apr 14, 2026
646386e
Revert "MWPW-191570 - fix stuck loading flag and hidden tab filter cl…
Axelcureno Apr 15, 2026
e005bc9
Merge branch 'main' into MWPW-191570
Roycethan Apr 15, 2026
a562bce
Merge branch 'main' into MWPW-191570
Axelcureno Apr 16, 2026
641dad3
MWPW-191570 - address QA feedback on translation Select items dialog
Axelcureno Apr 16, 2026
266fe83
Merge branch 'main' into MWPW-191570
Axelcureno Apr 16, 2026
8a3c81a
Merge branch 'main' into MWPW-191570
Axelcureno Apr 16, 2026
cbc44a0
MWPW-191570: restore dialog close + right-align footer CTAs
Axelcureno Apr 16, 2026
dde077b
MWPW-191570: stop filter checkbox change from clobbering tab selection
Axelcureno Apr 16, 2026
5949e88
MWPW-191570: stop filter popover opens from clearing filter state
Axelcureno Apr 16, 2026
dbbff1c
MWPW-191570: prevent duplicate close event from wiping confirmed sele…
Axelcureno Apr 16, 2026
8278a06
MWPW-191570: simplify Select items dialog after review
Axelcureno Apr 16, 2026
da54e56
MWPW-191570: add regression tests for Select items dialog fixes
Axelcureno Apr 16, 2026
ccf6e6c
MWPW-191570: make dialog search filter on every keystroke
Axelcureno Apr 17, 2026
81f1c26
nala: auto-detect worktree port for npm run nala local
Axelcureno Apr 17, 2026
0c81f94
MWPW-191570: load collections via dedicated model-filtered query
Axelcureno Apr 17, 2026
f7f17f5
MWPW-191570: simplify loadAllCollections after review
Axelcureno Apr 17, 2026
f589a4f
test(nala): update expected FR seeTerms for CCD Apps Teams card
Axelcureno Apr 17, 2026
27e194e
test(nala): bump translations list wait timeout 15s → 20s
Axelcureno Apr 17, 2026
8f2cccc
test(nala): wait for real data row or empty state, not table visibility
Axelcureno Apr 17, 2026
a065d33
MWPW-191570: make Selected-items side panel scroll internally
Axelcureno Apr 17, 2026
1ae3235
Revert "test(nala): update expected FR seeTerms for CCD Apps Teams card"
Axelcureno Apr 17, 2026
ffce0b2
Merge branch 'main' into MWPW-191570
Roycethan Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ masStudio-*.json
.cursor
.superpowers/

# Worktree documentation (per-ticket, not committed)
docs/

# web-components specific
!web-components/dist
web-components/commerce.json
Expand Down
7 changes: 2 additions & 5 deletions nala/studio/translations/translations.page.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,8 @@ export default class TranslationsPage {
}

async waitForListToLoad(timeout = 15000) {
await this.loadingIndicator.waitFor({ state: 'hidden', timeout }).catch(() => {});
await Promise.race([
this.translationTable.waitFor({ state: 'visible', timeout }),
this.emptyState.waitFor({ state: 'visible', timeout }),
]);
const dataRow = this.translationTable.locator('sp-table-row:not(.skeleton-row)').first();
await dataRow.or(this.emptyState).waitFor({ state: 'visible', timeout });
}

async getSentOnColumnTexts() {
Expand Down
30 changes: 29 additions & 1 deletion nala/utils/nala.run.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
#!/usr/bin/env node

import { spawn, spawnSync } from 'child_process';
import { readFileSync, existsSync } from 'fs';
import { dirname, basename, join } from 'path';

function detectWorktreePort() {
// Walk up from cwd looking for a parent dir named "worktrees"; if the cwd is
// <…>/worktrees/<BRANCH>/…, read <…>/worktrees/.ports for BRANCH=<offset>.
let dir = process.cwd();
while (dir !== dirname(dir)) {
const parent = dirname(dir);
if (basename(parent) === 'worktrees') {
const branch = basename(dir);
const portsFile = join(parent, '.ports');
if (!existsSync(portsFile)) return null;
const line = readFileSync(portsFile, 'utf-8')
.split('\n')
.find((l) => l.startsWith(`${branch}=`));
const offset = line ? parseInt(line.split('=')[1], 10) : NaN;
return Number.isFinite(offset) ? 3000 + offset : null;
}
dir = parent;
}
return null;
}

function displayHelp() {
console.log(`
Expand Down Expand Up @@ -99,7 +122,12 @@ function getLocalTestLiveUrl(env, masiourl, milolibs, repo = 'mas', owner = 'ado
process.env.MILO_LIBS = `?milolibs=${milolibs}`;
}

if (env === 'local') return 'http://localhost:3000';
if (env === 'local') {
// When run from a worktree checkout, auto-target that worktree's AEM port
// so `npm run nala local` works without needing to set LOCAL_TEST_LIVE_URL.
const worktreePort = detectWorktreePort();
return `http://localhost:${worktreePort ?? 3000}`;
}
if (env === 'libs') return 'http://localhost:6456';
return `https://${env}--${repo}--${owner}.aem.live`;
}
Expand Down
34 changes: 34 additions & 0 deletions studio/src/common/skeleton-styles.css.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
`;
2 changes: 1 addition & 1 deletion studio/src/mas-content.js
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,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));
}
}

Expand Down
118 changes: 117 additions & 1 deletion studio/src/mas-repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
} from './constants.js';
import { fragmentHasPersonalizationTag, isPznCountryTagId, PZN_TAG_ID_PREFIX } from './common/utils/personalization-utils.js';
import { Placeholder } from './aem/placeholder.js';
import { getFragmentName } from './translation/translation-utils.js';
import generateFragmentStore from './reactivity/source-fragment-store.js';
import { getDefaultLocaleCode } from '../../io/www/src/fragment/locales.js';
import { getDictionary } from '../libs/fragment-client.js';
Expand Down Expand Up @@ -102,6 +103,7 @@ export class MasRepository extends LitElement {
placeholders: null,
promotions: null,
translations: null,
collections: null,
};
this.dictionaryCache = new Map();
this.inflightDictionaryRequest = null;
Expand Down Expand Up @@ -359,6 +361,7 @@ export class MasRepository extends LitElement {
localSearch.status = STATUS_PUBLISHED;
}

let refilling = false;
try {
if (this.#abortControllers.search) this.#abortControllers.search.abort();
this.#searchCursor = null;
Expand Down Expand Up @@ -460,6 +463,10 @@ export class MasRepository extends LitElement {
this.#eagerLoadAllPznPages(cursorState, searchController);
} else {
this.#abortControllers.search = null;
if (cursorState) {
refilling = true;
this.#refillBelowThreshold(cursorState, searchController);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: loading flag race#refillBelowThreshold is fire-and-forget here. It sets loading=true synchronously (before its first await), but then searchFragments falls through to Store.fragments.list.loading.set(false) a few lines below. That overwrites the refill's loading guard.

At that point hasMore=true + loading=false — so the sentinel can fire loadNextPage() while #refillBelowThreshold is still iterating the same cursor.

The PZN path avoids this by setting hasMore=false before the fire-and-forget #eagerLoadAllPznPages call. The refill path doesn't have that guard.

Suggested fix — skip the outer loading.set(false) when refill is about to run and let refill's finally block own the loading lifecycle:

let refilling = false;
// …
if (cursorState) {
    refilling = true;
    this.#refillBelowThreshold(cursorState, searchController);
}
// after try block:
if (!refilling) Store.fragments.list.loading.set(false);

(The existing test passes because it exercises #refillBelowThreshold in isolation, without the outer searchFragments clobbering the flag.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in fd1b1c5. Added let refilling = false before the try block, set it when #refillBelowThreshold is fired, and guarded the trailing Store.fragments.list.loading.set(false) with if (!refilling).

Follow-up in 576165e: a challenge-agent pass flagged a remaining window — if a second searchFragments aborts the first after refilling=true but before the scheduled refill took ownership of the loading lifecycle, the catch path left loading pinned. The catch now also clears loading on AbortError when refilling was set.

}
}
}

Expand All @@ -477,10 +484,35 @@ export class MasRepository extends LitElement {
return;
}

Store.fragments.list.loading.set(false);
if (!refilling) Store.fragments.list.loading.set(false);
}

static MIN_PAGE_SIZE = 10;
/**
* Soft cap on the eager personalization-page loop in #eagerLoadAllPznPages.
* Once the cap is hit, hasMore is set to true and the rest is delivered on
* demand by loadNextPage() (one page per scroll-trigger). Pagination is not
* lost — it simply stops being eager-prefetched after this many pages.
*/
static MAX_EAGER_PZN_PAGES = 20;
/**
* Visible-row threshold for the post-filter refill loop in #refillBelowThreshold.
* When a cursor page, after #filterStoresByPersonalizationEnabled has been
* applied, has fewer than this many visible items AND the cursor is not
* exhausted, the loop fetches additional cursor pages until the threshold is
* met or the cursor runs out. Prevents the narrow-filter UX where a user sees
* "1 result" when the underlying catalog has many more matches spread across
* later cursor pages.
*/
static MIN_FILTERED_PAGE_RESULTS = 25;
/**
* Soft cap on the number of #fillPage rounds the refill loop will run before
* giving up. Mirrors MAX_EAGER_PZN_PAGES to keep the non-personalization
* refill path bounded when a filter matches very little in the catalog.
* When the cap is hit, hasMore stays true so loadNextPage can continue
* fetching on scroll.
*/
static MAX_REFILL_ROUNDS = 20;

async #fillPage(cursor, variants, surface, fragmentStores, limit = MasRepository.MIN_PAGE_SIZE, signal) {
let added = 0;
Expand All @@ -501,8 +533,50 @@ 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_PZN_PAGES) {
Store.fragments.list.hasMore.set(true);
break;
}
const done = await this.#fillPage(
cursor,
variants,
surface,
fragmentStores,
undefined,
searchController.signal,
);
pagesLoaded++;
if (this.#searchCursor !== cursorSnapshot) return;
Store.fragments.list.data.set([...this.#filterStoresByPersonalizationEnabled(fragmentStores)]);
if (done) {
this.#searchCursor = null;
return;
}
}
} catch (error) {
if (error.name === 'AbortError') return;
if (this.#searchCursor === cursorSnapshot) {
Store.fragments.list.hasMore.set(true);
}
}
}

async #refillBelowThreshold(cursorSnapshot, searchController) {
const { cursor, variants, surface, fragmentStores } = cursorSnapshot;
let rounds = 0;
Store.fragments.list.loading.set(true);
try {
while (this.#searchCursor === cursorSnapshot) {
const filtered = this.#filterStoresByPersonalizationEnabled(fragmentStores);
if (filtered.length >= MasRepository.MIN_FILTERED_PAGE_RESULTS) return;
if (rounds >= MasRepository.MAX_REFILL_ROUNDS) {
Store.fragments.list.hasMore.set(true);
return;
}
const beforeCount = fragmentStores.length;
const done = await this.#fillPage(
cursor,
variants,
Expand All @@ -511,10 +585,16 @@ export class MasRepository extends LitElement {
undefined,
searchController.signal,
);
rounds++;
if (this.#searchCursor !== cursorSnapshot) return;
if (fragmentStores.length === beforeCount && !done) {
Store.fragments.list.hasMore.set(true);
return;
}
Store.fragments.list.data.set([...this.#filterStoresByPersonalizationEnabled(fragmentStores)]);
if (done) {
this.#searchCursor = null;
Store.fragments.list.hasMore.set(false);
return;
}
}
Expand All @@ -523,6 +603,10 @@ export class MasRepository extends LitElement {
if (this.#searchCursor === cursorSnapshot) {
Store.fragments.list.hasMore.set(true);
}
} finally {
if (this.#searchCursor === cursorSnapshot || this.#searchCursor === null) {
Store.fragments.list.loading.set(false);
}
}
}

Expand Down Expand Up @@ -644,6 +728,38 @@ export class MasRepository extends LitElement {
}
}

async loadAllCollections() {
if (!this.search.value.path) return;
try {
if (this.#abortControllers.collections) this.#abortControllers.collections.abort();
this.#abortControllers.collections = new AbortController();

const damPath = getDamPath(this.search.value.path);
const locale = this.filters.value.locale;
const searchOptions = {
path: `${damPath}/${locale}`,
modelIds: [TAG_MODEL_ID_MAPPING['mas:studio/content-type/merch-card-collection']],
sort: [{ on: 'modifiedOrCreated', order: 'DESC' }],
};

const fragments = await this.searchFragmentList(searchOptions, 50, this.#abortControllers.collections);
const collections = [];
const collectionsByPath = new Map();
for (const fragment of fragments) {
const collection = { ...fragment, studioPath: getFragmentName(fragment) };
collections.push(collection);
collectionsByPath.set(fragment.path, collection);
}

Store.translationProjects.allCollections.set(collections);
Store.translationProjects.displayCollections.set(collections);
Store.translationProjects.collectionsByPaths.set(collectionsByPath);
} catch (error) {
if (error.name === 'AbortError') return;
this.processError(error, 'Could not load collections.');
}
}

async loadPreviewPlaceholders() {
if (!this.search.value.path) return;

Expand Down
Loading
Loading