From 2a11803f7a8cef0aa06197484fe3ccfe96748f46 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Thu, 25 Jun 2026 14:42:26 +0200 Subject: [PATCH 1/3] feat(product): page objects for virtual_combinations per-combination files Companion to PrestaShop/PrestaShop#41825. Adds: - virtualCombinationsProductDescription on the BO Products page - setCombinationVirtualProductFile / getCombinationVirtualProductFileName on the combinations tab - getProductDownloadFileName on the FO product page Some selectors are marked TODO and need confirmation against the rendered DOM. Co-Authored-By: Claude Opus 4.8 --- .../products/create/tabCombinations.ts | 2 + src/interfaces/BO/catalog/products/index.ts | 1 + src/interfaces/FO/product/index.ts | 1 + .../products/create/tabCombinations.ts | 45 +++++++++++++++++++ .../pages/BO/catalog/products/index.ts | 4 ++ .../develop/pages/FO/classic/product/index.ts | 14 ++++++ 6 files changed, 67 insertions(+) diff --git a/src/interfaces/BO/catalog/products/create/tabCombinations.ts b/src/interfaces/BO/catalog/products/create/tabCombinations.ts index be11c2c37..85be67a54 100644 --- a/src/interfaces/BO/catalog/products/create/tabCombinations.ts +++ b/src/interfaces/BO/catalog/products/create/tabCombinations.ts @@ -52,6 +52,7 @@ export interface BOProductsCreateTabCombinationsPageInterface extends BOBasePage reference: string, }>; getCombinationNameFromModal(page: Page): Promise; + getCombinationVirtualProductFileName(page: Page): Promise; getFilterBySizeButtonName(page: Page): Promise; getNumberOfCombinationsFromList(page: Page): Promise; getPaginationLabel(page: Page): Promise; @@ -67,6 +68,7 @@ export interface BOProductsCreateTabCombinationsPageInterface extends BOBasePage setLabelWhenInStock(page: Page, label: string): Promise; setLabelWhenOutOfStock(page: Page, label: string): Promise; setOptionWhenOutOfStock(page: Page, option: string): Promise; + setCombinationVirtualProductFile(page: Page, filePath: string): Promise; setProductAttributes(page: Page, attributes: ProductAttributes[]): Promise; sortTable(page: Page, sortBy: string, column: number, sortDirection: string): Promise; } diff --git a/src/interfaces/BO/catalog/products/index.ts b/src/interfaces/BO/catalog/products/index.ts index fa6f683c1..93771e983 100644 --- a/src/interfaces/BO/catalog/products/index.ts +++ b/src/interfaces/BO/catalog/products/index.ts @@ -16,6 +16,7 @@ export interface BOProductsPageInterface extends BOBasePagePageInterface { readonly productWithCombinationsDescription: string; readonly standardProductDescription: string; readonly virtualProductDescription: string; + readonly virtualCombinationsProductDescription: string; bulkActionsProduct(page: Page, action: string): Promise; bulkSelectProducts(page: Page, products?: FakerProduct[]): Promise diff --git a/src/interfaces/FO/product/index.ts b/src/interfaces/FO/product/index.ts index b7b38402c..90a8a12bf 100644 --- a/src/interfaces/FO/product/index.ts +++ b/src/interfaces/FO/product/index.ts @@ -45,6 +45,7 @@ export interface FoProductPageInterface extends FOBasePagePageInterface { getProductAttributes(page: Page): Promise; getProductAvailabilityLabel(page: Page): Promise; getProductCondition(page: Page): Promise; + getProductDownloadFileName(page: Page): Promise; getProductFeaturesList(page: Page): Promise; getProductImageUrls(page: Page): Promise; getProductInPackList(page: Page, productInList: number): Promise; diff --git a/src/versions/develop/pages/BO/catalog/products/create/tabCombinations.ts b/src/versions/develop/pages/BO/catalog/products/create/tabCombinations.ts index 877592805..fc13a10d0 100644 --- a/src/versions/develop/pages/BO/catalog/products/create/tabCombinations.ts +++ b/src/versions/develop/pages/BO/catalog/products/create/tabCombinations.ts @@ -170,6 +170,12 @@ class CombinationsTab extends BOBasePage implements BOProductsCreateTabCombinati private readonly editCombinationModalSaveButton: string; + private readonly editCombinationModalVirtualFileToggle: (toCheck: number) => string; + + private readonly editCombinationModalVirtualFile: string; + + private readonly editCombinationModalVirtualFileName: string; + private readonly editCombinationModalCloseButton: string; private readonly editCombinationCloseModal: string; @@ -329,6 +335,14 @@ class CombinationsTab extends BOBasePage implements BOProductsCreateTabCombinati this.editCombinationModalFinalPriceTaxIncludedInput = '#combination_form_price_impact_final_price_tax_included'; this.editCombinationModalLocationInput = '#combination_form_stock_options_stock_location'; this.editCombinationModalSaveButton = `${this.editCombinationModal} footer button.btn-primary`; + // Virtual product file section, shown in the combination modal only for the virtual_combinations product type. + // TODO(selector): ids inferred from the core CombinationFormType embedding VirtualProductFileType + // (child `virtual_product_file`, `#combination_form_*` prefix); confirm against the rendered modal DOM. + this.editCombinationModalVirtualFileToggle = (toCheck: number) => ( + `#combination_form_virtual_product_file_has_file_${toCheck}` + ); + this.editCombinationModalVirtualFile = '#combination_form_virtual_product_file_file'; + this.editCombinationModalVirtualFileName = '#combination_form_virtual_product_file_name'; this.editCombinationModalCloseButton = `${this.editCombinationModal} footer button.btn-close`; this.editCombinationCloseModal = `${this.editCombinationEditModal} div.modal-prevent-close div.modal.show`; this.editCombinationModalDiscardButton = `${this.editCombinationCloseModal} button.btn-primary`; @@ -654,6 +668,37 @@ class CombinationsTab extends BOBasePage implements BOProductsCreateTabCombinati return this.getAlertSuccessBlockParagraphContent(combinationFrame!); } + /** + * Upload a downloadable file to the currently open combination (virtual_combinations product type) + * @param page {Page} Browser tab + * @param filePath {string} Path of the file to upload + * @returns {Promise} + */ + async setCombinationVirtualProductFile(page: Page, filePath: string): Promise { + await this.waitForVisibleSelector(page, this.editCombinationIframe); + + const combinationFrame: Frame|null = page.frame({url: /sell\/catalog\/products\/combinations/gmi}); + + await this.setChecked(combinationFrame!, this.editCombinationModalVirtualFileToggle(1)); + await this.waitForVisibleSelector(combinationFrame!, this.editCombinationModalVirtualFile); + await this.uploadFile(combinationFrame!, this.editCombinationModalVirtualFile, filePath); + await this.setValue(combinationFrame!, this.editCombinationModalVirtualFileName, filePath); + await this.waitForSelectorAndClick(page, this.editCombinationModalSaveButton); + + return this.getAlertSuccessBlockParagraphContent(combinationFrame!); + } + + /** + * Get the name of the downloadable file attached to the currently open combination + * @param page {Page} Browser tab + * @returns {Promise} + */ + async getCombinationVirtualProductFileName(page: Page): Promise { + const combinationFrame: Frame|null = page.frame({url: /sell\/catalog\/products\/combinations/gmi}); + + return combinationFrame!.locator(this.editCombinationModalVirtualFileName).inputValue(); + } + /** * Get recent stock movements * @param page {Page} Browser tab diff --git a/src/versions/develop/pages/BO/catalog/products/index.ts b/src/versions/develop/pages/BO/catalog/products/index.ts index 5fadcd9de..5f9fd1c75 100644 --- a/src/versions/develop/pages/BO/catalog/products/index.ts +++ b/src/versions/develop/pages/BO/catalog/products/index.ts @@ -30,6 +30,8 @@ class ProductsPage extends BOBasePage implements BOProductsPageInterface { public readonly virtualProductDescription: string; + public readonly virtualCombinationsProductDescription: string; + public readonly packOfProductsDescription: string; protected newProductIframeURL: RegExp; @@ -221,6 +223,8 @@ class ProductsPage extends BOBasePage implements BOProductsPageInterface { + 'customers can choose.'; this.virtualProductDescription = 'An intangible product that doesn\'t require shipping. You can also add a ' + 'downloadable file.'; + this.virtualCombinationsProductDescription = 'A downloadable product available in several variations, ' + + 'each with its own file.'; this.packOfProductsDescription = 'A collection of products from your catalog.'; // Header selectors diff --git a/src/versions/develop/pages/FO/classic/product/index.ts b/src/versions/develop/pages/FO/classic/product/index.ts index d30d75f2d..ec02384c6 100644 --- a/src/versions/develop/pages/FO/classic/product/index.ts +++ b/src/versions/develop/pages/FO/classic/product/index.ts @@ -78,6 +78,8 @@ class ProductPage extends FOBasePage implements FoProductPageInterface { protected productCondition: string; + protected productDownloadFileName: string; + protected continueShoppingButton: string; protected productAvailability: string; @@ -256,6 +258,9 @@ class ProductPage extends FOBasePage implements FoProductPageInterface { this.productDetail = 'div.product-information a[href=\'#product-details\']'; this.productFeaturesList = '#product-details section.product-features'; this.productCondition = '#product-details div.product-condition'; + // Downloadable file offered for the currently selected combination (virtual_combinations product type). + // TODO(selector): the FO theme markup is not in this checkout; confirm the real per-combination download selector. + this.productDownloadFileName = '#product-details .product-download a'; this.continueShoppingButton = `${this.blockCartModal} div.cart-content-btn button`; this.productAvailability = '#product-availability'; this.productAvailabilityIcon = `${this.productAvailability} i`; @@ -1100,6 +1105,15 @@ class ProductPage extends FOBasePage implements FoProductPageInterface { return this.getTextContent(page, this.productCondition); } + /** + * Get the downloadable file name offered for the currently selected combination + * @param page {Page} Browser tab + * @returns {Promise} + */ + async getProductDownloadFileName(page: Page): Promise { + return this.getTextContent(page, this.productDownloadFileName); + } + /** * Is customization block visible * @param page {Page} Browser tab From c00ab13640cec1789146c77f32f4eb409fc42055 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Thu, 25 Jun 2026 16:17:37 +0200 Subject: [PATCH 2/3] docs: clarify combination virtual-file selectors are deterministic, not guessed The combination-modal field ids are generated deterministically by Symfony from the core form structure (combination_form root + embedded VirtualProductFileType); only the FO download selector remains to confirm against the rendered theme. Co-Authored-By: Claude Opus 4.8 --- .../pages/BO/catalog/products/create/tabCombinations.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/versions/develop/pages/BO/catalog/products/create/tabCombinations.ts b/src/versions/develop/pages/BO/catalog/products/create/tabCombinations.ts index fc13a10d0..dcb00eccd 100644 --- a/src/versions/develop/pages/BO/catalog/products/create/tabCombinations.ts +++ b/src/versions/develop/pages/BO/catalog/products/create/tabCombinations.ts @@ -336,8 +336,8 @@ class CombinationsTab extends BOBasePage implements BOProductsCreateTabCombinati this.editCombinationModalLocationInput = '#combination_form_stock_options_stock_location'; this.editCombinationModalSaveButton = `${this.editCombinationModal} footer button.btn-primary`; // Virtual product file section, shown in the combination modal only for the virtual_combinations product type. - // TODO(selector): ids inferred from the core CombinationFormType embedding VirtualProductFileType - // (child `virtual_product_file`, `#combination_form_*` prefix); confirm against the rendered modal DOM. + // Ids derive deterministically from the core form: root form[name="combination_form"] + embedded + // VirtualProductFileType child `virtual_product_file` (SwitchType `has_file` -> `_has_file_1`). this.editCombinationModalVirtualFileToggle = (toCheck: number) => ( `#combination_form_virtual_product_file_has_file_${toCheck}` ); From fbe8bab1b02a1f38228bccbd5cf948c8a5e5cbdc Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Mon, 29 Jun 2026 16:10:07 +0200 Subject: [PATCH 3/3] feat(product): pivot to per-combination is_virtual page objects Companion to PrestaShop/PrestaShop#41825 (design pivot, discussion #41826): remove the dropped virtual_combinations product-type description, add setCombinationIsVirtual for the per-combination virtual switch; the per-combination file methods are unchanged (file decoupled from the flag). Co-Authored-By: Claude Opus 4.8 --- .../products/create/tabCombinations.ts | 1 + src/interfaces/BO/catalog/products/index.ts | 1 - .../products/create/tabCombinations.ts | 21 +++++++++++++++++++ .../pages/BO/catalog/products/index.ts | 4 ---- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/interfaces/BO/catalog/products/create/tabCombinations.ts b/src/interfaces/BO/catalog/products/create/tabCombinations.ts index 85be67a54..1d54f11e4 100644 --- a/src/interfaces/BO/catalog/products/create/tabCombinations.ts +++ b/src/interfaces/BO/catalog/products/create/tabCombinations.ts @@ -68,6 +68,7 @@ export interface BOProductsCreateTabCombinationsPageInterface extends BOBasePage setLabelWhenInStock(page: Page, label: string): Promise; setLabelWhenOutOfStock(page: Page, label: string): Promise; setOptionWhenOutOfStock(page: Page, option: string): Promise; + setCombinationIsVirtual(page: Page, isVirtual: boolean): Promise; setCombinationVirtualProductFile(page: Page, filePath: string): Promise; setProductAttributes(page: Page, attributes: ProductAttributes[]): Promise; sortTable(page: Page, sortBy: string, column: number, sortDirection: string): Promise; diff --git a/src/interfaces/BO/catalog/products/index.ts b/src/interfaces/BO/catalog/products/index.ts index 93771e983..fa6f683c1 100644 --- a/src/interfaces/BO/catalog/products/index.ts +++ b/src/interfaces/BO/catalog/products/index.ts @@ -16,7 +16,6 @@ export interface BOProductsPageInterface extends BOBasePagePageInterface { readonly productWithCombinationsDescription: string; readonly standardProductDescription: string; readonly virtualProductDescription: string; - readonly virtualCombinationsProductDescription: string; bulkActionsProduct(page: Page, action: string): Promise; bulkSelectProducts(page: Page, products?: FakerProduct[]): Promise diff --git a/src/versions/develop/pages/BO/catalog/products/create/tabCombinations.ts b/src/versions/develop/pages/BO/catalog/products/create/tabCombinations.ts index dcb00eccd..fad065aff 100644 --- a/src/versions/develop/pages/BO/catalog/products/create/tabCombinations.ts +++ b/src/versions/develop/pages/BO/catalog/products/create/tabCombinations.ts @@ -176,6 +176,8 @@ class CombinationsTab extends BOBasePage implements BOProductsCreateTabCombinati private readonly editCombinationModalVirtualFileName: string; + private readonly editCombinationModalIsVirtualToggle: (toCheck: number) => string; + private readonly editCombinationModalCloseButton: string; private readonly editCombinationCloseModal: string; @@ -343,6 +345,8 @@ class CombinationsTab extends BOBasePage implements BOProductsCreateTabCombinati ); this.editCombinationModalVirtualFile = '#combination_form_virtual_product_file_file'; this.editCombinationModalVirtualFileName = '#combination_form_virtual_product_file_name'; + // Per-combination "Is virtual" switch (SwitchType renders Yes/No radios _1/_0). + this.editCombinationModalIsVirtualToggle = (toCheck: number) => `#combination_form_is_virtual_${toCheck}`; this.editCombinationModalCloseButton = `${this.editCombinationModal} footer button.btn-close`; this.editCombinationCloseModal = `${this.editCombinationEditModal} div.modal-prevent-close div.modal.show`; this.editCombinationModalDiscardButton = `${this.editCombinationCloseModal} button.btn-primary`; @@ -688,6 +692,23 @@ class CombinationsTab extends BOBasePage implements BOProductsCreateTabCombinati return this.getAlertSuccessBlockParagraphContent(combinationFrame!); } + /** + * Mark the currently open combination as virtual (or physical) and save + * @param page {Page} Browser tab + * @param isVirtual {boolean} Whether the combination is virtual (non-shippable) + * @returns {Promise} + */ + async setCombinationIsVirtual(page: Page, isVirtual: boolean): Promise { + await this.waitForVisibleSelector(page, this.editCombinationIframe); + + const combinationFrame: Frame|null = page.frame({url: /sell\/catalog\/products\/combinations/gmi}); + + await this.setChecked(combinationFrame!, this.editCombinationModalIsVirtualToggle(isVirtual ? 1 : 0)); + await this.waitForSelectorAndClick(page, this.editCombinationModalSaveButton); + + return this.getAlertSuccessBlockParagraphContent(combinationFrame!); + } + /** * Get the name of the downloadable file attached to the currently open combination * @param page {Page} Browser tab diff --git a/src/versions/develop/pages/BO/catalog/products/index.ts b/src/versions/develop/pages/BO/catalog/products/index.ts index 5f9fd1c75..5fadcd9de 100644 --- a/src/versions/develop/pages/BO/catalog/products/index.ts +++ b/src/versions/develop/pages/BO/catalog/products/index.ts @@ -30,8 +30,6 @@ class ProductsPage extends BOBasePage implements BOProductsPageInterface { public readonly virtualProductDescription: string; - public readonly virtualCombinationsProductDescription: string; - public readonly packOfProductsDescription: string; protected newProductIframeURL: RegExp; @@ -223,8 +221,6 @@ class ProductsPage extends BOBasePage implements BOProductsPageInterface { + 'customers can choose.'; this.virtualProductDescription = 'An intangible product that doesn\'t require shipping. You can also add a ' + 'downloadable file.'; - this.virtualCombinationsProductDescription = 'A downloadable product available in several variations, ' - + 'each with its own file.'; this.packOfProductsDescription = 'A collection of products from your catalog.'; // Header selectors