diff --git a/assets/js/admin/products-admin.js b/assets/js/admin/products-admin.js index 5ed037916..7cf1de715 100644 --- a/assets/js/admin/products-admin.js +++ b/assets/js/admin/products-admin.js @@ -825,6 +825,14 @@ jQuery( document ).ready( function( $ ) { const removedIds = attachmentIds.filter(id => !selectedAttachmentIds.includes(id)); const newIds = selectedAttachmentIds.filter(id => !attachmentIds.includes(id)); + // Check if the selection exceeds the Facebook image limit + const maxImages = window.facebook_for_woocommerce_products_admin && window.facebook_for_woocommerce_products_admin.max_facebook_images ? + parseInt(window.facebook_for_woocommerce_products_admin.max_facebook_images) : 21; + if (selectedAttachmentIds.length > maxImages) { + alert(`You can only select a maximum of ${maxImages} images for Facebook catalog sync. Please reduce your selection.`); + return; + } + // Remove unselected image thumbnails $container.find('.form-field').each(function () { const $imageThumbnail = $(this); @@ -890,6 +898,37 @@ jQuery( document ).ready( function( $ ) { attachment.fetch(); selection.add(attachment ? [attachment] : []); }); + + // Add real-time selection limit enforcement + const maxImages = window.facebook_for_woocommerce_products_admin && window.facebook_for_woocommerce_products_admin.max_facebook_images ? + parseInt(window.facebook_for_woocommerce_products_admin.max_facebook_images) : 21; + + let isValidating = false; + let lastAlertTime = 0; + + selection.on('add', function(model) { + if (isValidating) return; // Prevent infinite loop + + if (selection.length > maxImages) { + isValidating = true; + selection.remove(model); + + // Only show alert once every 2 seconds to prevent spam + const now = Date.now(); + if (now - lastAlertTime > 2000) { + lastAlertTime = now; + // Use setTimeout to prevent blocking and allow DOM to update + setTimeout(function() { + alert(`You can only select a maximum of ${maxImages} images for Facebook catalog sync.`); + }, 10); + } + + // Reset validation flag after a short delay + setTimeout(function() { + isValidating = false; + }, 100); + } + }); }); // Handle selection of media diff --git a/includes/Admin.php b/includes/Admin.php index 852c49c61..b8518f888 100644 --- a/includes/Admin.php +++ b/includes/Admin.php @@ -25,6 +25,9 @@ */ class Admin { + /** @var int maximum number of images allowed for Facebook catalog sync */ + const MAX_FACEBOOK_IMAGES = 21; + /** @var string the "sync and show" sync mode slug */ const SYNC_MODE_SYNC_AND_SHOW = 'sync_and_show'; @@ -195,6 +198,7 @@ public function enqueue_scripts() { 'product_not_ready_modal_message' => $this->get_product_not_ready_modal_message(), 'product_not_ready_modal_buttons' => $this->get_product_not_ready_modal_buttons(), 'product_removed_from_sync_field_id' => '#' . \WC_Facebook_Product::FB_REMOVE_FROM_SYNC, + 'max_facebook_images' => self::MAX_FACEBOOK_IMAGES, 'i18n' => [ 'missing_google_product_category_message' => __( 'Please enter a Google product category and at least one sub-category to sell this product on Instagram.', 'facebook-for-woocommerce' ), ], @@ -1610,14 +1614,34 @@ private function process_variation_post_data( $index ) { $image_url = isset( $_POST[ $posted_param ][ $index ] ) ? esc_url_raw( wp_unslash( $_POST[ $posted_param ][ $index ] ) ) : null; $posted_param = 'variable_' . \WC_Facebook_Product::FB_PRODUCT_VIDEO; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verification is handled in save_product_variation_edit_fields method - $video_urls = isset( $_POST[ $posted_param ][ $index ] ) ? esc_url_raw( wp_unslash( $_POST[ $posted_param ][ $index ] ) ) : []; + $video_urls = isset( $_POST[ $posted_param ][ $index ] ) ? esc_url_raw( wp_unslash( $_POST[ $posted_param ][ $index ] ) ) : []; // Fix: Look for the actual POST key format that WooCommerce generates $posted_param = 'variable_' . \WC_Facebook_Product::FB_PRODUCT_IMAGES . $index; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verification is handled in save_product_variation_edit_fields method - $image_ids = isset( $_POST[ $posted_param ] ) ? sanitize_text_field( wp_unslash( $_POST[ $posted_param ] ) ) : ''; + $image_ids = isset( $_POST[ $posted_param ] ) ? sanitize_text_field( wp_unslash( $_POST[ $posted_param ] ) ) : ''; + + // Validate the image limit for Facebook catalog + if ( ! empty( $image_ids ) ) { + $image_ids_array = array_filter( array_map( 'trim', explode( ',', $image_ids ) ), 'is_numeric' ); + if ( count( $image_ids_array ) > self::MAX_FACEBOOK_IMAGES ) { + add_action( + 'admin_notices', + function () { + $max_images = self::MAX_FACEBOOK_IMAGES; + echo '

' . + /* translators: %d: Maximum number of images allowed */ + sprintf( esc_html__( 'Facebook for WooCommerce: You can only select a maximum of %1$d images for Facebook catalog sync. Only the first %2$d images will be saved.', 'facebook-for-woocommerce' ), esc_html( $max_images ), esc_html( $max_images ) ) . + '

'; + } + ); + // Limit to maximum allowed images + $image_ids_array = array_slice( $image_ids_array, 0, self::MAX_FACEBOOK_IMAGES ); + $image_ids = implode( ',', $image_ids_array ); + } + } $posted_param = 'variable_' . \WC_Facebook_Product::FB_PRODUCT_PRICE; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verification is handled in save_product_variation_edit_fields method - $price = isset( $_POST[ $posted_param ][ $index ] ) ? wc_format_decimal( wc_clean( wp_unslash( $_POST[ $posted_param ][ $index ] ) ) ) : ''; + $price = isset( $_POST[ $posted_param ][ $index ] ) ? wc_format_decimal( wc_clean( wp_unslash( $_POST[ $posted_param ][ $index ] ) ) ) : ''; return array( 'description_plain' => $description_plain, diff --git a/tests/Unit/AdminTest.php b/tests/Unit/AdminTest.php index 5c63b1b05..687890e7b 100644 --- a/tests/Unit/AdminTest.php +++ b/tests/Unit/AdminTest.php @@ -658,4 +658,46 @@ public function test_multiple_images_persistence() { $restored_images = get_post_meta($variation_id, \WC_Facebook_Product::FB_PRODUCT_IMAGES, true); $this->assertEquals('400,500,600,700', $restored_images); } + + /** + * Test 21 image limit validation in save_product_variation_edit_fields + */ + public function test_save_product_variation_edit_fields_21_image_limit() { + // Create a variable product with variation + $variable_product = \WC_Helper_Product::create_variation_product(); + $variation = wc_get_product($variable_product->get_children()[0]); + $variation_id = $variation->get_id(); + $index = 0; + + // Create a string with 25 image IDs (exceeding the 21 limit) + $image_ids_over_limit = implode(',', range(1, 25)); + + // Mock POST data with too many images + $_POST["facebook_variation_nonce_{$variation_id}"] = wp_create_nonce('facebook_variation_save'); + $_POST['wc_facebook_sync_mode'] = 'sync_and_show'; + $_POST['variable_fb_product_image_source'] = [0 => 'multiple']; + $_POST["variable_fb_product_images{$index}"] = $image_ids_over_limit; + + // Call the method + $this->admin->save_product_variation_edit_fields($variation_id, $index); + + // Verify only first 21 images were saved + $saved_images = get_post_meta($variation_id, \WC_Facebook_Product::FB_PRODUCT_IMAGES, true); + $saved_ids_array = explode(',', $saved_images); + $this->assertEquals(21, count($saved_ids_array)); + + // Verify it's the first 21 images + $expected_first_21 = implode(',', range(1, 21)); + $this->assertEquals($expected_first_21, $saved_images); + + // Test with exactly 21 images (should pass) + $exactly_21_images = implode(',', range(100, 120)); + $_POST["variable_fb_product_images{$index}"] = $exactly_21_images; + + $this->admin->save_product_variation_edit_fields($variation_id, $index); + + $saved_images_21 = get_post_meta($variation_id, \WC_Facebook_Product::FB_PRODUCT_IMAGES, true); + $this->assertEquals($exactly_21_images, $saved_images_21); + $this->assertEquals(21, count(explode(',', $saved_images_21))); + } } diff --git a/tests/js/multiple-images.test.js b/tests/js/multiple-images.test.js index 66e9b44e4..4c443382e 100644 --- a/tests/js/multiple-images.test.js +++ b/tests/js/multiple-images.test.js @@ -504,4 +504,195 @@ describe('Multiple Images Functionality', function() { expect(global.wp).toBeUndefined(); }); }); + + describe('21 Image Limit Validation', function() { + beforeEach(function() { + // Setup DOM + document.body.innerHTML = ` +
+ + `; + + // Mock window.facebook_for_woocommerce_products_admin + global.window.facebook_for_woocommerce_products_admin = { + max_facebook_images: 21 + }; + + // Mock alert + global.alert = jest.fn(); + }); + + it('should enforce 21 image limit in handleVariationImageSelection', function() { + // Create mock selection with 25 images (exceeding limit) + const mockSelection = { + map: jest.fn((callback) => Array.from({length: 25}, (_, i) => callback({ id: i + 1 }))) + }; + + // Mock the handleVariationImageSelection function from the main script + const handleVariationImageSelection = function(selection, variationIndex) { + const selectedAttachmentIds = selection.map(attachment => attachment.id); + + // Check if the selection exceeds the Facebook image limit + const maxImages = window.facebook_for_woocommerce_products_admin && window.facebook_for_woocommerce_products_admin.max_facebook_images ? + parseInt(window.facebook_for_woocommerce_products_admin.max_facebook_images) : 21; + if (selectedAttachmentIds.length > maxImages) { + alert(`You can only select a maximum of ${maxImages} images for Facebook catalog sync. Please reduce your selection.`); + return; + } + + // Continue with normal processing... + const $hiddenField = $(`#variable_fb_product_images${variationIndex}`); + if ($hiddenField.length) { + $hiddenField.val(selectedAttachmentIds.join(',')); + } + }; + + // Call the function + handleVariationImageSelection(mockSelection, 0); + + // Verify alert was called with correct message + expect(global.alert).toHaveBeenCalledWith( + 'You can only select a maximum of 21 images for Facebook catalog sync. Please reduce your selection.' + ); + + // Verify hidden field was not updated (function returned early) + const hiddenField = document.getElementById('variable_fb_product_images0'); + expect(hiddenField.value).toBe(''); + }); + + it('should allow exactly 21 images', function() { + // Create mock selection with exactly 21 images + const mockSelection = { + map: jest.fn((callback) => Array.from({length: 21}, (_, i) => callback({ id: i + 1 }))) + }; + + // Mock jQuery for this test + global.$ = jest.fn((selector) => { + if (selector === '#variable_fb_product_images0') { + return { + length: 1, + val: jest.fn((value) => { + if (value !== undefined) { + document.getElementById('variable_fb_product_images0').value = value; + } + return document.getElementById('variable_fb_product_images0').value; + }) + }; + } + return { length: 0 }; + }); + + // Mock the handleVariationImageSelection function + const handleVariationImageSelection = function(selection, variationIndex) { + const selectedAttachmentIds = selection.map(attachment => attachment.id); + + const maxImages = window.facebook_for_woocommerce_products_admin && window.facebook_for_woocommerce_products_admin.max_facebook_images ? + parseInt(window.facebook_for_woocommerce_products_admin.max_facebook_images) : 21; + if (selectedAttachmentIds.length > maxImages) { + alert(`You can only select a maximum of ${maxImages} images for Facebook catalog sync. Please reduce your selection.`); + return; + } + + const $hiddenField = $(`#variable_fb_product_images${variationIndex}`); + if ($hiddenField.length) { + $hiddenField.val(selectedAttachmentIds.join(',')); + } + }; + + // Call the function + handleVariationImageSelection(mockSelection, 0); + + // Verify alert was not called + expect(global.alert).not.toHaveBeenCalled(); + + // Verify hidden field was updated correctly + const hiddenField = document.getElementById('variable_fb_product_images0'); + expect(hiddenField.value).toBe('1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21'); + }); + + it('should fall back to default limit when config is not available', function() { + // Remove the config + global.window.facebook_for_woocommerce_products_admin = undefined; + + // Create mock selection with 25 images + const mockSelection = { + map: jest.fn((callback) => Array.from({length: 25}, (_, i) => callback({ id: i + 1 }))) + }; + + // Mock the handleVariationImageSelection function + const handleVariationImageSelection = function(selection, variationIndex) { + const selectedAttachmentIds = selection.map(attachment => attachment.id); + + const maxImages = window.facebook_for_woocommerce_products_admin && window.facebook_for_woocommerce_products_admin.max_facebook_images ? + parseInt(window.facebook_for_woocommerce_products_admin.max_facebook_images) : 21; + if (selectedAttachmentIds.length > maxImages) { + alert(`You can only select a maximum of ${maxImages} images for Facebook catalog sync. Please reduce your selection.`); + return; + } + }; + + // Call the function + handleVariationImageSelection(mockSelection, 0); + + // Verify alert was called with default limit + expect(global.alert).toHaveBeenCalledWith( + 'You can only select a maximum of 21 images for Facebook catalog sync. Please reduce your selection.' + ); + }); + + it('should prevent real-time selection beyond 21 images with throttling', function(done) { + // Test the real-time validation logic with improved behavior + const maxImages = 21; + let selectionLength = 21; // Already at limit + let isValidating = false; + let lastAlertTime = 0; + + // Mock selection methods + const mockModel = { id: 22 }; + const mockRemove = jest.fn(); + + // Simulate the improved validation logic + const validateSelection = function() { + if (isValidating) return; + + if (selectionLength > maxImages) { + isValidating = true; + mockRemove(mockModel); + selectionLength--; // Simulate removal + + const now = Date.now(); + if (now - lastAlertTime > 2000) { + lastAlertTime = now; + setTimeout(function() { + alert(`You can only select a maximum of ${maxImages} images for Facebook catalog sync.`); + }, 10); + } + + setTimeout(function() { + isValidating = false; + }, 100); + } + }; + + // Test adding one more image (22nd) + selectionLength++; // Simulate adding + validateSelection(); + + // Wait for setTimeout to execute + setTimeout(function() { + // Verify alert was called + expect(global.alert).toHaveBeenCalledWith( + 'You can only select a maximum of 21 images for Facebook catalog sync.' + ); + + // Verify remove was called + expect(mockRemove).toHaveBeenCalledWith(mockModel); + + // Verify selection stayed at limit + expect(selectionLength).toBe(21); + + done(); + }, 50); + }); + }); }); \ No newline at end of file