diff --git a/nala/studio/translations/translations.page.js b/nala/studio/translations/translations.page.js index 2e2edd5d4..8312d3636 100644 --- a/nala/studio/translations/translations.page.js +++ b/nala/studio/translations/translations.page.js @@ -5,7 +5,7 @@ export default class TranslationsPage { const translationHost = page.locator('mas-translation'); this.loadingIndicator = translationHost.locator('.loading-container sp-progress-circle'); - this.translationTable = translationHost.locator('sp-table.translation-table'); + this.translationTable = translationHost.locator('sp-table.item-table'); this.tableHeaders = { translationProject: translationHost.locator('sp-table-head-cell:has-text("Translation Project")'), lastUpdatedBy: translationHost.locator('sp-table-head-cell:has-text("Last updated by")'), @@ -14,9 +14,9 @@ export default class TranslationsPage { }; this.emptyState = translationHost.locator('.translation-empty-state'); - this.tableRows = translationHost.locator('sp-table.translation-table sp-table-row'); + this.tableRows = translationHost.locator('sp-table.item-table sp-table-row'); - this.firstRow = translationHost.locator('sp-table.translation-table sp-table-row').first(); + this.firstRow = translationHost.locator('sp-table.item-table sp-table-row').first(); this.firstRowTitleCell = this.firstRow.locator('sp-table-cell').nth(0); this.firstRowActionMenu = this.firstRow.locator('sp-action-menu'); diff --git a/studio/src/translation/mas-items-selector.css.js b/studio/src/common/components/mas-items-selector.css.js similarity index 97% rename from studio/src/translation/mas-items-selector.css.js rename to studio/src/common/components/mas-items-selector.css.js index ed86c5795..c288828e7 100644 --- a/studio/src/translation/mas-items-selector.css.js +++ b/studio/src/common/components/mas-items-selector.css.js @@ -1,5 +1,5 @@ import { css } from 'lit'; -import { ghostButtonStyles } from './translation-common-styles.css.js'; +import { ghostButtonStyles } from '../styles/table-styles.css.js'; export const styles = [ ghostButtonStyles, diff --git a/studio/src/translation/mas-items-selector.js b/studio/src/common/components/mas-items-selector.js similarity index 77% rename from studio/src/translation/mas-items-selector.js rename to studio/src/common/components/mas-items-selector.js index 38ecd89a3..ed6ca9185 100644 --- a/studio/src/translation/mas-items-selector.js +++ b/studio/src/common/components/mas-items-selector.js @@ -1,14 +1,14 @@ import { LitElement, html, nothing } from 'lit'; import { repeat } from 'lit/directives/repeat.js'; -import Store from '../store.js'; -import ReactiveController from '../reactivity/reactive-controller.js'; -import { TABLE_TYPE } from '../constants.js'; -import { toggleSidebarIcon } from '../icons.js'; +import ReactiveController from '../../reactivity/reactive-controller.js'; +import { getItemsSelectionStore } from '../items-selection-store.js'; +import { TABLE_TYPE } from '../../constants.js'; +import { toggleSidebarIcon } from '../../icons.js'; import './mas-select-items-table.js'; import './mas-selected-items.js'; import './mas-search-and-filters.js'; import { styles } from './mas-items-selector.css.js'; -import { debounce } from '../utils.js'; +import { debounce } from '../../utils.js'; export const TABS = [ { value: TABLE_TYPE.CARDS, label: 'Fragments' }, @@ -23,6 +23,9 @@ class MasItemsSelector extends LitElement { viewOnly: { type: Boolean, state: true }, searchQuery: { type: String, state: true }, selectedTab: { type: String, state: true }, + /** @type {(fragmentData: object) => string} */ + getDisplayName: { type: Function }, + renderFragmentStatusCell: { type: Function }, }; constructor() { @@ -30,33 +33,33 @@ class MasItemsSelector extends LitElement { this.viewOnly = false; this.searchQuery = ''; this.selectedTab = TABLE_TYPE.CARDS; + this.getDisplayName = (fragmentData) => fragmentData?.path ?? ''; + this.renderFragmentStatusCell = () => nothing; } connectedCallback() { super.connectedCallback(); + const s = getItemsSelectionStore(); this.storeController = new ReactiveController(this, [ - Store.translationProjects.inEdit, - Store.translationProjects.showSelected, - Store.translationProjects.selectedCards, - Store.translationProjects.selectedCollections, - Store.translationProjects.selectedPlaceholders, + s.inEdit, + s.showSelected, + s.selectedCards, + s.selectedCollections, + s.selectedPlaceholders, ]); } get showSelected() { - return Store.translationProjects.showSelected.value; + return getItemsSelectionStore().showSelected.value; } get selectedCount() { - return ( - Store.translationProjects.selectedCards.value.length + - Store.translationProjects.selectedPlaceholders.value.length + - Store.translationProjects.selectedCollections.value.length - ); + const s = getItemsSelectionStore(); + return [...s.selectedCards.value, ...s.selectedPlaceholders.value, ...s.selectedCollections.value].length; } #toggleShowSelected() { - Store.translationProjects.showSelected.set(!this.showSelected); + getItemsSelectionStore().showSelected.set(!this.showSelected); } #setSearchQuery = debounce((value) => { @@ -79,7 +82,7 @@ class MasItemsSelector extends LitElement { #getTabLabel(tab) { if (this.viewOnly) { const valueUppercase = tab.value.charAt(0).toUpperCase() + tab.value.slice(1); - return `${tab.label} (${Store.translationProjects[`selected${valueUppercase}`].value.length})`; + return `${tab.label} (${getItemsSelectionStore()[`selected${valueUppercase}`].value.length})`; } return tab.label; } @@ -135,9 +138,13 @@ class MasItemsSelector extends LitElement { - ${this.viewOnly ? nothing : html``} + ${this.viewOnly + ? nothing + : html``} event.stopPropagation()}> diff --git a/studio/src/translation/mas-search-and-filters.css.js b/studio/src/common/components/mas-search-and-filters.css.js similarity index 100% rename from studio/src/translation/mas-search-and-filters.css.js rename to studio/src/common/components/mas-search-and-filters.css.js diff --git a/studio/src/translation/mas-search-and-filters.js b/studio/src/common/components/mas-search-and-filters.js similarity index 91% rename from studio/src/translation/mas-search-and-filters.js rename to studio/src/common/components/mas-search-and-filters.js index b330dc524..f30072b99 100644 --- a/studio/src/translation/mas-search-and-filters.js +++ b/studio/src/common/components/mas-search-and-filters.js @@ -1,10 +1,11 @@ import { LitElement, html, nothing } from 'lit'; import { repeat } from 'lit/directives/repeat.js'; -import { VARIANTS } from '../editors/variant-picker.js'; +import { VARIANTS } from '../../editors/variant-picker.js'; import { styles } from './mas-search-and-filters.css.js'; -import Store from '../store.js'; -import { FILTER_TYPE, TABLE_TYPE } from '../constants.js'; -import ReactiveController from '../reactivity/reactive-controller.js'; +import Store from '../../store.js'; +import { getItemsSelectionStore } from '../items-selection-store.js'; +import { FILTER_TYPE, TABLE_TYPE } from '../../constants.js'; +import ReactiveController from '../../reactivity/reactive-controller.js'; class MasSearchAndFilters extends LitElement { static styles = styles; @@ -40,8 +41,8 @@ class MasSearchAndFilters extends LitElement { connectedCallback() { super.connectedCallback(); this.commonDataController = new ReactiveController(this, [ - Store.translationProjects[`all${this.typeUppercased}`], - Store.translationProjects[`display${this.typeUppercased}`], + getItemsSelectionStore()[`all${this.typeUppercased}`], + getItemsSelectionStore()[`display${this.typeUppercased}`], Store[this.type === TABLE_TYPE.PLACEHOLDERS ? 'placeholders' : 'fragments'].list.loading, ]); const dataCallback = () => { @@ -51,16 +52,16 @@ class MasSearchAndFilters extends LitElement { this.#applyFilters(); this.requestUpdate(); }; - Store.translationProjects[`all${this.typeUppercased}`].subscribe(dataCallback); + getItemsSelectionStore()[`all${this.typeUppercased}`].subscribe(dataCallback); this.dataSubscription = { - unsubscribe: () => Store.translationProjects[`all${this.typeUppercased}`].unsubscribe(dataCallback), + unsubscribe: () => getItemsSelectionStore()[`all${this.typeUppercased}`].unsubscribe(dataCallback), }; } disconnectedCallback() { super.disconnectedCallback(); - Store.translationProjects[`display${this.typeUppercased}`].set( - Store.translationProjects[`all${this.typeUppercased}`].value, + getItemsSelectionStore()[`display${this.typeUppercased}`].set( + getItemsSelectionStore()[`all${this.typeUppercased}`].value, ); this.dataSubscription?.unsubscribe(); } @@ -105,7 +106,7 @@ class MasSearchAndFilters extends LitElement { const marketSegments = new Map(); const customerSegments = new Map(); const products = new Map(); - for (const fragment of Store.translationProjects[`all${this.typeUppercased}`].value) { + for (const fragment of getItemsSelectionStore()[`all${this.typeUppercased}`].value) { if (!fragment.tags) continue; for (const tag of fragment.tags) { @@ -271,7 +272,7 @@ class MasSearchAndFilters extends LitElement { } #applyFilters() { - const source = Store.translationProjects[`all${this.typeUppercased}`].value || []; + const source = getItemsSelectionStore()[`all${this.typeUppercased}`].value || []; const query = this.searchQuery?.toLowerCase(); const hasTemplate = this.templateFilter?.length > 0; const hasMarket = this.marketSegmentFilter?.length > 0; @@ -319,15 +320,15 @@ class MasSearchAndFilters extends LitElement { if (this.type === TABLE_TYPE.CARDS) { result.sort((a, b) => (b.groupedVariations?.length > 0 ? 1 : 0) - (a.groupedVariations?.length > 0 ? 1 : 0)); } - Store.translationProjects[`display${this.typeUppercased}`].set(result); + getItemsSelectionStore()[`display${this.typeUppercased}`].set(result); } renderCount() { return html`
${this.isLoading ? html`` - : html`${Store.translationProjects[`display${this.typeUppercased}`].value.length} - result${Store.translationProjects[`display${this.typeUppercased}`].value.length !== 1 ? 's' : ''}`} + : html`${getItemsSelectionStore()[`display${this.typeUppercased}`].value.length} + result${getItemsSelectionStore()[`display${this.typeUppercased}`].value.length !== 1 ? 's' : ''}`}
`; } diff --git a/studio/src/translation/mas-select-items-table.css.js b/studio/src/common/components/mas-select-items-table.css.js similarity index 98% rename from studio/src/translation/mas-select-items-table.css.js rename to studio/src/common/components/mas-select-items-table.css.js index 37778408c..9a36fc6b6 100644 --- a/studio/src/translation/mas-select-items-table.css.js +++ b/studio/src/common/components/mas-select-items-table.css.js @@ -5,7 +5,7 @@ import { tableColumnIconStyles, tableSelectedRowStyles, loadingContainerFlexStyles, -} from './translation-common-styles.css.js'; +} from '../styles/table-styles.css.js'; export const styles = [ tableHeaderBaseStyles, diff --git a/studio/src/translation/mas-select-items-table.js b/studio/src/common/components/mas-select-items-table.js similarity index 83% rename from studio/src/translation/mas-select-items-table.js rename to studio/src/common/components/mas-select-items-table.js index d377e6797..d56596850 100644 --- a/studio/src/translation/mas-select-items-table.js +++ b/studio/src/common/components/mas-select-items-table.js @@ -1,18 +1,17 @@ import { LitElement, html, nothing } from 'lit'; import { repeat } from 'lit/directives/repeat.js'; import { styles } from './mas-select-items-table.css.js'; -import Store from '../store.js'; -import StoreController from '../reactivity/store-controller.js'; -import { TABLE_TYPE } from '../constants.js'; -import { renderFragmentStatusCell } from './translation-utils.js'; -import ReactiveController from '../reactivity/reactive-controller.js'; -import { MasCollapsibleTableRow } from './mas-collapsible-table-row.js'; +import Store from '../../store.js'; +import { getItemsSelectionStore } from '../items-selection-store.js'; +import StoreController from '../../reactivity/store-controller.js'; +import { TABLE_TYPE } from '../../constants.js'; +import ReactiveController from '../../reactivity/reactive-controller.js'; import { loadAllPlaceholders, loadAllFragments, loadSelectedPlaceholders, loadSelectedFragments, -} from './translation-items-loader.js'; +} from '../utils/items-loader.js'; class MasSelectItemsTable extends LitElement { static styles = styles; @@ -22,6 +21,8 @@ class MasSelectItemsTable extends LitElement { viewOnly: { type: Boolean }, viewOnlyLoading: { type: Boolean, state: true }, viewOnlyFragments: { type: Array, state: true }, + getDisplayName: { type: Function }, + renderFragmentStatusCell: { type: Function }, }; hasMore = new StoreController(this, Store.fragments.list.hasMore); @@ -43,6 +44,8 @@ class MasSelectItemsTable extends LitElement { this.selectedPlaceholdersStoreController = null; this.observedSentinel = null; this.wasLoading = false; + this.getDisplayName = (fragmentData) => fragmentData?.path ?? ''; + this.renderFragmentStatusCell = () => nothing; } connectedCallback() { @@ -52,9 +55,9 @@ class MasSelectItemsTable extends LitElement { this.dataState.pendingCards = null; if (this.viewOnly) { if (this.type === TABLE_TYPE.PLACEHOLDERS) { - this.viewOnlyLoading = !!Store.translationProjects.selectedPlaceholders.value?.length; + this.viewOnlyLoading = !!getItemsSelectionStore().selectedPlaceholders.value?.length; this.dataSubscription = loadSelectedPlaceholders( - Store.translationProjects.selectedPlaceholders.value, + getItemsSelectionStore().selectedPlaceholders.value, (items) => { this.viewOnlyFragments = items; if (!Store.placeholders.list.loading.get()) { @@ -63,10 +66,10 @@ class MasSelectItemsTable extends LitElement { }, ); } else { - this.viewOnlyLoading = !!Store.translationProjects[`selected${this.typeUppercased}`].value?.length; + this.viewOnlyLoading = !!getItemsSelectionStore()[`selected${this.typeUppercased}`].value?.length; this.processAbortController = new AbortController(); loadSelectedFragments( - Store.translationProjects[`selected${this.typeUppercased}`].value, + getItemsSelectionStore()[`selected${this.typeUppercased}`].value, this.type, this.repository, { @@ -74,6 +77,7 @@ class MasSelectItemsTable extends LitElement { onItems: (items) => { this.viewOnlyFragments = items; }, + getDisplayName: this.getDisplayName, }, ).finally(() => { this.viewOnlyLoading = false; @@ -83,16 +87,18 @@ class MasSelectItemsTable extends LitElement { if (this.type === TABLE_TYPE.PLACEHOLDERS) { this.dataSubscription = loadAllPlaceholders(); } else { - this.dataSubscription = loadAllFragments(this.type, this.repository, this.dataState); + this.dataSubscription = loadAllFragments(this.type, this.repository, this.dataState, { + getDisplayName: this.getDisplayName, + }); } } this[`selected${this.typeUppercased}StoreController`] = new ReactiveController(this, [ Store.fragments.list.loading, Store.placeholders.list.loading, - Store.translationProjects[`selected${this.typeUppercased}`], + getItemsSelectionStore()[`selected${this.typeUppercased}`], ]); this[`display${this.typeUppercased}StoreController`] = new ReactiveController(this, [ - Store.translationProjects[`display${this.typeUppercased}`], + getItemsSelectionStore()[`display${this.typeUppercased}`], ]); } @@ -169,19 +175,19 @@ class MasSelectItemsTable extends LitElement { if (this.viewOnly) { return this.viewOnlyFragments; } - return Store.translationProjects[`display${this.typeUppercased}`].value; + return getItemsSelectionStore()[`display${this.typeUppercased}`].value; } get selectedInTable() { - return new Set(Store.translationProjects[`selected${this.typeUppercased}`].value); + return new Set(getItemsSelectionStore()[`selected${this.typeUppercased}`].value); } get tableColumns() { const TABLE_COLUMNS = { cards: { selectable: [ - { label: '', key: 'chevron', class: 'translation-table-icon-cell translation-table-icon-cell--chevron' }, - { label: '', key: 'checkbox', class: 'translation-table-icon-cell translation-table-icon-cell--checkbox' }, + { label: '', key: 'chevron', class: 'table-icon-cell table-icon-cell--chevron' }, + { label: '', key: 'checkbox', class: 'table-icon-cell table-icon-cell--checkbox' }, { label: 'Offer', key: 'offer', sortable: true }, { label: 'Fragment title', key: 'fragmentTitle' }, { label: 'Offer ID', key: 'offerId' }, @@ -189,7 +195,7 @@ class MasSelectItemsTable extends LitElement { { label: 'Status', key: 'status' }, ], viewOnly: [ - { label: '', key: 'chevron', class: 'translation-table-icon-cell translation-table-icon-cell--chevron' }, + { label: '', key: 'chevron', class: 'table-icon-cell table-icon-cell--chevron' }, { label: 'Offer', key: 'offer', sortable: true }, { label: 'Fragment title', key: 'fragmentTitle' }, { label: 'Offer ID', key: 'offerId' }, @@ -200,7 +206,7 @@ class MasSelectItemsTable extends LitElement { }, collections: { selectable: [ - { label: '', key: 'checkbox', class: 'translation-table-icon-cell translation-table-icon-cell--checkbox' }, + { label: '', key: 'checkbox', class: 'table-icon-cell table-icon-cell--checkbox' }, { label: 'Collection title', key: 'collectionTitle' }, { label: 'Path', key: 'path' }, { label: 'Status', key: 'status' }, @@ -213,7 +219,7 @@ class MasSelectItemsTable extends LitElement { }, placeholders: { selectable: [ - { label: '', key: 'checkbox', class: 'translation-table-icon-cell translation-table-icon-cell--checkbox' }, + { label: '', key: 'checkbox', class: 'table-icon-cell table-icon-cell--checkbox' }, { label: 'Key', key: 'key' }, { label: 'Value', key: 'value' }, { label: 'Status', key: 'status' }, @@ -233,7 +239,7 @@ class MasSelectItemsTable extends LitElement { const newSelected = this.selectedInTable.has(path) ? [...this.selectedInTable].filter((p) => p !== path) : [...this.selectedInTable, path]; - Store.translationProjects[`selected${this.typeUppercased}`].set(newSelected); + getItemsSelectionStore()[`selected${this.typeUppercased}`].set(newSelected); } #renderTableBody() { @@ -246,6 +252,8 @@ class MasSelectItemsTable extends LitElement { html``, )}`; case TABLE_TYPE.COLLECTIONS: @@ -260,7 +268,7 @@ class MasSelectItemsTable extends LitElement { > ${!this.viewOnly ? html` - + ${fragment.title || '-'} ${fragment.studioPath} - ${renderFragmentStatusCell(fragment.status)} + ${this.renderFragmentStatusCell(fragment.status)} `, )}`; case TABLE_TYPE.PLACEHOLDERS: @@ -285,7 +293,7 @@ class MasSelectItemsTable extends LitElement { aria-selected=${!this.viewOnly && this.selectedInTable.has(fragment.path) ? 'true' : 'false'} > ${!this.viewOnly - ? html` + ? html` ${fragment.value?.length > 100 ? `${fragment.value.slice(0, 100)}...` : fragment.value || '-'} - ${renderFragmentStatusCell(fragment.status)} + ${this.renderFragmentStatusCell(fragment.status)} `, )}`; @@ -321,7 +329,7 @@ class MasSelectItemsTable extends LitElement { ` : html`${this.itemsToDisplay.length > 0 - ? html` + ? html` ${repeat( this.tableColumns, diff --git a/studio/src/translation/mas-selected-items.css.js b/studio/src/common/components/mas-selected-items.css.js similarity index 95% rename from studio/src/translation/mas-selected-items.css.js rename to studio/src/common/components/mas-selected-items.css.js index 2fb70e7f8..2b02b4fec 100644 --- a/studio/src/translation/mas-selected-items.css.js +++ b/studio/src/common/components/mas-selected-items.css.js @@ -1,5 +1,5 @@ import { css } from 'lit'; -import { ghostButtonStyles } from './translation-common-styles.css.js'; +import { ghostButtonStyles } from '../styles/table-styles.css.js'; export const styles = [ ghostButtonStyles, diff --git a/studio/src/translation/mas-selected-items.js b/studio/src/common/components/mas-selected-items.js similarity index 65% rename from studio/src/translation/mas-selected-items.js rename to studio/src/common/components/mas-selected-items.js index b745d67b7..8894c609d 100644 --- a/studio/src/translation/mas-selected-items.js +++ b/studio/src/common/components/mas-selected-items.js @@ -1,31 +1,36 @@ import { LitElement, html, nothing } from 'lit'; import { repeat } from 'lit/directives/repeat.js'; import { styles } from './mas-selected-items.css.js'; -import Store from '../store.js'; -import ReactiveController from '../reactivity/reactive-controller.js'; -import { Fragment } from '../aem/fragment.js'; -import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../constants.js'; -import { fetchUnresolvedVariations } from './translation-items-loader.js'; +import Store from '../../store.js'; +import { getItemsSelectionStore } from '../items-selection-store.js'; +import ReactiveController from '../../reactivity/reactive-controller.js'; +import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../../constants.js'; +import { getItemTypeLabel } from '../utils/render-utils.js'; +import { fetchUnresolvedVariations } from '../utils/items-loader.js'; class MasSelectedItems extends LitElement { static styles = styles; + static properties = { + getDisplayName: { type: Function }, + }; #lastFetchedSelectedCardsKey = null; constructor() { super(); + this.getDisplayName = (fragmentData) => fragmentData?.path ?? ''; this.storeController = new ReactiveController(this, [ - Store.translationProjects.showSelected, - Store.translationProjects.selectedCards, - Store.translationProjects.selectedCollections, - Store.translationProjects.selectedPlaceholders, - Store.translationProjects.groupedVariationsByParent, + getItemsSelectionStore().showSelected, + getItemsSelectionStore().selectedCards, + getItemsSelectionStore().selectedCollections, + getItemsSelectionStore().selectedPlaceholders, + getItemsSelectionStore().groupedVariationsByParent, Store.fragments.list.loading, Store.placeholders.list.loading, ]); this.fetchController = new ReactiveController( this, - [Store.translationProjects.showSelected, Store.translationProjects.selectedCards], + [getItemsSelectionStore().showSelected, getItemsSelectionStore().selectedCards], this.maybeFetchUnresolvedVariations.bind(this), ); } @@ -35,16 +40,17 @@ class MasSelectedItems extends LitElement { maybeFetchUnresolvedVariations() { if (!this.showSelected || !this.repository) return; - const selectedCards = Store.translationProjects.selectedCards.value || []; + const selectedCards = getItemsSelectionStore().selectedCards.value || []; const selectedCardsKey = [...selectedCards].sort().join('\0'); if (selectedCardsKey === this.#lastFetchedSelectedCardsKey) return; this.#lastFetchedSelectedCardsKey = selectedCardsKey; fetchUnresolvedVariations( selectedCards, - Store.translationProjects.cardsByPaths.value, - Store.translationProjects.groupedVariationsByParent.value, + getItemsSelectionStore().cardsByPaths.value, + getItemsSelectionStore().groupedVariationsByParent.value, this.repository, + { getDisplayName: this.getDisplayName }, ); } @@ -54,28 +60,28 @@ class MasSelectedItems extends LitElement { } get selectedItems() { - const cards = Store.translationProjects.selectedCards.value - ?.map( + const cards = getItemsSelectionStore() + .selectedCards.value?.map( (path) => - Store.translationProjects.cardsByPaths.value?.get(path) ?? - Store.translationProjects.groupedVariationsData.value?.get(path), + getItemsSelectionStore().cardsByPaths.value?.get(path) ?? + getItemsSelectionStore().groupedVariationsData.value?.get(path), ) .filter(Boolean); - const collections = Store.translationProjects.selectedCollections.value - ?.map((path) => { - return Store.translationProjects.collectionsByPaths.value.get(path); + const collections = getItemsSelectionStore() + .selectedCollections.value?.map((path) => { + return getItemsSelectionStore().collectionsByPaths.value.get(path); }) .filter(Boolean); - const placeholders = Store.translationProjects.selectedPlaceholders.value - ?.map((path) => { - return Store.translationProjects.placeholdersByPaths.value.get(path); + const placeholders = getItemsSelectionStore() + .selectedPlaceholders.value?.map((path) => { + return getItemsSelectionStore().placeholdersByPaths.value.get(path); }) .filter(Boolean); return [...cards, ...collections, ...placeholders]; } get showSelected() { - return Store.translationProjects.showSelected.value; + return getItemsSelectionStore().showSelected.value; } get isLoadingItems() { @@ -83,15 +89,7 @@ class MasSelectedItems extends LitElement { } getType(item) { - if (!item) return 'Unknown type'; - switch (item.model.path) { - case CARD_MODEL_PATH: - return Fragment.isGroupedVariationPath(item.path) ? 'Grouped variation' : 'Default card'; - case COLLECTION_MODEL_PATH: - return 'Collection'; - default: - return 'Placeholder'; - } + return getItemTypeLabel(item); } getTitle(item) { @@ -120,8 +118,8 @@ class MasSelectedItems extends LitElement { type = 'Placeholders'; break; } - Store.translationProjects[`selected${type}`].set( - Store.translationProjects[`selected${type}`].value?.filter((selectedPath) => selectedPath !== item.path), + getItemsSelectionStore()[`selected${type}`].set( + getItemsSelectionStore()[`selected${type}`].value?.filter((selectedPath) => selectedPath !== item.path), ); } diff --git a/studio/src/common/items-selection-store.js b/studio/src/common/items-selection-store.js new file mode 100644 index 000000000..8a28ee66e --- /dev/null +++ b/studio/src/common/items-selection-store.js @@ -0,0 +1,22 @@ +let activeItemsSelectionStore = null; + +/** + * @param {{ allowUnset?: boolean }} [options] If "allowUnset" is true, returns null when no slice is bound instead of throwing. + * @returns {object|null} + */ +export function getItemsSelectionStore(options) { + if (activeItemsSelectionStore == null) { + if (options?.allowUnset) { + return null; + } + throw new Error('Items selection store not set.'); + } + return activeItemsSelectionStore; +} + +/** + * @param {object|null} slice + */ +export function setItemsSelectionStore(slice) { + activeItemsSelectionStore = slice; +} diff --git a/studio/src/common/styles/table-styles.css.js b/studio/src/common/styles/table-styles.css.js new file mode 100644 index 000000000..3dd655b15 --- /dev/null +++ b/studio/src/common/styles/table-styles.css.js @@ -0,0 +1,152 @@ +import { css } from 'lit'; + +export const ghostButtonStyles = css` + .ghost-button { + --mod-button-background-color-default: transparent; + --mod-button-background-color-hover: var(--spectrum-gray-200); + } +`; + +export const loadingContainerFlexStyles = css` + .loading-container--flex { + display: flex; + justify-content: center; + align-items: center; + } +`; + +export const tableHeaderBaseStyles = css` + .item-table { + --mod-table-header-background-color: var(--spectrum-gray-50); + --mod-table-border-radius: 0; + } + + .item-table sp-table-head { + border-top: 1px solid var(--spectrum-gray-300); + border-left: 1px solid var(--spectrum-gray-300); + border-right: 1px solid var(--spectrum-gray-300); + border-radius: 12px 12px 0 0; + } + + .item-table sp-table-head-cell { + align-content: center; + } + + .item-table sp-table-head-cell:first-of-type { + border-top-left-radius: 12px; + } + + .item-table sp-table-head-cell:last-of-type { + border-top-right-radius: 12px; + } +`; + +export const tableColumnIconStyles = css` + .table-icon-cell { + display: flex; + align-items: center; + flex: 0; + } + + .table-icon-cell--chevron { + padding: 29px; + } + + .table-icon-cell--checkbox { + padding: 22px; + } +`; + +export const tableCellBaseStyles = css` + .item-table sp-table-cell, + sp-table-cell { + display: flex; + align-items: center; + } + + .status-cell { + display: flex; + align-items: center; + gap: 6px; + + .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--spectrum-gray-500); + } + + .status-dot.green { + background-color: var(--spectrum-green-700); + } + + .status-dot.blue { + background-color: var(--spectrum-blue-800); + } + } +`; + +export const tableSelectedRowStyles = css` + sp-table-row[selected] { + --mod-table-row-background-color: var(--spectrum-blue-200); + --spectrum-table-cell-background-color: var(--spectrum-blue-200); + } +`; + +export const selectItemsFormSectionStyles = css` + .select-items { + sp-button { + --mod-button-background-color-default: transparent; + --mod-button-background-color-hover: var(--spectrum-gray-200); + } + + sp-icon-add { + width: 48px; + height: 48px; + } + + .label { + align-content: center; + } + } + + .items-empty-state { + display: flex; + flex-direction: row; + gap: 12px; + padding: 12px 24px; + border: 1px dashed var(--spectrum-gray-800); + border-radius: 10px; + } + + .selected-items { + display: flex; + flex-direction: column; + gap: 20px; + + .selected-items-header { + display: flex; + justify-content: space-between; + align-items: center; + + h2 { + margin: 0; + + span { + font-weight: 500; + } + } + + .toggle-btn { + --mod-button-background-color-down: var(--spectrum-gray-300); + --mod-button-content-color-default: var(--spectrum-gray-800); + --mod-button-content-color-hover: var(--spectrum-gray-900); + } + } + + h2 sp-icon-asterisk100 { + width: 10px; + height: 10px; + } + } +`; diff --git a/studio/src/common/utils/item-loading-browser.js b/studio/src/common/utils/item-loading-browser.js new file mode 100644 index 000000000..4bdc23053 --- /dev/null +++ b/studio/src/common/utils/item-loading-browser.js @@ -0,0 +1,54 @@ +import { getService } from '../../utils.js'; + +/** + * Loads offer data for a fragment using its OSI field. + * @param {Object} fragment + * @param {Object} options + * @param {Map} [options.cache] + * @param {AbortSignal} [options.signal] + * @param {number} [options.timeoutMs] + * @returns {Promise} + */ +export async function loadOfferData(fragment, { cache = new Map(), signal, timeoutMs = 10000 } = {}) { + const wcsOsi = fragment?.fields?.find(({ name }) => name === 'osi')?.values?.[0]; + if (!wcsOsi) return null; + + try { + if (cache.has(wcsOsi)) { + return cache.get(wcsOsi); + } + + if (signal?.aborted) return null; + + const service = getService(); + const priceOptions = service.collectPriceOptions({ wcsOsi }); + const [offersPromise] = service.resolveOfferSelectors(priceOptions); + if (!offersPromise) return null; + + let timeoutId; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error('Request timeout')), timeoutMs); + }); + + try { + const [offer] = await Promise.race([offersPromise, timeoutPromise]); + clearTimeout(timeoutId); + + if (signal?.aborted) return null; + + cache.set(wcsOsi, offer); + return offer; + } catch (error) { + clearTimeout(timeoutId); + throw error; + } + } catch (error) { + console.warn(`Failed to load offer data for fragment ${fragment?.id}:`, error.message); + + if (!signal?.aborted) { + cache.set(wcsOsi, null); + } + + return null; + } +} diff --git a/studio/src/common/utils/item-loading.js b/studio/src/common/utils/item-loading.js new file mode 100644 index 000000000..be3a2ebf9 --- /dev/null +++ b/studio/src/common/utils/item-loading.js @@ -0,0 +1,405 @@ +import { Fragment } from '../../aem/fragment.js'; +import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../../constants.js'; + +export const OFFER_DATA_CONCURRENCY_LIMIT = 5; +export const VARIATIONS_CONCURRENCY_LIMIT = 5; +export const LARGE_BATCH_YIELD_THRESHOLD = 50; + +/** + * Yields control to the event loop. + * @returns {Promise} + */ +export async function yieldToMain() { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +/** + * Processes async tasks with a concurrency limit and periodic yielding. + * + * NOTE: `Promise.resolve().then(...)` schedules all tasks into the microtask queue + * immediately. The concurrency throttle applies only to async work; any synchronous + * work inside `asyncFn` runs in parallel. + * + * @param {Array} items + * @param {Function} asyncFn + * @param {number} concurrencyLimit + * @param {number} batchSize + * @returns {Promise} + */ +export async function processConcurrently(items, asyncFn, concurrencyLimit, batchSize = 20) { + const results = new Array(items.length); + const executing = []; + let processedCount = 0; + + for (let i = 0; i < items.length; i++) { + const promise = Promise.resolve().then(() => asyncFn(items[i], i)); + results[i] = promise; + + if (concurrencyLimit <= items.length) { + const execution = promise.then(() => { + executing.splice(executing.indexOf(execution), 1); + processedCount += 1; + }); + executing.push(execution); + + if (executing.length >= concurrencyLimit) { + await Promise.race(executing); + } + + if (processedCount > 0 && processedCount % batchSize === 0) { + await yieldToMain(); + } + } + } + + await Promise.all(executing); + return Promise.all(results); +} + +/** + * Returns a flat path -> item map. + * @param {Array} items + * @returns {Map} + */ +export function buildItemsByPath(items = []) { + return new Map(items.map((item) => [item.path, item])); +} + +/** + * Selects items from a path map in the same order as selectedPaths. + * @param {Array} selectedPaths + * @param {Map} itemsByPath + * @returns {Array} + */ +export function selectItemsByPath(selectedPaths = [], itemsByPath = new Map()) { + return selectedPaths.map((path) => itemsByPath.get(path)).filter(Boolean); +} + +/** + * Flattens grouped variations by parent into a path -> variation map. + * @param {Map>} groupedVariationsByParent + * @returns {Map} + */ +export function flattenGroupedVariationsByParent(groupedVariationsByParent = new Map()) { + const flattened = new Map(); + for (const variationsMap of groupedVariationsByParent.values()) { + for (const [path, variation] of variationsMap) { + flattened.set(path, variation); + } + } + return flattened; +} + +/** + * Parses mixed fragment entries into cards and collections with optional display names. + * @param {Array<{ value?: Object }>} allFragments + * @param {Object} options + * @param {Function} [options.getDisplayName] + * @returns {{ allCards: Array, allCollections: Array }} + */ +export function parseFragmentsFromStore(allFragments = [], { getDisplayName } = {}) { + return allFragments.reduce( + (acc, fragmentStore) => { + const fragment = fragmentStore?.value ?? fragmentStore; + const mappedFragment = { + ...fragment, + ...(getDisplayName ? { studioPath: getDisplayName(fragment) } : {}), + }; + + if (fragment?.model?.path === CARD_MODEL_PATH) { + acc.allCards.push(mappedFragment); + } else if (fragment?.model?.path === COLLECTION_MODEL_PATH) { + acc.allCollections.push(mappedFragment); + } + + return acc; + }, + { allCards: [], allCollections: [] }, + ); +} + +/** + * Loads items by path and enriches them with an optional display name. + * @param {Array} selectedPaths + * @param {Object} options + * @param {Function} options.getByPath + * @param {Function} [options.getDisplayName] + * @returns {Promise>} + */ +export async function loadItemsByPath(selectedPaths, { getByPath, getDisplayName } = {}) { + if (!selectedPaths?.length || !getByPath) { + return []; + } + + const fragments = await processConcurrently( + selectedPaths, + async (path) => { + try { + const fragmentData = await getByPath(path); + return { + ...fragmentData, + ...(getDisplayName ? { studioPath: getDisplayName(new Fragment(fragmentData)) } : {}), + }; + } catch (error) { + console.warn(`Failed to fetch fragment at ${path}:`, error.message); + return null; + } + }, + OFFER_DATA_CONCURRENCY_LIMIT, + ); + + return fragments.filter(Boolean); +} + +/** + * Loads grouped variations for a card fragment. + * @param {Object} card + * @param {Object} options + * @param {Function} options.getByPath + * @param {Function} [options.getOfferData] + * @param {AbortSignal} [options.signal] + * @param {Map} [options.offerDataCache] + * @param {Function} [options.getDisplayName] + * @returns {Promise>} + */ +export async function loadGroupedVariations( + card, + { getByPath, getOfferData, signal, offerDataCache = new Map(), getDisplayName } = {}, +) { + if (!getByPath) return []; + + const fragment = new Fragment(card); + const groupedRefs = fragment.listGroupedVariations(); + if (!groupedRefs?.length) return []; + + const variations = await processConcurrently( + groupedRefs, + async (ref) => { + if (signal?.aborted) return null; + + try { + return await getByPath(ref.path); + } catch (error) { + console.warn(`Failed to fetch grouped variation at ${ref.path}:`, error.message); + return null; + } + }, + VARIATIONS_CONCURRENCY_LIMIT, + ); + + const validVariations = variations.filter( + (variation) => variation && Array.isArray(variation.fieldTags) && variation.fieldTags.length > 0, + ); + + const offerDataResults = getOfferData + ? await processConcurrently( + validVariations, + (variation) => getOfferData(variation, { cache: offerDataCache, signal }), + VARIATIONS_CONCURRENCY_LIMIT, + ) + : validVariations.map(() => null); + + return validVariations.map((variation, index) => ({ + ...variation, + ...(getDisplayName ? { studioPath: getDisplayName(new Fragment(variation)) } : {}), + offerData: offerDataResults[index] ?? null, + })); +} + +/** + * Enriches cards with offer data and grouped variations. + * @param {Array} cards + * @param {Object} options + * @param {Function} [options.getByPath] + * @param {Function} [options.getOfferData] + * @param {AbortSignal} [options.signal] + * @param {Map} [options.offerDataCache] + * @param {Function} [options.getDisplayName] + * @param {Map} [options.existingOfferDataByPath] + * @param {Map>} [options.existingGroupedVariationsByPath] + * @returns {Promise>} + */ +export async function enrichCards( + cards, + { + getByPath, + getOfferData, + signal, + offerDataCache = new Map(), + getDisplayName, + existingOfferDataByPath = new Map(), + existingGroupedVariationsByPath = new Map(), + } = {}, +) { + const offerDataByPath = new Map(existingOfferDataByPath); + const groupedVariationsByPath = new Map(existingGroupedVariationsByPath); + + const cardsNeedingOfferData = getOfferData ? cards.filter((card) => !offerDataByPath.has(card.path)) : []; + + if (cardsNeedingOfferData.length > 0) { + const offerDataResults = await processConcurrently( + cardsNeedingOfferData, + (card) => getOfferData(card, { cache: offerDataCache, signal }), + OFFER_DATA_CONCURRENCY_LIMIT, + ); + + if (signal?.aborted) return []; + await yieldToMain(); + + cardsNeedingOfferData.forEach((card, index) => { + offerDataByPath.set(card.path, offerDataResults[index]); + }); + } + + const cardsNeedingGroupedVariations = getByPath ? cards.filter((card) => !groupedVariationsByPath.has(card.path)) : []; + + if (cardsNeedingGroupedVariations.length > 0) { + const groupedVariationsResults = await processConcurrently( + cardsNeedingGroupedVariations, + (card) => + loadGroupedVariations(card, { + getByPath, + getOfferData, + signal, + offerDataCache, + getDisplayName, + }), + OFFER_DATA_CONCURRENCY_LIMIT, + ); + + if (signal?.aborted) return []; + await yieldToMain(); + + cardsNeedingGroupedVariations.forEach((card, index) => { + groupedVariationsByPath.set(card.path, groupedVariationsResults[index] ?? []); + }); + } + + const enrichedCards = cards.map((card) => ({ + ...card, + offerData: offerDataByPath.get(card.path) ?? null, + groupedVariations: groupedVariationsByPath.get(card.path) ?? [], + })); + + if (enrichedCards.length > LARGE_BATCH_YIELD_THRESHOLD) { + await yieldToMain(); + } + + if (signal?.aborted) return []; + return enrichedCards; +} + +/** + * Fetches and enriches a grouped variation by path. + * @param {string} variationPath + * @param {Object} options + * @param {Function} options.getByPath + * @param {Function} [options.getOfferData] + * @param {Map} [options.offerDataCache] + * @param {Function} [options.getDisplayName] + * @returns {Promise<{ parentCardPath: string, variation: Object }|null>} + */ +export async function fetchVariationDataByPath( + variationPath, + { getByPath, getOfferData, offerDataCache = new Map(), getDisplayName } = {}, +) { + if (!getByPath || !Fragment.isGroupedVariationPath(variationPath)) return null; + + const pznIdx = variationPath.indexOf('/pzn/'); + if (pznIdx === -1) return null; + const parentCardPath = variationPath.substring(0, pznIdx); + + try { + const variation = await getByPath(variationPath); + if (!variation || !Array.isArray(variation.fieldTags) || variation.fieldTags.length === 0) return null; + + const offerData = getOfferData ? await getOfferData(variation, { cache: offerDataCache }) : null; + + return { + parentCardPath, + variation: { + ...variation, + ...(getDisplayName ? { studioPath: getDisplayName(new Fragment(variation)) } : {}), + offerData, + }, + }; + } catch (error) { + console.warn(`Failed to fetch variation at ${variationPath}:`, error.message); + return null; + } +} + +/** + * Loads grouped variations for a card and returns them keyed by path. + * @param {Array} variationPaths + * @param {Object} options + * @param {Function} options.getByPath + * @param {Function} [options.getOfferData] + * @param {Map} [options.offerDataCache] + * @param {Function} [options.getDisplayName] + * @returns {Promise>} + */ +export async function loadCardVariationsByPath( + variationPaths, + { getByPath, getOfferData, offerDataCache = new Map(), getDisplayName } = {}, +) { + if (!variationPaths?.length || !getByPath) return new Map(); + + const variations = await processConcurrently( + variationPaths, + async (path) => { + try { + return await getByPath(path); + } catch (error) { + console.warn(`Failed to fetch variation at ${path}:`, error.message); + return null; + } + }, + VARIATIONS_CONCURRENCY_LIMIT, + ); + + const validVariations = variations.filter( + (variation) => variation && Array.isArray(variation.fieldTags) && variation.fieldTags.length > 0, + ); + + const offerDataResults = getOfferData + ? await processConcurrently( + validVariations, + (variation) => getOfferData(variation, { cache: offerDataCache }), + VARIATIONS_CONCURRENCY_LIMIT, + ) + : validVariations.map(() => null); + + return new Map( + validVariations.map((variation, index) => [ + variation.path, + { + ...variation, + ...(getDisplayName ? { studioPath: getDisplayName(new Fragment(variation)) } : {}), + offerData: offerDataResults[index] ?? null, + }, + ]), + ); +} + +/** + * Parses placeholder stores into a flat array of placeholders + * @param {Array<{ value?: Object, get?: Function }>} placeholderStores + * @param {Object} options + * @param {Function} [options.getDisplayName] + * @returns {Array} + */ +export function parsePlaceholdersFromStore(placeholderStores = [], { getDisplayName } = {}) { + return placeholderStores + .map((store) => { + const placeholder = store?.get?.() ?? store?.value ?? store; + if (!placeholder?.key) return null; + return { + ...placeholder, + ...(getDisplayName ? { studioPath: getDisplayName(placeholder) } : {}), + }; + }) + .filter(Boolean); +} diff --git a/studio/src/translation/translation-items-loader.js b/studio/src/common/utils/items-loader.js similarity index 63% rename from studio/src/translation/translation-items-loader.js rename to studio/src/common/utils/items-loader.js index d6e7b44c3..fd02b9f05 100644 --- a/studio/src/translation/translation-items-loader.js +++ b/studio/src/common/utils/items-loader.js @@ -1,103 +1,18 @@ -import Store from '../store.js'; -import { CARD_MODEL_PATH, TABLE_TYPE } from '../constants.js'; -import { Fragment } from '../aem/fragment.js'; -import { getFragmentName } from './translation-utils.js'; -import { getService } from '../utils.js'; - -const OFFER_DATA_CONCURRENCY_LIMIT = 5; -const VARIATIONS_CONCURRENCY_LIMIT = 5; - -/** - * Yields control to the browser event loop - * @returns {Promise} - */ -async function yieldToMain() { - return new Promise((resolve) => { - setTimeout(resolve, 0); - }); -} - -/** - * Process an array of async tasks with concurrency limiting and periodic UI updates - * @param {Array} items - Items to process - * @param {Function} asyncFn - Async function to apply to each item - * @param {Number} concurrencyLimit - Maximum number of concurrent operations - * @param {Number} batchSize - Number of items to process before yielding to UI - * @returns {Promise} Results in the same order as input items - */ -async function processConcurrently(items, asyncFn, concurrencyLimit, batchSize = 20) { - const results = new Array(items.length); - const executing = []; - let processedCount = 0; - - for (let i = 0; i < items.length; i++) { - const promise = Promise.resolve().then(() => asyncFn(items[i], i)); - results[i] = promise; - - if (concurrencyLimit <= items.length) { - const e = promise.then(() => { - executing.splice(executing.indexOf(e), 1); - processedCount++; - }); - executing.push(e); - if (executing.length >= concurrencyLimit) { - await Promise.race(executing); - } - - if (processedCount % batchSize === 0) { - await yieldToMain(); - } - } - } - - await Promise.all(executing); - return Promise.all(results); -} - -/** - * Loads offer data for a fragment using its OSI field - * @param {Object} fragment - Fragment object with fields - * @param {AbortSignal} signal - Optional abort signal for cancellation - * @param {Number} timeoutMs - Timeout in milliseconds (default: 10000) - * @returns {Promise} Offer data or null if not found/failed - */ -async function loadOfferData(fragment, signal, timeoutMs = 10000) { - const cache = Store.translationProjects.offerDataCache; - const wcsOsi = fragment?.fields?.find(({ name }) => name === 'osi')?.values?.[0]; - if (!wcsOsi) return null; - - try { - if (cache.has(wcsOsi)) { - return cache.get(wcsOsi); - } - if (signal?.aborted) return null; - - const service = getService(); - const priceOptions = service.collectPriceOptions({ wcsOsi }); - const [offersPromise] = service.resolveOfferSelectors(priceOptions); - if (!offersPromise) return null; - let timeoutId; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error('Request timeout')), timeoutMs); - }); - try { - const [offer] = await Promise.race([offersPromise, timeoutPromise]); - clearTimeout(timeoutId); - if (signal?.aborted) return null; - cache.set(wcsOsi, offer); - return offer; - } catch (err) { - clearTimeout(timeoutId); - throw err; - } - } catch (err) { - console.warn(`Failed to load offer data for fragment ${fragment.id}:`, err.message); - if (!signal?.aborted) { - cache.set(wcsOsi, null); - } - return null; - } -} +import Store from '../../store.js'; +import { getItemsSelectionStore } from '../items-selection-store.js'; +import { TABLE_TYPE } from '../../constants.js'; +import { Fragment } from '../../aem/fragment.js'; +import { loadOfferData } from './item-loading-browser.js'; +import { + processConcurrently, + yieldToMain, + OFFER_DATA_CONCURRENCY_LIMIT, + VARIATIONS_CONCURRENCY_LIMIT, + LARGE_BATCH_YIELD_THRESHOLD, + flattenGroupedVariationsByParent, + parseFragmentsFromStore, + enrichCards, +} from './item-loading.js'; /** * Loads grouped variations for a card fragment @@ -106,7 +21,7 @@ async function loadOfferData(fragment, signal, timeoutMs = 10000) { * @param {AbortSignal} signal - Optional abort signal for cancellation * @returns {Promise>} Array of variation objects with studioPath and offerData */ -async function loadGroupedVariations(card, repository, signal) { +async function loadGroupedVariations(card, repository, signal, getDisplayName) { if (!repository?.aem?.getFragmentByPath) return []; const fragment = new Fragment(card); const groupedRefs = fragment.listGroupedVariations(); @@ -132,13 +47,13 @@ async function loadGroupedVariations(card, repository, signal) { const offerDataResults = await processConcurrently( validVariations, - (variation) => loadOfferData(variation, signal), + (variation) => loadOfferData(variation, { cache: getItemsSelectionStore().offerDataCache, signal }), VARIATIONS_CONCURRENCY_LIMIT, ); return validVariations.map((variation, i) => ({ ...variation, - studioPath: getFragmentName(new Fragment(variation)), + studioPath: getDisplayName(new Fragment(variation)), offerData: offerDataResults[i] ?? null, })); } @@ -147,9 +62,10 @@ async function loadGroupedVariations(card, repository, signal) { * Fetches a single variation by path and merges it into groupedVariationsByParent * @param {string} variationPath - Full path to the variation fragment * @param {Object} repository - MasRepository instance with aem.getFragmentByPath + * @param {Function} options.getDisplayName - Display label for a Fragment * @returns {Promise} True if fetch and merge succeeded */ -export async function fetchVariationByPath(variationPath, repository) { +export async function fetchVariationByPath(variationPath, repository, { getDisplayName } = {}) { if (!repository?.aem?.getFragmentByPath || !Fragment.isGroupedVariationPath(variationPath)) return false; const pznIdx = variationPath.indexOf('/pzn/'); if (pznIdx === -1) return false; @@ -159,14 +75,14 @@ export async function fetchVariationByPath(variationPath, repository) { const variation = await repository.aem.getFragmentByPath(variationPath); if (!variation || !Array.isArray(variation.fieldTags) || variation.fieldTags.length === 0) return false; - const offerData = await loadOfferData(variation); + const offerData = await loadOfferData(variation, { cache: getItemsSelectionStore().offerDataCache }); const enriched = { ...variation, - studioPath: getFragmentName(new Fragment(variation)), + studioPath: getDisplayName(new Fragment(variation)), offerData, }; - const existing = Store.translationProjects.groupedVariationsByParent.value || new Map(); + const existing = getItemsSelectionStore().groupedVariationsByParent.value || new Map(); const innerMap = new Map(existing.get(parentCardPath) || []); innerMap.set(variationPath, enriched); const merged = new Map(existing); @@ -180,34 +96,12 @@ export async function fetchVariationByPath(variationPath, repository) { } /** - * Updates groupedVariationsByParent and keeps groupedVariationsData (flattened map) in sync. - * Call this instead of Store.translationProjects.groupedVariationsByParent.set() to avoid rebuilding the flattened map on every render. + * Updates groupedVariationsByParent. * @param {Map} groupedVariationsByParentValue - Map of cardPath -> Map of variationPath -> variation */ export function setCardVariationsByPaths(groupedVariationsByParentValue) { - Store.translationProjects.groupedVariationsByParent.set(groupedVariationsByParentValue); - const flattened = new Map(); - for (const variationsMap of groupedVariationsByParentValue.values()) { - for (const [path, variation] of variationsMap) { - flattened.set(path, variation); - } - } - Store.translationProjects.groupedVariationsData.set(flattened); -} - -/** - * Extracts card fragments from the shared fragment store, decorating each with studioPath. - * Collections come from repository.loadAllCollections() — not this stream. - * @param {Array} allFragments - Array of fragment store objects - * @returns {Array} Array of card objects - */ -function parseCardsFromStore(allFragments) { - return (allFragments || []) - .filter((fragment) => fragment.value.model.path === CARD_MODEL_PATH) - .map((fragment) => ({ - ...fragment.value, - studioPath: getFragmentName(fragment.value), - })); + getItemsSelectionStore().groupedVariationsByParent.set(groupedVariationsByParentValue); + getItemsSelectionStore().groupedVariationsData.set(flattenGroupedVariationsByParent(groupedVariationsByParentValue)); } /** @@ -219,7 +113,7 @@ function parseCardsFromStore(allFragments) { * @param {Object} repository - MasRepository instance * @param {Object} state - Mutable state { isProcessingCards, pendingCards, abortController } */ -async function processCardsData(allCards, repository, state) { +async function processCardsData(allCards, repository, state, getDisplayName) { if (state.isProcessingCards) { state.pendingCards = allCards; return; @@ -228,7 +122,7 @@ async function processCardsData(allCards, repository, state) { const signal = state.abortController?.signal; try { - const existingCards = Store.translationProjects.allCards.get() || []; + const existingCards = getItemsSelectionStore().allCards.get() || []; const existingOfferDataByPath = new Map( existingCards.filter((card) => card.offerData !== undefined).map((card) => [card.path, card.offerData]), ); @@ -242,7 +136,7 @@ async function processCardsData(allCards, repository, state) { if (cardsNeedingOfferData.length > 0) { const offerDataResults = await processConcurrently( cardsNeedingOfferData, - (card) => loadOfferData(card, signal), + (card) => loadOfferData(card, { cache: getItemsSelectionStore().offerDataCache, signal }), OFFER_DATA_CONCURRENCY_LIMIT, ); if (signal?.aborted) return; @@ -256,7 +150,7 @@ async function processCardsData(allCards, repository, state) { if (cardsNeedingGroupedVariations.length > 0 && repository) { const groupedVariationsResults = await processConcurrently( cardsNeedingGroupedVariations, - (card) => loadGroupedVariations(card, repository, signal), + (card) => loadGroupedVariations(card, repository, signal, getDisplayName), OFFER_DATA_CONCURRENCY_LIMIT, ); if (signal?.aborted) return; @@ -274,7 +168,7 @@ async function processCardsData(allCards, repository, state) { groupedVariations: existingGroupedVariationsByPath.get(card.path) ?? [], })); - if (enrichedCards.length > 50) { + if (enrichedCards.length > LARGE_BATCH_YIELD_THRESHOLD) { await yieldToMain(); } if (signal?.aborted) return; @@ -286,22 +180,22 @@ async function processCardsData(allCards, repository, state) { .map((card) => [card.path, new Map(card.groupedVariations.map((v) => [v.path, v]))]), ); if (prefetchedVariations.size > 0) { - const existing = Store.translationProjects.groupedVariationsByParent.value || new Map(); + const existing = getItemsSelectionStore().groupedVariationsByParent.value || new Map(); const merged = new Map(existing); for (const [cardPath, varMap] of prefetchedVariations) { merged.set(cardPath, varMap); } setCardVariationsByPaths(merged); } - Store.translationProjects.displayCards.set(enrichedCards); - Store.translationProjects.allCards.set(enrichedCards); - Store.translationProjects.cardsByPaths.set(cardsByPaths); + getItemsSelectionStore().displayCards.set(enrichedCards); + getItemsSelectionStore().allCards.set(enrichedCards); + getItemsSelectionStore().cardsByPaths.set(cardsByPaths); } finally { state.isProcessingCards = false; if (state.pendingCards && !signal?.aborted) { const next = state.pendingCards; state.pendingCards = null; - await processCardsData(next, repository, state); + await processCardsData(next, repository, state, getDisplayName); } } } @@ -311,15 +205,15 @@ async function processCardsData(allCards, repository, state) { * @returns {{ unsubscribe: () => void }} */ export function loadAllPlaceholders() { - if (Store.translationProjects.allPlaceholders.get()?.length) { + if (getItemsSelectionStore().allPlaceholders.get()?.length) { return { unsubscribe: () => {} }; } const callback = () => { const placeholderValues = Store.placeholders.list.data.get().map((placeholder) => placeholder.value); const placeholdersByPaths = new Map(placeholderValues.map((p) => [p.path, p])); - Store.translationProjects.displayPlaceholders.set(placeholderValues); - Store.translationProjects.allPlaceholders.set(placeholderValues); - Store.translationProjects.placeholdersByPaths.set(placeholdersByPaths); + getItemsSelectionStore().displayPlaceholders.set(placeholderValues); + getItemsSelectionStore().allPlaceholders.set(placeholderValues); + getItemsSelectionStore().placeholdersByPaths.set(placeholdersByPaths); }; Store.placeholders.list.data.subscribe(callback); return { unsubscribe: () => Store.placeholders.list.data.unsubscribe(callback) }; @@ -330,22 +224,28 @@ export function loadAllPlaceholders() { * @param {string} type - TABLE_TYPE.CARDS or TABLE_TYPE.COLLECTIONS * @param {Object} repository - MasRepository instance * @param {Object} state - Mutable state for process cancellation + * @param {Function} options.getDisplayName - Display label for raw fragment data * @returns {{ unsubscribe: () => void }} */ -export function loadAllFragments(type, repository, state = {}) { + +export function loadAllFragments(type, repository, state = {}, { getDisplayName } = {}) { // Collections load via repository.loadAllCollections() with a dedicated model-filtered // query; partitioning the shared card stream misses collections that sit deep in the // cursor on large surfaces (acom, nala) where cards dominate the first pages. if (type === TABLE_TYPE.COLLECTIONS) { return { unsubscribe: () => {} }; } + const typeUppercased = type.charAt(0).toUpperCase() + type.slice(1); + if (getItemsSelectionStore()[`all${typeUppercased}`].get()?.length) { + return { unsubscribe: () => {} }; + } if (state.subscribed) { return { unsubscribe: () => {} }; } state.subscribed = true; const callback = async () => { - const allCards = parseCardsFromStore(Store.fragments.list.data.get() || []); - await processCardsData(allCards, repository, state); + const { allCards } = parseFragmentsFromStore(Store.fragments.list.data.get() || [], { getDisplayName }); + await processCardsData(allCards, repository, state, getDisplayName); }; Store.fragments.list.data.subscribe(callback); return { @@ -382,11 +282,11 @@ export function loadSelectedPlaceholders(selectedPaths, onItems) { * @param {Array} selectedPaths - Paths of selected fragments * @param {string} type - TABLE_TYPE.CARDS or TABLE_TYPE.COLLECTIONS * @param {Object} repository - MasRepository instance - * @param {Object} options - { signal: AbortSignal, onItems: (items) => void } + * @param {Object} options - { signal: AbortSignal, onItems: (items) => void, getDisplayName } * @returns {Promise} */ export async function loadSelectedFragments(selectedPaths, type, repository, options = {}) { - const { signal, onItems } = options; + const { signal, onItems, getDisplayName } = options; if (!repository || !selectedPaths?.length) { if (onItems) onItems([]); return; @@ -401,7 +301,7 @@ export async function loadSelectedFragments(selectedPaths, type, repository, opt const fragment = new Fragment(fragmentData); return { ...fragmentData, - studioPath: getFragmentName(fragment), + studioPath: getDisplayName(fragment), }; } catch (err) { console.warn(`Failed to fetch fragment at ${path}:`, err.message); @@ -414,7 +314,15 @@ export async function loadSelectedFragments(selectedPaths, type, repository, opt const validFragments = fragments.filter(Boolean); if (type === TABLE_TYPE.CARDS) { - const enriched = await enrichCardsForViewOnly(validFragments, repository, signal); + const enriched = await enrichCards(validFragments, { + getByPath: repository.aem.getFragmentByPath, + getOfferData: loadOfferData, + signal, + getDisplayName, + offerDataCache: getItemsSelectionStore().offerDataCache, + existingOfferDataByPath: new Map(), + existingGroupedVariationsByPath: new Map(), + }); if (!signal?.aborted && onItems) onItems(enriched); } else if (onItems) { onItems(validFragments); @@ -425,38 +333,6 @@ export async function loadSelectedFragments(selectedPaths, type, repository, opt } } -/** - * Enriches cards with offer data and grouped variations (for view-only) - * @param {Array} cards - Card objects - * @param {Object} repository - MasRepository instance - * @param {AbortSignal} signal - Abort signal - * @returns {Promise>} Enriched cards - */ -async function enrichCardsForViewOnly(cards, repository, signal) { - const offerDataResults = await processConcurrently( - cards, - (card) => loadOfferData(card, signal), - OFFER_DATA_CONCURRENCY_LIMIT, - ); - if (signal?.aborted) return []; - - const groupedVariationsResults = repository - ? await processConcurrently( - cards, - (card) => loadGroupedVariations(card, repository, signal), - OFFER_DATA_CONCURRENCY_LIMIT, - ) - : cards.map(() => []); - - if (signal?.aborted) return []; - - return cards.map((card, i) => ({ - ...card, - offerData: offerDataResults[i] ?? null, - groupedVariations: groupedVariationsResults[i] ?? [], - })); -} - /** * Fetches unresolved grouped variation paths for selected cards. * Skips paths already in cardsByPaths or groupedVariationsByParent; uses unresolvedPathsFetched to avoid re-fetching. @@ -464,9 +340,16 @@ async function enrichCardsForViewOnly(cards, repository, signal) { * @param {Map} cardsByPaths - Map of path -> card from Store * @param {Map} groupedVariationsByParent - Map of cardPath -> Map of variationPath -> variation * @param {Object} repository - MasRepository instance + * @param {Function} options.getDisplayName - Display label for a Fragment * @returns {Promise} */ -export async function fetchUnresolvedVariations(selectedCards, cardsByPaths, groupedVariationsByParent, repository) { +export async function fetchUnresolvedVariations( + selectedCards, + cardsByPaths, + groupedVariationsByParent, + repository, + { getDisplayName } = {}, +) { const unresolvedPathsFetched = new Set(); const unresolved = (selectedCards || []).filter((path) => { if (!Fragment.isGroupedVariationPath(path)) return false; @@ -479,7 +362,7 @@ export async function fetchUnresolvedVariations(selectedCards, cardsByPaths, gro for (const path of unresolved) { unresolvedPathsFetched.add(path); - const fetchedSuccessfully = await fetchVariationByPath(path, repository); + const fetchedSuccessfully = await fetchVariationByPath(path, repository, { getDisplayName }); if (!fetchedSuccessfully) unresolvedPathsFetched.delete(path); } } @@ -490,10 +373,11 @@ export async function fetchUnresolvedVariations(selectedCards, cardsByPaths, gro * @param {string} cardPath - Path of the parent card * @param {Array} variationPaths - Paths of variation fragments to fetch * @param {Object} repository - MasRepository instance + * @param {Function} options.getDisplayName - Display label for a Fragment * @returns {Promise} */ -export async function loadCardVariations(cardPath, variationPaths, repository) { - const hadPath = Store.translationProjects.groupedVariationsByParent.value?.has(cardPath); +export async function loadCardVariations(cardPath, variationPaths, repository, { getDisplayName } = {}) { + const hadPath = getItemsSelectionStore().groupedVariationsByParent.value?.has(cardPath); if (!variationPaths?.length || hadPath || !repository) return; try { @@ -516,7 +400,7 @@ export async function loadCardVariations(cardPath, variationPaths, repository) { const offerDataResults = await processConcurrently( validVariations, - (variation) => loadOfferData(variation), + (variation) => loadOfferData(variation, { cache: getItemsSelectionStore().offerDataCache }), VARIATIONS_CONCURRENCY_LIMIT, ); @@ -525,13 +409,13 @@ export async function loadCardVariations(cardPath, variationPaths, repository) { variation.path, { ...variation, - studioPath: getFragmentName(new Fragment(variation)), + studioPath: getDisplayName(new Fragment(variation)), offerData: offerDataResults[index] ?? null, }, ]), ); - const existing = Store.translationProjects.groupedVariationsByParent.value || new Map(); + const existing = getItemsSelectionStore().groupedVariationsByParent.value || new Map(); const merged = new Map(existing); merged.set(cardPath, variationsByPaths); setCardVariationsByPaths(merged); diff --git a/studio/src/common/utils/render-utils.js b/studio/src/common/utils/render-utils.js new file mode 100644 index 000000000..4c8c26b5b --- /dev/null +++ b/studio/src/common/utils/render-utils.js @@ -0,0 +1,51 @@ +import { html, nothing } from 'lit'; +import { FRAGMENT_STATUS, CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../../constants.js'; +import { Fragment } from '../../aem/fragment.js'; + +/** + * Renders a fragment status cell with a colored dot and label. + * @param {string} [status] + * @returns {import('lit').TemplateResult|typeof nothing} + */ +export function renderFragmentStatusCell(status) { + if (!status) return nothing; + let statusClass = ''; + if (status === FRAGMENT_STATUS.PUBLISHED) { + statusClass = 'green'; + } else if (status === FRAGMENT_STATUS.MODIFIED) { + statusClass = 'blue'; + } + return html` +
+ ${status.charAt(0).toUpperCase()}${status.slice(1).toLowerCase()} +
`; +} + +/** + * Returns a human-readable item type label. + * @param {Object} item + * @returns {string} + */ +export function getItemTypeLabel(item) { + if (!item) return 'Unknown'; + if (Fragment.isGroupedVariationPath(item.path)) return 'Grouped variation'; + if (item.model?.path?.includes('/dictionary/')) return 'Placeholder'; + if (item.model?.path === COLLECTION_MODEL_PATH) return 'Collection'; + if (item.model?.path === CARD_MODEL_PATH) return 'Default'; + return 'Unknown'; +} + +/** + * Returns a display title for an item (card, collection, or placeholder). + * @param {Object} item + * @param {number} [maxLength=54] + * @returns {string} + */ +export function getItemTitle(item, maxLength = 54) { + if (!item) return '-'; + if (item.model?.path === CARD_MODEL_PATH || item.model?.path === COLLECTION_MODEL_PATH) { + const title = item.title || '-'; + return title.length > maxLength ? `${title.slice(0, maxLength)}...` : title; + } + return item.key || item.getFieldValue?.('key') || '-'; +} diff --git a/studio/src/translation/mas-collapsible-table-row.css.js b/studio/src/translation/mas-collapsible-table-row.css.js index 84193d959..c1d54f4e2 100644 --- a/studio/src/translation/mas-collapsible-table-row.css.js +++ b/studio/src/translation/mas-collapsible-table-row.css.js @@ -4,7 +4,7 @@ import { tableCellBaseStyles, tableSelectedRowStyles, loadingContainerFlexStyles, -} from './translation-common-styles.css.js'; +} from '../common/styles/table-styles.css.js'; export const styles = [ tableColumnIconStyles, diff --git a/studio/src/translation/mas-collapsible-table-row.js b/studio/src/translation/mas-collapsible-table-row.js index 46b1e9667..be3b6aad2 100644 --- a/studio/src/translation/mas-collapsible-table-row.js +++ b/studio/src/translation/mas-collapsible-table-row.js @@ -1,11 +1,10 @@ import { LitElement, html, nothing } from 'lit'; import { repeat } from 'lit/directives/repeat.js'; import { styles } from './mas-collapsible-table-row.css.js'; -import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../constants.js'; -import { renderFragmentStatusCell } from './translation-utils.js'; import { Fragment } from '../aem/fragment.js'; -import Store from '../store.js'; -import { loadCardVariations, fetchVariationByPath } from './translation-items-loader.js'; +import { getItemTypeLabel } from '../common/utils/render-utils.js'; +import { getItemsSelectionStore } from '../common/items-selection-store.js'; +import { loadCardVariations, fetchVariationByPath } from '../common/utils/items-loader.js'; import ReactiveController from '../reactivity/reactive-controller.js'; export class MasCollapsibleTableRow extends LitElement { @@ -20,10 +19,14 @@ export class MasCollapsibleTableRow extends LitElement { isLoadingVariations: { type: Boolean, state: true }, resizeObserver: { type: Object }, repository: { type: Object, state: true }, + getDisplayName: { type: Function }, + renderFragmentStatusCell: { type: Function }, }; constructor() { super(); + this.getDisplayName = (fragmentData) => fragmentData?.path ?? ''; + this.renderFragmentStatusCell = () => nothing; if (!this.tabs) { this.tabs = [ { @@ -41,8 +44,8 @@ export class MasCollapsibleTableRow extends LitElement { this.isTopLevelExpanded = false; this.expandedVariationsPaths = new Set(); this.resizeObserver = null; - this.variationsController = new ReactiveController(this, [Store.translationProjects.groupedVariationsByParent]); - this.selectedCardsController = new ReactiveController(this, [Store.translationProjects.selectedCards]); + this.variationsController = new ReactiveController(this, [getItemsSelectionStore().groupedVariationsByParent]); + this.selectedCardsController = new ReactiveController(this, [getItemsSelectionStore().selectedCards]); } connectedCallback() { @@ -77,11 +80,11 @@ export class MasCollapsibleTableRow extends LitElement { } get topLevelCardVariationsByPaths() { - return Store.translationProjects.groupedVariationsByParent.value.get(this.topLevelCard.path) || new Map(); + return getItemsSelectionStore().groupedVariationsByParent.value.get(this.topLevelCard.path) || new Map(); } get selectedCards() { - return Store.translationProjects.selectedCards.value || []; + return getItemsSelectionStore().selectedCards.value || []; } get cells() { @@ -117,7 +120,7 @@ export class MasCollapsibleTableRow extends LitElement { ?selected=${isSelected} aria-selected=${isSelected ? 'true' : 'false'} > - + `} - + ${this.isGroupedVariation - ? html` + ? html` `} ` - : html``} + : html``} ${repeat(this.cells, (cell) => this[`render${cell}`](this.topLevelCard) ?? nothing)} @@ -226,23 +227,11 @@ export class MasCollapsibleTableRow extends LitElement { } renderStatus(item) { - return renderFragmentStatusCell(item?.status); + return this.renderFragmentStatusCell(item?.status); } renderItemType(item) { - if (Fragment.isGroupedVariationPath(item?.path)) { - return html`Grouped variation`; - } - if (item?.model?.path.includes('/dictionary/')) { - return html`Placeholder`; - } - if (item?.model?.path === COLLECTION_MODEL_PATH) { - return html`Collection`; - } - if (item?.model?.path === CARD_MODEL_PATH) { - return html`Default`; - } - return html`no type`; + return html`${getItemTypeLabel(item)}`; } async #copyToClipboard(e, text) { @@ -303,11 +292,11 @@ export class MasCollapsibleTableRow extends LitElement { #toggleSelect(e, path) { e.stopPropagation(); - const current = Store.translationProjects.selectedCards.value || []; + const current = getItemsSelectionStore().selectedCards.value || []; if (current.includes(path)) { - Store.translationProjects.selectedCards.set(current.filter((p) => p !== path)); + getItemsSelectionStore().selectedCards.set(current.filter((p) => p !== path)); } else { - Store.translationProjects.selectedCards.set([...current, path]); + getItemsSelectionStore().selectedCards.set([...current, path]); } } @@ -315,19 +304,23 @@ export class MasCollapsibleTableRow extends LitElement { e.stopPropagation(); this.isTopLevelExpanded = !this.isTopLevelExpanded; if (this.isGroupedVariation) { - if (Store.translationProjects.groupedVariationsData.value?.get(this.topLevelCard.path)) return; + if (getItemsSelectionStore().groupedVariationsData.value?.get(this.topLevelCard.path)) return; this.isLoadingVariations = true; - fetchVariationByPath(this.topLevelCard.path, this.repository).finally(() => { + fetchVariationByPath(this.topLevelCard.path, this.repository, { + getDisplayName: this.getDisplayName, + }).finally(() => { this.isLoadingVariations = false; }); } else { if ( - Store.translationProjects.groupedVariationsByParent.value?.has(this.topLevelCard.path) || + getItemsSelectionStore().groupedVariationsByParent.value?.has(this.topLevelCard.path) || !this.variationPaths.length ) return; this.isLoadingVariations = true; - loadCardVariations(this.topLevelCard.path, this.variationPaths, this.repository).finally(() => { + loadCardVariations(this.topLevelCard.path, this.variationPaths, this.repository, { + getDisplayName: this.getDisplayName, + }).finally(() => { this.isLoadingVariations = false; }); } @@ -348,8 +341,8 @@ export class MasCollapsibleTableRow extends LitElement { renderGroupedVariationDetailsRow(variationPath) { return this.isLoadingVariations ? html` - - + +
@@ -357,11 +350,11 @@ export class MasCollapsibleTableRow extends LitElement { ` : html` - - - ${this.renderPromoCode(Store.translationProjects.groupedVariationsData.value?.get(variationPath))} + + + ${this.renderPromoCode(getItemsSelectionStore().groupedVariationsData.value?.get(variationPath))} - ${this.renderTags(Store.translationProjects.groupedVariationsData.value?.get(variationPath))} + ${this.renderTags(getItemsSelectionStore().groupedVariationsData.value?.get(variationPath))} `; @@ -377,14 +370,14 @@ export class MasCollapsibleTableRow extends LitElement { ?selected=${isSelected} aria-selected=${isSelected ? 'true' : 'false'} > - + ${this.isTopLevelExpanded ? html`` : html``} - + - + `; } @@ -836,7 +851,11 @@ class MasTranslationEditor extends LitElement {
${this.isSelectedItemsOpen - ? html`` + ? html`` : nothing} ` } diff --git a/studio/src/translation/mas-translation.css.js b/studio/src/translation/mas-translation.css.js index 4199004db..45f26b5c7 100644 --- a/studio/src/translation/mas-translation.css.js +++ b/studio/src/translation/mas-translation.css.js @@ -28,7 +28,7 @@ export const styles = [ } } - .translation-table { + .item-table { sp-table-head-cell:last-child, sp-table-cell:last-child { max-width: 100px; diff --git a/studio/src/translation/mas-translation.js b/studio/src/translation/mas-translation.js index f819743d4..908fdb91c 100644 --- a/studio/src/translation/mas-translation.js +++ b/studio/src/translation/mas-translation.js @@ -106,13 +106,13 @@ class MasTranslation extends LitElement { get translationsProjectsContent() { const isLoading = Store.translationProjects?.list?.loading?.get(); if (isLoading && !this.translationProjectsData.length) { - return html` + return html` ${this.translationProjectsTableHead} ${Array.from({ length: 5 }, translationSkeletonRow)} `; } if (this.translationProjectsData.length) { - return html` + return html` ${this.translationProjectsTableHead} ${repeat( diff --git a/studio/src/translation/translation-common-styles.css.js b/studio/src/translation/translation-common-styles.css.js index 8457fa3cb..e3d213c44 100644 --- a/studio/src/translation/translation-common-styles.css.js +++ b/studio/src/translation/translation-common-styles.css.js @@ -1,11 +1,6 @@ import { css } from 'lit'; -export const ghostButtonStyles = css` - .ghost-button { - --mod-button-background-color-default: transparent; - --mod-button-background-color-hover: var(--spectrum-gray-200); - } -`; +export { ghostButtonStyles, tableHeaderBaseStyles, tableCellBaseStyles } from '../common/styles/table-styles.css.js'; export const loadingContainerCenteredStyles = css` .loading-container--absolute { @@ -15,89 +10,3 @@ export const loadingContainerCenteredStyles = css` transform: translate(-50%, -50%); } `; - -export const loadingContainerFlexStyles = css` - .loading-container--flex { - display: flex; - justify-content: center; - align-items: center; - } -`; - -export const tableHeaderBaseStyles = css` - .translation-table { - --mod-table-header-background-color: var(--spectrum-gray-50); - --mod-table-border-radius: 0; - } - - .translation-table sp-table-head { - border-top: 1px solid var(--spectrum-gray-300); - border-left: 1px solid var(--spectrum-gray-300); - border-right: 1px solid var(--spectrum-gray-300); - border-radius: 12px 12px 0 0; - } - - .translation-table sp-table-head-cell { - align-content: center; - } - - .translation-table sp-table-head-cell:first-of-type { - border-top-left-radius: 12px; - } - - .translation-table sp-table-head-cell:last-of-type { - border-top-right-radius: 12px; - } -`; - -export const tableColumnIconStyles = css` - .translation-table-icon-cell { - display: flex; - align-items: center; - flex: 0; - } - - .translation-table-icon-cell--chevron { - padding: 29px; - } - - .translation-table-icon-cell--checkbox { - padding: 22px; - } -`; - -export const tableCellBaseStyles = css` - .translation-table sp-table-cell, - sp-table-cell { - display: flex; - align-items: center; - } - - .status-cell { - display: flex; - align-items: center; - gap: 6px; - - .status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background-color: var(--spectrum-gray-500); - } - - .status-dot.green { - background-color: var(--spectrum-green-700); - } - - .status-dot.blue { - background-color: var(--spectrum-blue-800); - } - } -`; - -export const tableSelectedRowStyles = css` - sp-table-row[selected] { - --mod-table-row-background-color: var(--spectrum-blue-200); - --spectrum-table-cell-background-color: var(--spectrum-blue-200); - } -`; diff --git a/studio/test/common/item-loading-browser.test.js b/studio/test/common/item-loading-browser.test.js new file mode 100644 index 000000000..c7a6478b5 --- /dev/null +++ b/studio/test/common/item-loading-browser.test.js @@ -0,0 +1,75 @@ +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; +import { loadOfferData } from '../../src/common/utils/item-loading-browser.js'; + +describe('common/utils/item-loading-browser', () => { + let sandbox; + let commerceService; + + const createMockCommerceService = () => { + const service = document.createElement('mas-commerce-service'); + service.collectPriceOptions = sinon.stub().returns({}); + service.resolveOfferSelectors = sinon.stub().returns([Promise.resolve([{ offerId: 'test-offer-id' }])]); + document.body.appendChild(service); + return service; + }; + + const removeMockCommerceService = () => { + const service = document.querySelector('mas-commerce-service'); + if (service) service.remove(); + }; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + commerceService = createMockCommerceService(); + }); + + afterEach(() => { + sandbox.restore(); + removeMockCommerceService(); + }); + + it('returns null when the fragment does not have an OSI field', async () => { + const offerData = await loadOfferData({ + path: '/content/dam/mas/acom/en_US/cards/card-without-osi', + fields: [], + }); + + expect(offerData).to.equal(null); + expect(commerceService.collectPriceOptions.called).to.be.false; + }); + + it('reuses cached data for subsequent calls', async () => { + const cache = new Map(); + const fragment = { + path: '/content/dam/mas/acom/en_US/cards/card1', + fields: [{ name: 'osi', values: ['osi-123'] }], + }; + + const firstResult = await loadOfferData(fragment, { cache }); + const secondResult = await loadOfferData(fragment, { cache }); + + expect(firstResult).to.deep.equal({ offerId: 'test-offer-id' }); + expect(secondResult).to.deep.equal({ offerId: 'test-offer-id' }); + expect(commerceService.collectPriceOptions.calledOnce).to.be.true; + expect(cache.get('osi-123')).to.deep.equal({ offerId: 'test-offer-id' }); + }); + + it('returns null and caches the failure when offer loading throws', async () => { + commerceService.resolveOfferSelectors = sandbox.stub().returns([Promise.reject(new Error('boom'))]); + const cache = new Map(); + + const offerData = await loadOfferData( + { + id: 'card-1', + path: '/content/dam/mas/acom/en_US/cards/card1', + fields: [{ name: 'osi', values: ['osi-123'] }], + }, + { cache }, + ); + + expect(offerData).to.equal(null); + expect(cache.has('osi-123')).to.be.true; + expect(cache.get('osi-123')).to.equal(null); + }); +}); diff --git a/studio/test/common/item-loading.test.js b/studio/test/common/item-loading.test.js new file mode 100644 index 000000000..f5d408008 --- /dev/null +++ b/studio/test/common/item-loading.test.js @@ -0,0 +1,343 @@ +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; +import { Fragment } from '../../src/aem/fragment.js'; +import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../../src/constants.js'; +import { + buildItemsByPath, + enrichCards, + fetchVariationDataByPath, + flattenGroupedVariationsByParent, + loadCardVariationsByPath, + loadGroupedVariations, + loadItemsByPath, + parseFragmentsFromStore, + parsePlaceholdersFromStore, + processConcurrently, + selectItemsByPath, +} from '../../src/common/utils/item-loading.js'; + +describe('common/utils/item-loading', () => { + let sandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('buildItemsByPath creates a path-indexed map', () => { + const items = [ + { path: '/a', title: 'A' }, + { path: '/b', title: 'B' }, + ]; + + const map = buildItemsByPath(items); + + expect(map.get('/a')).to.equal(items[0]); + expect(map.get('/b')).to.equal(items[1]); + }); + + it('selectItemsByPath preserves selected path order and filters missing items', () => { + const first = { path: '/a', title: 'A' }; + const second = { path: '/b', title: 'B' }; + const map = new Map([ + ['/a', first], + ['/b', second], + ]); + + const selected = selectItemsByPath(['/b', '/missing', '/a'], map); + + expect(selected).to.deep.equal([second, first]); + }); + + it('flattenGroupedVariationsByParent flattens nested maps', () => { + const variation = { path: '/card/pzn/v1', title: 'Variation' }; + const flattened = flattenGroupedVariationsByParent(new Map([['/card', new Map([['/card/pzn/v1', variation]])]])); + + expect(flattened.get('/card/pzn/v1')).to.equal(variation); + }); + + it('processConcurrently preserves result order while respecting the concurrency limit', async () => { + let activeCount = 0; + let maxActiveCount = 0; + + const results = await processConcurrently( + [1, 2, 3, 4], + async (item) => { + activeCount += 1; + maxActiveCount = Math.max(maxActiveCount, activeCount); + await new Promise((resolve) => setTimeout(resolve, 5)); + activeCount -= 1; + return item * 2; + }, + 2, + 2, + ); + + expect(results).to.deep.equal([2, 4, 6, 8]); + expect(maxActiveCount).to.equal(2); + }); + + it('parseFragmentsFromStore splits cards and collections and applies display name', () => { + const getDisplayName = sandbox.stub().returns('Display Name'); + const allFragments = [ + { + value: { + path: '/card', + title: 'Card', + model: { path: CARD_MODEL_PATH }, + }, + }, + { + value: { + path: '/collection', + title: 'Collection', + model: { path: COLLECTION_MODEL_PATH }, + }, + }, + ]; + + const { allCards, allCollections } = parseFragmentsFromStore(allFragments, { getDisplayName }); + + expect(allCards).to.have.lengthOf(1); + expect(allCards[0].studioPath).to.equal('Display Name'); + expect(allCollections).to.have.lengthOf(1); + expect(allCollections[0].studioPath).to.equal('Display Name'); + }); + + it('loadItemsByPath fetches items and decorates them with a display name', async () => { + const getDisplayName = sandbox.stub().returns('Display Name'); + const fragmentData = { + path: '/content/dam/mas/acom/en_US/cards/card1', + title: 'Card 1', + model: { path: CARD_MODEL_PATH }, + fields: [], + }; + const getByPath = sandbox.stub().resolves(fragmentData); + + const items = await loadItemsByPath([fragmentData.path], { getByPath, getDisplayName }); + + expect(items).to.have.lengthOf(1); + expect(items[0].studioPath).to.equal('Display Name'); + expect(getByPath.calledOnceWith(fragmentData.path)).to.be.true; + }); + + it('loadItemsByPath skips paths that fail to load and keeps the successful items', async () => { + const getByPath = sandbox.stub(); + getByPath.onFirstCall().resolves({ + path: '/content/dam/mas/acom/en_US/cards/card1', + title: 'Card 1', + model: { path: CARD_MODEL_PATH }, + fields: [], + }); + getByPath.onSecondCall().rejects(new Error('not found')); + + const items = await loadItemsByPath( + ['/content/dam/mas/acom/en_US/cards/card1', '/content/dam/mas/acom/en_US/cards/missing'], + { getByPath }, + ); + + expect(items).to.have.lengthOf(1); + expect(items[0].path).to.equal('/content/dam/mas/acom/en_US/cards/card1'); + }); + + it('loadGroupedVariations returns enriched grouped variations for valid refs only', async () => { + const getDisplayName = sandbox.stub().returns('Variation Name'); + const getOfferData = sandbox.stub().resolves({ offerId: 'test-offer-id' }); + const validVariationPath = '/content/dam/mas/acom/en_US/cards/card1/pzn/v1'; + const invalidVariationPath = '/content/dam/mas/acom/en_US/cards/card1/pzn/v2'; + const card = new Fragment({ + path: '/content/dam/mas/acom/en_US/cards/card1', + title: 'Card 1', + model: { path: CARD_MODEL_PATH }, + fields: [{ name: 'variations', values: [validVariationPath, invalidVariationPath] }], + references: [{ path: validVariationPath }, { path: invalidVariationPath }], + }); + const getByPath = sandbox.stub(); + + getByPath.withArgs(validVariationPath).resolves({ + path: validVariationPath, + fieldTags: [{ id: 'tag-1' }], + fields: [{ name: 'osi', values: ['osi-123'] }], + }); + getByPath.withArgs(invalidVariationPath).resolves({ + path: invalidVariationPath, + fieldTags: [], + fields: [{ name: 'osi', values: ['osi-456'] }], + }); + + const variations = await loadGroupedVariations(card, { getByPath, getOfferData, getDisplayName }); + + expect(variations).to.have.lengthOf(1); + expect(variations[0].path).to.equal(validVariationPath); + expect(variations[0].studioPath).to.equal('Variation Name'); + expect(variations[0].offerData).to.deep.equal({ offerId: 'test-offer-id' }); + }); + + it('fetchVariationDataByPath resolves and enriches grouped variations', async () => { + const getDisplayName = sandbox.stub().returns('Variation Name'); + const getOfferData = sandbox.stub().resolves({ offerId: 'test-offer-id' }); + const variationPath = '/content/dam/mas/acom/en_US/cards/parent/pzn/v1'; + const getByPath = sandbox.stub().resolves({ + path: variationPath, + fieldTags: [{ id: 'tag-1' }], + fields: [{ name: 'osi', values: ['osi-123'] }], + }); + + const result = await fetchVariationDataByPath(variationPath, { getByPath, getOfferData, getDisplayName }); + + expect(result.parentCardPath).to.equal('/content/dam/mas/acom/en_US/cards/parent'); + expect(result.variation.path).to.equal(variationPath); + expect(result.variation.studioPath).to.equal('Variation Name'); + expect(result.variation.offerData).to.deep.equal({ offerId: 'test-offer-id' }); + }); + + it('fetchVariationDataByPath returns null for a non-grouped variation path', async () => { + const getByPath = sandbox.stub(); + + const result = await fetchVariationDataByPath('/content/dam/mas/acom/en_US/cards/card1', { getByPath }); + + expect(result).to.equal(null); + expect(getByPath.called).to.be.false; + }); + + it('loadCardVariationsByPath returns a keyed map of enriched variations', async () => { + const getDisplayName = sandbox.stub().returns('Variation Name'); + const getOfferData = sandbox.stub().resolves({ offerId: 'test-offer-id' }); + const variationPath = '/content/dam/mas/acom/en_US/cards/parent/pzn/v1'; + const getByPath = sandbox.stub().resolves({ + path: variationPath, + fieldTags: [{ id: 'tag-1' }], + fields: [{ name: 'osi', values: ['osi-123'] }], + }); + + const variationsByPath = await loadCardVariationsByPath([variationPath], { + getByPath, + getOfferData, + getDisplayName, + }); + + expect(variationsByPath.get(variationPath).studioPath).to.equal('Variation Name'); + expect(variationsByPath.get(variationPath).offerData).to.deep.equal({ offerId: 'test-offer-id' }); + }); + + it('loadCardVariationsByPath filters out invalid variations', async () => { + const validVariationPath = '/content/dam/mas/acom/en_US/cards/parent/pzn/v1'; + const invalidVariationPath = '/content/dam/mas/acom/en_US/cards/parent/pzn/v2'; + const getByPath = sandbox.stub(); + + getByPath.withArgs(validVariationPath).resolves({ + path: validVariationPath, + fieldTags: [{ id: 'tag-1' }], + fields: [{ name: 'osi', values: ['osi-123'] }], + }); + getByPath.withArgs(invalidVariationPath).resolves({ + path: invalidVariationPath, + fieldTags: [], + fields: [{ name: 'osi', values: ['osi-456'] }], + }); + + const variationsByPath = await loadCardVariationsByPath([validVariationPath, invalidVariationPath], { getByPath }); + + expect(variationsByPath.size).to.equal(1); + expect(variationsByPath.has(validVariationPath)).to.be.true; + expect(variationsByPath.has(invalidVariationPath)).to.be.false; + }); + + it('enrichCards adds offer data and grouped variations while reusing existing data', async () => { + const groupedVariationPath = '/content/dam/mas/acom/en_US/cards/card1/pzn/v1'; + const cardData = new Fragment({ + path: '/content/dam/mas/acom/en_US/cards/card1', + title: 'Card 1', + model: { path: CARD_MODEL_PATH }, + tags: [], + fields: [ + { name: 'osi', values: ['osi-123'] }, + { name: 'variations', values: [groupedVariationPath] }, + ], + references: [{ path: groupedVariationPath }], + }); + const getByPath = sandbox.stub().resolves({ + path: groupedVariationPath, + fieldTags: [{ id: 'tag-1' }], + fields: [{ name: 'osi', values: ['osi-variation'] }], + }); + const getOfferData = sandbox.stub().resolves({ offerId: 'variation-offer' }); + + const cards = await enrichCards([cardData], { + getByPath, + getOfferData, + getDisplayName: () => 'Display Name', + existingOfferDataByPath: new Map([[cardData.path, { offerId: 'cached-offer' }]]), + }); + + expect(cards).to.have.lengthOf(1); + expect(cards[0].offerData).to.deep.equal({ offerId: 'cached-offer' }); + expect(cards[0].groupedVariations).to.have.lengthOf(1); + expect(cards[0].groupedVariations[0].path).to.equal(groupedVariationPath); + }); + + it('enrichCards returns an empty array when the signal is already aborted after loading offer data', async () => { + const abortController = new AbortController(); + abortController.abort(); + const cardData = new Fragment({ + path: '/content/dam/mas/acom/en_US/cards/card1', + title: 'Card 1', + model: { path: CARD_MODEL_PATH }, + tags: [], + fields: [{ name: 'osi', values: ['osi-123'] }], + }); + const getOfferData = sandbox.stub().resolves({ offerId: 'offer-1' }); + + const cards = await enrichCards([cardData], { + getByPath: sandbox.stub(), + getOfferData, + signal: abortController.signal, + }); + + expect(cards).to.deep.equal([]); + }); + it('parsePlaceholdersFromStore extracts placeholders and applies display name', () => { + const getDisplayName = sinon.stub().returns('placeholder: buy-now'); + const stores = [ + { + get: () => ({ + key: 'buy-now', + value: 'Buy now', + path: '/content/dam/mas/acom/en_US/placeholders/buy-now', + status: 'Published', + }), + }, + { + get: () => ({ + key: 'save-now', + value: 'Save now', + path: '/content/dam/mas/acom/en_US/placeholders/save-now', + status: 'Draft', + }), + }, + ]; + + const result = parsePlaceholdersFromStore(stores, { getDisplayName }); + + expect(result).to.have.lengthOf(2); + expect(result[0].key).to.equal('buy-now'); + expect(result[0].studioPath).to.equal('placeholder: buy-now'); + expect(result[1].key).to.equal('save-now'); + }); + + it('parsePlaceholdersFromStore filters out entries without a key', () => { + const stores = [ + { get: () => ({ key: 'valid', value: 'Yes' }) }, + { get: () => ({ value: 'No key here' }) }, + { get: () => null }, + ]; + + const result = parsePlaceholdersFromStore(stores); + + expect(result).to.have.lengthOf(1); + expect(result[0].key).to.equal('valid'); + }); +}); diff --git a/studio/test/common/utils/render-utils.test.js b/studio/test/common/utils/render-utils.test.js new file mode 100644 index 000000000..af955daf8 --- /dev/null +++ b/studio/test/common/utils/render-utils.test.js @@ -0,0 +1,76 @@ +import { expect } from '@esm-bundle/chai'; +import { nothing, render } from 'lit'; +import { renderFragmentStatusCell, getItemTypeLabel, getItemTitle } from '../../../src/common/utils/render-utils.js'; +import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, FRAGMENT_STATUS } from '../../../src/constants.js'; + +describe('render-utils', () => { + describe('renderFragmentStatusCell', () => { + it('returns nothing when status is missing', () => { + expect(renderFragmentStatusCell()).to.equal(nothing); + expect(renderFragmentStatusCell('')).to.equal(nothing); + }); + + it('renders published status with green class', () => { + const container = document.createElement('div'); + render(renderFragmentStatusCell(FRAGMENT_STATUS.PUBLISHED), container); + const dot = container.querySelector('.status-dot'); + expect(dot?.classList.contains('green')).to.be.true; + expect(container.textContent).to.include('Published'); + }); + + it('renders modified status with blue class', () => { + const container = document.createElement('div'); + render(renderFragmentStatusCell(FRAGMENT_STATUS.MODIFIED), container); + const dot = container.querySelector('.status-dot'); + expect(dot?.classList.contains('blue')).to.be.true; + expect(container.textContent).to.include('Modified'); + }); + }); + + describe('getItemTypeLabel', () => { + it('returns Unknown for falsy item', () => { + expect(getItemTypeLabel(null)).to.equal('Unknown'); + expect(getItemTypeLabel(undefined)).to.equal('Unknown'); + }); + + it('returns Grouped variation when path is a grouped variation path', () => { + expect(getItemTypeLabel({ path: '/content/x/pzn/y/var' })).to.equal('Grouped variation'); + }); + + it('returns Placeholder for dictionary model', () => { + expect(getItemTypeLabel({ model: { path: '/conf/.../dictionary/foo' } })).to.equal('Placeholder'); + }); + + it('returns Collection for collection model', () => { + expect(getItemTypeLabel({ model: { path: COLLECTION_MODEL_PATH } })).to.equal('Collection'); + }); + + it('returns Default for card model', () => { + expect(getItemTypeLabel({ model: { path: CARD_MODEL_PATH } })).to.equal('Default'); + }); + }); + + describe('getItemTitle', () => { + it('returns dash for falsy item', () => { + expect(getItemTitle(null)).to.equal('-'); + }); + + it('truncates long card titles', () => { + const long = 'a'.repeat(60); + expect(getItemTitle({ model: { path: CARD_MODEL_PATH }, title: long }).length).to.be.lessThan(long.length); + expect(getItemTitle({ model: { path: CARD_MODEL_PATH }, title: long })).to.include('...'); + }); + + it('uses key for placeholder-like items', () => { + expect(getItemTitle({ key: 'my-key' })).to.equal('my-key'); + }); + + it('uses getFieldValue when present', () => { + expect( + getItemTitle({ + getFieldValue: (f) => (f === 'key' ? 'from-field' : ''), + }), + ).to.equal('from-field'); + }); + }); +}); diff --git a/studio/test/translation/mas-collapsible-table-row.test.js b/studio/test/translation/mas-collapsible-table-row.test.js index 3e7f941cc..3f6a1ab69 100644 --- a/studio/test/translation/mas-collapsible-table-row.test.js +++ b/studio/test/translation/mas-collapsible-table-row.test.js @@ -3,8 +3,10 @@ import { html } from 'lit'; import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; import sinon from 'sinon'; import Store from '../../src/store.js'; -import { setCardVariationsByPaths } from '../../src/translation/translation-items-loader.js'; +import { setItemsSelectionStore } from '../../src/common/items-selection-store.js'; +import { setCardVariationsByPaths } from '../../src/common/utils/items-loader.js'; import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, FRAGMENT_STATUS } from '../../src/constants.js'; +import { renderFragmentStatusCell } from '../../src/translation/translation-utils.js'; import '../../src/swc.js'; import '../../src/translation/mas-collapsible-table-row.js'; @@ -48,6 +50,7 @@ describe('MasCollapsibleTableRow', () => { beforeEach(() => { sandbox = sinon.createSandbox(); + setItemsSelectionStore(Store.translationProjects); resetStore(); createMockRepository(); }); @@ -57,6 +60,7 @@ describe('MasCollapsibleTableRow', () => { sandbox.restore(); resetStore(); removeMockRepository(); + setItemsSelectionStore(null); }); describe('initialization', () => { @@ -424,7 +428,10 @@ describe('MasCollapsibleTableRow', () => { it('should render published status with green class', async () => { const topLevelCard = createMockTopLevelCard({ status: FRAGMENT_STATUS.PUBLISHED }); const el = await fixture( - html``, + html``, ); const statusDot = el.shadowRoot.querySelector('.status-dot.green'); expect(statusDot).to.exist; @@ -433,7 +440,10 @@ describe('MasCollapsibleTableRow', () => { it('should render modified status with blue class', async () => { const topLevelCard = createMockTopLevelCard({ status: FRAGMENT_STATUS.MODIFIED }); const el = await fixture( - html``, + html``, ); const statusDot = el.shadowRoot.querySelector('.status-dot.blue'); expect(statusDot).to.exist; @@ -488,7 +498,7 @@ describe('MasCollapsibleTableRow', () => { expect(groupedCell).to.exist; }); - it('should render "no type" for unknown model path', async () => { + it('should render "Unknown" for unknown model path', async () => { const topLevelCard = createMockTopLevelCard({ modelPath: '/conf/mas/settings/dam/cfm/models/unknown', }); @@ -496,7 +506,7 @@ describe('MasCollapsibleTableRow', () => { html``, ); const shadowText = el.shadowRoot?.textContent || ''; - expect(shadowText).to.include('no type'); + expect(shadowText).to.include('Unknown'); }); }); @@ -794,7 +804,7 @@ describe('MasCollapsibleTableRow', () => { const el = await fixture( html``, ); - const chevronCell = el.shadowRoot.querySelector('.translation-table-icon-cell--chevron'); + const chevronCell = el.shadowRoot.querySelector('.table-icon-cell--chevron'); expect(chevronCell).to.exist; }); diff --git a/studio/test/translation/mas-items-selector.test.js b/studio/test/translation/mas-items-selector.test.js index 2adbbb364..843e7e5e2 100644 --- a/studio/test/translation/mas-items-selector.test.js +++ b/studio/test/translation/mas-items-selector.test.js @@ -3,16 +3,18 @@ import { html } from 'lit'; import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; import sinon from 'sinon'; import Store from '../../src/store.js'; +import { setItemsSelectionStore } from '../../src/common/items-selection-store.js'; import { TABLE_TYPE } from '../../src/constants.js'; import '../../src/swc.js'; -import '../../src/translation/mas-items-selector.js'; -import { TABS } from '../../src/translation/mas-items-selector.js'; +import '../../src/common/components/mas-items-selector.js'; +import { TABS } from '../../src/common/components/mas-items-selector.js'; describe('MasItemsSelector', () => { let sandbox; beforeEach(() => { sandbox = sinon.createSandbox(); + setItemsSelectionStore(Store.translationProjects); Store.translationProjects.inEdit.set(null); Store.translationProjects.showSelected.set(false); Store.translationProjects.selectedCards.set([]); @@ -28,6 +30,7 @@ describe('MasItemsSelector', () => { Store.translationProjects.selectedCards.set([]); Store.translationProjects.selectedCollections.set([]); Store.translationProjects.selectedPlaceholders.set([]); + setItemsSelectionStore(null); }); describe('TABS constant', () => { diff --git a/studio/test/translation/mas-search-and-filters.test.js b/studio/test/translation/mas-search-and-filters.test.js index 8306fa033..440b3f399 100644 --- a/studio/test/translation/mas-search-and-filters.test.js +++ b/studio/test/translation/mas-search-and-filters.test.js @@ -3,9 +3,10 @@ import { html } from 'lit'; import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; import sinon from 'sinon'; import Store from '../../src/store.js'; +import { setItemsSelectionStore } from '../../src/common/items-selection-store.js'; import { TABLE_TYPE, FILTER_TYPE } from '../../src/constants.js'; import '../../src/swc.js'; -import '../../src/translation/mas-search-and-filters.js'; +import '../../src/common/components/mas-search-and-filters.js'; describe('MasSearchAndFilters', () => { let sandbox; @@ -27,6 +28,7 @@ describe('MasSearchAndFilters', () => { beforeEach(() => { sandbox = sinon.createSandbox(); + setItemsSelectionStore(Store.translationProjects); Store.translationProjects.allCards.set([]); Store.translationProjects.displayCards.set([]); Store.translationProjects.allCollections.set([]); @@ -50,6 +52,7 @@ describe('MasSearchAndFilters', () => { Store.fragments.list.loading.set(false); Store.placeholders.list.loading.set(false); Store.placeholders.list.data.set([]); + setItemsSelectionStore(null); }); describe('initialization', () => { diff --git a/studio/test/translation/mas-select-items-table.test.js b/studio/test/translation/mas-select-items-table.test.js index ac373db11..38a459e1d 100644 --- a/studio/test/translation/mas-select-items-table.test.js +++ b/studio/test/translation/mas-select-items-table.test.js @@ -3,9 +3,12 @@ import { html } from 'lit'; import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; import sinon from 'sinon'; import Store from '../../src/store.js'; +import { setItemsSelectionStore } from '../../src/common/items-selection-store.js'; import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH, TABLE_TYPE, FRAGMENT_STATUS } from '../../src/constants.js'; +import { renderFragmentStatusCell } from '../../src/translation/translation-utils.js'; import '../../src/swc.js'; -import '../../src/translation/mas-select-items-table.js'; +import '../../src/translation/mas-collapsible-table-row.js'; +import '../../src/common/components/mas-select-items-table.js'; describe('MasSelectItemsTable', () => { let sandbox; @@ -109,6 +112,7 @@ describe('MasSelectItemsTable', () => { beforeEach(() => { sandbox = sinon.createSandbox(); + setItemsSelectionStore(Store.translationProjects); resetStore(); mockCommerceService = createMockCommerceService(); }); @@ -118,6 +122,7 @@ describe('MasSelectItemsTable', () => { sandbox.restore(); resetStore(); removeMockCommerceService(); + setItemsSelectionStore(null); }); describe('initialization', () => { @@ -540,10 +545,14 @@ 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'); @@ -553,10 +562,14 @@ 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'); @@ -566,10 +579,14 @@ 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'); diff --git a/studio/test/translation/mas-selected-items.test.js b/studio/test/translation/mas-selected-items.test.js index e4072df33..9ec59851b 100644 --- a/studio/test/translation/mas-selected-items.test.js +++ b/studio/test/translation/mas-selected-items.test.js @@ -3,10 +3,11 @@ import { html } from 'lit'; import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; import sinon from 'sinon'; import Store from '../../src/store.js'; -import { setCardVariationsByPaths } from '../../src/translation/translation-items-loader.js'; +import { setItemsSelectionStore } from '../../src/common/items-selection-store.js'; +import { setCardVariationsByPaths } from '../../src/common/utils/items-loader.js'; import { CARD_MODEL_PATH, COLLECTION_MODEL_PATH } from '../../src/constants.js'; import '../../src/swc.js'; -import '../../src/translation/mas-selected-items.js'; +import '../../src/common/components/mas-selected-items.js'; describe('MasSelectedItems', () => { let sandbox; @@ -58,6 +59,7 @@ describe('MasSelectedItems', () => { beforeEach(() => { sandbox = sinon.createSandbox(); + setItemsSelectionStore(Store.translationProjects); Store.translationProjects.showSelected.set(false); Store.translationProjects.selectedCards.set([]); Store.translationProjects.selectedCollections.set([]); @@ -73,6 +75,7 @@ describe('MasSelectedItems', () => { Store.translationProjects.selectedCollections.set([]); Store.translationProjects.selectedPlaceholders.set([]); resetMaps(); + setItemsSelectionStore(null); }); describe('initialization', () => { @@ -333,7 +336,7 @@ describe('MasSelectedItems', () => { const el = await fixture(html``); const typeEl = el.shadowRoot.querySelector('.type'); expect(typeEl).to.exist; - expect(typeEl.textContent.trim()).to.equal('Default card'); + expect(typeEl.textContent.trim()).to.equal('Default'); }); it('should render remove button for each item', async () => { @@ -519,7 +522,7 @@ describe('MasSelectedItems', () => { it('should handle undefined item gracefully in getType', async () => { const el = await fixture(html``); - expect(el.getType(undefined)).to.equal('Unknown type'); + expect(el.getType(undefined)).to.equal('Unknown'); }); }); }); diff --git a/studio/test/translation/mas-translation-editor.test.js b/studio/test/translation/mas-translation-editor.test.js index c479316e6..3e71477b9 100644 --- a/studio/test/translation/mas-translation-editor.test.js +++ b/studio/test/translation/mas-translation-editor.test.js @@ -4,6 +4,7 @@ import { html } from 'lit'; import { fixture, fixtureCleanup } from '@open-wc/testing-helpers/pure'; import { PAGE_NAMES, QUICK_ACTION, TRANSLATION_PROJECT_MODEL_ID } from '../../src/constants.js'; import Store from '../../src/store.js'; +import { setItemsSelectionStore } from '../../src/common/items-selection-store.js'; import router from '../../src/router.js'; import Events from '../../src/events.js'; import { Fragment } from '../../src/aem/fragment.js'; @@ -88,6 +89,7 @@ describe('MasTranslationEditor', () => { beforeEach(() => { sandbox = sinon.createSandbox(); + setItemsSelectionStore(Store.translationProjects); toastEmitStub = sandbox.stub(Events.toast, 'emit'); originalQuerySelector = document.querySelector.bind(document); defaultMockRepository = { @@ -119,6 +121,7 @@ describe('MasTranslationEditor', () => { fixtureCleanup(); sandbox.restore(); resetStores(); + setItemsSelectionStore(null); }); describe('initialization', () => { diff --git a/studio/test/translation/mas-translation.test.js b/studio/test/translation/mas-translation.test.js index f63048522..c8b08a9fb 100644 --- a/studio/test/translation/mas-translation.test.js +++ b/studio/test/translation/mas-translation.test.js @@ -162,7 +162,7 @@ describe('MasTranslation', () => { const mockProjects = [createMockTranslationProject('1', 'Project 1')]; Store.translationProjects.list.data.value = mockProjects; const el = await fixture(html``); - const table = el.shadowRoot.querySelector('.translation-table'); + const table = el.shadowRoot.querySelector('.item-table'); expect(table).to.exist; }); diff --git a/studio/test/translation/translation-items-loader.test.js b/studio/test/translation/translation-items-loader.test.js index a2d7442ad..086f89c09 100644 --- a/studio/test/translation/translation-items-loader.test.js +++ b/studio/test/translation/translation-items-loader.test.js @@ -1,6 +1,7 @@ import { expect } from '@esm-bundle/chai'; import sinon from 'sinon'; import Store from '../../src/store.js'; +import { setItemsSelectionStore } from '../../src/common/items-selection-store.js'; import { Fragment } from '../../src/aem/fragment.js'; import { TABLE_TYPE, COLLECTION_MODEL_PATH, CARD_MODEL_PATH } from '../../src/constants.js'; import { @@ -12,11 +13,12 @@ import { fetchUnresolvedVariations, fetchVariationByPath, setCardVariationsByPaths, -} from '../../src/translation/translation-items-loader.js'; +} from '../../src/common/utils/items-loader.js'; describe('translation-items-loader', () => { let sandbox; + const mockGetDisplayName = () => 'mock-display-name'; const resetStore = () => { Store.translationProjects.allCards.set([]); Store.translationProjects.cardsByPaths.set(new Map()); @@ -50,6 +52,7 @@ describe('translation-items-loader', () => { beforeEach(() => { sandbox = sinon.createSandbox(); + setItemsSelectionStore(Store.translationProjects); resetStore(); createMockCommerceService(); }); @@ -58,6 +61,7 @@ describe('translation-items-loader', () => { sandbox.restore(); resetStore(); removeMockCommerceService(); + setItemsSelectionStore(null); }); describe('setCardVariationsByPaths', () => { @@ -115,10 +119,16 @@ 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, {}, { getDisplayName: mockGetDisplayName }); + expect(result.unsubscribe).to.be.a('function'); + expect(Store.translationProjects.allCards.get()).to.have.lengthOf(1); + }); + it('should not subscribe for collections (loaded via repository.loadAllCollections)', async () => { const before = Store.translationProjects.allCollections.get(); const result = loadAllFragments(TABLE_TYPE.COLLECTIONS, null, {}); - const mockCollection = { value: { path: '/content/dam/mas/acom/en_US/collections/test', @@ -149,7 +159,7 @@ describe('translation-items-loader', () => { }, }; - const result = loadAllFragments(TABLE_TYPE.CARDS, repo, state); + const result = loadAllFragments(TABLE_TYPE.CARDS, repo, state, { getDisplayName: mockGetDisplayName }); const mockCardData = { path: '/content/dam/mas/acom/en_US/cards/card1', @@ -195,8 +205,7 @@ describe('translation-items-loader', () => { fields: [{ name: 'variations', values: [variationPath] }], references: [{ path: variationPath }], }); - - const result = loadAllFragments(TABLE_TYPE.CARDS, repo, state); + const result = loadAllFragments(TABLE_TYPE.CARDS, repo, state, { getDisplayName: mockGetDisplayName }); Store.fragments.list.data.set([{ value: mockCardFragment }]); await new Promise((r) => setTimeout(r, 200)); @@ -253,14 +262,17 @@ describe('translation-items-loader', () => { describe('loadSelectedFragments', () => { it('should call onItems with empty array when repository is null', async () => { const onItems = sinon.stub(); - await loadSelectedFragments(['/path/1'], TABLE_TYPE.CARDS, null, { onItems }); + await loadSelectedFragments(['/path/1'], TABLE_TYPE.CARDS, null, { + onItems, + getDisplayName: mockGetDisplayName, + }); expect(onItems.calledWith([])).to.be.true; }); it('should call onItems with empty array when selectedPaths is empty', async () => { const onItems = sinon.stub(); const repo = { aem: { getFragmentByPath: sinon.stub() } }; - await loadSelectedFragments([], TABLE_TYPE.CARDS, repo, { onItems }); + await loadSelectedFragments([], TABLE_TYPE.CARDS, repo, { onItems, getDisplayName: mockGetDisplayName }); expect(onItems.calledWith([])).to.be.true; }); @@ -277,7 +289,10 @@ describe('translation-items-loader', () => { }; const onItems = sinon.stub(); - await loadSelectedFragments([mockFragment.path], TABLE_TYPE.COLLECTIONS, repo, { onItems }); + await loadSelectedFragments([mockFragment.path], TABLE_TYPE.COLLECTIONS, repo, { + onItems, + getDisplayName: mockGetDisplayName, + }); expect(repo.aem.getFragmentByPath.calledWith(mockFragment.path)).to.be.true; expect(onItems.called).to.be.true; @@ -307,7 +322,10 @@ describe('translation-items-loader', () => { }; const onItems = sinon.stub(); - await loadSelectedFragments([cardPath], TABLE_TYPE.CARDS, repo, { onItems }); + await loadSelectedFragments([cardPath], TABLE_TYPE.CARDS, repo, { + onItems, + getDisplayName: mockGetDisplayName, + }); expect(repo.aem.getFragmentByPath.calledWith(cardPath)).to.be.true; expect(onItems.called).to.be.true; @@ -326,7 +344,10 @@ describe('translation-items-loader', () => { aem: { getFragmentByPath: sinon.stub().rejects(new Error('Fetch failed')) }, }; - await loadSelectedFragments(['/invalid/path'], TABLE_TYPE.CARDS, repo, { onItems }); + await loadSelectedFragments(['/invalid/path'], TABLE_TYPE.CARDS, repo, { + onItems, + getDisplayName: mockGetDisplayName, + }); expect(onItems.calledWith([])).to.be.true; }); @@ -341,7 +362,9 @@ describe('translation-items-loader', () => { }, }; - await loadSelectedFragments(['/path'], TABLE_TYPE.COLLECTIONS, repo, {}); + await loadSelectedFragments(['/path'], TABLE_TYPE.COLLECTIONS, repo, { + getDisplayName: mockGetDisplayName, + }); expect(repo.aem.getFragmentByPath.called).to.be.true; }); @@ -365,6 +388,7 @@ describe('translation-items-loader', () => { await loadSelectedFragments([cardPath], TABLE_TYPE.CARDS, repo, { signal: abortedController.signal, onItems, + getDisplayName: mockGetDisplayName, }); expect(onItems.called).to.be.false; @@ -374,7 +398,7 @@ describe('translation-items-loader', () => { describe('loadCardVariations', () => { it('should return early when variationPaths is empty', async () => { const repo = { aem: { getFragmentByPath: sinon.stub() } }; - await loadCardVariations('/card/path', [], repo); + await loadCardVariations('/card/path', [], repo, { getDisplayName: mockGetDisplayName }); expect(repo.aem.getFragmentByPath.called).to.be.false; }); @@ -384,13 +408,13 @@ describe('translation-items-loader', () => { setCardVariationsByPaths(existingMap); const repo = { aem: { getFragmentByPath: sinon.stub() } }; - await loadCardVariations('/card/path', ['/var/path'], repo); + await loadCardVariations('/card/path', ['/var/path'], repo, { getDisplayName: mockGetDisplayName }); expect(repo.aem.getFragmentByPath.called).to.be.false; }); it('should return early when repository is null', async () => { - await loadCardVariations('/card/path', ['/var/path'], null); + await loadCardVariations('/card/path', ['/var/path'], null, { getDisplayName: mockGetDisplayName }); expect(Store.translationProjects.groupedVariationsByParent.value?.has('/card/path')).to.be.false; }); @@ -409,7 +433,7 @@ describe('translation-items-loader', () => { }, }; - await loadCardVariations(cardPath, [variationPath], repo); + await loadCardVariations(cardPath, [variationPath], repo, { getDisplayName: mockGetDisplayName }); expect(repo.aem.getFragmentByPath.calledWith(variationPath)).to.be.true; const variationsByPaths = Store.translationProjects.groupedVariationsByParent.value?.get(cardPath); @@ -437,7 +461,7 @@ describe('translation-items-loader', () => { }, }; - await loadCardVariations(cardPath, [invalidPath], repo); + await loadCardVariations(cardPath, [invalidPath], repo, { getDisplayName: mockGetDisplayName }); const variationsByPaths = Store.translationProjects.groupedVariationsByParent.value?.get(cardPath); expect(variationsByPaths).to.exist; @@ -451,7 +475,7 @@ describe('translation-items-loader', () => { aem: { getFragmentByPath: sinon.stub().rejects(new Error('Network error')) }, }; - await loadCardVariations(cardPath, [variationPath], repo); + await loadCardVariations(cardPath, [variationPath], repo, { getDisplayName: mockGetDisplayName }); expect(repo.aem.getFragmentByPath.calledWith(variationPath)).to.be.true; const variationsMap = Store.translationProjects.groupedVariationsByParent.value?.get(cardPath); @@ -479,7 +503,7 @@ describe('translation-items-loader', () => { aem: { getFragmentByPath: sinon.stub().resolves(mockVar) }, }; - await loadCardVariations(cardPath2, [varPath2], repo); + await loadCardVariations(cardPath2, [varPath2], repo, { getDisplayName: mockGetDisplayName }); const result = Store.translationProjects.groupedVariationsByParent.value; expect(result.has(cardPath1)).to.be.true; @@ -493,19 +517,19 @@ describe('translation-items-loader', () => { const variationPath = '/content/dam/mas/acom/en_US/cards/parent/pzn/var1'; it('should return false when repository is null', async () => { - const result = await fetchVariationByPath(variationPath, null); + const result = await fetchVariationByPath(variationPath, null, { getDisplayName: mockGetDisplayName }); expect(result).to.be.false; }); it('should return false when repository has no getFragmentByPath', async () => { - const result = await fetchVariationByPath(variationPath, {}); + const result = await fetchVariationByPath(variationPath, {}, { getDisplayName: mockGetDisplayName }); expect(result).to.be.false; }); it('should return false when path is not a grouped variation path', async () => { const cardPath = '/content/dam/mas/acom/en_US/cards/card1'; const repo = { aem: { getFragmentByPath: sinon.stub() } }; - const result = await fetchVariationByPath(cardPath, repo); + const result = await fetchVariationByPath(cardPath, repo, { getDisplayName: mockGetDisplayName }); expect(result).to.be.false; expect(repo.aem.getFragmentByPath.called).to.be.false; }); @@ -513,7 +537,7 @@ describe('translation-items-loader', () => { it('should return false when path has no /pzn/ segment', async () => { const invalidPath = '/content/dam/mas/acom/en_US/cards/parent/invalid/var1'; const repo = { aem: { getFragmentByPath: sinon.stub() } }; - const result = await fetchVariationByPath(invalidPath, repo); + const result = await fetchVariationByPath(invalidPath, repo, { getDisplayName: mockGetDisplayName }); expect(result).to.be.false; expect(repo.aem.getFragmentByPath.called).to.be.false; }); @@ -522,7 +546,7 @@ describe('translation-items-loader', () => { const repo = { aem: { getFragmentByPath: sinon.stub().rejects(new Error('Network error')) }, }; - const result = await fetchVariationByPath(variationPath, repo); + const result = await fetchVariationByPath(variationPath, repo, { getDisplayName: mockGetDisplayName }); expect(result).to.be.false; }); @@ -536,7 +560,7 @@ describe('translation-items-loader', () => { aem: { getFragmentByPath: sinon.stub().resolves(mockVariation) }, }; - const result = await fetchVariationByPath(variationPath, repo); + const result = await fetchVariationByPath(variationPath, repo, { getDisplayName: mockGetDisplayName }); expect(result).to.be.true; const cardPath = '/content/dam/mas/acom/en_US/cards/parent'; @@ -553,20 +577,22 @@ describe('translation-items-loader', () => { it('should not fetch when selectedCards is empty', async () => { const repo = { aem: { getFragmentByPath: sinon.stub() } }; - await fetchUnresolvedVariations([], new Map(), new Map(), repo); + await fetchUnresolvedVariations([], new Map(), new Map(), repo, { getDisplayName: mockGetDisplayName }); expect(repo.aem.getFragmentByPath.called).to.be.false; }); it('should not fetch when selectedCards is null', async () => { const repo = { aem: { getFragmentByPath: sinon.stub() } }; - await fetchUnresolvedVariations(null, new Map(), new Map(), repo); + await fetchUnresolvedVariations(null, new Map(), new Map(), repo, { getDisplayName: mockGetDisplayName }); expect(repo.aem.getFragmentByPath.called).to.be.false; }); it('should skip non-grouped-variation paths', async () => { const defaultCardPath = '/content/dam/mas/acom/en_US/cards/card1'; const repo = { aem: { getFragmentByPath: sinon.stub() } }; - await fetchUnresolvedVariations([defaultCardPath], new Map(), new Map(), repo); + await fetchUnresolvedVariations([defaultCardPath], new Map(), new Map(), repo, { + getDisplayName: mockGetDisplayName, + }); expect(repo.aem.getFragmentByPath.called).to.be.false; }); @@ -574,7 +600,9 @@ describe('translation-items-loader', () => { const cardsByPaths = new Map(); cardsByPaths.set(variationPath, { path: variationPath }); const repo = { aem: { getFragmentByPath: sinon.stub() } }; - await fetchUnresolvedVariations([variationPath], cardsByPaths, new Map(), repo); + await fetchUnresolvedVariations([variationPath], cardsByPaths, new Map(), repo, { + getDisplayName: mockGetDisplayName, + }); expect(repo.aem.getFragmentByPath.called).to.be.false; }); @@ -585,7 +613,9 @@ describe('translation-items-loader', () => { const groupedVariationsByParent = new Map(); groupedVariationsByParent.set(cardPath, variationsMap); const repo = { aem: { getFragmentByPath: sinon.stub() } }; - await fetchUnresolvedVariations([variationPath], new Map(), groupedVariationsByParent, repo); + await fetchUnresolvedVariations([variationPath], new Map(), groupedVariationsByParent, repo, { + getDisplayName: mockGetDisplayName, + }); expect(repo.aem.getFragmentByPath.called).to.be.false; }); @@ -599,7 +629,9 @@ describe('translation-items-loader', () => { aem: { getFragmentByPath: sinon.stub().resolves(mockVariation) }, }; - await fetchUnresolvedVariations([variationPath], new Map(), new Map(), repo); + await fetchUnresolvedVariations([variationPath], new Map(), new Map(), repo, { + getDisplayName: mockGetDisplayName, + }); expect(repo.aem.getFragmentByPath.calledWith(variationPath)).to.be.true; const cardPath = '/content/dam/mas/acom/en_US/cards/parent'; @@ -613,7 +645,9 @@ describe('translation-items-loader', () => { aem: { getFragmentByPath: sinon.stub().rejects(new Error('Network error')) }, }; - await fetchUnresolvedVariations([variationPath], new Map(), new Map(), repo); + await fetchUnresolvedVariations([variationPath], new Map(), new Map(), repo, { + getDisplayName: mockGetDisplayName, + }); expect(repo.aem.getFragmentByPath.calledWith(variationPath)).to.be.true; const cardPath = '/content/dam/mas/acom/en_US/cards/parent'; @@ -631,7 +665,9 @@ describe('translation-items-loader', () => { aem: { getFragmentByPath: sinon.stub().resolves(mockInvalidVariation) }, }; - await fetchUnresolvedVariations([variationPath], new Map(), new Map(), repo); + await fetchUnresolvedVariations([variationPath], new Map(), new Map(), repo, { + getDisplayName: mockGetDisplayName, + }); expect(repo.aem.getFragmentByPath.calledWith(variationPath)).to.be.true; const cardPath = '/content/dam/mas/acom/en_US/cards/parent'; @@ -692,7 +728,9 @@ describe('translation-items-loader', () => { fields: [], }; - const { unsubscribe } = loadAllFragments(TABLE_TYPE.CARDS, null, state); + const { unsubscribe } = loadAllFragments(TABLE_TYPE.CARDS, null, state, { + getDisplayName: mockGetDisplayName, + }); Store.fragments.list.data.set([{ value: new Fragment(card) }]); await new Promise((r) => setTimeout(r, 100));