diff --git a/acm-autocomplete.js b/acm-autocomplete.js new file mode 100644 index 0000000..5f8a853 --- /dev/null +++ b/acm-autocomplete.js @@ -0,0 +1,394 @@ +/** + * 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 = { + + /** + * Check if Select2 is available. + * + * @return {boolean} True if Select2 is loaded. + */ + isSelect2Available: function() { + return typeof $.fn.select2 === 'function'; + }, + + /** + * Initialize the autocomplete functionality. + */ + init: function() { + this.bindEvents(); + this.initExistingFields(); + this.updateAddButtonVisibility(); + }, + + /** + * Bind event handlers. + */ + bindEvents: function() { + var self = this; + + // 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(); + }); + + // 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.cleanupNewRows(); + self.updateAddButtonVisibility(); + }, 100 ); + }); + + // Handle remove conditional click. + $( document ).on( 'click', '.acm-remove-conditional', function() { + setTimeout( function() { + self.updateAddButtonVisibility(); + }, 100 ); + }); + }, + + /** + * Initialize any existing conditional fields on page load. + * Only targets the Add form, not inline edit. + */ + initExistingFields: function() { + var self = this; + + // 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' ); + + // 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(); + + // 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. + * + * @param {jQuery} $select The conditional select element. + */ + handleConditionalChange: function( $select ) { + var conditional = $select.val(); + var $argumentsContainer = $select.closest( '.conditional-single-field' ).find( '.conditional-arguments' ); + + // Check if we have a Select2 select element or the original input. + var $existingSelect = $argumentsContainer.find( 'select.acm-autocomplete-select' ); + var $hiddenInput = $argumentsContainer.find( 'input[data-original-name="acm-arguments[]"]' ); + + // If there's a Select2 select, destroy it and restore the input. + if ( $existingSelect.length ) { + var currentVal = $existingSelect.val(); + $existingSelect.select2( 'destroy' ); + $existingSelect.remove(); + + // Restore the original input's name and visibility. + $hiddenInput + .attr( 'name', 'acm-arguments[]' ) + .removeAttr( 'data-original-name' ) + .val( currentVal || '' ) + .show(); + } + + // Get the input (either restored or original). + var $input = $argumentsContainer.find( 'input[name="acm-arguments[]"]' ); + + // Reset the input value. + $input.val( '' ); + + // Hide arguments container when no conditional selected or 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 ); + } + }, + + /** + * 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; + } + + // 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(); + + // Hide the original input. + $input.hide(); + + // Create a select element for Select2 (it requires a ' ) + .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[]' ); + + // 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', + 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%' + }); + }, + + /** + * 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...'; + }, + + /** + * 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; + }, + + /** + * 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' ); + } + } + }; + + // Initialize when document is ready. + $( document ).ready( function() { + ConditionalAutocomplete.init(); + }); + +} )( jQuery, window.acmAutocomplete ); 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/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..400a63a --- /dev/null +++ b/src/UI/class-conditional-autocomplete.php @@ -0,0 +1,286 @@ + 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/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/tests/Integration/UI/ConditionalAutocompleteTest.php b/tests/Integration/UI/ConditionalAutocompleteTest.php new file mode 100644 index 0000000..7bfcbb6 --- /dev/null +++ b/tests/Integration/UI/ConditionalAutocompleteTest.php @@ -0,0 +1,245 @@ +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'] ); + + // Dequeue scripts to prevent test pollution. + wp_dequeue_script( 'acm-conditional-autocomplete' ); + wp_dequeue_script( 'selectWoo' ); + wp_dequeue_style( 'select2' ); + + parent::tear_down(); + } +} 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 @@
- +