Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions assets/js/admin/products-admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
30 changes: 27 additions & 3 deletions includes/Admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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' ),
],
Expand Down Expand Up @@ -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 '<div class="notice notice-error is-dismissible"><p>' .
/* 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 ) ) .
'</p></div>';
}
);
// 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,
Expand Down
42 changes: 42 additions & 0 deletions tests/Unit/AdminTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}
}
191 changes: 191 additions & 0 deletions tests/js/multiple-images.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<div id="fb_product_images_selected_thumbnails_0" class="fb-product-images-thumbnails"></div>
<input type="hidden" id="variable_fb_product_images0" value="" />
`;

// 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);
});
});
});
Loading