diff --git a/.eslintrc.js b/.eslintrc.js
index 545adecb43..23fd8f345b 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -5,8 +5,23 @@ module.exports = {
],
parserOptions: {
ecmaVersion: 'latest',
+ ecmaFeatures: {
+ jsx: true,
+ },
},
rules: {
'no-console': 'off',
},
+ overrides: [
+ {
+ files: [
+ '**/__tests__/**/*.js',
+ '**/tests/**/*.js',
+ 'assets/src/__tests__/*.js',
+ ],
+ env: {
+ jest: true,
+ },
+ },
+ ],
};
diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml
index 306f87d6ed..cc10ddbdcf 100644
--- a/.github/workflows/code-coverage.yml
+++ b/.github/workflows/code-coverage.yml
@@ -55,6 +55,15 @@ jobs:
composer-options: "--prefer-dist --with-dependencies"
custom-cache-suffix: $(date -u -d "-0 month -$(($(date +%d)-1)) days" "+%F")-codecov-v2
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+
+ - name: Install npm dependencies
+ run: npm ci
+
- name: Install WordPress Test Suite
shell: bash
run: tests/bin/install-wp-tests.sh wordpress_tests root root 127.0.0.1:3306 latest
@@ -123,6 +132,39 @@ jobs:
path: phpunit-output.log
retention-days: 7
+ - name: Run Jest tests with coverage
+ id: jest_coverage_current
+ run: |
+ set +e
+ npm run test:unit:coverage -- --coverage --coverageReporters=clover --coverageReporters=text > jest-coverage.log 2>&1
+ JEST_EXIT=$?
+ set -e
+
+ echo "=== Jest test output ==="
+ cat jest-coverage.log
+
+ if [ -f coverage/clover.xml ]; then
+ # Extract coverage from Jest Clover XML
+ STATEMENTS=$(xmllint --xpath 'string(//project/metrics/@statements)' coverage/clover.xml 2>/dev/null || echo "0")
+ COVERED=$(xmllint --xpath 'string(//project/metrics/@coveredstatements)' coverage/clover.xml 2>/dev/null || echo "0")
+
+ if [ "$STATEMENTS" != "0" ] && [ -n "$STATEMENTS" ] && [ -n "$COVERED" ]; then
+ JEST_COVERAGE=$(echo "scale=2; ($COVERED * 100) / $STATEMENTS" | bc)
+ else
+ JEST_COVERAGE="0"
+ fi
+
+ echo "jest_coverage=$JEST_COVERAGE" >> $GITHUB_OUTPUT
+ echo "Jest coverage: $JEST_COVERAGE%"
+
+ # Save for base branch comparison
+ cp coverage/clover.xml jest-coverage.xml
+ else
+ echo "jest_coverage=0" >> $GITHUB_OUTPUT
+ echo "No Jest coverage generated"
+ fi
+ continue-on-error: true
+
- name: Generate coverage report summary
id: coverage
run: |
@@ -237,6 +279,33 @@ jobs:
head -20 base-coverage-details.txt || true
continue-on-error: true
+ - name: Generate Jest coverage for base branch
+ if: github.event_name == 'pull_request'
+ id: base_jest_coverage
+ run: |
+ # Install npm dependencies on base branch
+ npm ci || true
+
+ # Run Jest coverage
+ npm run test:unit:coverage -- --coverage --coverageReporters=clover > base-jest-coverage.log 2>&1 || true
+
+ if [ -f coverage/clover.xml ]; then
+ STATEMENTS=$(xmllint --xpath 'string(//project/metrics/@statements)' coverage/clover.xml 2>/dev/null || echo "0")
+ COVERED=$(xmllint --xpath 'string(//project/metrics/@coveredstatements)' coverage/clover.xml 2>/dev/null || echo "0")
+
+ if [ "$STATEMENTS" != "0" ] && [ -n "$STATEMENTS" ] && [ -n "$COVERED" ]; then
+ BASE_JEST_COVERAGE=$(echo "scale=2; ($COVERED * 100) / $STATEMENTS" | bc)
+ else
+ BASE_JEST_COVERAGE="0"
+ fi
+ echo "base_jest_coverage=$BASE_JEST_COVERAGE" >> $GITHUB_OUTPUT
+ echo "Base Jest coverage: $BASE_JEST_COVERAGE%"
+ else
+ echo "base_jest_coverage=0" >> $GITHUB_OUTPUT
+ echo "No base Jest coverage generated"
+ fi
+ continue-on-error: true
+
- name: Generate coverage diff report
if: github.event_name == 'pull_request'
id: coverage_diff
@@ -364,13 +433,14 @@ jobs:
fi
- name: Comment PR with coverage
- if: github.event_name == 'pull_request'
+ if: github.event_name == 'pull_request' && always()
uses: actions/github-script@v7
env:
COVERAGE_CHANGES: ${{ steps.coverage_diff.outputs.coverage_changes }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
+ // PHP Coverage
const current = parseFloat('${{ steps.coverage.outputs.current_coverage }}') || 0;
const base = parseFloat('${{ steps.base_coverage.outputs.base_coverage }}') || 0;
const diff = (current - base).toFixed(2);
@@ -378,6 +448,13 @@ jobs:
const coverageEmoji = current >= 80 ? '🎉' : current >= 60 ? '📈' : current >= 40 ? '📊' : '📉';
const status = diff >= -0.5 ? '✅' : '⚠️';
+ // Jest Coverage
+ const jestCurrent = parseFloat('${{ steps.jest_coverage_current.outputs.jest_coverage }}') || 0;
+ const jestBase = parseFloat('${{ steps.base_jest_coverage.outputs.base_jest_coverage }}') || 0;
+ const jestDiff = (jestCurrent - jestBase).toFixed(2);
+ const jestDiffEmoji = jestDiff >= 0 ? '📈' : '📉';
+ const jestCoverageEmoji = jestCurrent >= 80 ? '🎉' : jestCurrent >= 60 ? '📈' : jestCurrent >= 40 ? '📊' : '📉';
+
// Parse coverage changes JSON from environment variable
let changesJson = {};
try {
@@ -441,24 +518,39 @@ jobs:
const comment = `## ${status} Code Coverage Report
+ ### PHP Coverage (PHPUnit)
+
| Metric | Value |
|--------|-------|
- | **Total Coverage** | **${current.toFixed(2)}%** ${coverageEmoji} |
- | Base Coverage | ${base.toFixed(2)}% |
- | Difference | ${diffEmoji} **${diff}%** |
+ | **Current** | **${current.toFixed(2)}%** ${coverageEmoji} |
+ | Base | ${base.toFixed(2)}% |
+ | Change | ${diffEmoji} **${diff}%** |
- ${current >= 40 ? '✅ Coverage meets minimum threshold (40%)' : '⚠️ Coverage below recommended 40% threshold'}
+ ${current >= 40 ? '✅ PHP coverage meets threshold (40%)' : '⚠️ PHP coverage below 40%'}
- ${diff < -0.5 ? '⚠️ **Warning:** Coverage dropped by more than 0.5%. Please add tests.' : ''}
- ${diff >= 0 ? '🎉 Great job maintaining/improving code coverage!' : ''}
+ ### JavaScript Coverage (Jest)
+
+ | Metric | Value |
+ |--------|-------|
+ | **Current** | **${jestCurrent.toFixed(2)}%** ${jestCoverageEmoji} |
+ | Base | ${jestBase.toFixed(2)}% |
+ | Change | ${jestDiffEmoji} **${jestDiff}%** |
+
+ ${jestCurrent >= 40 ? '✅ Jest coverage meets threshold (40%)' : '⚠️ Jest coverage below 40%'}
+
+ ---
+
+ ${diff < -0.5 ? '⚠️ **Warning:** PHP coverage dropped by more than 0.5%. Please add tests.' : ''}
+ ${diff >= 0 ? '🎉 Great job maintaining/improving PHP coverage!' : ''}
${detailedChanges}
ℹ️ About this report
- - All tests run in a single job with Xdebug coverage
- - Security tests excluded from coverage to prevent output issues
+ - PHP tests run with Xdebug coverage
+ - Jest tests run with built-in coverage
+ - Security tests excluded from PHP coverage
- Coverage calculated from line coverage percentages
element.
- const imgEl = document.createElement( 'img' );
- imgEl.src =
- progressPlannerFocusElement.base_url +
- '/assets/images/icon_progress_planner.svg';
- imgEl.alt = points
- ? prplL10n( 'fixThisIssue' ).replace( '%d', points )
- : '';
-
- // Create a span element for the points.
- const spanEl = document.createElement( 'span' );
- spanEl.textContent = content;
-
- // Create a span element for the wrapper.
- const wrapperEl = document.createElement( 'span' );
- wrapperEl.classList.add( 'prpl-element-awards-points-icon-wrapper' );
- wrapperEl.setAttribute( 'data-prpl-task-id', taskId );
-
- // Add the image and span to the wrapper.
- wrapperEl.appendChild( imgEl );
- wrapperEl.appendChild( spanEl );
-
- return wrapperEl;
-};
-
-/**
- * Maybe focus on the element, based on the URL.
- *
- * @param {Object} task The task object.
- */
-const prplMaybeFocusOnElement = ( task ) => {
- // Check if we want to focus on the element, based on the URL.
- const url = new URL( window.location.href );
- const focusOnElement = url.searchParams.get( 'pp-focus-el' );
- if ( focusOnElement === task.task_id ) {
- let focused = false;
- const iconEls = document.querySelectorAll(
- `[data-prpl-task-id="${ task.task_id }"]`
- );
- iconEls.forEach( ( el ) => {
- el.classList.add( 'focused' );
- if ( ! focused ) {
- el.focus();
- el.scrollIntoView( { behavior: 'smooth' } );
- focused = true;
- }
- } );
- }
-};
-
-/**
- * Add the points indicator to the element.
- *
- * @param {Object} task The task object.
- */
-const prplAddPointsIndicatorToElement = ( task ) => {
- const points = task.points || 1;
- document.querySelectorAll( task.link_setting.iconEl ).forEach( ( el ) => {
- const iconEl = prplGetIndicatorElement(
- task.is_complete ? '✓' : '+' + points,
- task.task_id,
- points
- );
- if ( task.is_complete ) {
- iconEl.classList.add( 'complete' );
- }
-
- // Create a positioning wrapper.
- const wrapperEl = document.createElement( 'span' );
- wrapperEl.classList.add(
- 'prpl-element-awards-points-icon-positioning-wrapper'
- );
-
- // Add the icon to the wrapper.
- wrapperEl.appendChild( iconEl );
- el.appendChild( wrapperEl );
- } );
-};
-
-if ( progressPlannerFocusElement.tasks ) {
- /**
- * Add the points indicator to the element and maybe focus on it.
- */
- progressPlannerFocusElement.tasks.forEach( ( task ) => {
- prplAddPointsIndicatorToElement( task );
- prplMaybeFocusOnElement( task );
- } );
-
- /**
- * Add the points indicator to the page title.
- */
- const prplPageTitle = document.querySelector( 'h1' );
- const prplPageTitleIndicator = prplGetIndicatorElement(
- progressPlannerFocusElement.completedPoints +
- '/' +
- progressPlannerFocusElement.totalPoints,
- 'total'
- );
- prplPageTitle.appendChild( prplPageTitleIndicator );
-}
diff --git a/assets/js/grid-masonry.js b/assets/js/grid-masonry.js
deleted file mode 100644
index 67eb0aedfe..0000000000
--- a/assets/js/grid-masonry.js
+++ /dev/null
@@ -1,80 +0,0 @@
-/* global prplDocumentReady */
-/*
- * Grid Masonry
- *
- * A script to allow a grid to behave like a masonry layout.
- * Inspired by https://medium.com/@andybarefoot/a-masonry-style-layout-using-css-grid-8c663d355ebb
- *
- * Dependencies: progress-planner/document-ready
- */
-
-/**
- * Trigger a resize event on the grid.
- */
-const prplTriggerGridResize = () => {
- setTimeout( () => {
- window.dispatchEvent( new CustomEvent( 'prpl/grid/resize' ) );
- } );
-};
-
-prplDocumentReady( () => {
- prplTriggerGridResize();
- setTimeout( prplTriggerGridResize, 1000 );
-} );
-
-window.addEventListener( 'resize', prplTriggerGridResize );
-
-// Fire event after all images are loaded.
-window.addEventListener( 'load', prplTriggerGridResize );
-
-window.addEventListener(
- 'prpl/grid/resize',
- () => {
- /**
- * Update the grid masonry items (row spans).
- */
- document
- .querySelectorAll( '.prpl-widget-wrapper' )
- .forEach( ( item ) => {
- if ( ! item || item.classList.contains( 'in-popover' ) ) {
- return;
- }
-
- const innerContainer = item.querySelector(
- '.widget-inner-container'
- );
- if ( ! innerContainer ) {
- return;
- }
-
- const rowHeight = parseInt(
- window
- .getComputedStyle(
- document.querySelector( '.prpl-widgets-container' )
- )
- .getPropertyValue( 'grid-auto-rows' )
- );
-
- const paddingTop = parseInt(
- window
- .getComputedStyle( item )
- .getPropertyValue( 'padding-top' )
- );
- const paddingBottom = parseInt(
- window
- .getComputedStyle( item )
- .getPropertyValue( 'padding-bottom' )
- );
-
- const rowSpan = Math.ceil(
- ( innerContainer.getBoundingClientRect().height +
- paddingTop +
- paddingBottom ) /
- rowHeight
- );
-
- item.style.gridRowEnd = 'span ' + ( rowSpan + 1 );
- } );
- },
- false
-);
diff --git a/assets/js/header-filters.js b/assets/js/header-filters.js
deleted file mode 100644
index 7eb1bdf92f..0000000000
--- a/assets/js/header-filters.js
+++ /dev/null
@@ -1,19 +0,0 @@
-// Handle changes to the range dropdown.
-document
- .getElementById( 'prpl-select-range' )
- .addEventListener( 'change', function () {
- const range = this.value;
- const url = new URL( window.location.href );
- url.searchParams.set( 'range', range );
- window.location.href = url.href;
- } );
-
-// Handle changes to the frequency dropdown.
-document
- .getElementById( 'prpl-select-frequency' )
- .addEventListener( 'change', function () {
- const frequency = this.value;
- const url = new URL( window.location.href );
- url.searchParams.set( 'frequency', frequency );
- window.location.href = url.href;
- } );
diff --git a/assets/js/license-generator.js b/assets/js/license-generator.js
index 5dfc2bb109..1c7edcdd1f 100644
--- a/assets/js/license-generator.js
+++ b/assets/js/license-generator.js
@@ -42,7 +42,6 @@ class LicenseGenerator {
* @return {Promise} Promise that resolves when license is saved
*/
static saveLicenseKey( licenseKey ) {
- console.log( 'License key: ' + licenseKey );
return LicenseGenerator.ajaxRequest( {
url: LicenseGenerator.config.adminAjaxUrl,
data: {
diff --git a/assets/js/onboarding/OnboardTask.js b/assets/js/onboarding/OnboardTask.js
deleted file mode 100644
index 2db23b7816..0000000000
--- a/assets/js/onboarding/OnboardTask.js
+++ /dev/null
@@ -1,470 +0,0 @@
-/**
- * OnboardTask - Handles individual tasks that open within the column
- * Used by the MoreTasksStep for tasks that require user input
- * Toggles visibility of task list and shows task content in the same column
- */
-/* global ProgressPlannerOnboardData, ProgressPlannerTourUtils */
-
-// eslint-disable-next-line no-unused-vars
-class PrplOnboardTask {
- constructor( el, wizard ) {
- this.el = el;
- this.id = el.dataset.taskId;
- this.wizard = wizard;
- this.taskContent = null;
- this.formValues = {};
- this.openTaskBtn = el.querySelector( '[prpl-open-task]' );
-
- // Register task open event
- this.openTaskBtn?.addEventListener( 'click', () => this.open() );
- }
-
- /**
- * Get the tour footer element via the current step
- * @return {HTMLElement|null} The tour footer element or null if not found
- */
- getTourFooter() {
- // Get the current step and use its getTourFooter method
- const currentStep =
- this.wizard?.tourSteps?.[ this.wizard.state.currentStep ];
- if ( currentStep && typeof currentStep.getTourFooter === 'function' ) {
- return currentStep.getTourFooter();
- }
-
- // Fallback in case step doesn't have the method
- return this.wizard?.contentWrapper?.querySelector( '.tour-footer' );
- }
-
- registerEvents() {
- this.taskContent.addEventListener( 'click', ( e ) => {
- if ( e.target.classList.contains( 'prpl-complete-task-btn' ) ) {
- const formData = new FormData(
- this.taskContent.querySelector( 'form' )
- );
- this.formValues = Object.fromEntries( formData.entries() );
- this.complete();
- }
- } );
-
- // Close button handler
- const closeBtn = this.taskContent.querySelector(
- '.prpl-task-close-btn'
- );
- closeBtn?.addEventListener( 'click', () => this.close() );
-
- this.setupFormValidation();
-
- // Initialize upload handling (only if upload field exists)
- this.setupFileUpload();
-
- this.el.addEventListener( 'prplFileUploaded', ( e ) => {
- // Handle file upload for the 'set site icon' task.
- if ( 'core-siteicon' === e.detail.fileInput.dataset.taskId ) {
- // Element which will be used to store the file post ID.
- const nextElementSibling =
- e.detail.fileInput.nextElementSibling;
-
- nextElementSibling.value = e.detail.filePost.id;
-
- // Trigger change so validation is triggered and "Complete" button is enabled.
- nextElementSibling.dispatchEvent(
- new CustomEvent( 'change', {
- bubbles: true,
- } )
- );
- }
- } );
- }
-
- open() {
- if ( this.taskContent ) {
- return; // Already open
- }
-
- // Find the column containing the task list
- const taskList = this.wizard.popover.querySelector( '.prpl-task-list' );
- if ( ! taskList ) {
- return;
- }
-
- const column = taskList.closest( '.prpl-column' );
- if ( ! column ) {
- return;
- }
-
- // Hide the task list
- taskList.style.display = 'none';
-
- // Hide the tour footer (it's part of the step content)
- const tourFooter = this.getTourFooter();
- if ( tourFooter ) {
- tourFooter.style.display = 'none';
- }
-
- // Get task content from template
- const content = this.el
- .querySelector( 'template' )
- .content.cloneNode( true );
-
- // Create task content wrapper
- this.taskContent = document.createElement( 'div' );
- this.taskContent.className = 'prpl-task-content-active';
- this.taskContent.appendChild( content );
-
- // Find the complete button in the form
- const completeBtn = this.taskContent.querySelector(
- '.prpl-complete-task-btn'
- );
-
- if ( completeBtn ) {
- // Create close button
- const closeBtn = document.createElement( 'button' );
- closeBtn.type = 'button';
- closeBtn.className = 'prpl-btn prpl-task-close-btn';
- closeBtn.innerHTML =
- ' ' +
- ProgressPlannerOnboardData.l10n.backToRecommendations;
-
- // Create button wrapper
- const buttonWrapper = document.createElement( 'div' );
- buttonWrapper.className = 'prpl-task-buttons';
-
- // Move complete button into wrapper
- completeBtn.parentNode.insertBefore( buttonWrapper, completeBtn );
- buttonWrapper.appendChild( closeBtn );
- buttonWrapper.appendChild( completeBtn );
- }
-
- // Add task content to the column
- column.appendChild( this.taskContent );
-
- // Hide the popover close button
- const popoverCloseBtn = this.wizard.popover.querySelector(
- '#prpl-tour-close-btn'
- );
- if ( popoverCloseBtn ) {
- popoverCloseBtn.style.display = 'none';
- }
-
- // Register events
- this.registerEvents();
- }
-
- close() {
- if ( ! this.taskContent ) {
- return;
- }
-
- // Remove task content
- this.taskContent.remove();
-
- // Show the task list
- const taskList = this.wizard.popover.querySelector( '.prpl-task-list' );
- if ( taskList ) {
- taskList.style.display = '';
- }
-
- // Show the tour footer (it's part of the step content)
- const tourFooter = this.getTourFooter();
- if ( tourFooter ) {
- tourFooter.style.display = '';
- }
-
- // Show the popover close button
- const popoverCloseBtn = this.wizard.popover.querySelector(
- '#prpl-tour-close-btn'
- );
- if ( popoverCloseBtn ) {
- popoverCloseBtn.style.display = '';
- }
-
- // Clean up
- this.taskContent = null;
- }
-
- complete() {
- ProgressPlannerTourUtils.completeTask( this.id, this.formValues )
- .then( () => {
- this.el.classList.add( 'prpl-task-completed' );
- const taskBtn = this.el.querySelector(
- '.prpl-complete-task-btn'
- );
- if ( taskBtn ) {
- taskBtn.disabled = true;
- }
-
- this.close();
- this.notifyParent();
- } )
- .catch( ( error ) => {
- console.error( error );
- // TODO: Handle error.
- } );
- }
-
- notifyParent() {
- const event = new CustomEvent( 'taskCompleted', {
- bubbles: true,
- detail: { id: this.id, formValues: this.formValues },
- } );
- this.el.dispatchEvent( event );
- }
-
- setupFormValidation() {
- const form = this.taskContent.querySelector( 'form' );
- const submitButton = this.taskContent.querySelector(
- '.prpl-complete-task-btn'
- );
-
- if ( ! form || ! submitButton ) {
- return;
- }
-
- const validateElements = form.querySelectorAll( '[data-validate]' );
- if ( validateElements.length === 0 ) {
- return;
- }
-
- const checkValidation = () => {
- let isValid = true;
-
- validateElements.forEach( ( element ) => {
- const validationType = element.getAttribute( 'data-validate' );
- let elementValid = false;
-
- switch ( validationType ) {
- case 'required':
- elementValid =
- element.value !== null &&
- element.value !== undefined &&
- element.value !== '';
- break;
- case 'not-empty':
- elementValid = element.value.trim() !== '';
- break;
- default:
- elementValid = true;
- }
-
- if ( ! elementValid ) {
- isValid = false;
- }
- } );
-
- submitButton.disabled = ! isValid;
- };
-
- checkValidation();
- validateElements.forEach( ( element ) => {
- element.addEventListener( 'change', checkValidation );
- element.addEventListener( 'input', checkValidation );
- } );
- }
-
- /**
- * Handles drag-and-drop or manual file upload for specific tasks.
- * Only runs if the form contains an upload field.
- */
- setupFileUpload() {
- const uploadContainer = this.taskContent.querySelector(
- '[data-upload-field]'
- );
- if ( ! uploadContainer ) {
- return;
- } // no upload for this task
-
- const fileInput = uploadContainer.querySelector( 'input[type="file"]' );
- const statusDiv = uploadContainer.querySelector(
- '.prpl-upload-status'
- );
-
- // Visual drag behavior
- [ 'dragenter', 'dragover' ].forEach( ( event ) => {
- uploadContainer.addEventListener( event, ( e ) => {
- e.preventDefault();
- uploadContainer.classList.add( 'dragover' );
- } );
- } );
-
- [ 'dragleave', 'drop' ].forEach( ( event ) => {
- uploadContainer.addEventListener( event, ( e ) => {
- e.preventDefault();
- uploadContainer.classList.remove( 'dragover' );
- } );
- } );
-
- uploadContainer.addEventListener( 'drop', ( e ) => {
- const file = e.dataTransfer.files[ 0 ];
- if ( file ) {
- this.uploadFile( file, statusDiv ).then( ( response ) => {
- this.el.dispatchEvent(
- new CustomEvent( 'prplFileUploaded', {
- detail: { file, filePost: response, fileInput },
- bubbles: true,
- } )
- );
- } );
- }
- } );
-
- fileInput?.addEventListener( 'change', ( e ) => {
- const file = e.target.files[ 0 ];
- if ( file ) {
- this.uploadFile( file, statusDiv, fileInput ).then(
- ( response ) => {
- this.el.dispatchEvent(
- new CustomEvent( 'prplFileUploaded', {
- detail: { file, filePost: response, fileInput },
- bubbles: true,
- } )
- );
- }
- );
- }
- } );
-
- // Remove button handler.
- const removeBtn = uploadContainer.querySelector( '.prpl-file-remove-btn' );
- const previewDiv = uploadContainer.querySelector( '.prpl-file-preview' );
- removeBtn?.addEventListener( 'click', () => {
- this.removeUploadedFile( uploadContainer, previewDiv );
- } );
- }
-
- async uploadFile( file, statusDiv ) {
- // Validate file extension
- if ( ! this.isValidFaviconFile( file ) ) {
- const fileInput =
- this.taskContent.querySelector( 'input[type="file"]' );
- const acceptedTypes = fileInput?.accept || 'supported file types';
- statusDiv.textContent = `Invalid file type. Please upload a file with one of these formats: ${ acceptedTypes }`;
- return;
- }
-
- statusDiv.textContent = `Uploading ${ file.name }...`;
-
- const formData = new FormData();
- formData.append( 'file', file );
- formData.append( 'prplFileUpload', '1' );
-
- return fetch( '/wp-json/wp/v2/media', {
- method: 'POST',
- headers: {
- 'X-WP-Nonce': ProgressPlannerOnboardData.nonceWPAPI, // usually wp_localize_script adds this
- },
- body: formData,
- credentials: 'same-origin',
- } )
- .then( ( res ) => {
- if ( 201 !== res.status ) {
- throw new Error( 'Failed to upload file' );
- }
- return res.json();
- } )
- .then( ( response ) => {
- // Testing only, no need to display file name in production.
- // statusDiv.textContent = `${ file.name } uploaded.`;
- statusDiv.style.display = 'none';
-
- // Update the file preview.
- const previewDiv =
- this.taskContent.querySelector( '.prpl-file-preview' );
- if ( previewDiv ) {
- previewDiv.innerHTML = `
`;
- previewDiv.style.display = 'block';
-
- // Add has-image class to drop zone to update styling.
- const dropZone = this.taskContent.querySelector(
- '.prpl-file-drop-zone'
- );
- if ( dropZone ) {
- dropZone.classList.add( 'has-image' );
-
- // Show the remove button.
- const removeBtn = dropZone.querySelector(
- '.prpl-file-remove-btn'
- );
- if ( removeBtn ) {
- removeBtn.hidden = false;
- }
- }
- }
- return response;
- } )
- .catch( ( error ) => {
- console.error( error );
- statusDiv.textContent = `Error: ${ error.message }`;
- } );
- }
-
- /**
- * Validate if file matches the accepted file types from the input
- * @param {File} file The file to validate
- * @return {boolean} True if file extension is supported
- */
- isValidFaviconFile( file ) {
- const fileInput =
- this.taskContent.querySelector( 'input[type="file"]' );
- if ( ! fileInput || ! fileInput.accept ) {
- return true; // No restrictions if no accept attribute
- }
-
- const acceptedTypes = fileInput.accept
- .split( ',' )
- .map( ( type ) => type.trim() );
- const fileName = file.name.toLowerCase();
-
- return acceptedTypes.some( ( type ) => {
- if ( type.startsWith( '.' ) ) {
- // Extension-based validation
- return fileName.endsWith( type );
- } else if ( type.includes( '/' ) ) {
- // MIME type-based validation
- return file.type === type;
- }
- return false;
- } );
- }
-
- /**
- * Remove uploaded file and reset the drop zone state.
- * @param {HTMLElement} dropZone The drop zone element.
- * @param {HTMLElement} previewDiv The preview container element.
- */
- removeUploadedFile( dropZone, previewDiv ) {
- // Clear the preview.
- previewDiv.innerHTML = '';
- previewDiv.style.display = 'none';
-
- // Remove has-image class.
- dropZone.classList.remove( 'has-image' );
-
- // Hide the remove button.
- const removeBtn = dropZone.querySelector( '.prpl-file-remove-btn' );
- if ( removeBtn ) {
- removeBtn.hidden = true;
- }
-
- // Clear the file input.
- const fileInput = dropZone.querySelector( 'input[type="file"]' );
- if ( fileInput ) {
- fileInput.value = '';
- }
-
- // Clear the hidden post_id input and trigger validation.
- const postIdInput = dropZone.querySelector( 'input[name="post_id"]' );
- if ( postIdInput ) {
- postIdInput.value = '';
- postIdInput.dispatchEvent(
- new CustomEvent( 'change', { bubbles: true } )
- );
- }
-
- // Show status div again.
- const statusDiv = dropZone.querySelector( '.prpl-upload-status' );
- if ( statusDiv ) {
- statusDiv.style.display = '';
- statusDiv.textContent = '';
- }
- }
-}
diff --git a/assets/js/onboarding/onboarding.js b/assets/js/onboarding/onboarding.js
deleted file mode 100644
index 0d93ac0740..0000000000
--- a/assets/js/onboarding/onboarding.js
+++ /dev/null
@@ -1,490 +0,0 @@
-/**
- * Progress Planner Onboarding Wizard
- * Handles the onboarding wizard functionality
- *
- * Dependencies: progress-planner/license-generator
- */
-/* global ProgressPlannerOnboardData */
-
-// eslint-disable-next-line no-unused-vars
-class ProgressPlannerOnboardWizard {
- constructor( config ) {
- this.config = config;
- this.state = {
- currentStep: 0,
- data: {
- moreTasksCompleted: {},
- firstTaskCompleted: false,
- finished: false,
- },
- cleanup: null,
- };
-
- // Store previously focused element for accessibility
- this.previouslyFocusedElement = null;
-
- // Restore saved progress if available
- this.restoreSavedProgress();
-
- // Make state work with reactive updates.
- this.setupStateProxy();
-
- // Set DOM related properties FIRST.
- this.popover = document.getElementById( this.config.popoverId );
- this.contentWrapper = this.popover.querySelector(
- '.tour-content-wrapper'
- );
-
- // Popover buttons.
- this.closeBtn = this.popover.querySelector( '#prpl-tour-close-btn' );
-
- // Initialize tour steps AFTER popover is set
- this.tourSteps = this.initializeTourSteps();
-
- // Setup event listeners after DOM is ready
- this.setupEventListeners();
- }
-
- /**
- * Restore saved progress from server
- */
- restoreSavedProgress() {
- if (
- ! this.config.savedProgress ||
- typeof this.config.savedProgress !== 'object'
- ) {
- return;
- }
-
- const savedState = this.config.savedProgress;
-
- // Restore currentStep if valid
- if (
- typeof savedState.currentStep === 'number' &&
- savedState.currentStep >= 0
- ) {
- this.state.currentStep = savedState.currentStep;
- console.log(
- 'Restored onboarding progress to step:',
- this.state.currentStep
- );
- }
-
- // Restore data object if present
- if ( savedState.data && typeof savedState.data === 'object' ) {
- // Merge saved data with default state
- this.state.data = {
- ...this.state.data,
- ...savedState.data,
- };
-
- // Ensure moreTasksCompleted is an object
- if (
- ! this.state.data.moreTasksCompleted ||
- typeof this.state.data.moreTasksCompleted !== 'object'
- ) {
- this.state.data.moreTasksCompleted = {};
- }
-
- console.log( 'Restored onboarding data:', this.state.data );
- }
- }
-
- /**
- * Initialize tour steps configuration
- * Creates instances of step components
- */
- initializeTourSteps() {
- // Create instances of step components.
- const steps = this.config.steps.map( ( stepName ) => {
- if (
- window[ `Prpl${ stepName }` ] &&
- typeof window[ `Prpl${ stepName }` ] === 'object'
- ) {
- return window[ `Prpl${ stepName }` ];
- }
-
- console.error(
- `Step class "${ stepName }" not found. Available on window:`,
- Object.keys( window ).filter( ( key ) =>
- key.includes( 'Step' )
- )
- );
-
- return null;
- } );
-
- // Set wizard reference for each step
- steps.forEach( ( step ) => step.setWizard( this ) );
-
- return steps;
- }
-
- /**
- * Render current step
- */
- renderStep() {
- const step = this.tourSteps[ this.state.currentStep ];
-
- // Render step content
- this.contentWrapper.innerHTML = step.render();
-
- // Cleanup previous step
- if ( this.state.cleanup ) {
- this.state.cleanup();
- this.state.cleanup = null;
- }
-
- // Mount current step and store cleanup function
- this.state.cleanup = step.onMount( this.state );
-
- // Setup next button (handled by step now)
- step.setupNextButton();
-
- // Update step indicator
- this.popover.dataset.prplStep = this.state.currentStep;
- this.updateStepNavigation();
- }
-
- /**
- * Update step navigation in left column
- */
- updateStepNavigation() {
- const stepItems = this.popover.querySelectorAll(
- '.prpl-nav-step-item'
- );
- let activeStepTitle = '';
-
- stepItems.forEach( ( item, index ) => {
- const icon = item.querySelector( '.prpl-step-icon' );
- const stepNumber = index + 1;
-
- // Remove all state classes
- item.classList.remove( 'prpl-active', 'prpl-completed' );
-
- // Add appropriate class and update icon
- if ( index < this.state.currentStep ) {
- // Completed step: show checkmark
- item.classList.add( 'prpl-completed' );
- icon.textContent = '✓';
- } else if ( index === this.state.currentStep ) {
- // Active step: show number
- item.classList.add( 'prpl-active' );
- icon.textContent = stepNumber;
- activeStepTitle =
- item.querySelector( '.prpl-step-label' ).textContent;
- } else {
- // Future step: show number
- icon.textContent = stepNumber;
- }
- } );
-
- // Update mobile step label
- const mobileStepLabel = this.popover.querySelector(
- '#prpl-onboarding-mobile-step-label'
- );
- if ( mobileStepLabel ) {
- mobileStepLabel.textContent = activeStepTitle;
- }
- }
-
- /**
- * Move to next step
- */
- async nextStep() {
- console.log(
- 'nextStep() called, current step:',
- this.state.currentStep
- );
- const step = this.tourSteps[ this.state.currentStep ];
-
- // Check if user can proceed from current step
- if ( ! step.canProceed( this.state ) ) {
- console.log( 'Cannot proceed - step requirements not met' );
- return;
- }
-
- // Call beforeNextStep if step has it (for async operations like license generation)
- if ( step.beforeNextStep ) {
- try {
- await step.beforeNextStep();
- } catch ( error ) {
- console.error( 'Error in beforeNextStep:', error );
- return; // Don't proceed if beforeNextStep fails
- }
- }
-
- if ( this.state.currentStep < this.tourSteps.length - 1 ) {
- this.state.currentStep++;
- console.log( 'Moving to step:', this.state.currentStep );
- this.saveProgressToServer();
- this.renderStep();
- } else {
- console.log( 'Finishing tour - reached last step' );
- this.state.data.finished = true;
- this.closeTour();
-
- // Redirect to the Progress Planner dashboard
- if (
- this.config.lastStepRedirectUrl &&
- this.config.lastStepRedirectUrl.length > 0
- ) {
- window.location.href = this.config.lastStepRedirectUrl;
- }
- }
- }
-
- /**
- * Move to previous step, currently not used.
- */
- prevStep() {
- if ( this.state.currentStep > 0 ) {
- this.state.currentStep--;
- this.renderStep();
- }
- }
-
- /**
- * Close the tour
- */
- closeTour() {
- if ( this.popover ) {
- this.popover.hidePopover();
- }
- this.saveProgressToServer();
-
- // Cleanup active step
- if ( this.state.cleanup ) {
- this.state.cleanup();
- }
-
- // Reset cleanup
- this.state.cleanup = null;
-
- // Restore focus to previously focused element for accessibility
- if (
- this.previouslyFocusedElement &&
- typeof this.previouslyFocusedElement.focus === 'function'
- ) {
- this.previouslyFocusedElement.focus();
- this.previouslyFocusedElement = null;
- }
- }
-
- /**
- * Start the onboarding
- */
- startOnboarding() {
- if ( this.popover ) {
- // Store currently focused element for accessibility
- this.previouslyFocusedElement =
- this.popover.ownerDocument.activeElement;
-
- this.popover.showPopover();
- this.updateStepNavigation();
- this.renderStep();
-
- // Move focus to popover for keyboard accessibility
- // Use setTimeout to ensure popover is visible before focusing
- setTimeout( () => {
- this.popover.focus();
- }, 0 );
- }
- }
-
- /**
- * Save progress to server
- */
- async saveProgressToServer() {
- try {
- const response = await fetch( this.config.adminAjaxUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded',
- },
- body: new URLSearchParams( {
- state: JSON.stringify( this.state ),
- nonce: this.config.nonceProgressPlanner,
- action: 'progress_planner_onboarding_save_progress',
- } ),
- credentials: 'same-origin',
- } );
-
- if ( ! response.ok ) {
- throw new Error( 'Request failed: ' + response.status );
- }
-
- return response.json();
- } catch ( error ) {
- console.error( 'Failed to save tour progress:', error );
- }
- }
-
- /**
- * Update next button state
- * Delegates to current step's updateNextButton method
- */
- updateNextButton() {
- const step = this.tourSteps[ this.state.currentStep ];
- if ( step && typeof step.updateNextButton === 'function' ) {
- step.updateNextButton();
- }
- }
-
- /**
- * Update DOM, used for reactive updates.
- * All changes which should happen when the state changes should be done here.
- */
- updateDOM() {
- this.updateNextButton();
- }
-
- /**
- * Setup event listeners
- */
- setupEventListeners() {
- console.log( 'Setting up event listeners...' );
- if ( this.popover ) {
- console.log( 'Popover found:', this.popover );
-
- this.popover.addEventListener( 'beforetoggle', ( event ) => {
- if ( event.newState === 'open' ) {
- console.log( 'Tour opened' );
- }
- if ( event.newState === 'closed' ) {
- console.log( 'Tour closed' );
- }
- } );
-
- // Note: nextBtn click handler is now set up in renderStep()
- // since the button is part of the step content
-
- if ( this.closeBtn ) {
- this.closeBtn.addEventListener( 'click', ( e ) => {
- console.log( 'Close button clicked!' );
-
- // Display quit confirmation if on welcome step (since privacy policy is accepted there)
- if ( this.state.currentStep === 0 ) {
- e.preventDefault();
- this.showQuitConfirmation();
- return;
- }
-
- this.state.data.finished =
- this.state.currentStep === this.tourSteps.length - 1;
- this.closeTour();
- } );
- }
- } else {
- console.error( 'Popover not found!' );
- }
- }
-
- /**
- * Show quit confirmation when trying to close without accepting privacy
- */
- showQuitConfirmation() {
- // Replace content with confirmation message
- const originalContent = this.contentWrapper.innerHTML;
-
- // Get template from DOM
- const template = document.getElementById(
- 'prpl-onboarding-quit-confirmation'
- );
- if ( ! template ) {
- console.error( 'Quit confirmation template not found' );
- return;
- }
-
- this.contentWrapper.innerHTML = template.innerHTML;
-
- // Add event listeners
- const quitYes = this.contentWrapper.querySelector( '#prpl-quit-yes' );
- const quitNo = this.contentWrapper.querySelector( '#prpl-quit-no' );
-
- if ( quitYes ) {
- quitYes.addEventListener( 'click', ( e ) => {
- e.preventDefault();
- this.closeTour();
- } );
- }
-
- if ( quitNo ) {
- quitNo.addEventListener( 'click', ( e ) => {
- e.preventDefault();
- // Restore original content
- this.contentWrapper.innerHTML = originalContent;
-
- // Re-mount the step
- this.renderStep();
- } );
- }
- }
-
- /**
- * Setup state proxy for reactive updates
- */
- setupStateProxy() {
- this.state.data = this.createDeepProxy( this.state.data, () =>
- this.updateDOM()
- );
- }
-
- /**
- * Create deep proxy for nested object changes
- * @param {Object} target
- * @param {Function} callback
- */
- createDeepProxy( target, callback ) {
- // Recursively wrap existing nested objects first
- for ( const key of Object.keys( target ) ) {
- if (
- target[ key ] &&
- typeof target[ key ] === 'object' &&
- ! Array.isArray( target[ key ] )
- ) {
- target[ key ] = this.createDeepProxy( target[ key ], callback );
- }
- }
-
- return new Proxy( target, {
- set: ( obj, prop, value ) => {
- if (
- value &&
- typeof value === 'object' &&
- ! Array.isArray( value )
- ) {
- value = this.createDeepProxy( value, callback );
- }
- obj[ prop ] = value;
- callback();
- return true;
- },
- } );
- }
-}
-
-class ProgressPlannerTourUtils {
- /**
- * Complete a task via AJAX
- * @param {string} taskId
- * @param {Object} formValues
- */
- static async completeTask( taskId, formValues = {} ) {
- const response = await fetch( ProgressPlannerOnboardData.adminAjaxUrl, {
- method: 'POST',
- body: new URLSearchParams( {
- form_values: JSON.stringify( formValues ),
- task_id: taskId,
- nonce: ProgressPlannerOnboardData.nonceProgressPlanner,
- action: 'progress_planner_onboarding_complete_task',
- } ),
- } );
-
- if ( ! response.ok ) {
- throw new Error( 'Request failed: ' + response.status );
- }
-
- return response.json();
- }
-}
diff --git a/assets/js/onboarding/steps/BadgesStep.js b/assets/js/onboarding/steps/BadgesStep.js
deleted file mode 100644
index 516636fadb..0000000000
--- a/assets/js/onboarding/steps/BadgesStep.js
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * Badges step - Explains the badge system to users
- * Simple informational step with no user interaction required
- */
-/* global OnboardingStep */
-
-class PrplBadgesStep extends OnboardingStep {
- constructor() {
- super( {
- templateId: 'onboarding-step-badges',
- } );
- }
-
- /**
- * Mount badges step and lazy-load badge graphic
- * Badge is only loaded after privacy policy is accepted
- * @return {Function} Cleanup function
- */
- onMount() {
- const gaugeElement = document.getElementById( 'prpl-gauge-onboarding' );
-
- if ( ! gaugeElement ) {
- return () => {};
- }
-
- // Create badge element using innerHTML to properly instantiate the custom element
- const badgeId = gaugeElement.getAttribute( 'data-badge-id' );
- const badgeName = gaugeElement.getAttribute( 'data-badge-name' );
- const brandingId = gaugeElement.getAttribute( 'data-branding-id' );
-
- gaugeElement.innerHTML = `
-
${ this.escapeHtml( message ) }
-element right after the form - const existingErrorElement = formElement.parentNode.querySelector( - 'p.prpl-interactive-task-error-message' - ); - - if ( ! existingErrorElement ) { - // Add paragraph with error message. - const errorParagraph = document.createElement( 'p' ); - errorParagraph.classList.add( - 'prpl-note', - 'prpl-note-error', - 'prpl-interactive-task-error-message' - ); - errorParagraph.textContent = prplL10n( 'somethingWentWrong' ); - - // Append after the form element. - formElement.insertAdjacentElement( 'afterend', errorParagraph ); - } - }, - - /** - * Show loading state. - * - * @param {HTMLFormElement} formElement - The form element. - * @return {void} - */ - showLoading: ( formElement ) => { - let submitButton = formElement.querySelector( 'button[type="submit"]' ); - - if ( ! submitButton ) { - submitButton = formElement.querySelector( - 'button[data-action="completeTask"]' - ); - } - - submitButton.disabled = true; - - // Add spinner. - const spinner = document.createElement( 'span' ); - spinner.classList.add( 'prpl-spinner' ); - spinner.innerHTML = - ''; // WP spinner. - - // Append spinner after submit button. - submitButton.after( spinner ); - }, - - /** - * Hide loading state. - * - * @param {HTMLFormElement} formElement - The form element. - * @return {void} - */ - hideLoading: ( formElement ) => { - let submitButton = formElement.querySelector( 'button[type="submit"]' ); - - if ( ! submitButton ) { - submitButton = formElement.querySelector( - 'button[data-action="completeTask"]' - ); - } - - submitButton.disabled = false; - const spinner = formElement.querySelector( 'span.prpl-spinner' ); - if ( spinner ) { - spinner.remove(); - } - }, -}; diff --git a/assets/js/recommendations/remove-terms-without-posts.js b/assets/js/recommendations/remove-terms-without-posts.js deleted file mode 100644 index 2ed1fbbf67..0000000000 --- a/assets/js/recommendations/remove-terms-without-posts.js +++ /dev/null @@ -1,214 +0,0 @@ -/* global progressPlanner, prplInteractiveTaskFormListener */ -/** - * Remove Terms Without Posts recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task, progress-planner/ajax-request, progress-planner/suggested-task - */ -( function () { - /** - * Remove Terms Without Posts class. - */ - class RemoveTermsWithoutPosts { - /** - * Constructor. - */ - constructor() { - this.popoverId = 'prpl-popover-remove-terms-without-posts'; - - // Early return if the popover is not found. - if ( ! document.getElementById( this.popoverId ) ) { - return; - } - - this.currentTermData = null; - this.currentTaskElement = null; - this.elements = this.getElements(); - this.init(); - } - - /** - * Get all DOM elements. - * - * @return {Object} Object containing all DOM elements. - */ - getElements() { - const popover = document.getElementById( this.popoverId ); - return { - popover, - popoverTitle: popover.querySelector( '.prpl-popover-title' ), - - termNameElement: popover.querySelector( - '#prpl-delete-term-name' - ), - taxonomyElement: popover.querySelector( - '#prpl-delete-term-taxonomy' - ), - taxonomyNameElement: popover.querySelector( - '#prpl-delete-term-taxonomy-name' - ), - termIdField: popover.querySelector( '#prpl-delete-term-id' ), - taxonomyField: popover.querySelector( '#prpl-delete-taxonomy' ), - }; - } - - /** - * Initialize the component. - */ - init() { - this.bindEvents(); - } - - /** - * Bind event listeners. - */ - bindEvents() { - // Listen for the generic interactive task action event. - document.addEventListener( - 'prpl-interactive-task-action-remove-terms-without-posts', - ( event ) => { - this.handleInteractiveTaskAction( event ); - - // After the event is handled, initialize the form listener. - this.initFormListener(); - } - ); - } - - /** - * Handle interactive task action event. - * - * @param {CustomEvent} event The custom event with task context data. - */ - handleInteractiveTaskAction( event ) { - this.currentTermData = { - termId: this.decodeHtmlEntities( event.detail.target_term_id ), - taxonomy: this.decodeHtmlEntities( - event.detail.target_taxonomy - ), - taxonomyName: this.decodeHtmlEntities( - event.detail.target_taxonomy_name - ), - termName: this.decodeHtmlEntities( - event.detail.target_term_name - ), - }; - - // Store reference to the task element that triggered this. - this.currentTaskElement = event.target.closest( - '.prpl-suggested-task' - ); - - // Update the popover content with the term data. - this.updatePopoverContent( - this.currentTermData.termId, - this.currentTermData.taxonomy, - this.currentTermData.termName, - this.currentTermData.taxonomyName, - this.decodeHtmlEntities( event.detail.post_title ) - ); - } - - /** - * Update the popover content. - * - * @param {string} termId The term ID. - * @param {string} taxonomy The taxonomy. - * @param {string} termName The term name. - * @param {string} taxonomyName The taxonomy name. - * @param {string} postTitle The post title. - */ - updatePopoverContent( - termId, - taxonomy, - termName, - taxonomyName, - postTitle - ) { - if ( this.elements.popoverTitle ) { - this.elements.popoverTitle.textContent = postTitle; - } - - if ( this.elements.termNameElement ) { - this.elements.termNameElement.textContent = termName; - } - - if ( this.elements.taxonomyElement ) { - this.elements.taxonomyElement.textContent = taxonomy; - } - - if ( this.elements.taxonomyNameElement ) { - this.elements.taxonomyNameElement.textContent = taxonomyName; - } - - if ( this.elements.termIdField ) { - this.elements.termIdField.value = termId; - } - - if ( this.elements.taxonomyField ) { - this.elements.taxonomyField.value = taxonomy; - } - } - - /** - * Initialize the form listener. - */ - initFormListener() { - if ( ! this.currentTermData || ! this.currentTaskElement ) { - return; - } - - prplInteractiveTaskFormListener.customSubmit( { - taskId: this.currentTaskElement.dataset.taskId, - popoverId: this.popoverId, - callback: () => { - return new Promise( ( resolve, reject ) => { - fetch( progressPlanner.ajaxUrl, { - method: 'POST', - headers: { - 'Content-Type': - 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams( { - action: 'prpl_interactive_task_submit_remove-terms-without-posts', - nonce: progressPlanner.nonce, - term_id: this.elements.termIdField.value, - taxonomy: this.elements.taxonomyField.value, - } ), - } ) - .then( () => { - this.currentTaskElement = null; - this.currentTermData = null; - } ) - .then( ( response ) => { - resolve( { response, success: true } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ); - }, - } ); - } - - /** - * Decodes HTML entities in a string (like ", &, etc.) - * @param {string} str The string to decode. - * @return {string} The decoded string. - */ - decodeHtmlEntities( str ) { - if ( typeof str !== 'string' ) { - return str; - } - - return str - .replace( /"/g, '"' ) - .replace( /'/g, "'" ) - .replace( /</g, '<' ) - .replace( />/g, '>' ) - .replace( /&/g, '&' ); - } - } - - // Initialize the component. - new RemoveTermsWithoutPosts(); -} )(); diff --git a/assets/js/recommendations/rename-uncategorized-category.js b/assets/js/recommendations/rename-uncategorized-category.js deleted file mode 100644 index 0adc4b9128..0000000000 --- a/assets/js/recommendations/rename-uncategorized-category.js +++ /dev/null @@ -1,76 +0,0 @@ -/* global prplInteractiveTaskFormListener, progressPlanner, prplDocumentReady */ - -/* - * Rename the Uncategorized category. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -prplInteractiveTaskFormListener.customSubmit( { - taskId: 'rename-uncategorized-category', - popoverId: 'prpl-popover-rename-uncategorized-category', - callback: () => { - const name = document.querySelector( - '#prpl-popover-rename-uncategorized-category input[name="prpl_uncategorized_category_name"]' - ); - const slug = document.querySelector( - '#prpl-popover-rename-uncategorized-category input[name="prpl_uncategorized_category_slug"]' - ); - - return new Promise( ( resolve, reject ) => { - fetch( progressPlanner.ajaxUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams( { - action: 'prpl_interactive_task_submit_rename-uncategorized-category', - nonce: progressPlanner.nonce, - uncategorized_category_name: name.value, - uncategorized_category_slug: slug.value, - } ), - } ) - .then( ( response ) => { - resolve( { response, success: true } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ); - }, -} ); - -prplDocumentReady( () => { - const name = document.querySelector( - '#prpl-popover-rename-uncategorized-category input[name="prpl_uncategorized_category_name"]' - ); - const slug = document.querySelector( - '#prpl-popover-rename-uncategorized-category input[name="prpl_uncategorized_category_slug"]' - ); - - if ( ! name || ! slug ) { - return; - } - - // Function to check if both fields are valid and toggle button state - const toggleSubmitButton = () => { - const submitButton = document.querySelector( - '#prpl-popover-rename-uncategorized-category button[type="submit"]' - ); - const isNameValid = - name.value && - name.value.toLowerCase() !== name.placeholder.toLowerCase(); - const isSlugValid = - slug.value && - slug.value.toLowerCase() !== slug.placeholder.toLowerCase(); - - submitButton.disabled = ! ( isNameValid && isSlugValid ); - }; - - // If there is no name or slug or it is the same as placeholder the submit button should be disabled. - toggleSubmitButton(); - - // Add event listeners to both fields - name.addEventListener( 'input', toggleSubmitButton ); - slug.addEventListener( 'input', toggleSubmitButton ); -} ); diff --git a/assets/js/recommendations/sample-page.js b/assets/js/recommendations/sample-page.js deleted file mode 100644 index 7b10ed1cb4..0000000000 --- a/assets/js/recommendations/sample-page.js +++ /dev/null @@ -1,37 +0,0 @@ -/* global prplInteractiveTaskFormListener, samplePageData */ - -/* - * Core Blog Description recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -prplInteractiveTaskFormListener.customSubmit( { - taskId: 'sample-page', - popoverId: 'prpl-popover-sample-page', - callback: () => { - return new Promise( ( resolve, reject ) => { - const post = new wp.api.models.Page( { - id: samplePageData.postId, - } ); - post.fetch() - .then( () => { - // Handle the case when plain URL structure is used, it used to result in invalid URL (404): http://localhost:8080/index.php?rest_route=/wp/v2/prpl_recommendations/35?force=true - const url = post.url().includes( 'rest_route=' ) - ? post.url() + '&force=true' - : post.url() + '?force=true'; - - post.destroy( { url } ) - .then( () => { - resolve( { success: true } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ); - }, -} ); diff --git a/assets/js/recommendations/search-engine-visibility.js b/assets/js/recommendations/search-engine-visibility.js deleted file mode 100644 index 9d4e13139a..0000000000 --- a/assets/js/recommendations/search-engine-visibility.js +++ /dev/null @@ -1,14 +0,0 @@ -/* global prplInteractiveTaskFormListener */ - -/* - * Search Engine Visibility recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ -prplInteractiveTaskFormListener.settings( { - settingAPIKey: 'blog_public', - setting: 'blog_public', - taskId: 'search-engine-visibility', - popoverId: 'prpl-popover-search-engine-visibility', - action: 'prpl_interactive_task_submit_search-engine-visibility', -} ); diff --git a/assets/js/recommendations/select-locale.js b/assets/js/recommendations/select-locale.js deleted file mode 100644 index 425fbf87da..0000000000 --- a/assets/js/recommendations/select-locale.js +++ /dev/null @@ -1,15 +0,0 @@ -/* global prplInteractiveTaskFormListener */ - -/* - * Select Locale recommendation. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -prplInteractiveTaskFormListener.settings( { - settingAPIKey: 'language', - setting: 'language', - taskId: 'select-locale', - popoverId: 'prpl-popover-select-locale', - action: 'prpl_interactive_task_submit_select-locale', -} ); diff --git a/assets/js/recommendations/select-timezone.js b/assets/js/recommendations/select-timezone.js deleted file mode 100644 index 35f2607a3a..0000000000 --- a/assets/js/recommendations/select-timezone.js +++ /dev/null @@ -1,26 +0,0 @@ -/* global prplInteractiveTaskFormListener, prplDocumentReady */ - -/* - * Set the site timezone. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -prplInteractiveTaskFormListener.settings( { - settingAPIKey: 'timezone', - setting: 'timezone', - taskId: 'select-timezone', - popoverId: 'prpl-popover-select-timezone', - action: 'prpl_interactive_task_submit_select-timezone', -} ); - -prplDocumentReady( () => { - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - const timezoneSelect = document.querySelector( 'select#timezone' ); - const timezoneSaved = timezoneSelect?.dataset?.timezoneSaved || 'false'; - - // Try to preselect the timezone. - if ( timezone && timezoneSelect && 'false' === timezoneSaved ) { - timezoneSelect.value = timezone; - } -} ); diff --git a/assets/js/recommendations/set-date-format.js b/assets/js/recommendations/set-date-format.js deleted file mode 100644 index 82a00f13f4..0000000000 --- a/assets/js/recommendations/set-date-format.js +++ /dev/null @@ -1,129 +0,0 @@ -/* global prplInteractiveTaskFormListener, prplDocumentReady, progressPlanner */ - -/* - * Set the site date format. - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -prplInteractiveTaskFormListener.customSubmit( { - taskId: 'set-date-format', - popoverId: 'prpl-popover-set-date-format', - callback: () => { - return new Promise( ( resolve, reject ) => { - const format = document.querySelector( - '#prpl-popover-set-date-format input[name="date_format"]:checked' - ); - const customFormat = document.querySelector( - '#prpl-popover-set-date-format input[name="date_format_custom"]' - ); - - fetch( progressPlanner.ajaxUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams( { - action: 'prpl_interactive_task_submit_set-date-format', - nonce: progressPlanner.nonce, - date_format: format.value, - date_format_custom: customFormat.value, - } ), - } ) - .then( ( response ) => { - resolve( { response, success: true } ); - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ); - }, -} ); - -prplDocumentReady( () => { - // Handle date format radio button clicks - document - .querySelectorAll( - '#prpl-popover-set-date-format input[name="date_format"]' - ) - .forEach( function ( input ) { - input.addEventListener( 'click', function () { - if ( 'date_format_custom_radio' !== this.id ) { - const customInput = document.querySelector( - '#prpl-popover-set-date-format input[name="date_format_custom"]' - ); - const fieldset = customInput.closest( 'fieldset' ); - const exampleElement = fieldset.querySelector( '.example' ); - const formatText = - this.parentElement.querySelector( - '.format-i18n' - ).textContent; - - customInput.value = this.value; - exampleElement.textContent = formatText; - } - } ); - } ); - - // Handle custom date format input - const customDateInput = document.querySelector( - 'input[name="date_format_custom"]' - ); - - if ( customDateInput ) { - customDateInput.addEventListener( 'click', function () { - document.getElementById( - 'date_format_custom_radio' - ).checked = true; - } ); - - customDateInput.addEventListener( 'input', function () { - document.getElementById( - 'date_format_custom_radio' - ).checked = true; - - const format = this; - const fieldset = format.closest( 'fieldset' ); - const example = fieldset.querySelector( '.example' ); - - // Debounce the event callback while users are typing. - clearTimeout( format.dataset.timer ); - format.dataset.timer = setTimeout( function () { - // If custom date is not empty. - if ( format.value ) { - // Find the spinner element within the fieldset - const spinner = fieldset.querySelector( '.spinner' ); - if ( spinner ) { - spinner.classList.add( 'is-active' ); - } - - // Use fetch instead of $.post - fetch( progressPlanner.ajaxUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams( { - action: 'date_format', - date: format.value, - } ), - } ) - .then( function ( response ) { - return response.text(); - } ) - .then( function ( data ) { - example.textContent = data; - } ) - .catch( function ( error ) { - console.error( 'Error:', error ); - } ) - .finally( function () { - if ( spinner ) { - spinner.classList.remove( 'is-active' ); - } - } ); - } - }, 500 ); - } ); - } -} ); diff --git a/assets/js/recommendations/set-page.js b/assets/js/recommendations/set-page.js deleted file mode 100644 index fbca6755be..0000000000 --- a/assets/js/recommendations/set-page.js +++ /dev/null @@ -1,140 +0,0 @@ -/* global prplInteractiveTaskFormListener, progressPlanner, prplDocumentReady */ - -/* - * Set page settings (About, Contact, FAQ, etc.) - * - * Dependencies: progress-planner/recommendations/interactive-task - */ - -// Initialize custom submit handlers for all set-page tasks. -prplDocumentReady( function () { - // Find all set-page popovers. - const popovers = document.querySelectorAll( - '[id^="prpl-popover-set-page-"]' - ); - - popovers.forEach( function ( popover ) { - // Extract page name from popover ID (e.g., "prpl-popover-set-page-about" -> "about") - const popoverId = popover.id; - const match = popoverId.match( /prpl-popover-set-page-(.+)/ ); - if ( ! match ) { - return; - } - - const pageName = match[ 1 ]; - const taskId = 'set-page-' + pageName; - - // Skip if already initialized. - if ( popover.dataset.setPageInitialized ) { - return; - } - popover.dataset.setPageInitialized = 'true'; - - prplInteractiveTaskFormListener.customSubmit( { - taskId, - popoverId, - callback: () => { - return new Promise( ( resolve, reject ) => { - const pageValue = document.querySelector( - '#' + - popoverId + - ' input[name="pages[' + - pageName + - '][have_page]"]:checked' - ); - - if ( ! pageValue ) { - reject( { - success: false, - error: new Error( 'Page value not found' ), - } ); - return; - } - - const pageId = document.querySelector( - '#' + - popoverId + - ' select[name="pages[' + - pageName + - '][id]"]' - ); - - fetch( progressPlanner.ajaxUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams( { - action: 'prpl_interactive_task_submit_set-page', - nonce: progressPlanner.nonce, - have_page: pageValue.value, - id: pageId ? pageId.value : '', - task_id: taskId, - } ), - } ) - .then( ( response ) => response.json() ) - .then( ( data ) => { - if ( data.success ) { - resolve( { response: data, success: true } ); - } else { - reject( { success: false, error: data } ); - } - } ) - .catch( ( error ) => { - reject( { success: false, error } ); - } ); - } ); - }, - } ); - } ); -} ); - -const prplTogglePageSelectorSettingVisibility = function ( page, value ) { - const itemRadiosWrapperEl = document.querySelector( - `.prpl-pages-item-${ page } .radios` - ); - - if ( ! itemRadiosWrapperEl ) { - return; - } - - const selectPageWrapper = - itemRadiosWrapperEl.querySelector( '.prpl-select-page' ); - - if ( ! selectPageWrapper ) { - return; - } - - // Show only create button. - if ( 'no' === value || 'not-applicable' === value ) { - // Hide