From c62d4ee4318cb6de184e77e417263395a8c6057a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harun=20Ba=C5=A1i=C4=87?= Date: Tue, 17 Feb 2026 17:23:06 +0100 Subject: [PATCH 1/4] feat(test-settings): implement AI selector management --- assets/admin.scss | 42 ++- assets/icons/settings.svg | 6 + assets/icons/wand-magic-sparkles.svg | 3 + assets/styles/animations.scss | 11 + components/test-settings/_style.scss | 312 ++++++++++++++++++ components/test-settings/index.php | 38 +++ components/test-settings/script.js | 306 +++++++++++++++++ .../tests-page/views/tests-page-list.php | 2 + includes/core/class-plugin.php | 4 + .../list-tables/class-tests-list-table.php | 26 +- includes/models/class-test.php | 95 +++++- .../rest-api/class-rest-tests-controller.php | 27 ++ includes/services/class-test-service.php | 95 +++++- includes/tables/class-tests-table.php | 3 +- 14 files changed, 960 insertions(+), 10 deletions(-) create mode 100644 assets/icons/settings.svg create mode 100644 assets/icons/wand-magic-sparkles.svg create mode 100644 components/test-settings/_style.scss create mode 100644 components/test-settings/index.php create mode 100644 components/test-settings/script.js diff --git a/assets/admin.scss b/assets/admin.scss index fc67ee3..d77cbb9 100644 --- a/assets/admin.scss +++ b/assets/admin.scss @@ -62,6 +62,13 @@ // } } +.column-status .vrts-testing-status-wrapper { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + .vrts-test-runs-list-table:not(.vrts-test-runs-list-queue-table) { .test-run-row[data-has-alerts] { @@ -79,7 +86,6 @@ } } - &.column-title { position: relative; display: flex; @@ -176,7 +182,6 @@ &--manual { background: rgba(5, 116, 206, 0.1); color: #045495; - } &--update { @@ -330,6 +335,35 @@ } } +.vrts-gradient-border { + border-radius: inherit; + inset: 0; + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask-composite: exclude; + opacity: 0; + overflow: hidden; + padding: 0; + pointer-events: none; + position: absolute; + transition: + padding 0.2s, + opacity 0.2s; + z-index: 0; + + &::before { + animation: vrts-rotate 2s linear infinite; + animation-play-state: paused; + aspect-ratio: 1 / 1; + block-size: auto; + content: ""; + inline-size: 200%; + inset-block-start: 50%; + inset-inline-start: 50%; + position: absolute; + translate: -50% -50%; + } +} + .vrts-gradient-loader { position: absolute; top: 1px; @@ -339,7 +373,9 @@ background: linear-gradient(90deg, #ddd, #fff, #ddd); background-size: 200% 100%; animation: vrts-shimmer 1.5s linear paused infinite; - transition: opacity 0.2s, visibility 0.2s; + transition: + opacity 0.2s, + visibility 0.2s; opacity: 0; visibility: hidden; z-index: 10; diff --git a/assets/icons/settings.svg b/assets/icons/settings.svg new file mode 100644 index 0000000..055ce61 --- /dev/null +++ b/assets/icons/settings.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/wand-magic-sparkles.svg b/assets/icons/wand-magic-sparkles.svg new file mode 100644 index 0000000..9312ee0 --- /dev/null +++ b/assets/icons/wand-magic-sparkles.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/styles/animations.scss b/assets/styles/animations.scss index a025c53..c2abfc7 100644 --- a/assets/styles/animations.scss +++ b/assets/styles/animations.scss @@ -85,3 +85,14 @@ background-color: rgba(32, 113, 177, 0.2); } } + +@keyframes vrts-gradient-text { + + 0% { + background-position: 0% 0; + } + + 100% { + background-position: -200% 0; + } +} diff --git a/components/test-settings/_style.scss b/components/test-settings/_style.scss new file mode 100644 index 0000000..70b3a77 --- /dev/null +++ b/components/test-settings/_style.scss @@ -0,0 +1,312 @@ +$vrts-settings-icon-mask: "data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10 18C10 19.6568 8.65686 21 7 21C5.34314 21 4 19.6568 4 18C4 16.3432 5.34314 15 7 15C8.65686 15 10 16.3432 10 18Z' stroke='%23000' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='M20 6C20 4.34314 18.6568 3 17 3C15.3432 3 14 4.34314 14 6C14 7.65686 15.3432 9 17 9C18.6568 9 20 7.65686 20 6Z' stroke='%23000' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='M7 15V3' stroke='%23000' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='M17 9V21' stroke='%23000' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"; +$vrts-wand-icon-mask: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 640'%3E%3Cpath d='M295.4 37L310.2 73.8L347 88.6C350 89.8 352 92.8 352 96C352 99.2 350 102.2 347 103.4L310.2 118.2L295.4 155C294.2 158 291.2 160 288 160C284.8 160 281.8 158 280.6 155L265.8 118.2L229 103.4C226 102.2 224 99.2 224 96C224 92.8 226 89.8 229 88.6L265.8 73.8L280.6 37C281.8 34 284.8 32 288 32C291.2 32 294.2 34 295.4 37zM142.7 105.7L164.2 155.8L214.3 177.3C220.2 179.8 224 185.6 224 192C224 198.4 220.2 204.2 214.3 206.7L164.2 228.2L142.7 278.3C140.2 284.2 134.4 288 128 288C121.6 288 115.8 284.2 113.3 278.3L91.8 228.2L41.7 206.7C35.8 204.2 32 198.4 32 192C32 185.6 35.8 179.8 41.7 177.3L91.8 155.8L113.3 105.7C115.8 99.8 121.6 96 128 96C134.4 96 140.2 99.8 142.7 105.7zM496 368C502.4 368 508.2 371.8 510.7 377.7L532.2 427.8L582.3 449.3C588.2 451.8 592 457.6 592 464C592 470.4 588.2 476.2 582.3 478.7L532.2 500.2L510.7 550.3C508.2 556.2 502.4 560 496 560C489.6 560 483.8 556.2 481.3 550.3L459.8 500.2L409.7 478.7C403.8 476.2 400 470.4 400 464C400 457.6 403.8 451.8 409.7 449.3L459.8 427.8L481.3 377.7C483.8 371.8 489.6 368 496 368zM492 64C503 64 513.6 68.4 521.5 76.2L563.8 118.5C571.6 126.4 576 137 576 148C576 159 571.6 169.6 563.8 177.5L475.6 265.7L374.3 164.4L462.5 76.2C470.4 68.4 481 64 492 64zM76.2 462.5L340.4 198.3L441.7 299.6L177.5 563.8C169.6 571.6 159 576 148 576C137 576 126.4 571.6 118.5 563.8L76.2 521.5C68.4 513.6 64 503 64 492C64 481 68.4 470.4 76.2 462.5z' fill='%23000'/%3E%3C/svg%3E"; + +.vrts-test-settings-button { + all: unset; + cursor: pointer; + line-height: 0; + color: #757575; + border-radius: 20px; + width: 24px; + position: relative; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + corner-shape: squircle; // stylelint-disable-line property-no-unknown + transition: + color 0.2s, + background-color 0.2s; + + .vrts-gradient-border { + border-radius: 20px; + corner-shape: squircle; // stylelint-disable-line property-no-unknown + mask: + url(#{$vrts-settings-icon-mask}) no-repeat center / 14px 14px, + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + mask-composite: add, exclude; + opacity: 1; + padding: 1px; + + &::before { + background-color: #787c82; + transition: filter 0.2s; + } + } + + &:hover, + &:focus-visible { + .vrts-gradient-border::before { + filter: brightness(0.8); + } + } + + &[data-status="waiting"], + &[data-ai-seen="false"] { + .vrts-gradient-border { + padding: 1.5px; + + &::before { + animation-play-state: running; + } + } + } + + &[data-status="waiting"] { + .vrts-gradient-border::before { + background: conic-gradient( + from 0deg at 50% 50%, + transparent 0%, + #8c8f94 100% + ); + } + } + + &[data-ai-seen="false"]:not([data-status="waiting"]) { + .vrts-gradient-border::before { + background: conic-gradient( + from 0deg at 50% 50%, + #0894ff 0%, + #c959dd 22%, + #ff2e54 45%, + #ff9004 76%, + #0894ff 100% + ); + } + } +} + +.vrts-test-settings-modal { + .vrts-modal__content { + max-width: 450px; + } + + .vrts-modal__content-inner { + padding-top: 15px; + } + + h3 { + margin: 0 0 8px; + font-size: 0.875rem; + } + + &__action { + margin-top: 20px; + gap: 1rem; + display: flex; + align-items: center; + justify-content: space-between; + } + + &__save { + display: flex; + align-items: center; + gap: 0.5rem; + } + + &__action-success { + color: #00a32a; + font-size: 0.75rem; + display: none; + + &.is-active { + display: block; + } + } + + .spinner { + margin: 0; + display: none; + + &.is-active { + display: block; + } + } + + .description { + color: #757575; + } + + &__ai-panel { + margin-top: 0.5rem; + margin-bottom: 1rem; + border: 1px solid #f0f0f1; + border-radius: 6px; + padding: 10px 12px; + } + + &__ai-summary { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + color: #50575e; + + &::before { + content: ""; + display: block; + width: 14px; + height: 14px; + flex-shrink: 0; + background: conic-gradient( + from 0deg at 50% 50%, + #0894ff 0%, + #c959dd 22%, + #ff2e54 45%, + #ff9004 76%, + #0894ff 100% + ); + mask: url("#{$vrts-wand-icon-mask}") no-repeat center / contain; + } + } + + &__ai-toggle { + all: unset; + cursor: pointer; + color: #2271b1; + font-size: 0.75rem; + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 0.375rem; + + svg { + width: 6px; + height: auto; + transition: transform 0.25s ease; + } + + &.is-open svg { + transform: rotate(180deg); + } + + &:hover, + &:focus-visible { + text-decoration: underline; + } + } + + &__ai-details { + max-height: 0; + opacity: 0; + overflow: hidden; + transition: + max-height 0.25s ease, + opacity 0.25s ease, + margin 0.25s ease; + + &.is-open { + max-height: 300px; + opacity: 1; + margin-top: 8px; + } + } + + &__ai-details-inner { + max-height: 300px; + overflow-y: auto; + } + + &__ai-row { + display: flex; + align-items: baseline; + gap: 8px; + padding: 6px 0; + font-size: 0.6875rem; + + & + & { + border-top: 1px solid #f0f0f1; + } + + code { + font-size: 0.6875rem; + font-family: Menlo, Consolas, Monaco, "Liberation Mono", "Lucida Console", + monospace; + color: #1e1e1e; + background: #f0f0f1; + padding: 2px 6px; + border-radius: 3px; + white-space: nowrap; + flex-shrink: 0; + } + } + + &__ai-reason { + color: #757575; + } + + &__ai-button { + background: none !important; + border-color: transparent !important; + box-shadow: none !important; + color: #50575e !important; + position: relative; + + > .vrts-gradient-border { + // stylelint-disable-line no-descending-specificity + opacity: 1; + padding: 2px; + inset: -2px; + + &::before { + // stylelint-disable-line no-descending-specificity + animation-play-state: running; + } + } + + > span:not(.vrts-gradient-border) { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + gap: 4px; + background: linear-gradient( + 90deg, + #0894ff 0%, + #c959dd 24%, + #ff2e54 48%, + #ff9004 72%, + #0894ff 100% + ); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; // stylelint-disable-line property-no-vendor-prefix + transition: color 0.3s; + } + + .vrts-gradient-border::before { + // stylelint-disable-line no-descending-specificity + background: conic-gradient( + from 0deg at 50% 50%, + #0894ff 0%, + #c959dd 22%, + #ff2e54 45%, + #ff9004 76%, + #0894ff 100% + ); + } + + &:hover, + &:focus-visible { + > span:not(.vrts-gradient-border) { + color: transparent; + animation: vrts-gradient-text 3s linear infinite; + } + } + + &.is-loading { + > span:not(.vrts-gradient-border) { + color: transparent; + animation: vrts-gradient-text 3s linear infinite; + } + } + } + + &__ai-suggest { + display: flex; + align-items: center; + gap: 0.5rem; + } +} diff --git a/components/test-settings/index.php b/components/test-settings/index.php new file mode 100644 index 0000000..560884c --- /dev/null +++ b/components/test-settings/index.php @@ -0,0 +1,38 @@ + + + diff --git a/components/test-settings/script.js b/components/test-settings/script.js new file mode 100644 index 0000000..6fcf73b --- /dev/null +++ b/components/test-settings/script.js @@ -0,0 +1,306 @@ +const AI_SELECTOR_POOL = [ + { + selector: '.cookie-banner', + reason: 'Consent overlay, appears conditionally', + }, + { + selector: '.ads-container', + reason: 'Ad content changes between page loads', + }, + { + selector: '#popup-overlay', + reason: 'Modal popup, not always visible', + }, + { + selector: '.chat-widget', + reason: 'Live chat state varies per visit', + }, + { + selector: '.notification-bar', + reason: 'Dismissible banner, shown conditionally', + }, + { + selector: '.carousel-slide', + reason: 'Rotating content changes on each load', + }, + { + selector: '.dynamic-counter', + reason: 'Counter value updates in real time', + }, + { + selector: '#live-chat', + reason: 'Chat window state is unpredictable', + }, + { + selector: '.video-autoplay', + reason: 'Video frame differs on each capture', + }, + { + selector: '.social-feed', + reason: 'Feed content refreshes dynamically', + }, + { + selector: '.rotating-banner', + reason: 'Banner rotates between creatives', + }, + { + selector: '.countdown-timer', + reason: 'Timer value changes every second', + }, +]; + +class VrtsTestSettings extends window.HTMLElement { + constructor() { + super(); + this.aiSelectors = []; + this.resolveElements(); + this.bindFunctions(); + this.bindEvents(); + } + + resolveElements() { + this.$modal = this.querySelector( 'vrts-modal' ); + this.$form = this.querySelector( '[data-vrts-test-settings-form]' ); + this.$textarea = this.$form.querySelector( + '[name="hide_css_selectors"]' + ); + this.$postIdInput = this.$form.querySelector( '[name="post_id"]' ); + this.$testIdInput = this.$form.querySelector( '[name="test_id"]' ); + this.$save = this.querySelector( + '.vrts-test-settings-modal__save' + ); + this.$spinner = this.$save.querySelector( '.spinner' ); + this.$success = this.querySelector( + '.vrts-test-settings-modal__action-success' + ); + this.$aiPanel = this.querySelector( + '.vrts-test-settings-modal__ai-panel' + ); + this.$aiCount = this.querySelector( '[data-ai-count]' ); + this.$aiToggle = this.querySelector( '[data-ai-toggle]' ); + this.$aiDetails = this.querySelector( + '.vrts-test-settings-modal__ai-details' + ); + this.$aiSuggest = this.querySelector( + '.vrts-test-settings-modal__ai-suggest' + ); + this.$aiSpinner = this.$aiSuggest.querySelector( '.spinner' ); + this.$aiButton = this.querySelector( + '.vrts-test-settings-modal__ai-button' + ); + } + + bindFunctions() { + this.onButtonClick = this.onButtonClick.bind( this ); + this.onFormSubmit = this.onFormSubmit.bind( this ); + this.onModalClose = this.onModalClose.bind( this ); + this.onToggleClick = this.onToggleClick.bind( this ); + this.onAiSuggestClick = this.onAiSuggestClick.bind( this ); + } + + bindEvents() { + document.addEventListener( 'click', this.onButtonClick ); + this.$form.addEventListener( 'submit', this.onFormSubmit ); + this.$modal.addEventListener( 'hide', this.onModalClose ); + this.$aiToggle.addEventListener( 'click', this.onToggleClick ); + this.$aiButton.addEventListener( 'click', this.onAiSuggestClick ); + } + + onButtonClick( e ) { + const button = e.target.closest( '.vrts-test-settings-button' ); + if ( ! button ) { + return; + } + + const postId = button.getAttribute( 'data-post-id' ); + const testId = button.getAttribute( 'data-test-id' ); + const hiddenData = document.getElementById( 'inline_' + testId ); + const selectors = + hiddenData?.querySelector( '.hide_css_selectors' )?.textContent || + ''; + const aiSeen = + hiddenData?.querySelector( '.ai_selectors_seen' )?.textContent || + '1'; + const aiSelectorsRaw = + hiddenData?.querySelector( '.ai_selectors' )?.textContent || ''; + + this.$postIdInput.value = postId; + this.$testIdInput.value = testId; + this.$textarea.value = selectors; + this.$success.classList.remove( 'is-active' ); + + // Populate AI panel. + this.aiSelectors = []; + this.$aiDetails.classList.remove( 'is-open' ); + this.$aiToggle.classList.remove( 'is-open' ); + this.showAiPanel( aiSelectorsRaw ); + + // Handle AI seen state. + if ( aiSeen === '0' ) { + // Mark as seen via REST. + fetch( + `${ window.vrts_admin_vars.rest_url }/tests/${ testId }/ai-seen`, + { + method: 'POST', + headers: { + 'X-WP-Nonce': window.vrts_admin_vars.rest_nonce, + }, + } + ); + + // Clear gradient on button and update tooltip. + button.setAttribute( 'data-ai-seen', 'true' ); + button.title = button.getAttribute( 'aria-label' ); + + // Update inline data. + const aiSeenEl = hiddenData?.querySelector( '.ai_selectors_seen' ); + if ( aiSeenEl ) { + aiSeenEl.textContent = '1'; + } + } + } + + showAiPanel( aiSelectorsRaw ) { + let newSelectors = []; + try { + newSelectors = + typeof aiSelectorsRaw === 'string' && aiSelectorsRaw + ? JSON.parse( aiSelectorsRaw ) + : aiSelectorsRaw || []; + } catch ( err ) { + newSelectors = []; + } + + // Append new selectors, avoiding duplicates. + const existingSet = new Set( + this.aiSelectors.map( ( item ) => item.selector ) + ); + newSelectors.forEach( ( item ) => { + if ( ! existingSet.has( item.selector ) ) { + this.aiSelectors.push( item ); + } + } ); + + if ( this.aiSelectors.length > 0 ) { + this.$aiPanel.hidden = false; + this.$aiCount.textContent = this.aiSelectors.length; + const rows = this.aiSelectors + .map( + ( item ) => + `
${ item.selector }${ item.reason }
` + ) + .join( '' ); + this.$aiDetails.innerHTML = `
${ rows }
`; + } else { + this.$aiPanel.hidden = true; + } + } + + onFormSubmit( e ) { + e.preventDefault(); + const formData = new window.FormData( this.$form ); + const postId = formData.get( 'post_id' ); + const testId = formData.get( 'test_id' ); + + this.$spinner.classList.add( 'is-active' ); + this.$success.classList.remove( 'is-active' ); + + fetch( `${ window.vrts_admin_vars.rest_url }/tests/post/${ postId }`, { + method: 'PUT', + headers: { + 'X-WP-Nonce': window.vrts_admin_vars.rest_nonce, + }, + body: new URLSearchParams( formData ), + } ) + .then( ( response ) => response.json() ) + .then( () => { + this.$spinner.classList.remove( 'is-active' ); + this.$success.classList.add( 'is-active' ); + setTimeout( () => { + this.$success.classList.remove( 'is-active' ); + }, 5000 ); + + // Update hidden inline data so next open shows fresh value. + const hiddenData = document.getElementById( + 'inline_' + testId + ); + if ( hiddenData ) { + const el = hiddenData.querySelector( + '.hide_css_selectors' + ); + if ( el ) { + el.textContent = formData.get( 'hide_css_selectors' ); + } + } + } ); + } + + onAiSuggestClick() { + if ( this.$aiButton.classList.contains( 'is-loading' ) ) { + return; + } + + this.$aiButton.classList.add( 'is-loading' ); + this.$aiSpinner.classList.add( 'is-active' ); + + setTimeout( () => { + // Get existing selectors to avoid duplicates. + const current = this.$textarea.value.trim(); + const existing = current + ? current.split( ',' ).map( ( s ) => s.trim() ) + : []; + + const available = AI_SELECTOR_POOL.filter( + ( item ) => ! existing.includes( item.selector ) + ); + + if ( available.length === 0 ) { + this.$aiButton.classList.remove( 'is-loading' ); + this.$aiSpinner.classList.remove( 'is-active' ); + return; + } + + const count = Math.min( + Math.floor( Math.random() * 3 ) + 1, + available.length + ); + const shuffled = [ ...available ].sort( () => Math.random() - 0.5 ); + const selected = shuffled.slice( 0, count ); + const newSelectors = selected + .map( ( item ) => item.selector ) + .join( ', ' ); + + if ( current ) { + this.$textarea.value = current + ', ' + newSelectors; + } else { + this.$textarea.value = newSelectors; + } + + this.showAiPanel( selected ); + this.$aiButton.classList.remove( 'is-loading' ); + this.$aiSpinner.classList.remove( 'is-active' ); + }, 3000 ); + } + + onToggleClick() { + this.$aiDetails.classList.toggle( 'is-open' ); + this.$aiToggle.classList.toggle( 'is-open' ); + } + + onModalClose() { + this.$success.classList.remove( 'is-active' ); + this.$aiDetails.classList.remove( 'is-open' ); + this.$aiToggle.classList.remove( 'is-open' ); + this.aiSelectors = []; + } + + disconnectedCallback() { + document.removeEventListener( 'click', this.onButtonClick ); + this.$form?.removeEventListener( 'submit', this.onFormSubmit ); + this.$modal?.removeEventListener( 'hide', this.onModalClose ); + this.$aiToggle?.removeEventListener( 'click', this.onToggleClick ); + this.$aiButton?.removeEventListener( 'click', this.onAiSuggestClick ); + } +} + +window.customElements.define( 'vrts-test-settings', VrtsTestSettings ); diff --git a/components/tests-page/views/tests-page-list.php b/components/tests-page/views/tests-page-list.php index 3993865..c6ae657 100644 --- a/components/tests-page/views/tests-page-list.php +++ b/components/tests-page/views/tests-page-list.php @@ -73,6 +73,8 @@ class="page-title-action button-secondary" } ?> + + component( 'test-settings' ); ?> diff --git a/includes/core/class-plugin.php b/includes/core/class-plugin.php index 2a91bb2..43cdd82 100644 --- a/includes/core/class-plugin.php +++ b/includes/core/class-plugin.php @@ -349,6 +349,10 @@ private function wp_kses_svg() { 'height' => true, 'version' => true, 'fill' => true, + 'color' => true, + 'stroke' => true, + 'stroke-width' => true, + 'stroke-linejoin' => true, 'viewbox' => true, 'xmlns' => true, 'aria-hidden' => true, diff --git a/includes/list-tables/class-tests-list-table.php b/includes/list-tables/class-tests-list-table.php index 4fef971..55a3b02 100644 --- a/includes/list-tables/class-tests-list-table.php +++ b/includes/list-tables/class-tests-list-table.php @@ -142,9 +142,14 @@ public function column_post_title( $item ) { $this->row_actions( $actions ) ); + $ai_selectors_seen = isset( $item->parsed_meta['ai_selectors_seen'] ) && false === $item->parsed_meta['ai_selectors_seen'] ? '0' : '1'; + $ai_selectors_json = isset( $item->parsed_meta['ai_selectors'] ) ? esc_html( wp_json_encode( $item->parsed_meta['ai_selectors'] ) ) : ''; + $quickedit_hidden_fields = " "; return $row_actions . $quickedit_hidden_fields; @@ -323,6 +328,7 @@ public function prepare_items() { * @param object|array $item The current item. */ public function single_row( $item ) { + $item->parsed_meta = ! empty( $item->meta ) ? maybe_unserialize( $item->meta ) : []; $classes = 'iedit'; ?> @@ -403,11 +409,27 @@ public function inline_edit() { private function render_column_status( $item ) { $status_data = Test::get_status_data( $item ); + $ai_seen = isset( $item->parsed_meta['ai_selectors_seen'] ) && false === $item->parsed_meta['ai_selectors_seen'] ? 'false' : 'true'; + $tooltip = 'false' === $ai_seen + ? esc_attr__( 'AI-optimized configuration available', 'visual-regression-tests' ) + : esc_attr__( 'Test settings', 'visual-regression-tests' ); + + $settings_button = sprintf( + '', + esc_attr( $item->post_id ), + esc_attr( $item->id ), + esc_attr( $status_data['class'] ), + esc_attr( $ai_seen ), + esc_attr__( 'Test settings', 'visual-regression-tests' ), + $tooltip + ); + return sprintf( - '

%s

%s

', + '

%s

%s

%s
', 'vrts-testing-status--' . $status_data['class'], $status_data['text'], - $status_data['instructions'] + $status_data['instructions'], + $settings_button ); } diff --git a/includes/models/class-test.php b/includes/models/class-test.php index 1d54530..ae7e937 100644 --- a/includes/models/class-test.php +++ b/includes/models/class-test.php @@ -107,6 +107,7 @@ public static function get_items( $args = [], $return_count = false ) { tests.next_run_date, tests.is_running, tests.hide_css_selectors, + tests.meta, posts.post_title, CASE WHEN alerts.latest_id is not null THEN '6-has-alert' @@ -222,7 +223,8 @@ public static function get_item( $id = 0 ) { test.last_comparison_date, test.next_run_date, test.is_running, - test.hide_css_selectors + test.hide_css_selectors, + test.meta FROM $tests_table as test LEFT JOIN ( SELECT MAX(id) as latest_id, post_id @@ -707,6 +709,7 @@ public static function cast_values( $test ) { $test->next_run_date = ! is_null( $test->next_run_date ) ? mysql2date( 'c', $test->next_run_date ) : null; $test->last_comparison_date = ! is_null( $test->last_comparison_date ) ? mysql2date( 'c', $test->last_comparison_date ) : null; $test->is_running = ! is_null( $test->is_running ) ? (bool) $test->is_running : null; + $test->meta = ! empty( $test->meta ) ? maybe_unserialize( $test->meta ) : []; return $test; } @@ -871,7 +874,7 @@ public static function get_status_data( $test ) { $instructions = esc_html__( 'Refresh page to see result', 'visual-regression-tests' ); break; case 'scheduled': - $class = 'waiting'; + $class = 'running'; $text = esc_html__( 'Scheduled', 'visual-regression-tests' ); $next_run = Test_Run::get_next_scheduled_run(); if ( $next_run ) { @@ -987,6 +990,94 @@ public static function get_screenshot_data( $test ) { ]; } + /** + * Get the raw meta array for a test. + * + * @param int $id Test ID. + * + * @return array + */ + private static function get_raw_meta( $id ) { + global $wpdb; + + $tests_table = Tests_Table::get_table_name(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok. + $raw = $wpdb->get_var( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok. + "SELECT meta FROM $tests_table WHERE id = %d", + $id + ) + ); + + return ! empty( $raw ) ? maybe_unserialize( $raw ) : []; + } + + /** + * Save the meta array for a test. + * + * @param int $id Test ID. + * @param array $meta Meta array. + * + * @return bool + */ + private static function save_meta( $id, $meta ) { + global $wpdb; + + $tests_table = Tests_Table::get_table_name(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok. + $result = $wpdb->update( + $tests_table, + [ 'meta' => ! empty( $meta ) ? maybe_serialize( $meta ) : null ], + [ 'id' => $id ] + ); + + return false !== $result; + } + + /** + * Get a specific meta key from a test. + * + * @param int $id Test ID. + * @param string $key Meta key. + * + * @return mixed|null + */ + public static function get_meta( $id, $key ) { + $meta = self::get_raw_meta( $id ); + return isset( $meta[ $key ] ) ? $meta[ $key ] : null; + } + + /** + * Set a specific meta key on a test. + * + * @param int $id Test ID. + * @param array $values Associative array of key => value pairs. + * + * @return bool + */ + public static function set_meta( $id, $values ) { + $meta = self::get_raw_meta( $id ); + $meta = array_merge( $meta, $values ); + return self::save_meta( $id, $meta ); + } + + /** + * Delete a specific meta key from a test. + * + * @param int $id Test ID. + * @param string $key Meta key. + * + * @return bool + */ + public static function delete_meta( $id, $key ) { + $meta = self::get_raw_meta( $id ); + unset( $meta[ $key ] ); + return self::save_meta( $id, $meta ); + } + /** * Get tests by service test ids. * diff --git a/includes/rest-api/class-rest-tests-controller.php b/includes/rest-api/class-rest-tests-controller.php index 1a12965..e169bdb 100644 --- a/includes/rest-api/class-rest-tests-controller.php +++ b/includes/rest-api/class-rest-tests-controller.php @@ -66,6 +66,12 @@ public function register_routes() { 'callback' => [ $this, 'update_test_callback' ], 'permission_callback' => [ $this, 'user_can_create' ], ]); + + register_rest_route($this->namespace, $this->resource_name . '/(?P\d+)/ai-seen', [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'ai_seen_callback' ], + 'permission_callback' => [ $this, 'user_can_create' ], + ]); } /** @@ -174,6 +180,27 @@ public function update_test_callback( WP_REST_Request $request ) { return rest_ensure_response( $error ); } + /** + * Marks AI selectors as seen for a test. + * + * @param WP_REST_Request $request Current request. + */ + public function ai_seen_callback( WP_REST_Request $request ) { + $data = $request->get_params(); + $test_id = $data['test_id'] ?? 0; + + if ( 0 !== $test_id ) { + Test::set_meta( (int) $test_id, [ 'ai_selectors_seen' => true ] ); + return rest_ensure_response( [ 'success' => true ], 200 ); + } + + $error = new WP_Error( + 'rest_ai_seen_failed', + esc_html__( 'Could not mark AI selectors as seen.', 'visual-regression-tests' ), + [ 'status' => 400 ] ); + return rest_ensure_response( $error ); + } + /** * Checks if a given request has access to create items. * diff --git a/includes/services/class-test-service.php b/includes/services/class-test-service.php index 0cfc79e..a2d51c4 100644 --- a/includes/services/class-test-service.php +++ b/includes/services/class-test-service.php @@ -41,7 +41,7 @@ public function update_test_from_comparison( $alert_id, $test_id, $data ) { $update_data, [ 'service_test_id' => $test_id ] ); - } + }//end if } /** @@ -68,7 +68,21 @@ public function update_test_from_schedule( $test_id, $data ) { ], [ 'service_test_id' => $test_id ] ); - } + + // Generate AI selectors if none exist after initial screenshot. + $post_id = Test::get_post_id_by_service_test_id( $test_id ); + if ( $post_id ) { + $test = Test::get_item_by_post_id( $post_id ); + if ( $test && empty( $test->hide_css_selectors ) ) { + $result = self::generate_ai_selectors(); + Test::save_hide_css_selectors( $test->id, $result['selectors'] ); + Test::set_meta( $test->id, [ + 'ai_selectors' => $result['ai_selectors'], + 'ai_selectors_seen' => false, + ] ); + } + } + }//end if } /** @@ -446,6 +460,83 @@ public function update_css_hide_selectors( $test_id, $css_hide_selector ) { } } + /** + * Generate random AI selectors from a predefined pool. + * + * @return array{selectors: string, ai_selectors: array} Selector string and structured data. + */ + private static function generate_ai_selectors() { + $pool = [ + [ + 'selector' => '.cookie-banner', + 'reason' => 'Consent overlay, appears conditionally', + ], + [ + 'selector' => '.ads-container', + 'reason' => 'Ad content changes between page loads', + ], + [ + 'selector' => '#popup-overlay', + 'reason' => 'Modal popup, not always visible', + ], + [ + 'selector' => '.chat-widget', + 'reason' => 'Live chat state varies per visit', + ], + [ + 'selector' => '.notification-bar', + 'reason' => 'Dismissible banner, shown conditionally', + ], + [ + 'selector' => '.carousel-slide', + 'reason' => 'Rotating content changes on each load', + ], + [ + 'selector' => '.dynamic-counter', + 'reason' => 'Counter value updates in real time', + ], + [ + 'selector' => '#live-chat', + 'reason' => 'Chat window state is unpredictable', + ], + [ + 'selector' => '.video-autoplay', + 'reason' => 'Video frame differs on each capture', + ], + [ + 'selector' => '.social-feed', + 'reason' => 'Feed content refreshes dynamically', + ], + [ + 'selector' => '.rotating-banner', + 'reason' => 'Banner rotates between creatives', + ], + [ + 'selector' => '.countdown-timer', + 'reason' => 'Timer value changes every second', + ], + ]; + + // phpcs:ignore WordPress.WP.AlternativeFunctions.rand_mt_rand -- Non-security random. + $count = mt_rand( 1, 3 ); + $keys = array_rand( $pool, $count ); + + if ( ! is_array( $keys ) ) { + $keys = [ $keys ]; + } + + $selected = array_map( function ( $key ) use ( $pool ) { + return $pool[ $key ]; + }, $keys ); + + $selectors_string = implode( ', ', array_column( $selected, 'selector' ) ); + + return [ + 'selectors' => $selectors_string, + 'ai_selectors' => array_values( $selected ), + ]; + } + /** * Resume test. * diff --git a/includes/tables/class-tests-table.php b/includes/tables/class-tests-table.php index 963dd54..b477e12 100644 --- a/includes/tables/class-tests-table.php +++ b/includes/tables/class-tests-table.php @@ -4,7 +4,7 @@ class Tests_Table { - const DB_VERSION = '1.5'; + const DB_VERSION = '1.6'; const TABLE_NAME = 'vrts_tests'; /** @@ -59,6 +59,7 @@ public static function install_table() { next_run_date datetime, is_running boolean, hide_css_selectors longtext, + meta longtext, PRIMARY KEY (id) ) $charset_collate;"; From 0a4fb59c8d96defb788640e4c51c2fa11f71df64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harun=20Ba=C5=A1i=C4=87?= Date: Thu, 26 Feb 2026 13:27:22 +0100 Subject: [PATCH 2/4] feat(test-settings): enhance AI selector panel with loading and empty states, add chevron icon for toggle --- assets/admin.scss | 34 ++- assets/icons/chevron-down.svg | 3 + components/test-settings/_style.scss | 279 ++++++++---------- components/test-settings/index.php | 29 +- components/test-settings/script.js | 196 +++++------- .../list-tables/class-tests-list-table.php | 6 +- includes/services/class-test-service.php | 50 ++-- 7 files changed, 283 insertions(+), 314 deletions(-) create mode 100644 assets/icons/chevron-down.svg diff --git a/assets/admin.scss b/assets/admin.scss index d77cbb9..4a42678 100644 --- a/assets/admin.scss +++ b/assets/admin.scss @@ -244,6 +244,35 @@ } } +.vrts-tooltip-popup { + position: fixed; + z-index: 99999999999; + pointer-events: none; + animation: vrts-fade-in 0.15s ease-in-out; + + .vrts-tooltip-content-inner { + display: block; + max-width: 200px; + background-color: #1e1e1e; + color: #f0f0f0; + font-size: 0.75rem; + line-height: 1.4; + padding: 8px 10px; + border-radius: 6px; + box-shadow: 2px 2px 8px 4px rgba(30, 30, 30, 0.12); + -webkit-font-smoothing: antialiased; + } + + &__arrow { + position: absolute; + left: 50%; + top: 100%; + transform: translateX(-50%); + border: 6px solid transparent; + border-top-color: #1e1e1e; + } +} + .vrts-testing-toogle { display: flex; align-items: center; @@ -338,7 +367,9 @@ .vrts-gradient-border { border-radius: inherit; inset: 0; - mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); mask-composite: exclude; opacity: 0; overflow: hidden; @@ -354,6 +385,7 @@ animation: vrts-rotate 2s linear infinite; animation-play-state: paused; aspect-ratio: 1 / 1; + background: conic-gradient(from 0deg at 50% 50%, #0894ff 0%, #c959dd 22%, #ff2e54 45%, #ff9004 76%, #0894ff 100%); block-size: auto; content: ""; inline-size: 200%; diff --git a/assets/icons/chevron-down.svg b/assets/icons/chevron-down.svg new file mode 100644 index 0000000..70aeb91 --- /dev/null +++ b/assets/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/components/test-settings/_style.scss b/components/test-settings/_style.scss index 70b3a77..6a91045 100644 --- a/components/test-settings/_style.scss +++ b/components/test-settings/_style.scss @@ -31,54 +31,42 @@ $vrts-wand-icon-mask: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/s padding: 1px; &::before { - background-color: #787c82; + background: #787c82; transition: filter 0.2s; } } &:hover, &:focus-visible { + .vrts-gradient-border::before { filter: brightness(0.8); } } - &[data-status="waiting"], - &[data-ai-seen="false"] { + &[data-status="waiting"] { + .vrts-gradient-border { - padding: 1.5px; + padding: 1px; &::before { animation-play-state: running; + background: conic-gradient(from 0deg at 50% 50%, transparent 0%, #0894ff 25%, #c959dd 45%, #ff2e54 65%, #ff9004 85%, #0894ff 100%); } } } - &[data-status="waiting"] { - .vrts-gradient-border::before { - background: conic-gradient( - from 0deg at 50% 50%, - transparent 0%, - #8c8f94 100% - ); - } - } - &[data-ai-seen="false"]:not([data-status="waiting"]) { + .vrts-gradient-border::before { - background: conic-gradient( - from 0deg at 50% 50%, - #0894ff 0%, - #c959dd 22%, - #ff2e54 45%, - #ff9004 76%, - #0894ff 100% - ); + background: conic-gradient(from 0deg at 50% 50%, #0894ff 0%, #c959dd 22%, #ff2e54 45%, #ff9004 76%, #0894ff 100%); } } } +/* stylelint-disable no-descending-specificity */ .vrts-test-settings-modal { + .vrts-modal__content { max-width: 450px; } @@ -87,17 +75,14 @@ $vrts-wand-icon-mask: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/s padding-top: 15px; } - h3 { - margin: 0 0 8px; - font-size: 0.875rem; + textarea { + line-height: 1.7; } &__action { margin-top: 20px; - gap: 1rem; display: flex; align-items: center; - justify-content: space-between; } &__save { @@ -125,24 +110,98 @@ $vrts-wand-icon-mask: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/s } } - .description { - color: #757575; - } - &__ai-panel { - margin-top: 0.5rem; + margin-top: 1rem; margin-bottom: 1rem; - border: 1px solid #f0f0f1; border-radius: 6px; - padding: 10px 12px; + border: 1px solid #e2e4e7; + position: relative; + + .vrts-gradient-border { + padding: 1px; + inset: -1px; + transition-duration: 0.3s; + } + + &[data-ai-state="results"]:hover:not(:has(.is-open)):not(.is-closing) + .vrts-gradient-border, + &[data-ai-state="results"].is-animating .vrts-gradient-border { + opacity: 1; + + &::before { + animation-play-state: running; + } + } + + // Hide toggle in loading and empty states. + &[data-ai-state="loading"], + &[data-ai-state="empty"] { + + .vrts-test-settings-modal__ai-toggle { + display: none; + } + } + + // Loading state: gradient text, dashed border. + &[data-ai-state="loading"] { + border-color: transparent; + + .vrts-gradient-border { + opacity: 0.5; + } + + .vrts-test-settings-modal__ai-summary { + cursor: default; + pointer-events: none; + background: linear-gradient(90deg, #0894ff 0%, #c959dd 25%, #ff2e54 50%, #ff9004 75%, #0894ff 100%); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; // stylelint-disable-line property-no-unknown + -webkit-text-fill-color: transparent; // stylelint-disable-line property-no-unknown + animation: vrts-gradient-text 3s linear infinite; + font-weight: 600; + + &::before { + background: conic-gradient(from 0deg at 50% 50%, #0894ff 0%, #c959dd 22%, #ff2e54 45%, #ff9004 76%, #0894ff 100%); + } + } + } + + // Empty state: muted. + &[data-ai-state="empty"] { + border-color: #e2e4e7; + border-style: dashed; + + .vrts-gradient-border { + display: none; + } + + .vrts-test-settings-modal__ai-summary { + cursor: default; + pointer-events: none; + color: #787878; + + &::before { + background: #787878; + } + } + } } &__ai-summary { + all: unset; + box-sizing: border-box; + cursor: pointer; display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: #50575e; + padding: 0.75rem; + border-bottom: 1px solid transparent; + transition: border-color 0.2s; + font-weight: 500; + width: 100%; &::before { content: ""; @@ -150,21 +209,20 @@ $vrts-wand-icon-mask: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/s width: 14px; height: 14px; flex-shrink: 0; - background: conic-gradient( - from 0deg at 50% 50%, - #0894ff 0%, - #c959dd 22%, - #ff2e54 45%, - #ff9004 76%, - #0894ff 100% - ); + background: conic-gradient(from 0deg at 50% 50%, #0894ff 0%, #c959dd 22%, #ff2e54 45%, #ff9004 76%, #0894ff 100%); mask: url("#{$vrts-wand-icon-mask}") no-repeat center / contain; } + + &.is-open { + border-color: #e2e4e7; + } + + &.is-open .vrts-test-settings-modal__ai-toggle svg { + transform: rotate(180deg); + } } &__ai-toggle { - all: unset; - cursor: pointer; color: #2271b1; font-size: 0.75rem; margin-left: auto; @@ -173,140 +231,65 @@ $vrts-wand-icon-mask: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/s gap: 0.375rem; svg { - width: 6px; + width: 12px; height: auto; transition: transform 0.25s ease; } - - &.is-open svg { - transform: rotate(180deg); - } - - &:hover, - &:focus-visible { - text-decoration: underline; - } } &__ai-details { max-height: 0; - opacity: 0; overflow: hidden; - transition: - max-height 0.25s ease, - opacity 0.25s ease, - margin 0.25s ease; + transition: max-height 0.25s ease; &.is-open { max-height: 300px; - opacity: 1; - margin-top: 8px; } } &__ai-details-inner { + box-sizing: border-box; + padding: 1rem 0.75rem; max-height: 300px; overflow-y: auto; - } - - &__ai-row { display: flex; - align-items: baseline; - gap: 8px; - padding: 6px 0; - font-size: 0.6875rem; + flex-direction: column; + gap: 16px; - & + & { - border-top: 1px solid #f0f0f1; + &::-webkit-scrollbar { + width: 10px; } - code { - font-size: 0.6875rem; - font-family: Menlo, Consolas, Monaco, "Liberation Mono", "Lucida Console", - monospace; - color: #1e1e1e; - background: #f0f0f1; - padding: 2px 6px; - border-radius: 3px; - white-space: nowrap; - flex-shrink: 0; + &::-webkit-scrollbar-track { + background: transparent; } - } - - &__ai-reason { - color: #757575; - } - - &__ai-button { - background: none !important; - border-color: transparent !important; - box-shadow: none !important; - color: #50575e !important; - position: relative; - > .vrts-gradient-border { - // stylelint-disable-line no-descending-specificity - opacity: 1; - padding: 2px; - inset: -2px; - - &::before { - // stylelint-disable-line no-descending-specificity - animation-play-state: running; - } + &::-webkit-scrollbar-thumb { + background: #dcdcde; + border-radius: 6px; + border: 2px solid #fff; } + mask-image: linear-gradient(to bottom, #000 0%, #000 100%); - > span:not(.vrts-gradient-border) { - position: relative; - z-index: 1; - display: inline-flex; - align-items: center; - gap: 4px; - background: linear-gradient( - 90deg, - #0894ff 0%, - #c959dd 24%, - #ff2e54 48%, - #ff9004 72%, - #0894ff 100% - ); - background-size: 200% 100%; - background-clip: text; - -webkit-background-clip: text; // stylelint-disable-line property-no-vendor-prefix - transition: color 0.3s; + &.has-overflow { + mask-image: linear-gradient(to bottom, #000 0%, #000 calc(100% - 32px), transparent 100%); } + } - .vrts-gradient-border::before { - // stylelint-disable-line no-descending-specificity - background: conic-gradient( - from 0deg at 50% 50%, - #0894ff 0%, - #c959dd 22%, - #ff2e54 45%, - #ff9004 76%, - #0894ff 100% - ); - } + &__ai-item { - &:hover, - &:focus-visible { - > span:not(.vrts-gradient-border) { - color: transparent; - animation: vrts-gradient-text 3s linear infinite; - } + code { + font-size: 0.675rem; + background: rgba(0, 0, 0, 0.05); + padding: 3px 7px; + border-radius: 32px; + display: inline-block; } - &.is-loading { - > span:not(.vrts-gradient-border) { - color: transparent; - animation: vrts-gradient-text 3s linear infinite; - } + .description { + margin: 6px 0 0 5px; + font-size: 0.725rem; } } - - &__ai-suggest { - display: flex; - align-items: center; - gap: 0.5rem; - } } +/* stylelint-enable no-descending-specificity */ diff --git a/components/test-settings/index.php b/components/test-settings/index.php index 560884c..0b3ef01 100644 --- a/components/test-settings/index.php +++ b/components/test-settings/index.php @@ -13,22 +13,27 @@

-