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