From e6a1ed480b8814a522731f361a83a4769a23e099 Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Sun, 21 Dec 2025 14:30:49 +0000 Subject: [PATCH 1/7] feat: add autocomplete for conditional arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Select2-based autocomplete for conditional arguments (categories, tags, pages, posts) to make it easier for users to select values when configuring ad code conditions. Key implementation details: - Uses WordPress's bundled SelectWoo/Select2 library - AJAX search with 3-character minimum and 100 result limit - Supports both taxonomy terms and post type searches - Allows custom values via Select2's tags option - Extensible via `acm_autocomplete_conditionals` and `acm_autocomplete_results` filters Fixes #42 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- acm-autocomplete.js | 215 +++++++++++++++ ad-code-manager.php | 5 + phpunit.xml.dist | 2 +- src/UI/class-conditional-autocomplete.php | 259 ++++++++++++++++++ .../UI/ConditionalAutocompleteTest.php | 233 ++++++++++++++++ 5 files changed, 713 insertions(+), 1 deletion(-) create mode 100644 acm-autocomplete.js create mode 100644 src/UI/class-conditional-autocomplete.php create mode 100644 tests/Integration/UI/ConditionalAutocompleteTest.php diff --git a/acm-autocomplete.js b/acm-autocomplete.js new file mode 100644 index 0000000..94e2559 --- /dev/null +++ b/acm-autocomplete.js @@ -0,0 +1,215 @@ +/** + * Conditional Autocomplete for Ad Code Manager + * + * Provides Select2-powered autocomplete for conditional arguments + * like categories, tags, pages, etc. + * + * @since 0.9.0 + */ +( function( $, acmAutocomplete ) { + 'use strict'; + + if ( typeof acmAutocomplete === 'undefined' ) { + return; + } + + var ConditionalAutocomplete = { + + /** + * Initialize the autocomplete functionality. + */ + init: function() { + this.bindEvents(); + this.initExistingFields(); + }, + + /** + * Bind event handlers. + */ + bindEvents: function() { + var self = this; + + // Use event delegation for dynamically added conditional selects. + $( document ).on( 'change', 'select[name="acm-conditionals[]"]', function() { + self.handleConditionalChange( $( this ) ); + }); + + // Re-initialize when new conditional rows are added. + $( document ).on( 'click', '.add-more-conditionals', function() { + // Small delay to allow DOM to update. + setTimeout( function() { + self.initExistingFields(); + }, 100 ); + }); + }, + + /** + * Initialize any existing conditional fields on page load. + */ + initExistingFields: function() { + var self = this; + + $( 'select[name="acm-conditionals[]"]' ).each( function() { + var $select = $( this ); + var conditional = $select.val(); + + if ( conditional && self.hasAutocomplete( conditional ) ) { + self.initAutocomplete( $select ); + } + }); + }, + + /** + * Handle when a conditional select changes. + * + * @param {jQuery} $select The conditional select element. + */ + handleConditionalChange: function( $select ) { + var conditional = $select.val(); + var $argumentsContainer = $select.closest( '.conditional-single-field' ).find( '.conditional-arguments' ); + var $input = $argumentsContainer.find( 'input[name="acm-arguments[]"]' ); + + // Destroy existing Select2 if present. + if ( $input.hasClass( 'select2-hidden-accessible' ) ) { + $input.select2( 'destroy' ); + } + + // Reset the input. + $input.val( '' ).attr( 'type', 'text' ).show(); + + // If this conditional supports autocomplete, initialize it. + if ( conditional && this.hasAutocomplete( conditional ) ) { + this.initAutocomplete( $select ); + } + }, + + /** + * Check if a conditional has autocomplete configuration. + * + * @param {string} conditional The conditional function name. + * @return {boolean} True if autocomplete is available. + */ + hasAutocomplete: function( conditional ) { + return acmAutocomplete.conditionals.hasOwnProperty( conditional ); + }, + + /** + * Get the autocomplete configuration for a conditional. + * + * @param {string} conditional The conditional function name. + * @return {Object|null} The configuration or null. + */ + getConfig: function( conditional ) { + return acmAutocomplete.conditionals[ conditional ] || null; + }, + + /** + * Initialize Select2 autocomplete on an arguments input. + * + * @param {jQuery} $conditionalSelect The conditional select element. + */ + initAutocomplete: function( $conditionalSelect ) { + var self = this; + var conditional = $conditionalSelect.val(); + var config = this.getConfig( conditional ); + + if ( ! config ) { + return; + } + + var $argumentsContainer = $conditionalSelect.closest( '.conditional-single-field' ).find( '.conditional-arguments' ); + var $input = $argumentsContainer.find( 'input[name="acm-arguments[]"]' ); + var currentValue = $input.val(); + + // Initialize Select2 with AJAX. + $input.select2({ + ajax: { + url: acmAutocomplete.ajaxUrl, + dataType: 'json', + delay: 250, + data: function( params ) { + return { + action: 'acm_search_terms', + nonce: acmAutocomplete.nonce, + search: params.term, + conditional: conditional, + type: config.type, + taxonomy: config.taxonomy || '', + post_type: config.post_type || '' + }; + }, + processResults: function( response ) { + if ( response.success && response.data.results ) { + return { + results: response.data.results + }; + } + return { results: [] }; + }, + cache: true + }, + minimumInputLength: acmAutocomplete.minChars, + placeholder: self.getPlaceholder( conditional ), + allowClear: true, + tags: true, // Allow custom values (user can type their own). + createTag: function( params ) { + var term = $.trim( params.term ); + if ( term === '' ) { + return null; + } + return { + id: term, + text: term, + newTag: true + }; + }, + language: { + inputTooShort: function() { + return acmAutocomplete.i18n.inputTooShort; + }, + searching: function() { + return acmAutocomplete.i18n.searching; + }, + noResults: function() { + return acmAutocomplete.i18n.noResults; + }, + errorLoading: function() { + return acmAutocomplete.i18n.errorLoading; + } + }, + width: '100%' + }); + + // If there's an existing value, set it. + if ( currentValue ) { + var option = new Option( currentValue, currentValue, true, true ); + $input.append( option ).trigger( 'change' ); + } + }, + + /** + * Get placeholder text for a conditional. + * + * @param {string} conditional The conditional function name. + * @return {string} The placeholder text. + */ + getPlaceholder: function( conditional ) { + var placeholders = { + 'is_category': acmAutocomplete.i18n.searchCategories || 'Search categories...', + 'has_category': acmAutocomplete.i18n.searchCategories || 'Search categories...', + 'is_tag': acmAutocomplete.i18n.searchTags || 'Search tags...', + 'has_tag': acmAutocomplete.i18n.searchTags || 'Search tags...', + 'is_page': acmAutocomplete.i18n.searchPages || 'Search pages...', + 'is_single': acmAutocomplete.i18n.searchPosts || 'Search posts...' + }; + + return placeholders[ conditional ] || 'Search...'; + } + }; + + // Initialize when document is ready. + $( document ).ready( function() { + ConditionalAutocomplete.init(); + }); + +} )( jQuery, window.acmAutocomplete ); diff --git a/ad-code-manager.php b/ad-code-manager.php index 1a5e93a..7d2c051 100644 --- a/ad-code-manager.php +++ b/ad-code-manager.php @@ -27,6 +27,7 @@ namespace Automattic\AdCodeManager; use Ad_Code_Manager; +use Automattic\AdCodeManager\UI\Conditional_Autocomplete; use Automattic\AdCodeManager\UI\Contextual_Help; use Automattic\AdCodeManager\UI\Plugin_Actions; @@ -37,6 +38,7 @@ require_once __DIR__ . '/src/class-acm-wp-list-table.php'; require_once __DIR__ . '/src/class-acm-widget.php'; require_once __DIR__ . '/src/class-ad-code-manager.php'; +require_once __DIR__ . '/src/UI/class-conditional-autocomplete.php'; require_once __DIR__ . '/src/UI/class-contextual-help.php'; require_once __DIR__ . '/src/UI/class-plugin-actions.php'; @@ -51,6 +53,9 @@ function () { add_action( 'admin_init', function () { + $conditional_autocomplete = new Conditional_Autocomplete(); + $conditional_autocomplete->run(); + $contextual_help = new Contextual_Help(); $contextual_help->run(); diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 1d5a597..c7776ae 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -19,7 +19,7 @@ tests/Unit - tests/Integration + tests/Integration diff --git a/src/UI/class-conditional-autocomplete.php b/src/UI/class-conditional-autocomplete.php new file mode 100644 index 0000000..791eaec --- /dev/null +++ b/src/UI/class-conditional-autocomplete.php @@ -0,0 +1,259 @@ + admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'acm-search-terms' ), + 'minChars' => self::MIN_CHARS, + 'conditionals' => $this->get_autocomplete_conditionals(), + 'i18n' => array( + 'searching' => __( 'Searching...', 'ad-code-manager' ), + 'noResults' => __( 'No results found', 'ad-code-manager' ), + 'inputTooShort' => sprintf( + /* translators: %d: minimum number of characters */ + __( 'Please enter %d or more characters', 'ad-code-manager' ), + self::MIN_CHARS + ), + 'errorLoading' => __( 'Error loading results', 'ad-code-manager' ), + ), + ) + ); + } + + /** + * Get the conditionals that support autocomplete. + * + * Returns a mapping of conditional function names to their search configuration. + * + * @return array + */ + private function get_autocomplete_conditionals(): array { + $conditionals = array( + 'is_category' => array( + 'type' => 'taxonomy', + 'taxonomy' => 'category', + ), + 'has_category' => array( + 'type' => 'taxonomy', + 'taxonomy' => 'category', + ), + 'is_tag' => array( + 'type' => 'taxonomy', + 'taxonomy' => 'post_tag', + ), + 'has_tag' => array( + 'type' => 'taxonomy', + 'taxonomy' => 'post_tag', + ), + 'is_page' => array( + 'type' => 'post_type', + 'post_type' => 'page', + ), + 'is_single' => array( + 'type' => 'post_type', + 'post_type' => 'post', + ), + ); + + /** + * Filters the conditionals that support autocomplete. + * + * Allows themes and plugins to add or modify which conditionals + * get autocomplete functionality and how they search. + * + * @since 0.9.0 + * + * @param array $conditionals Associative array of conditional configurations. + * Each key is a conditional function name. + * Each value is an array with: + * - 'type': 'taxonomy' or 'post_type' + * - 'taxonomy': (for taxonomy type) The taxonomy to search + * - 'post_type': (for post_type type) The post type to search + */ + return apply_filters( 'acm_autocomplete_conditionals', $conditionals ); + } + + /** + * AJAX handler for searching terms. + * + * @return void + */ + public function ajax_search_terms(): void { + check_ajax_referer( 'acm-search-terms', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Permission denied.', 'ad-code-manager' ) ) ); + } + + $search = isset( $_GET['search'] ) ? sanitize_text_field( wp_unslash( $_GET['search'] ) ) : ''; + $conditional = isset( $_GET['conditional'] ) ? sanitize_key( $_GET['conditional'] ) : ''; + $type = isset( $_GET['type'] ) ? sanitize_key( $_GET['type'] ) : ''; + $taxonomy = isset( $_GET['taxonomy'] ) ? sanitize_key( $_GET['taxonomy'] ) : ''; + $post_type = isset( $_GET['post_type'] ) ? sanitize_key( $_GET['post_type'] ) : ''; + + if ( strlen( $search ) < self::MIN_CHARS ) { + wp_send_json_error( array( 'message' => __( 'Search term too short.', 'ad-code-manager' ) ) ); + } + + $results = array(); + + if ( 'taxonomy' === $type && ! empty( $taxonomy ) ) { + $results = $this->search_taxonomy_terms( $search, $taxonomy ); + } elseif ( 'post_type' === $type && ! empty( $post_type ) ) { + $results = $this->search_posts( $search, $post_type ); + } + + /** + * Filters the autocomplete search results. + * + * @since 0.9.0 + * + * @param array $results The search results. + * @param string $search The search term. + * @param string $conditional The conditional function name. + * @param string $type The search type ('taxonomy' or 'post_type'). + */ + $results = apply_filters( 'acm_autocomplete_results', $results, $search, $conditional, $type ); + + wp_send_json_success( array( 'results' => $results ) ); + } + + /** + * Search for taxonomy terms. + * + * @param string $search The search term. + * @param string $taxonomy The taxonomy to search. + * @return array + */ + private function search_taxonomy_terms( string $search, string $taxonomy ): array { + $terms = get_terms( + array( + 'taxonomy' => $taxonomy, + 'search' => $search, + 'number' => self::MAX_RESULTS, + 'hide_empty' => false, + 'orderby' => 'name', + 'order' => 'ASC', + ) + ); + + if ( is_wp_error( $terms ) || empty( $terms ) ) { + return array(); + } + + $results = array(); + foreach ( $terms as $term ) { + $results[] = array( + 'id' => $term->slug, + 'text' => sprintf( '%s (%s)', $term->name, $term->slug ), + ); + } + + return $results; + } + + /** + * Search for posts. + * + * @param string $search The search term. + * @param string $post_type The post type to search. + * @return array + */ + private function search_posts( string $search, string $post_type ): array { + $posts = get_posts( + array( + 'post_type' => $post_type, + 's' => $search, + 'posts_per_page' => self::MAX_RESULTS, + 'post_status' => 'publish', + 'orderby' => 'title', + 'order' => 'ASC', + ) + ); + + if ( empty( $posts ) ) { + return array(); + } + + $results = array(); + foreach ( $posts as $post ) { + // For is_page/is_single, we can use ID, slug, or title. + $results[] = array( + 'id' => (string) $post->ID, + 'text' => sprintf( '%s (ID: %d)', $post->post_title, $post->ID ), + ); + } + + return $results; + } +} diff --git a/tests/Integration/UI/ConditionalAutocompleteTest.php b/tests/Integration/UI/ConditionalAutocompleteTest.php new file mode 100644 index 0000000..f56201d --- /dev/null +++ b/tests/Integration/UI/ConditionalAutocompleteTest.php @@ -0,0 +1,233 @@ +autocomplete = new Conditional_Autocomplete(); + } + + /** + * Test that the run method registers the necessary hooks. + * + * @covers \Automattic\AdCodeManager\UI\Conditional_Autocomplete::run + */ + public function test_run_registers_hooks(): void { + $this->autocomplete->run(); + + self::assertNotFalse( + has_action( 'admin_enqueue_scripts', array( $this->autocomplete, 'enqueue_scripts' ) ) + ); + self::assertNotFalse( + has_action( 'wp_ajax_acm_search_terms', array( $this->autocomplete, 'ajax_search_terms' ) ) + ); + } + + /** + * Test that scripts are not enqueued on non-ACM pages. + * + * @covers \Automattic\AdCodeManager\UI\Conditional_Autocomplete::enqueue_scripts + */ + public function test_scripts_not_enqueued_on_other_pages(): void { + $this->autocomplete->enqueue_scripts( 'edit.php' ); + + self::assertFalse( wp_script_is( 'acm-conditional-autocomplete', 'enqueued' ) ); + } + + /** + * Test that the acm_autocomplete_conditionals filter is applied. + * + * @covers \Automattic\AdCodeManager\UI\Conditional_Autocomplete + */ + public function test_autocomplete_conditionals_filter(): void { + $filter_called = false; + $custom_conditionals = array( + 'custom_conditional' => array( + 'type' => 'taxonomy', + 'taxonomy' => 'custom_tax', + ), + ); + + add_filter( + 'acm_autocomplete_conditionals', + function ( $conditionals ) use ( &$filter_called, $custom_conditionals ) { + $filter_called = true; + return array_merge( $conditionals, $custom_conditionals ); + } + ); + + // Trigger script enqueue to get the conditionals. + set_current_screen( 'settings_page_ad-code-manager' ); + $this->autocomplete->enqueue_scripts( 'settings_page_ad-code-manager' ); + + self::assertTrue( $filter_called ); + } + + /** + * Test default autocomplete conditionals include expected entries. + * + * @covers \Automattic\AdCodeManager\UI\Conditional_Autocomplete + */ + public function test_default_autocomplete_conditionals(): void { + // Use reflection to access the private method. + $reflection = new ReflectionClass( $this->autocomplete ); + $method = $reflection->getMethod( 'get_autocomplete_conditionals' ); + $method->setAccessible( true ); + + $conditionals = $method->invoke( $this->autocomplete ); + + // Verify expected conditionals are present. + self::assertArrayHasKey( 'is_category', $conditionals ); + self::assertArrayHasKey( 'has_category', $conditionals ); + self::assertArrayHasKey( 'is_tag', $conditionals ); + self::assertArrayHasKey( 'has_tag', $conditionals ); + self::assertArrayHasKey( 'is_page', $conditionals ); + self::assertArrayHasKey( 'is_single', $conditionals ); + + // Verify structure of a taxonomy conditional. + self::assertEquals( 'taxonomy', $conditionals['is_category']['type'] ); + self::assertEquals( 'category', $conditionals['is_category']['taxonomy'] ); + + // Verify structure of a post_type conditional. + self::assertEquals( 'post_type', $conditionals['is_page']['type'] ); + self::assertEquals( 'page', $conditionals['is_page']['post_type'] ); + } + + /** + * Test taxonomy term search. + * + * @covers \Automattic\AdCodeManager\UI\Conditional_Autocomplete + */ + public function test_search_taxonomy_terms(): void { + // Create test categories. + self::factory()->category->create( array( 'name' => 'Technology News' ) ); + self::factory()->category->create( array( 'name' => 'Tech Reviews' ) ); + self::factory()->category->create( array( 'name' => 'Sports' ) ); + + // Use reflection to access the private method. + $reflection = new ReflectionClass( $this->autocomplete ); + $method = $reflection->getMethod( 'search_taxonomy_terms' ); + $method->setAccessible( true ); + + $results = $method->invoke( $this->autocomplete, 'Tech', 'category' ); + + self::assertCount( 2, $results ); + self::assertArrayHasKey( 'id', $results[0] ); + self::assertArrayHasKey( 'text', $results[0] ); + } + + /** + * Test post search. + * + * @covers \Automattic\AdCodeManager\UI\Conditional_Autocomplete + */ + public function test_search_posts(): void { + // Create test pages. + self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_title' => 'About Us', + 'post_status' => 'publish', + ) + ); + self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_title' => 'About Our Team', + 'post_status' => 'publish', + ) + ); + self::factory()->post->create( + array( + 'post_type' => 'page', + 'post_title' => 'Contact', + 'post_status' => 'publish', + ) + ); + + // Use reflection to access the private method. + $reflection = new ReflectionClass( $this->autocomplete ); + $method = $reflection->getMethod( 'search_posts' ); + $method->setAccessible( true ); + + $results = $method->invoke( $this->autocomplete, 'About', 'page' ); + + self::assertCount( 2, $results ); + self::assertArrayHasKey( 'id', $results[0] ); + self::assertArrayHasKey( 'text', $results[0] ); + } + + /** + * Test empty search returns empty array. + * + * @covers \Automattic\AdCodeManager\UI\Conditional_Autocomplete + */ + public function test_search_no_results(): void { + // Use reflection to access the private method. + $reflection = new ReflectionClass( $this->autocomplete ); + $method = $reflection->getMethod( 'search_taxonomy_terms' ); + $method->setAccessible( true ); + + $results = $method->invoke( $this->autocomplete, 'NonexistentTerm', 'category' ); + + self::assertIsArray( $results ); + self::assertEmpty( $results ); + } + + /** + * Test search for tags returns correct structure. + * + * @covers \Automattic\AdCodeManager\UI\Conditional_Autocomplete + */ + public function test_search_tags(): void { + // Create test tags. + self::factory()->tag->create( array( 'name' => 'WordPress Tips' ) ); + self::factory()->tag->create( array( 'name' => 'WordPress Plugins' ) ); + + // Use reflection to access the private method. + $reflection = new ReflectionClass( $this->autocomplete ); + $method = $reflection->getMethod( 'search_taxonomy_terms' ); + $method->setAccessible( true ); + + $results = $method->invoke( $this->autocomplete, 'WordPress', 'post_tag' ); + + self::assertCount( 2, $results ); + // Results should use slug as ID. + self::assertStringContainsString( 'wordpress', $results[0]['id'] ); + } + + /** + * Clean up after each test. + */ + public function tear_down(): void { + unset( $_GET['search'], $_GET['conditional'], $_GET['type'], $_GET['taxonomy'], $_GET['post_type'], $_GET['nonce'] ); + parent::tear_down(); + } +} From 78398df2f28f84c13b7c00f7fafd92bbc1ebf1b5 Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Sun, 21 Dec 2025 14:36:44 +0000 Subject: [PATCH 2/7] fix: convert input to hidden type for Select2 AJAX compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select2 with AJAX data requires hidden inputs, not text inputs. Also fix test pollution by dequeuing scripts between tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- acm-autocomplete.js | 8 +++++++- tests/Integration/UI/ConditionalAutocompleteTest.php | 12 ++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/acm-autocomplete.js b/acm-autocomplete.js index 94e2559..5420cda 100644 --- a/acm-autocomplete.js +++ b/acm-autocomplete.js @@ -72,9 +72,11 @@ // Destroy existing Select2 if present. if ( $input.hasClass( 'select2-hidden-accessible' ) ) { $input.select2( 'destroy' ); + // Remove the Select2 container that gets left behind. + $argumentsContainer.find( '.select2-container' ).remove(); } - // Reset the input. + // Reset the input to a visible text input. $input.val( '' ).attr( 'type', 'text' ).show(); // If this conditional supports autocomplete, initialize it. @@ -121,6 +123,10 @@ var $input = $argumentsContainer.find( 'input[name="acm-arguments[]"]' ); var currentValue = $input.val(); + // Select2 with AJAX requires a hidden input, not text input. + // Convert the text input to hidden type for Select2. + $input.attr( 'type', 'hidden' ); + // Initialize Select2 with AJAX. $input.select2({ ajax: { diff --git a/tests/Integration/UI/ConditionalAutocompleteTest.php b/tests/Integration/UI/ConditionalAutocompleteTest.php index f56201d..7bfcbb6 100644 --- a/tests/Integration/UI/ConditionalAutocompleteTest.php +++ b/tests/Integration/UI/ConditionalAutocompleteTest.php @@ -31,6 +31,12 @@ final class ConditionalAutocompleteTest extends TestCase { */ public function set_up(): void { parent::set_up(); + + // Ensure scripts are dequeued at the start of each test. + wp_dequeue_script( 'acm-conditional-autocomplete' ); + wp_dequeue_script( 'selectWoo' ); + wp_dequeue_style( 'select2' ); + $this->autocomplete = new Conditional_Autocomplete(); } @@ -228,6 +234,12 @@ public function test_search_tags(): void { */ public function tear_down(): void { unset( $_GET['search'], $_GET['conditional'], $_GET['type'], $_GET['taxonomy'], $_GET['post_type'], $_GET['nonce'] ); + + // Dequeue scripts to prevent test pollution. + wp_dequeue_script( 'acm-conditional-autocomplete' ); + wp_dequeue_script( 'selectWoo' ); + wp_dequeue_style( 'select2' ); + parent::tear_down(); } } From e879cbf1957c409dbbd0d35374ef54fe31ccfeac Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Sun, 21 Dec 2025 14:42:14 +0000 Subject: [PATCH 3/7] fix: load Select2 from CDN when not already available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select2/selectWoo is not bundled with WordPress core - it comes from WooCommerce. When neither selectWoo nor select2 is registered, we now load Select2 v4.0.13 from jsDelivr CDN. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/UI/class-conditional-autocomplete.php | 33 ++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/UI/class-conditional-autocomplete.php b/src/UI/class-conditional-autocomplete.php index 791eaec..400a63a 100644 --- a/src/UI/class-conditional-autocomplete.php +++ b/src/UI/class-conditional-autocomplete.php @@ -43,6 +43,13 @@ public function run(): void { add_action( 'wp_ajax_acm_search_terms', array( $this, 'ajax_search_terms' ) ); } + /** + * Select2 version to use from CDN. + * + * @var string + */ + private const SELECT2_VERSION = '4.0.13'; + /** * Enqueue scripts and styles for the autocomplete functionality. * @@ -54,15 +61,35 @@ public function enqueue_scripts( string $hook_suffix ): void { return; } - // Enqueue Select2 from WordPress (available since WP 4.0). - wp_enqueue_script( 'selectWoo' ); + // Register Select2 from CDN if not already available. + // Check for common Select2 handles (selectWoo from WooCommerce, select2 from other plugins). + if ( ! wp_script_is( 'selectWoo', 'registered' ) && ! wp_script_is( 'select2', 'registered' ) ) { + wp_register_script( + 'select2', + 'https://cdn.jsdelivr.net/npm/select2@' . self::SELECT2_VERSION . '/dist/js/select2.min.js', + array( 'jquery' ), + self::SELECT2_VERSION, + true + ); + wp_register_style( + 'select2', + 'https://cdn.jsdelivr.net/npm/select2@' . self::SELECT2_VERSION . '/dist/css/select2.min.css', + array(), + self::SELECT2_VERSION + ); + } + + // Determine which Select2 handle to use (prefer selectWoo if available). + $select2_handle = wp_script_is( 'selectWoo', 'registered' ) ? 'selectWoo' : 'select2'; + + wp_enqueue_script( $select2_handle ); wp_enqueue_style( 'select2' ); // Enqueue our autocomplete handler. wp_enqueue_script( 'acm-conditional-autocomplete', plugins_url( 'acm-autocomplete.js', dirname( __DIR__, 2 ) . '/ad-code-manager.php' ), - array( 'jquery', 'selectWoo' ), + array( 'jquery', $select2_handle ), filemtime( dirname( __DIR__, 2 ) . '/acm-autocomplete.js' ), true ); From 93f398ae9fda874d0045676f39b055cb96bf7067 Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Sun, 21 Dec 2025 14:56:53 +0000 Subject: [PATCH 4/7] fix: use dynamic select element for Select2 AJAX compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select2 with AJAX requires a for AJAX). + var $select = $( '' ) + .attr( 'name', 'acm-arguments[]' ) + .addClass( 'acm-autocomplete-select' ); + + // If there's an existing value, add it as an option. + if ( currentValue ) { + $select.append( new Option( currentValue, currentValue, true, true ) ); + } + + // Insert the select after the hidden input. + $input.after( $select ); + + // Remove the name from the original input to avoid duplicate submission. + $input.removeAttr( 'name' ).attr( 'data-original-name', 'acm-arguments[]' ); // Initialize Select2 with AJAX. - $input.select2({ + $select.select2({ ajax: { url: acmAutocomplete.ajaxUrl, dataType: 'json', @@ -185,12 +213,6 @@ }, width: '100%' }); - - // If there's an existing value, set it. - if ( currentValue ) { - var option = new Option( currentValue, currentValue, true, true ); - $input.append( option ).trigger( 'change' ); - } }, /** From ad363f36c7d4c6279f3bbd10e176a9fbac390f7e Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Sun, 21 Dec 2025 15:00:11 +0000 Subject: [PATCH 5/7] fix: hide arguments field for conditionals that take no parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conditionals like is_home, is_front_page, is_404, etc. don't accept arguments, so showing an input field is confusing. Now the arguments field is hidden when these conditionals are selected. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- acm-autocomplete.js | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/acm-autocomplete.js b/acm-autocomplete.js index c503868..76b46ef 100644 --- a/acm-autocomplete.js +++ b/acm-autocomplete.js @@ -92,6 +92,15 @@ // Reset the input value. $input.val( '' ); + // Hide arguments container for conditionals that take no parameters. + if ( conditional && this.hasNoParameters( conditional ) ) { + $argumentsContainer.hide(); + return; + } + + // Show arguments container for conditionals that take parameters. + $argumentsContainer.show(); + // If this conditional supports autocomplete, initialize it. if ( conditional && this.hasAutocomplete( conditional ) ) { this.initAutocomplete( $select ); @@ -232,6 +241,35 @@ }; return placeholders[ conditional ] || 'Search...'; + }, + + /** + * Check if a conditional takes no parameters. + * + * @param {string} conditional The conditional function name. + * @return {boolean} True if the conditional takes no parameters. + */ + hasNoParameters: function( conditional ) { + var noParamConditionals = [ + 'is_home', + 'is_front_page', + 'is_archive', + 'is_search', + 'is_404', + 'is_date', + 'is_year', + 'is_month', + 'is_day', + 'is_time', + 'is_feed', + 'is_comment_feed', + 'is_trackback', + 'is_preview', + 'is_paged', + 'is_admin' + ]; + + return noParamConditionals.indexOf( conditional ) !== -1; } }; From 227c6957213ae94ea0045746933006d40c2edbea Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Sun, 21 Dec 2025 15:15:31 +0000 Subject: [PATCH 6/7] fix: improve admin UI layout and usability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change "Add more" to "Add another condition" - Remove boxed styling from Configuration section - Fix Select2 field heights to match native inputs - Fix Edit labels to use proper labels from provider config - Link labels to their corresponding form fields - Hide "Add another condition" button until first condition selected - Show Logical Operator only when 2+ conditions are set - Move Logical Operator field near Conditionals in Edit view 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- acm-autocomplete.js | 50 ++++++++++++++++++++++-- acm.css | 68 +++++++++++++++++++++++++++++++++ src/class-acm-wp-list-table.php | 46 ++++++++++++---------- views/ad-code-manager.tpl.php | 61 +++++++++++++++-------------- 4 files changed, 170 insertions(+), 55 deletions(-) diff --git a/acm-autocomplete.js b/acm-autocomplete.js index 76b46ef..c38c147 100644 --- a/acm-autocomplete.js +++ b/acm-autocomplete.js @@ -21,6 +21,7 @@ init: function() { this.bindEvents(); this.initExistingFields(); + this.updateAddButtonVisibility(); }, /** @@ -32,6 +33,7 @@ // Use event delegation for dynamically added conditional selects. $( document ).on( 'change', 'select[name="acm-conditionals[]"]', function() { self.handleConditionalChange( $( this ) ); + self.updateAddButtonVisibility(); }); // Re-initialize when new conditional rows are added. @@ -39,6 +41,14 @@ // Small delay to allow DOM to update. setTimeout( function() { self.initExistingFields(); + self.updateAddButtonVisibility(); + }, 100 ); + }); + + // Handle remove conditional click. + $( document ).on( 'click', '.acm-remove-conditional', function() { + setTimeout( function() { + self.updateAddButtonVisibility(); }, 100 ); }); }, @@ -52,8 +62,17 @@ $( 'select[name="acm-conditionals[]"]' ).each( function() { var $select = $( this ); var conditional = $select.val(); + var $argumentsContainer = $select.closest( '.conditional-single-field' ).find( '.conditional-arguments' ); - if ( conditional && self.hasAutocomplete( conditional ) ) { + // Hide arguments for empty selection or no-parameter conditionals. + if ( ! conditional || self.hasNoParameters( conditional ) ) { + $argumentsContainer.hide(); + return; + } + + // Show arguments and init autocomplete if applicable. + $argumentsContainer.show(); + if ( self.hasAutocomplete( conditional ) ) { self.initAutocomplete( $select ); } }); @@ -92,8 +111,8 @@ // Reset the input value. $input.val( '' ); - // Hide arguments container for conditionals that take no parameters. - if ( conditional && this.hasNoParameters( conditional ) ) { + // Hide arguments container when no conditional selected or for conditionals that take no parameters. + if ( ! conditional || this.hasNoParameters( conditional ) ) { $argumentsContainer.hide(); return; } @@ -270,6 +289,31 @@ ]; return noParamConditionals.indexOf( conditional ) !== -1; + }, + + /** + * Update visibility of the "Add another condition" button. + * + * Shows the button only when at least one condition is selected. + */ + updateAddButtonVisibility: function() { + var $form = $( '#add-adcode' ); + var $addButton = $form.find( '.form-add-more' ); + var hasSelectedCondition = false; + + // Check if any conditional select has a value. + $form.find( 'select[name="acm-conditionals[]"]' ).each( function() { + if ( $( this ).val() ) { + hasSelectedCondition = true; + return false; // Break the loop. + } + }); + + if ( hasSelectedCondition ) { + $addButton.addClass( 'visible' ); + } else { + $addButton.removeClass( 'visible' ); + } } }; diff --git a/acm.css b/acm.css index 3f1303d..3b55ff7 100644 --- a/acm.css +++ b/acm.css @@ -160,6 +160,22 @@ tr:hover .row-actions { .acm-global-options { clear: both; + margin-bottom: 20px; +} + +.acm-global-options .acm-config-form p { + display: flex; + align-items: center; + gap: 10px; + margin: 0; +} + +.acm-global-options .acm-config-form label { + font-weight: 600; +} + +.acm-global-options .acm-config-form .button { + margin-left: 10px; } .acm-global-options input[type="text"] { @@ -171,3 +187,55 @@ tr:hover .row-actions { float: left; width: 125px; } + +/* Ensure conditional row has consistent height even when arguments hidden */ +#add-adcode .conditional-single-field { + min-height: 36px; + margin-bottom: 8px; +} + +/* Select2 styling for conditional autocomplete */ +#add-adcode .conditional-arguments .select2-container { + width: 100% !important; +} + +#add-adcode .conditional-arguments .select2-container--default .select2-selection--single { + height: 30px; + border-color: #8c8f94; + border-radius: 4px; +} + +#add-adcode .conditional-arguments .select2-container--default .select2-selection--single .select2-selection__rendered { + line-height: 28px; + padding-left: 8px; +} + +#add-adcode .conditional-arguments .select2-container--default .select2-selection--single .select2-selection__arrow { + height: 28px; +} + +/* Inline edit Select2 styling */ +.inline-edit-col .conditional-arguments .select2-container { + width: 70% !important; + margin-bottom: 5px; +} + +/* Hide Add button initially until first condition is selected */ +#add-adcode .form-add-more { + display: none; +} + +#add-adcode .form-add-more.visible { + display: block; +} + +/* Operator field styling in inline edit */ +.inline-edit-col .acm-operator-field { + clear: both; + margin-top: 10px; + padding-top: 10px; +} + +.inline-edit-col .acm-operator-field .acm-section-label { + margin-bottom: 5px; +} diff --git a/src/class-acm-wp-list-table.php b/src/class-acm-wp-list-table.php index 0d91ef9..4f0b4cf 100644 --- a/src/class-acm-wp-list-table.php +++ b/src/class-acm-wp-list-table.php @@ -273,26 +273,43 @@ function column_id( $item ) { $output .= 'Remove'; } } - $output .= ''; + $output .= ''; + // Build the field for the logical operator (near conditionals) + $condition_count = ! empty( $item['conditionals'] ) ? count( $item['conditionals'] ) : 0; + $operator_style = $condition_count < 2 ? ' style="display:none;"' : ''; + $output .= '
'; + $output .= ''; + $output .= ''; + $output .= '
'; $output .= ''; // Build the fields for the normal columns $output .= '
'; $output .= ''; foreach ( (array) $item['url_vars'] as $slug => $value ) { $output .= '
'; - $column_id = 'acm-column[' . $slug . ']'; - $output .= ''; - // Support for select dropdowns + $column_id = 'acm-column-' . $slug; + // Get the proper label from provider's ad_code_args $ad_code_args = wp_filter_object_list( $ad_code_manager->current_provider->ad_code_args, array( 'key' => $slug ) ); $ad_code_arg = array_shift( $ad_code_args ); + $field_label = isset( $ad_code_arg['label'] ) ? $ad_code_arg['label'] : ucwords( str_replace( '_', ' ', $slug ) ); + $output .= ''; + // Support for select dropdowns if ( isset( $ad_code_arg['type'] ) && 'select' == $ad_code_arg['type'] ) { - $output .= ''; foreach ( $ad_code_arg['options'] as $key => $label ) { $output .= ''; } $output .= ''; } else { - $output .= ''; + $output .= ''; } $output .= '
'; } @@ -300,20 +317,7 @@ function column_id( $item ) { // Build the field for the priority $output .= '
'; $output .= ''; - $output .= ''; - $output .= '
'; - // Build the field for the logical operator - $output .= '
'; - $output .= ''; - $output .= ''; + $output .= ''; $output .= '
'; $output .= '
'; @@ -387,9 +391,9 @@ function inline_edit() {
-
+

diff --git a/views/ad-code-manager.tpl.php b/views/ad-code-manager.tpl.php index 8c7d9b4..4b73889 100644 --- a/views/ad-code-manager.tpl.php +++ b/views/ad-code-manager.tpl.php @@ -41,39 +41,15 @@ } ?>

- - -
-
- -
-
-
-wp_list_table->prepare_items(); -$this->wp_list_table->display(); -?> -
- -
-
- -
-
- - -

-
-
-
- + +

+ -

- + +

-
+
+ +
+
+ +
+
+

+
+wp_list_table->prepare_items(); +$this->wp_list_table->display(); +?> +
+ +
+
+ +
+
+ + +

@@ -164,7 +163,7 @@
- +

From 62e46d48e3746110e7abc11dea4652a9a8aa35c2 Mon Sep 17 00:00:00 2001 From: Gary Jones Date: Sun, 21 Dec 2025 17:11:09 +0000 Subject: [PATCH 7/7] fix: scope autocomplete to Add form only, add Select2 fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove inline edit autocomplete initialization (will be addressed with dedicated edit page in future PR) - Scope all autocomplete functionality to #add-adcode form only - Add isSelect2Available() check to gracefully handle CDN failures - Prevent JS errors when Select2 fails to load 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- acm-autocomplete.js | 79 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/acm-autocomplete.js b/acm-autocomplete.js index c38c147..5f8a853 100644 --- a/acm-autocomplete.js +++ b/acm-autocomplete.js @@ -15,6 +15,15 @@ var ConditionalAutocomplete = { + /** + * Check if Select2 is available. + * + * @return {boolean} True if Select2 is loaded. + */ + isSelect2Available: function() { + return typeof $.fn.select2 === 'function'; + }, + /** * Initialize the autocomplete functionality. */ @@ -30,8 +39,8 @@ bindEvents: function() { var self = this; - // Use event delegation for dynamically added conditional selects. - $( document ).on( 'change', 'select[name="acm-conditionals[]"]', function() { + // Use event delegation for dynamically added conditional selects (Add form only). + $( document ).on( 'change', '#add-adcode select[name="acm-conditionals[]"]', function() { self.handleConditionalChange( $( this ) ); self.updateAddButtonVisibility(); }); @@ -40,7 +49,7 @@ $( document ).on( 'click', '.add-more-conditionals', function() { // Small delay to allow DOM to update. setTimeout( function() { - self.initExistingFields(); + self.cleanupNewRows(); self.updateAddButtonVisibility(); }, 100 ); }); @@ -55,11 +64,13 @@ /** * Initialize any existing conditional fields on page load. + * Only targets the Add form, not inline edit. */ initExistingFields: function() { var self = this; - $( 'select[name="acm-conditionals[]"]' ).each( function() { + // Only target the Add form to avoid conflicts with inline edit. + $( '#add-adcode select[name="acm-conditionals[]"]' ).each( function() { var $select = $( this ); var conditional = $select.val(); var $argumentsContainer = $select.closest( '.conditional-single-field' ).find( '.conditional-arguments' ); @@ -72,12 +83,59 @@ // Show arguments and init autocomplete if applicable. $argumentsContainer.show(); - if ( self.hasAutocomplete( conditional ) ) { + + // Only init autocomplete if not already initialized. + var $existingSelect2 = $argumentsContainer.find( 'select.acm-autocomplete-select' ); + if ( self.hasAutocomplete( conditional ) && ! $existingSelect2.length ) { self.initAutocomplete( $select ); } }); }, + /** + * Clean up newly added conditional rows. + * + * When rows are cloned from the master template, they may contain + * leftover Select2 markup that needs to be cleaned up. + */ + cleanupNewRows: function() { + var self = this; + + $( 'select[name="acm-conditionals[]"]' ).each( function() { + var $conditionalSelect = $( this ); + var conditional = $conditionalSelect.val(); + var $argumentsContainer = $conditionalSelect.closest( '.conditional-single-field' ).find( '.conditional-arguments' ); + + // If no conditional is selected, clean up any Select2 remnants. + if ( ! conditional ) { + // Remove any cloned Select2 elements. + $argumentsContainer.find( 'select.acm-autocomplete-select' ).remove(); + $argumentsContainer.find( '.select2-container' ).remove(); + + // Restore the original input if it was hidden. + var $hiddenInput = $argumentsContainer.find( 'input[data-original-name="acm-arguments[]"]' ); + if ( $hiddenInput.length ) { + $hiddenInput + .attr( 'name', 'acm-arguments[]' ) + .removeAttr( 'data-original-name' ) + .val( '' ) + .show(); + } + + // Ensure input exists and is visible with correct attributes. + var $input = $argumentsContainer.find( 'input[name="acm-arguments[]"]' ); + if ( ! $input.length ) { + $argumentsContainer.prepend( '' ); + } else { + $input.val( '' ).attr( 'type', 'text' ).show(); + } + + // Hide the arguments container since no conditional is selected. + $argumentsContainer.hide(); + } + }); + }, + /** * Handle when a conditional select changes. * @@ -160,6 +218,11 @@ return; } + // Skip if Select2 is not available (CDN failed to load). + if ( ! this.isSelect2Available() ) { + return; + } + var $argumentsContainer = $conditionalSelect.closest( '.conditional-single-field' ).find( '.conditional-arguments' ); var $input = $argumentsContainer.find( 'input[name="acm-arguments[]"]' ); var currentValue = $input.val(); @@ -183,8 +246,14 @@ // Remove the name from the original input to avoid duplicate submission. $input.removeAttr( 'name' ).attr( 'data-original-name', 'acm-arguments[]' ); + // Determine the dropdown parent - use body for inline edit to avoid z-index issues. + var $dropdownParent = $argumentsContainer.closest( '.acm-editor-row' ).length + ? $( 'body' ) + : $argumentsContainer; + // Initialize Select2 with AJAX. $select.select2({ + dropdownParent: $dropdownParent, ajax: { url: acmAutocomplete.ajaxUrl, dataType: 'json',