diff --git a/.distignore b/.distignore index 9a5cba0e4a..446c22cc39 100644 --- a/.distignore +++ b/.distignore @@ -1,6 +1,9 @@ -/.wordpress-org /.git /.github +/.husky +/.vscode +/.wordpress-org +/bin /coverage /tests /vendor @@ -8,14 +11,18 @@ .editorconfig .eslintrc.js .env.example -.gitignore +.eslintcrc.js .gitattributes +.gitignore .php-cs-fixer.dist.php +.stylelintrc.json composer.json composer.lock -package.json package-lock.json +package.json phpcs.xml.dist phpstan.neon.dist phpunit.xml.dist +playwright.config.js README.md +SECURITY.md diff --git a/.gitattributes b/.gitattributes index 313f925cd5..c5bc96da13 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,16 +1,25 @@ -.editorconfig export-ignore -.gitattributes export-ignore .github/* export-ignore +.husky/* export-ignore +.vscode/* export-ignore +.wordpress-org/* export-ignore +bin/* export-ignore +coverage/* export-ignore tests/* export-ignore +.distignore export-ignore +.editorconfig export-ignore +.env.example export-ignore +.eslintcrc.js export-ignore +.gitattributes export-ignore .gitignore export-ignore +.php-cs-fixer.dist.php export-ignore +.stylelintrc.json export-ignore composer.json export-ignore composer.lock export-ignore package.json export-ignore package-lock.json export-ignore -.wordpress-org/* export-ignore phpcs.xml.dist export-ignore phpstan.neon.dist export-ignore phpunit.xml.dist export-ignore +playwright.config.js export-ignore README.md export-ignore -.wordpress-org/* export-ignore -.vscode/* export-ignore +SECURITY.md export-ignore diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml new file mode 100644 index 0000000000..306f87d6ed --- /dev/null +++ b/.github/workflows/code-coverage.yml @@ -0,0 +1,509 @@ +name: Code Coverage + +on: + pull_request: + push: + branches: + - develop + - main + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + code-coverage: + name: Code Coverage Check + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: false + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: wordpress_tests + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=10s --health-retries=10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for accurate diff + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + ini-values: zend.assertions=1, error_reporting=-1, display_errors=On, memory_limit=512M + coverage: xdebug # Use Xdebug for coverage (PCOV was causing PHP to crash) + tools: composer + + - name: Install SVN and XML tools + run: | + sudo apt-get update + sudo apt-get install -y subversion libxml2-utils + + - name: Install Composer dependencies + uses: ramsey/composer-install@v2 + with: + dependency-versions: "highest" + composer-options: "--prefer-dist --with-dependencies" + custom-cache-suffix: $(date -u -d "-0 month -$(($(date +%d)-1)) days" "+%F")-codecov-v2 + + - name: Install WordPress Test Suite + shell: bash + run: tests/bin/install-wp-tests.sh wordpress_tests root root 127.0.0.1:3306 latest + + - name: Generate code coverage report for current branch + run: | + echo "=== Debug: PHP Configuration ===" + php -i | grep -E "(memory_limit|max_execution_time|xdebug)" + echo "=== Debug: Check Xdebug is loaded ===" + php -m | grep xdebug || echo "Xdebug not loaded" + php -r "var_dump(extension_loaded('xdebug'));" + echo "=== Running PHPUnit with coverage ===" + echo "Start time: $(date)" + echo "Memory before: $(free -h | grep Mem)" + + # Run PHPUnit with coverage - allow test failures but ensure coverage is generated + # test-class-security.php is excluded via phpunit.xml.dist to avoid output contamination + set +e + # Run PHPUnit and capture both test output and coverage text separately + php -d memory_limit=512M -d max_execution_time=300 \ + vendor/bin/phpunit --configuration phpunit.xml.dist \ + --coverage-clover=coverage.xml \ + --coverage-text --colors=never > phpunit-with-coverage.log 2>&1 + PHPUNIT_EXIT=$? + set -e + + # Extract test output (everything before coverage section) for debugging + # Coverage section typically starts with a line like "Code Coverage Report:" or summary table + # Extract everything up to (but not including) the coverage section + awk '/Code Coverage Report:|^Summary|^ Classes:|^ Methods:|^ Lines:/{exit} {print}' phpunit-with-coverage.log > phpunit-output.log || cat phpunit-with-coverage.log > phpunit-output.log + + # Extract coverage text output (the coverage section) + # Coverage section starts with summary or "Code Coverage Report" + awk '/Code Coverage Report:|^Summary|^ Classes:|^ Methods:|^ Lines:/{flag=1} flag' phpunit-with-coverage.log > current-coverage-full.txt || tail -200 phpunit-with-coverage.log > current-coverage-full.txt + + echo "End time: $(date)" + echo "Memory after: $(free -h | grep Mem)" + echo "=== Debug: PHPUnit exit code: $PHPUNIT_EXIT ===" + echo "=== Note: Exit code $PHPUNIT_EXIT (0=success, 1=test failures, 2=errors, >128=signal termination) ===" + echo "=== Debug: Line count of PHPUnit output ===" + wc -l phpunit-output.log + echo "=== Debug: Last 100 lines of PHPUnit output ===" + tail -100 phpunit-output.log + echo "=== Debug: After running PHPUnit ===" + ls -la coverage* 2>/dev/null || echo "No coverage files in current directory" + echo "=== Checking if coverage report was generated ===" + if [ -f coverage.xml ]; then + echo "SUCCESS: coverage.xml exists!" + ls -lh coverage.xml + echo "First 20 lines of coverage.xml:" + head -20 coverage.xml + else + echo "FAIL: coverage.xml was not generated" + echo "=== Checking for errors in PHPUnit output ===" + grep -i "error\|fatal\|exception\|segfault\|out of memory" phpunit-output.log || echo "No obvious errors found" + # Exit with error if coverage wasn't generated + exit 1 + fi + continue-on-error: false + + - name: Upload PHPUnit output for debugging + if: always() + uses: actions/upload-artifact@v4 + with: + name: phpunit-output + path: phpunit-output.log + retention-days: 7 + + - name: Generate coverage report summary + id: coverage + run: | + # Extract overall coverage from coverage.xml (Clover format) + # This avoids running PHPUnit twice - we already have coverage.xml from the first run + if [ -f coverage.xml ]; then + # Extract metrics from Clover XML using xmllint + # Fallback to Python if xmllint fails + STATEMENTS=$(xmllint --xpath 'string(//project/metrics/@statements)' coverage.xml 2>/dev/null || python3 -c "import xml.etree.ElementTree as ET; tree = ET.parse('coverage.xml'); metrics = tree.find('.//project/metrics'); print(metrics.get('statements', '0') if metrics is not None else '0')" 2>/dev/null || echo "0") + COVERED_STATEMENTS=$(xmllint --xpath 'string(//project/metrics/@coveredstatements)' coverage.xml 2>/dev/null || python3 -c "import xml.etree.ElementTree as ET; tree = ET.parse('coverage.xml'); metrics = tree.find('.//project/metrics'); print(metrics.get('coveredstatements', '0') if metrics is not None else '0')" 2>/dev/null || echo "0") + + # Calculate coverage percentage + if [ "$STATEMENTS" != "0" ] && [ -n "$STATEMENTS" ] && [ -n "$COVERED_STATEMENTS" ]; then + COVERAGE=$(echo "scale=2; ($COVERED_STATEMENTS * 100) / $STATEMENTS" | bc) + else + COVERAGE="0" + fi + + echo "current_coverage=$COVERAGE" >> $GITHUB_OUTPUT + echo "Current code coverage: $COVERAGE% (from coverage.xml)" + echo "Statements: $COVERED_STATEMENTS / $STATEMENTS" + else + echo "ERROR: coverage.xml not found!" + echo "current_coverage=0" >> $GITHUB_OUTPUT + exit 1 + fi + + # Coverage text output was already extracted from phpunit-with-coverage.log in the previous step + # If extraction failed, try to generate it again as fallback + if [ ! -s current-coverage-full.txt ]; then + echo "Warning: Could not extract coverage text from phpunit-with-coverage.log, generating separately..." + vendor/bin/phpunit --configuration phpunit.xml.dist --coverage-text --colors=never > current-coverage-full.txt 2>&1 || true + fi + + # Save detailed per-file coverage for later comparison + # PHPUnit outputs class name on one line, stats on the next line + # We need to combine them: "ClassName" + " Methods: X% Lines: Y%" + awk ' + /^[A-Za-z_]/ { classname = $0; next } + /^ Methods:.*Lines:/ { + gsub(/\x1b\[[0-9;]*m/, "", classname); + gsub(/\x1b\[[0-9;]*m/, "", $0); + print classname " " $0 + } + ' current-coverage-full.txt > current-coverage-details.txt || true + + echo "=== Current coverage details saved ===" + head -20 current-coverage-details.txt || true + + - name: Checkout base branch for comparison + if: github.event_name == 'pull_request' + run: | + # Save current branch coverage files + cp current-coverage-details.txt /tmp/current-coverage-details.txt 2>/dev/null || true + cp current-coverage-full.txt /tmp/current-coverage-full.txt 2>/dev/null || true + + # Stash any local changes (like composer.lock) + git stash --include-untracked || true + git fetch origin ${{ github.base_ref }} + git checkout origin/${{ github.base_ref }} + + - name: Install dependencies on base branch + if: github.event_name == 'pull_request' + run: | + composer install --no-interaction --prefer-dist --optimize-autoloader + + - name: Generate coverage report for base branch + if: github.event_name == 'pull_request' + id: base_coverage + run: | + # Generate coverage for base branch (including coverage.xml) + vendor/bin/phpunit --configuration phpunit.xml.dist \ + --coverage-clover=base-coverage.xml \ + --coverage-text --colors=never > base-coverage-full.txt 2>&1 || true + + # Extract overall coverage from base-coverage.xml (Clover format) + if [ -f base-coverage.xml ]; then + # Extract metrics from Clover XML using xmllint + # Fallback to Python if xmllint fails + STATEMENTS=$(xmllint --xpath 'string(//project/metrics/@statements)' base-coverage.xml 2>/dev/null || python3 -c "import xml.etree.ElementTree as ET; tree = ET.parse('base-coverage.xml'); metrics = tree.find('.//project/metrics'); print(metrics.get('statements', '0') if metrics is not None else '0')" 2>/dev/null || echo "0") + COVERED_STATEMENTS=$(xmllint --xpath 'string(//project/metrics/@coveredstatements)' base-coverage.xml 2>/dev/null || python3 -c "import xml.etree.ElementTree as ET; tree = ET.parse('base-coverage.xml'); metrics = tree.find('.//project/metrics'); print(metrics.get('coveredstatements', '0') if metrics is not None else '0')" 2>/dev/null || echo "0") + + # Calculate coverage percentage + if [ "$STATEMENTS" != "0" ] && [ -n "$STATEMENTS" ] && [ -n "$COVERED_STATEMENTS" ]; then + BASE_COVERAGE=$(echo "scale=2; ($COVERED_STATEMENTS * 100) / $STATEMENTS" | bc) + else + BASE_COVERAGE="0" + fi + + echo "base_coverage=$BASE_COVERAGE" >> $GITHUB_OUTPUT + echo "Base branch code coverage: $BASE_COVERAGE% (from base-coverage.xml)" + echo "Statements: $COVERED_STATEMENTS / $STATEMENTS" + else + # Fallback to text extraction if XML not available + BASE_COVERAGE=$(grep "^ Lines:" base-coverage-full.txt | tail -1 | awk '{print $2}' | sed 's/%//' || echo "0") + echo "base_coverage=$BASE_COVERAGE" >> $GITHUB_OUTPUT + echo "Base branch code coverage: $BASE_COVERAGE% (from text fallback)" + fi + + # Extract per-file coverage for comparison + # PHPUnit outputs class name on one line, stats on the next line + awk ' + /^[A-Za-z_]/ { classname = $0; next } + /^ Methods:.*Lines:/ { + gsub(/\x1b\[[0-9;]*m/, "", classname); + gsub(/\x1b\[[0-9;]*m/, "", $0); + print classname " " $0 + } + ' base-coverage-full.txt > base-coverage-details.txt || true + + echo "=== Base coverage details saved ===" + head -20 base-coverage-details.txt || true + continue-on-error: true + + - name: Generate coverage diff report + if: github.event_name == 'pull_request' + id: coverage_diff + run: | + # Restore current branch coverage files + cp /tmp/current-coverage-details.txt current-coverage-details.txt 2>/dev/null || true + + # Create a Python script to compare coverage + cat > compare_coverage.py << 'PYTHON_SCRIPT' + import re + import sys + import json + + def parse_coverage_line(line): + """Parse a coverage line to extract class name and line coverage percentage.""" + # Example line: "Progress_Planner\Activity Methods: 55.56% ( 5/ 9) Lines: 91.92% ( 91/ 99)" + match = re.search(r'^([\w\\]+)\s+Methods:\s+([\d.]+)%.*Lines:\s+([\d.]+)%\s+\(\s*(\d+)/\s*(\d+)\)', line) + if match: + class_name = match.group(1) + # Group 2 is methods percentage (not used) + line_percent = float(match.group(3)) # Lines percentage + covered_lines = int(match.group(4)) # Covered lines count + total_lines = int(match.group(5)) # Total lines count + return class_name, line_percent, covered_lines, total_lines + return None, None, None, None + + def load_coverage(filename): + """Load coverage data from file.""" + coverage = {} + try: + with open(filename, 'r') as f: + for line in f: + class_name, percent, covered, total = parse_coverage_line(line) + if class_name: + coverage[class_name] = { + 'percent': percent, + 'covered': covered, + 'total': total + } + except FileNotFoundError: + pass + return coverage + + # Load current and base coverage + current = load_coverage('current-coverage-details.txt') + base = load_coverage('base-coverage-details.txt') + + # Find changes + changes = { + 'new_files': [], + 'improved': [], + 'degraded': [], + 'unchanged': [] + } + + # Check all current files + for class_name in sorted(current.keys()): + curr_data = current[class_name] + if class_name not in base: + # New file + changes['new_files'].append({ + 'class': class_name, + 'coverage': curr_data['percent'], + 'lines': f"{curr_data['covered']}/{curr_data['total']}" + }) + else: + base_data = base[class_name] + diff = curr_data['percent'] - base_data['percent'] + if abs(diff) < 0.01: # Less than 0.01% difference + continue # Skip unchanged files for brevity + elif diff > 0: + changes['improved'].append({ + 'class': class_name, + 'old': base_data['percent'], + 'new': curr_data['percent'], + 'diff': diff + }) + else: + changes['degraded'].append({ + 'class': class_name, + 'old': base_data['percent'], + 'new': curr_data['percent'], + 'diff': diff + }) + + # Output as JSON for GitHub Actions + print(json.dumps(changes)) + PYTHON_SCRIPT + + # Run the comparison + CHANGES_JSON=$(python3 compare_coverage.py) + echo "coverage_changes<> $GITHUB_OUTPUT + echo "$CHANGES_JSON" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + echo "=== Coverage changes ===" + echo "$CHANGES_JSON" | python3 -m json.tool || echo "$CHANGES_JSON" + continue-on-error: true + + - name: Compare coverage and enforce threshold + if: github.event_name == 'pull_request' + run: | + CURRENT="${{ steps.coverage.outputs.current_coverage }}" + BASE="${{ steps.base_coverage.outputs.base_coverage }}" + + # Default to 0 if base coverage couldn't be determined + BASE=${BASE:-0} + + echo "Current Coverage: $CURRENT%" + echo "Base Coverage: $BASE%" + + # Calculate the difference + DIFF=$(echo "$CURRENT - $BASE" | bc) + echo "Coverage Difference: $DIFF%" + + # Check if coverage dropped by more than 0.5% + THRESHOLD=-0.5 + if (( $(echo "$DIFF < $THRESHOLD" | bc -l) )); then + echo "❌ Code coverage dropped by ${DIFF}%, which exceeds the allowed threshold of ${THRESHOLD}%" + echo "Please add tests to maintain or improve code coverage." + exit 1 + else + echo "βœ… Code coverage check passed!" + echo "Coverage change: ${DIFF}%" + fi + + - name: Comment PR with coverage + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + env: + COVERAGE_CHANGES: ${{ steps.coverage_diff.outputs.coverage_changes }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + 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); + const diffEmoji = diff >= 0 ? 'πŸ“ˆ' : 'πŸ“‰'; + const coverageEmoji = current >= 80 ? 'πŸŽ‰' : current >= 60 ? 'πŸ“ˆ' : current >= 40 ? 'πŸ“Š' : 'πŸ“‰'; + const status = diff >= -0.5 ? 'βœ…' : '⚠️'; + + // Parse coverage changes JSON from environment variable + let changesJson = {}; + try { + const changesStr = process.env.COVERAGE_CHANGES || '{}'; + changesJson = JSON.parse(changesStr); + } catch (e) { + console.log('Failed to parse coverage changes:', e); + console.log('Raw value:', process.env.COVERAGE_CHANGES); + } + + // Build detailed changes section + let detailedChanges = ''; + let hasChanges = false; + + // Build inner content for details + let changesContent = ''; + + // New files with coverage + if (changesJson.new_files && changesJson.new_files.length > 0) { + hasChanges = true; + changesContent += '\n### πŸ†• New Files\n\n'; + changesContent += '| Class | Coverage | Lines |\n'; + changesContent += '|-------|----------|-------|\n'; + for (const file of changesJson.new_files) { + const emoji = file.coverage >= 80 ? '🟒' : file.coverage >= 60 ? '🟑' : 'πŸ”΄'; + changesContent += `| ${emoji} \`${file.class}\` | ${file.coverage.toFixed(2)}% | ${file.lines} |\n`; + } + } + + // Improved coverage + if (changesJson.improved && changesJson.improved.length > 0) { + hasChanges = true; + changesContent += '\n### πŸ“ˆ Coverage Improved\n\n'; + changesContent += '| Class | Before | After | Change |\n'; + changesContent += '|-------|--------|-------|--------|\n'; + const sortedImproved = changesJson.improved.sort((a, b) => b.diff - a.diff); + for (const file of sortedImproved) { + changesContent += `| \`${file.class}\` | ${file.old.toFixed(2)}% | ${file.new.toFixed(2)}% | +${file.diff.toFixed(2)}% |\n`; + } + } + + // Degraded coverage + if (changesJson.degraded && changesJson.degraded.length > 0) { + hasChanges = true; + changesContent += '\n### πŸ“‰ Coverage Decreased\n\n'; + changesContent += '| Class | Before | After | Change |\n'; + changesContent += '|-------|--------|-------|--------|\n'; + const sortedDegraded = changesJson.degraded.sort((a, b) => a.diff - b.diff); + for (const file of sortedDegraded) { + changesContent += `| \`${file.class}\` | ${file.old.toFixed(2)}% | ${file.new.toFixed(2)}% | ${file.diff.toFixed(2)}% |\n`; + } + } + + // Wrap in collapsible details if there are changes + if (hasChanges) { + const totalFiles = (changesJson.new_files?.length || 0) + + (changesJson.improved?.length || 0) + + (changesJson.degraded?.length || 0); + detailedChanges = `\n
\nπŸ“Š File-level Coverage Changes (${totalFiles} files)\n${changesContent}\n
\n`; + } + + const comment = `## ${status} Code Coverage Report + + | Metric | Value | + |--------|-------| + | **Total Coverage** | **${current.toFixed(2)}%** ${coverageEmoji} | + | Base Coverage | ${base.toFixed(2)}% | + | Difference | ${diffEmoji} **${diff}%** | + + ${current >= 40 ? 'βœ… Coverage meets minimum threshold (40%)' : '⚠️ Coverage below recommended 40% threshold'} + + ${diff < -0.5 ? '⚠️ **Warning:** Coverage dropped by more than 0.5%. Please add tests.' : ''} + ${diff >= 0 ? 'πŸŽ‰ Great job maintaining/improving code coverage!' : ''} + + ${detailedChanges} + +
+ ℹ️ About this report + + - All tests run in a single job with Xdebug coverage + - Security tests excluded from coverage to prevent output issues + - Coverage calculated from line coverage percentages + +
+ `; + + // Find existing coverage report comment + const {data: comments} = await github.rest.issues.listComments({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('Code Coverage Report') + ); + + if (botComment) { + // Update existing comment + await github.rest.issues.updateComment({ + comment_id: botComment.id, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); + } else { + // Create new comment + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); + } + + - name: Generate HTML coverage report + if: always() + run: | + vendor/bin/phpunit --coverage-html=coverage-html + continue-on-error: true + + - name: Upload HTML coverage report as artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage-html/ + retention-days: 30 diff --git a/.github/workflows/coverage-status-check.yml b/.github/workflows/coverage-status-check.yml new file mode 100644 index 0000000000..af3a7527d1 --- /dev/null +++ b/.github/workflows/coverage-status-check.yml @@ -0,0 +1,54 @@ +name: Coverage Status Check - DISABLED + +on: + workflow_dispatch: + +jobs: + coverage-gate: + name: Coverage Gate + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Wait for coverage check + timeout-minutes: 10 + run: | + echo "Waiting for Code Coverage Check to complete..." + + # Wait up to 10 minutes for the check to appear and complete + for i in {1..60}; do + # Get the status of the Code Coverage Check + STATUS=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/check-runs \ + --jq '.check_runs[] | select(.name == "Code Coverage Check") | .status' || echo "") + + CONCLUSION=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }}/check-runs \ + --jq '.check_runs[] | select(.name == "Code Coverage Check") | .conclusion' || echo "") + + if [ -n "$STATUS" ]; then + echo "Check found with status: $STATUS, conclusion: $CONCLUSION" + + if [ "$STATUS" == "completed" ]; then + if [ "$CONCLUSION" == "success" ]; then + echo "βœ… Code coverage check passed!" + exit 0 + else + echo "❌ Code coverage check failed with conclusion: $CONCLUSION" + exit 1 + fi + fi + else + echo "Check not found yet (attempt $i/60)" + fi + + sleep 10 + done + + echo "❌ Timeout waiting for Code Coverage Check" + exit 1 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Coverage gate passed + run: echo "βœ… Code coverage requirements met!" diff --git a/.github/workflows/cs.yml b/.github/workflows/cs.yml index e78f6f5c9d..f272aec2fa 100644 --- a/.github/workflows/cs.yml +++ b/.github/workflows/cs.yml @@ -3,6 +3,11 @@ name: CS on: # Run on all relevant pushes (except to main) and on all relevant pull requests. push: + branches: + - main + - develop + - 'release/[0-9]+.[0-9]+*' + - 'hotfix/[0-9]+.[0-9]+*' paths: - '**.php' - 'composer.json' diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index c3b8373a8d..2ce19b0f37 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -3,6 +3,11 @@ name: Test on: # Run on pushes to select branches and on all pull requests. push: + branches: + - main + - develop + - 'release/[0-9]+.[0-9]+*' + - 'hotfix/[0-9]+.[0-9]+*' pull_request: # Allow manually triggering the workflow. workflow_dispatch: @@ -21,7 +26,7 @@ jobs: matrix: include: - php_version: '8.2' - wp_version: '6.2' + wp_version: '6.7' multisite: false - php_version: '8.2' diff --git a/.github/workflows/playground-merged.yml b/.github/workflows/playground-merged.yml index d1f46a6970..0a129e50ac 100644 --- a/.github/workflows/playground-merged.yml +++ b/.github/workflows/playground-merged.yml @@ -14,6 +14,8 @@ jobs: pull-requests: write actions: read steps: + - uses: actions/checkout@v4 + - name: Prepare blueprint with artifact link id: blueprint run: | diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml index 714bee2a9c..cdfc5863c9 100644 --- a/.github/workflows/playground.yml +++ b/.github/workflows/playground.yml @@ -12,7 +12,7 @@ jobs: actions: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 # Prepare a folder named exactly like the repo as the plugin root. # If the repo already has such a folder (common for WP plugins), use it. @@ -45,9 +45,30 @@ jobs: PLUGIN_FILE="${{ steps.prep.outputs.PKG_DIR }}/${{ github.event.repository.name }}.php" PR_NUMBER="${{ github.event.number }}" - # Extract current version and add PR number + # Extract current version CURRENT_VERSION=$(grep -o "Version:[[:space:]]*[0-9.]*" "$PLUGIN_FILE" | sed 's/Version:[[:space:]]*//') - NEW_VERSION="${CURRENT_VERSION} - PR ${PR_NUMBER}" + + # Increment patch version if it exists, otherwise increment minor version + # Handle versions like 2.1.5 or 2.1 + if [[ "$CURRENT_VERSION" =~ ^([0-9]+)\.([0-9]+)(\.([0-9]+))?$ ]]; then + MAJOR="${BASH_REMATCH[1]}" + MINOR="${BASH_REMATCH[2]}" + PATCH="${BASH_REMATCH[4]}" + + # Build new version with 'b' suffix + if [ -n "$PATCH" ]; then + # If patch exists, increment patch by 1 + PATCH=$((PATCH + 1)) + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}b - PR ${PR_NUMBER}" + else + # If no patch, increment minor by 1 + MINOR=$((MINOR + 1)) + NEW_VERSION="${MAJOR}.${MINOR}b - PR ${PR_NUMBER}" + fi + else + # Fallback: if version format is unexpected, just add .1 and 'b' + NEW_VERSION="${CURRENT_VERSION}.1b - PR ${PR_NUMBER}" + fi # Replace the version line sed -i "s/Version:[[:space:]]*[0-9.]*/Version: ${NEW_VERSION}/" "$PLUGIN_FILE" @@ -76,7 +97,7 @@ jobs: # Upload the FOLDER (not a .zip). The artifact service zips it for us, # keeping the top-level folder name inside the archive. - name: Upload plugin artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: ${{ github.event.repository.name }} path: ${{ steps.prep.outputs.PKG_DIR }} diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 09995e802b..49cdf31381 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -16,7 +16,10 @@ env: on: push: branches: + - main - develop + - 'release/[0-9]+.[0-9]+*' + - 'hotfix/[0-9]+.[0-9]+*' pull_request: jobs: diff --git a/.github/workflows/plugin-check.yml b/.github/workflows/plugin-check.yml new file mode 100644 index 0000000000..fbac09a930 --- /dev/null +++ b/.github/workflows/plugin-check.yml @@ -0,0 +1,33 @@ +name: 'WordPress.org Plugin Check' +on: # rebuild any PRs and main branch changes + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + tools: wp-cli + + - name: Install latest version of dist-archive-command + run: wp package install wp-cli/dist-archive-command:@stable + + - name: Build plugin + run: | + wp dist-archive . ./${{ github.event.repository.name }}.zip + mkdir build + unzip ${{ github.event.repository.name }}.zip -d build + + - name: Run plugin check + uses: wordpress/plugin-check-action@v1.1.4 + with: + build-dir: './build/${{ github.event.repository.name }}' + exclude-checks: | + direct_file_access diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index a57a11f9e3..5f31f7d22a 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -3,6 +3,11 @@ name: Security on: # Run on all pushes and on all pull requests. push: + branches: + - main + - develop + - 'release/[0-9]+.[0-9]+*' + - 'hotfix/[0-9]+.[0-9]+*' pull_request: # Also run this workflow every Monday at 6:00. schedule: diff --git a/.github/workflows/upgrade-compat.yml b/.github/workflows/upgrade-compat.yml index 595ef186d4..8607f632e5 100644 --- a/.github/workflows/upgrade-compat.yml +++ b/.github/workflows/upgrade-compat.yml @@ -16,7 +16,10 @@ env: on: push: branches: + - main - develop + - 'release/[0-9]+.[0-9]+*' + - 'hotfix/[0-9]+.[0-9]+*' pull_request: # Allow manually triggering the workflow. workflow_dispatch: @@ -135,4 +138,4 @@ jobs: docker exec $WP_CONTAINER wp plugin activate wordpress-seo-premium --allow-root # Show plugin settings - docker exec $WP_CONTAINER wp option get progress_planner_settings --allow-root \ No newline at end of file + docker exec $WP_CONTAINER wp option get progress_planner_settings --allow-root diff --git a/.gitignore b/.gitignore index 83c571da28..47ae5d782f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ playwright/.cache/ auth.json # Environment variables -.env \ No newline at end of file +.env +coverage/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f72e8b67b..d38edb7d44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ += 1.10.0 = + +Added these recommendations from Ravi: + +* Reduce number of autoloaded options + = 1.9.0 = In this release we've added an integration with the **All In One Seo** plugin so you’ll now see personalized suggestions based on your current SEO configuration. diff --git a/README.md b/README.md index 0172747937..6b6e782e52 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ [![Test](https://github.com/ProgressPlanner/progress-planner/actions/workflows/phpunit.yml/badge.svg)](https://github.com/ProgressPlanner/progress-planner/actions/workflows/phpunit.yml) +[![Code Coverage](https://github.com/ProgressPlanner/progress-planner/actions/workflows/code-coverage.yml/badge.svg)](https://github.com/ProgressPlanner/progress-planner/actions/workflows/code-coverage.yml) [![CS](https://github.com/ProgressPlanner/progress-planner/actions/workflows/cs.yml/badge.svg)](https://github.com/ProgressPlanner/progress-planner/actions/workflows/cs.yml) [![PHPStan](https://github.com/ProgressPlanner/progress-planner/actions/workflows/phpstan.yml/badge.svg)](https://github.com/ProgressPlanner/progress-planner/actions/workflows/phpstan.yml) [![Lint](https://github.com/ProgressPlanner/progress-planner/actions/workflows/lint.yml/badge.svg)](https://github.com/ProgressPlanner/progress-planner/actions/workflows/lint.yml) @@ -24,6 +25,39 @@ This post explains what Progress Planner does and how to use it: [What does Prog You can find [installation instructions here](https://prpl.fyi/install). +## Contributing + +### Running Tests + +To run the test suite: + +```bash +composer test +``` + +### Code Coverage + +To generate code coverage reports locally, you need either [PCOV](https://pecl.php.net/package/PCOV) (recommended) or [Xdebug](https://xdebug.org/) installed: + +```bash +composer coverage +``` + +This will generate: +- An HTML coverage report in the `coverage-html/` directory +- A text-based coverage summary in your terminal + +**Coverage Requirements:** Pull requests must maintain code coverage within 0.5% of the base branch. PRs that drop coverage by more than 0.5% will be blocked until additional tests are added. + +### Other Quality Commands + +```bash +composer check-cs # Check coding standards +composer fix-cs # Auto-fix coding standards +composer phpstan # Run static analysis +composer lint # Check PHP syntax +``` + ## Branches on this repository We use a couple of branches in this repository to keep things clean: diff --git a/assets/css/admin.css b/assets/css/admin.css index 192f8d591b..09733f4e85 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -80,6 +80,24 @@ body.toplevel_page_progress-planner { margin-top: var(--prpl-padding); } +/*------------------------------------*\ + Styles for the container of the page when the privacy policy is not accepted. +\*------------------------------------*/ +.prpl-pp-not-accepted { + + .prpl-start-onboarding-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--prpl-padding); + } + + .prpl-start-onboarding-graphic { + width: 250px; + } +} + /*------------------------------------*\ Generic styles. \*------------------------------------*/ diff --git a/assets/css/onboard.css b/assets/css/onboard.css deleted file mode 100644 index bac24ea7dd..0000000000 --- a/assets/css/onboard.css +++ /dev/null @@ -1,23 +0,0 @@ -#prpl-onboarding-form .prpl-form-fields label { - display: grid; - grid-template-columns: 1fr 3fr; - margin-bottom: 0.5em; - gap: var(--prpl-padding); -} - -#prpl-onboarding-form label > span:has(input[type="checkbox"]) { - display: flex; - align-items: baseline; -} - -.prpl-onboard-form-radio-select { - - label { - display: block !important; - } -} - -#prpl-onboarding-submit-wrapper { - display: flex; - align-items: center; -} diff --git a/assets/css/onboarding/onboarding.css b/assets/css/onboarding/onboarding.css new file mode 100644 index 0000000000..c3362563dd --- /dev/null +++ b/assets/css/onboarding/onboarding.css @@ -0,0 +1,1353 @@ +.prpl-popover-onboarding { + + --prpl-color-text: #4b5563; + + /* Paper */ + --prpl-onboarding-popover-background: var(--prpl-background-paper, #fff); + + /* Steps navigation */ + --prpl-background-steps: var(--prpl-background, #f6f7f9); + --prpl-background-info: var(--prpl-background-content, #f6f5fb); + --prpl-background-step-active: var(--prpl-graph-color-4, #534786); + --prpl-color-number-step-active: var(--prpl-background-paper, #fff); + --prpl-color-ui-icon: #6b7280; /* already exists in variables-color.css */ + --prpl-color-text-step-active: var(--prpl-color-headings, #38296d); + --prpl-color-border: #d1d5db; /* already exists in variables-color.css */ + + /* Button secondary */ + --prpl-color-button-secondary: var(--prpl-background-point, #f9b23c ); + --prpl-color-button-secondary-hover: var(--prpl-color-monthly, #faa310); + --prpl-color-button-secondary-text: #374151; + --prpl-color-button-secondary-icon: var(--prpl-color-button-secondary-text); + + /* Button disabled */ + --prpl-color-button-inactive: var(--prpl-color-gauge-remain, #e1e3e7 ); + --prpl-color-button-inactive-text: var(--prpl-color-text, #4b5563); + + --prpl-background-step-label: var(--prpl-background-step-active); + --prpl-color-step-label: var(--prpl-background-paper, #fff); + + /* Required text */ + --prpl-color-text-error: var(--prpl-color-button-primary, #dd324f); + --prpl-color-alert-error: #e73136; /* already exists in variables-color.css */ + + /* General error */ + --prpl-background-alert-error: #fef2f2;/* already exists in variables-color.css */ + --prpl-color-alert-error-text: #7f1d1d; /* already exists in variables-color.css */ + + /* Custom contros (inputs, radio, checkbox) */ + --prpl-color-selection-controls-inactive: #9ca3af; + --prpl-color-selection-controls: var(--prpl-graph-color-4, #534786); + + --prpl-color-field-border: var(--prpl-color-border); + --prpl-color-field-border-active: var(--prpl-color-alert-info, #2563eb); + --prpl-background-field-active: var(--prpl-background-alert-info, #eff6ff); + + + font-family: system-ui, Arial, sans-serif; + + padding: 0; + box-sizing: border-box; + + border-radius: 8px; + font-weight: 400; + max-height: 82vh; + width: 1200px; + max-width: 80vw; + color: var(--prpl-color-text); + background-color: var(--prpl-onboarding-popover-background); + border: 1px solid var(--prpl-color-ui-icon); + + /* Popover backdrop. */ + &::backdrop { + background: rgba(0, 0, 0, 0.5); + } + + /* Popover close button. */ + .prpl-popover-close { + position: absolute; + top: 5px; + right: 5px; + padding: 0.5em; + cursor: pointer; + background: none; + border: none; + color: var(--prpl-color-text); + z-index: 10; + } + + /* General styles. */ + & * { + box-sizing: border-box; + } + + p { + font-size: 1rem; + margin-top: 0; + margin-bottom: 0.5rem; + + &:last-child { + margin-bottom: 0; + } + } + + h2 { + font-family: system-ui, Arial, sans-serif; + margin-top: 0; + margin-bottom: 0.75rem; + font-size: 1.25rem; + } + + h3 { + font-family: system-ui, Arial, sans-serif; + margin-top: 0; + margin-bottom: 1rem; + font-size: 1rem; + font-weight: 600; + color: var(--prpl-color-text); + + &:last-child { + margin-bottom: 0; + } + } + + /* Copied from WP Core CSS. */ + select { + font-size: 14px; + line-height: 2; + color: #2c3338; + border-color: var(--prpl-color-field-border); + box-shadow: none; + border-radius: 3px; + padding: 0 24px 0 8px; + min-height: 30px; + + /* max-width: 25rem; */ + width: 100%; + -webkit-appearance: none; + background: #fff url(data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M5%206l5%205%205-5%202%201-7%207-7-7%202-1z%22%20fill%3D%22%23555%22%2F%3E%3C%2Fsvg%3E) no-repeat right 5px top 55%; + background-size: 16px 16px; + cursor: pointer; + vertical-align: middle; + } + + input[type="text"], + input[type="email"] { + width: 100%; + padding: 0.5rem; + border: 1px solid var(--prpl-color-field-border); + border-radius: 0.25rem; + font-size: 14px; + line-height: 1.4; + color: var(--prpl-color-text); + } + + /* Used for radio and checkbox inputs. */ + .prpl-custom-inputs-wrapper{ + padding-left: 3px; + + /* To prevent custom radio and checkbox from being cut off. */ + display: flex; + flex-direction: column; + gap: 0.5rem; + + .prpl-checkbox-wrapper, + .prpl-radio-wrapper { + display: flex; + flex-direction: column; + justify-content: flex-start; + } + + /* Hide the default input, because WP has it's own styles (which include pseudo-elements). */ + .prpl-custom-checkbox input[type="checkbox"], + .prpl-custom-radio input[type="radio"] { + position: absolute; + opacity: 0; + width: 0; + height: 0; + } + + /* Shared styles for the custom radios and checkboxes. */ + .prpl-custom-control { + display: inline-block; + vertical-align: middle; + margin-right: 12px; + width: 20px; + height: 20px; + box-sizing: border-box; + position: relative; + transition: border-color 0.2s, background 0.2s; + flex-shrink: 0; + } + + /* Label text styling */ + .prpl-custom-checkbox, + .prpl-custom-radio { + display: flex; + align-items: center; + margin-bottom: 0.5rem; + cursor: pointer; + user-select: none; + } + + /* Checkbox styles */ + .prpl-custom-checkbox { + + .prpl-custom-control { + border: 1px solid var(--prpl-color-selection-controls-inactive); + + /* border-radius: 6px; */ + background-color: var(--prpl-onboarding-popover-background); + } + + input[type="checkbox"] { + + /* Checkbox checked (on) */ + &:checked+.prpl-custom-control { + background: var(--prpl-color-selection-controls); + border-color: var(--prpl-color-selection-controls); + } + } + + /* Checkmark */ + .prpl-custom-control::after { + content: ""; + position: absolute; + left: 6px; + top: 2px; + width: 4px; + height: 9px; + border: solid var(--prpl-onboarding-popover-background); + border-width: 0 2px 2px 0; + opacity: 0; + transform: scale(0.8) rotate(45deg); + transition: opacity 0.2s, transform 0.2s; + } + + input[type="checkbox"]:checked+.prpl-custom-control::after { + opacity: 1; + transform: scale(1) rotate(45deg); + } + } + + /* Radio styles */ + .prpl-custom-radio { + + .prpl-custom-control { + border: 1px solid var(--prpl-color-selection-controls-inactive); + border-radius: 50%; + background-color: var(--prpl-onboarding-popover-background); + } + + /* Radio hover (off) */ + input[type="radio"] { + + /* Radio checked (on) */ + &:checked+.prpl-custom-control { + background: var(--prpl-color-selection-controls); + border-color: var(--prpl-color-selection-controls); + } + } + + /* Radio dot */ + .prpl-custom-control::after { + content: ""; + position: absolute; + top: 5px; + left: 5px; + width: 8px; + height: 8px; + background-color: var(--prpl-onboarding-popover-background); + border-radius: 50%; + opacity: 0; + transition: opacity 0.2s; + } + + input[type="radio"]:checked+.prpl-custom-control::after { + opacity: 1; + background-color: var(--prpl-onboarding-popover-background); + } + } + } + + /* Main layout container */ + .prpl-onboarding-layout { + display: flex; + + /* gap: 1.5rem; */ + min-height: 350px; + } + + /* Left column: Step navigation */ + .prpl-onboarding-navigation { + width: 340px; + background: var(--prpl-background-steps); + border-right: none; + padding: 2rem 1.5rem; + flex-shrink: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 1.5rem; + } + + .prpl-onboarding-logo { + height: 60px; + max-height: 100%; + + img, svg { + height: 100%; + width: auto; + max-width: 100%; + } + } + + .prpl-step-list { + list-style: none; + margin: 0; + padding: 0; + } + + .prpl-nav-step-item { + margin-top: 10px; + display: flex; + align-items: flex-start; + gap: 14px; + padding: 0; + margin-bottom: 4px; + cursor: default; + + &:first-child { + margin-top: 0; + } + } + + .prpl-step-icon { + width: 20px; + height: 20px; + border-radius: 50%; + border: 1px solid var(--prpl-color-border); + color: var(--prpl-color-text); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 2px; + font-size: 14px; + } + + .prpl-nav-step-item.prpl-active .prpl-step-icon { + background: var(--prpl-background-step-active); + border-color: var(--prpl-background-step-active); + color: var(--prpl-color-number-step-active); + } + + .prpl-nav-step-item.prpl-completed .prpl-step-icon { + background: var(--prpl-color-ui-icon); + border-color: var(--prpl-color-ui-icon); + color: var(--prpl-background-paper); + } + + .prpl-step-label { + font-size: 15px; + color: var(--prpl-color-ui-icon); + line-height: 1.5; + } + + .prpl-nav-step-item.prpl-active .prpl-step-label { + color: var(--prpl-color-text-step-active); + font-weight: 700; + } + + .prpl-nav-step-item.prpl-completed .prpl-step-label { + color: var(--prpl-color-text-step-active); + } + + #prpl-onboarding-mobile-step-label { + display: none; + margin-bottom: 0.25rem; + font-size: 14px; + color: var(--prpl-background-step-active); + font-weight: 600; + } + + /* Right section: Content area */ + .prpl-onboarding-content { + padding: 2rem 1.5rem; + flex: 1; + display: flex; + flex-direction: column; + + /* gap: 1.5rem; */ + } + + .tour-content-wrapper { + flex: 1; + + /* overflow-y: auto; */ + } + + .tour-header { + + .tour-title { + color: var(--prpl-background-step-active); + font-weight: 600; + } + } + + .tour-footer { + margin-top: 1rem; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 10px; + width: 100%; + + .prpl-tour-next-wrapper { + margin-left: auto; + display: flex; + align-items: center; + gap: 10px; + } + } + + .tour-content { + font-size: 16px; + line-height: 1.5; + + img { + max-width: 100%; + height: auto; + } + + } + + .prpl-columns-wrapper-flex { + display: flex; + gap: 1.5rem; + + /* overflow: hidden; */ + + /* padding-bottom: 10px; */ + + .prpl-background-content { + padding: 20px; + border-radius: 12px; + background-color: var(--prpl-background-info); + } + + .prpl-column { + flex-grow: 1; + flex-basis: 50%; + flex-direction: column; + } + + &.prpl-columns-2-1 { + + .prpl-column:first-child { + flex: 2 1 0; + min-width: 0; + } + + .prpl-column:last-child { + flex: 1 1 0; + min-width: 0; + } + } + + &.prpl-columns-1-2 { + + .prpl-column:first-child { + flex: 1 1 0; + min-width: 0; + } + + .prpl-column:last-child { + flex: 2 1 0; + min-width: 0; + } + } + } + + .prpl-btn { + display: inline-block; + margin: 0; + padding: 0.75rem 1.25rem; + + text-decoration: none; + cursor: pointer; + font-size: 16px; + + /* color: var(--prpl-color-button-primary-text); */ + + /* background: var(--prpl-color-button-primary); */ + background-color: var(--prpl-color-button-secondary); + color: var(--prpl-color-button-secondary-text); + line-height: 1.25; + box-shadow: none; + border: none; + border-radius: 6px; + transition: all 0.25s ease-in-out; + font-weight: 600; + text-align: center; + box-sizing: border-box; + position: relative; + z-index: 1; + + flex-shrink: 0; + + /* &:not([disabled]):hover, + &:not([disabled]):focus { + background: var(--prpl-color-button-primary-hover); + } */ + + &.prpl-btn-secondary { + background-color: var(--prpl-color-button-secondary); + color: var(--prpl-color-button-secondary-text); + + &:not([disabled]):not(.prpl-btn-disabled):hover, + &:not([disabled]):not(.prpl-btn-disabled):focus { + background-color: var(--prpl-color-button-secondary-hover); + color: var(--prpl-color-button-secondary-text); + } + } + + &.prpl-btn-disabled, + &:disabled { + + &, + &:hover{ + color: var(--prpl-color-button-inactive-text); + color: rgb(from var(--prpl-color-button-inactive-text) r g b / 0.88); + background-color: var(--prpl-color-button-inactive); + cursor: not-allowed; + } + } + } + + .prpl-complete-task-btn:not(.prpl-btn) { + border: none; + background: none; + cursor: pointer; + padding: 0; + margin: 0; + font-size: 16px; + color: var(--prpl-color-link); + } + +/* + .prpl-complete-task-btn-completed:not(.prpl-btn) { + color: #059669; + pointer-events: none; + opacity: 0.5; + } */ + + .prpl-complete-task-btn-error { + color: #9f0712; + } + + /* Generic error message (reusable for all steps) */ + .prpl-error-message { + animation: prpl-slide-down 0.3s ease-out; + } + + @keyframes prpl-slide-down { + + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } + } + + .prpl-error-box { + display: flex; + align-items: flex-start; + gap: 15px; + background: var(--prpl-background-alert-error); + padding: 20px; + border-radius: 6px; + border-left: 4px solid var(--prpl-color-alert-error); + + .prpl-error-icon { + color: var(--prpl-color-alert-error); + width: 20px; + height: 20px; + flex-shrink: 0; + + img, svg { + width: 100%; + height: 100%; + } + } + + h3 { + margin: 0 0 8px 0; + font-size: 16px; + font-weight: 700; + color: var(--prpl-color-alert-error-text); + } + + p { + margin: 0; + font-size: 15px; + line-height: 1.6; + color: var(--prpl-color-alert-error-text); + } + } + + /* Popover: Quit confirmation */ + &:has(.prpl-quit-confirmation) { + + #prpl-tour-close-btn, + .prpl-onboarding-navigation { + display: none; + } + } + + .prpl-column:has(.prpl-quit-confirmation) { + justify-content: flex-start !important; + } + + .prpl-quit-confirmation { + display: flex; + flex-direction: column; + gap: 30px; + } + + .prpl-quit-message { + display: flex; + align-items: flex-start; + gap: 15px; + background: var(--prpl-background-alert-error); + padding: 20px; + border-radius: 6px; + border-left: 4px solid var(--prpl-color-alert-error); + + h3 { + font-size: 18px; + font-weight: 700; + } + + p { + font-size: 15px; + line-height: 1.6; + } + } + + .prpl-quit-actions { + display: flex; + gap: 40px; + justify-content: space-between; + align-items: center; + } + + .prpl-quit-link { + font-size: 15px; + color: var(--prpl-color-link); + text-decoration: underline; + cursor: pointer; + + &.prpl-quit-link-primary { + font-weight: 600; + font-size: 16px; + } + } + + #prpl-quit-confirmation-graphic { + display: flex; + justify-content: center; + align-items: center; + + img, + svg { + height: 250px; + + /* Set height since it is what we do in the PP dashboard. */ + max-width: 100%; + } + } + + /* Form for onboarding tasks. */ + .prpl-onboarding-task-form { + display: flex; + align-items: center; + gap: 0.5rem; + border: none; + padding: 0; + margin: 0; + background: none; + + .prpl-complete-task-btn { + flex-shrink: 0; + margin-top: 1rem; + } + } + + /* Welcome Step */ + &[data-prpl-step="0"] { + + .prpl-column:not(.prpl-column-content) { + display: flex; + justify-content: center; + align-items: center; + } + + #prpl-welcome-graphic { + display: flex; + justify-content: center; + align-items: center; + + img, svg { + height: 250px; /* Set height since it is what we do in the PP dashboard. */ + max-width: 100%; + } + } + } + + /* Privacy checkbox */ + .prpl-privacy-checkbox-wrapper { + margin-top: 20px; + + label { + gap: 10px; + cursor: pointer; + font-size: 15px; + color: var(--prpl-color-text); + } + + input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + } + + a { + color: var(--prpl-color-link); + text-decoration: underline; + } + + .prpl-required-indicator { + font-size: 0.875rem; + font-style: italic; + + &::before { + content: '('; + } + + &::after { + content: ')'; + } + + &.prpl-required-indicator-active { + color: var(--prpl-color-text-error); + font-size: 1rem; + font-style: normal; + + &::before { + display: inline-flex; + align-items: center; + justify-content: center; + content: '!'; + width: 16px; + height: 16px; + border-radius: 50%; + background-color: var(--prpl-color-alert-error); + color: #fff; + font-size: 12px; + margin-right: 5px; + } + + &::after { + content: '!'; + } + } + } + } + + /* What's Next Step */ + .prpl-suggested-task-points { + font-size: var(--prpl-font-size-xs, 12px); + font-weight: 700; + color: var(--prpl-text-point); + background-color: var(--prpl-background-point); + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + } + + /* First Task Step */ + .prpl-onboarding-task { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + + /* .prpl-onboarding-task-title { + margin: 0 0 1rem 0; + font-size: 18px; + font-weight: 600; + } */ + + .prpl-onboarding-task-form { + margin-top: 1rem; + width: 100%; + flex-direction: column; + + .prpl-complete-task-btn { + align-self: flex-end; + + &.prpl-complete-task-btn-completed { + opacity: 0.5; + pointer-events: none; + } + } + } + } + + /* Badges Step */ + .prpl-gauge-wrapper { + max-width: 100%; + text-align: center; + } + + /* Email Frequency Step */ + + /* .prpl-email-frequency-options { + + . + display: flex; + gap: 10px; + flex-direction: column; + } */ + + #prpl-email-form { + margin-top: 1rem; + + .prpl-form-field { + margin-top: 1rem; + + &:first-child { + margin-top: 0; + } + + label { + display: inline-block; + margin-bottom: 0.5rem; + } + } + } + + /* Settings Step */ + .tour-content-wrapper:has(.prpl-setting-item) { + display: flex; + flex-direction: column; + flex-grow: 1; + + .tour-content, + .prpl-setting-item, + .prpl-setting-content { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: 1.5rem; + } + + .prpl-setting-title { + margin: 0 0 1rem 0; + } + + .prpl-settings-progress { + font-size: 14px; + color: var(--prpl-color-step-label); + padding: 4px 8px; + border-radius: 20px; + background-color: var(--prpl-background-step-active); + } + + .prpl-setting-footer { + display: flex; + justify-content: flex-end; + align-items: flex-start; + gap: 10px; + margin-top: 1rem; + + .prpl-save-setting-btn { + flex-shrink: 0; + } + } + + .prpl-select-page { + + &.prpl-disabled { + opacity: 0.5; + pointer-events: none; + } + } + + .prpl-setting-note { + display: none; + gap: 10px; + border-radius: 12px; + padding: 1rem; + color: var(--prpl-color-field-border-active); + font-size: 14px; + background-color: var(--prpl-background-field-active); + + .prpl-setting-note-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + color: var(--prpl-color-field-border-active); + } + } + + /* Post types sub-step */ + #prpl-post-types-include-wrapper { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + /* Toggle checkbox for post types. */ + .prpl-post-type-toggle-wrapper { + display: flex; + align-items: center; + } + + .prpl-post-type-toggle-label { + display: flex; + align-items: center; + gap: 0.75rem; + cursor: pointer; + position: relative; + } + + .prpl-post-type-toggle-input { + position: absolute; + opacity: 0; + width: 0; + height: 0; + margin: 0; + padding: 0; + } + + .prpl-post-type-toggle-switch { + position: relative; + width: 44px; + height: 24px; + border-radius: 12px; + background-color: var(--prpl-color-selection-controls-inactive); + transition: background-color 0.2s; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + &::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background-color: var(--prpl-onboarding-popover-background); + transition: transform 0.2s; + z-index: 1; + } + + svg { + position: absolute; + width: 12px; + height: 12px; + top: 50%; + left: 6px; + transform: translateY(-50%); + z-index: 2; + transition: opacity 0.2s, left 0.2s, color 0.2s; + color: var(--prpl-color-ui-icon); + } + + .prpl-toggle-icon-check { + display: none; + } + + .prpl-toggle-icon-x { + display: block; + } + } + + .prpl-post-type-toggle-label:hover .prpl-post-type-toggle-switch svg { + opacity: 0.6; + } + + .prpl-post-type-toggle-input:checked ~ .prpl-post-type-toggle-switch { + background-color: var(--prpl-background-step-active); + + &::after { + transform: translateX(20px); + } + + svg { + left: 26px; + color: var(--prpl-background-step-active); + transform: translateY(-50%); + } + + .prpl-toggle-icon-check { + display: block; + } + + .prpl-toggle-icon-x { + display: none; + } + } + + .prpl-post-type-toggle-text { + font-size: 16px; + line-height: 1.5; + transition: opacity 0.2s; + } + + .prpl-post-type-toggle-input:not(:checked) ~ .prpl-post-type-toggle-text { + opacity: 0.78; + } + + .prpl-post-type-toggle-input:checked ~ .prpl-post-type-toggle-text { + opacity: 1; + } + } + + /* More Tasks Step */ + + #prpl-success-graphic { + display: flex; + justify-content: center; + align-items: center; + + img, svg { + height: 250px; + max-width: 100%; + } + } + + /* Intro substep */ + .prpl-more-tasks-substep[data-substep="more-tasks-intro"] { + display: flex; + flex-direction: column; + gap: 1.5rem; + } + + .prpl-more-tasks-intro-buttons { + margin-top: 1rem; + display: flex; + align-items: center; + justify-content: center; + gap: 1.5rem; + flex-wrap: wrap; + } + + .prpl-finish-onboarding { + background: none; + border: none; + color: var(--prpl-color-link, #534786); + text-decoration: underline; + font-size: 16px; + cursor: pointer; + padding: 0.75rem 0; + } + + .prpl-finish-onboarding:hover, + .prpl-finish-onboarding:focus { + color: var(--prpl-color-link-hover, #38296d); + } + + /* Tasks substep */ + .prpl-more-tasks-substep[data-substep="more-tasks-tasks"] .prpl-task-list { + margin: 0; + } + + .prpl-column:has(.prpl-task-list) { + display: flex; + align-items: center; + } + + .prpl-task-list { + list-style: none; + padding: 0; + margin: 0; + width: 100%; + border: 1px solid #ccc; + + li { + padding: 7px 5px; + margin: 0; + + &:nth-child(odd) { + background-color: var(--prpl-background-steps); + } + + .task-title { + color: var(--prpl-color-text); + font-weight: 500; + } + } + } + + .prpl-complete-task-item { + display: flex; + gap: 30px; + justify-content: space-between; + } + + .prpl-task-arrow { + padding-right: 0.5rem; + } + + .prpl-task-item-button-wrapper { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; + } + + .prpl-task-completed-icon { + display: none; + width: 20px; + height: 20px; + border-radius: 50%; + align-items: center; + justify-content: center; + color: #fff; + background-color: #059669; + } + + .prpl-task-completed { + + /* Show the completed icon. */ + .prpl-task-completed-icon { + display: inline-flex; + } + + /* Hide the trigger button and +1. */ + .prpl-task-item-button-wrapper { + display: none; + } + } + + .tour-content-wrapper:has(.prpl-task-content-active) { + display: flex; + flex-direction: column; + flex-grow: 1; + + /* Task content active, TOOD: change markup so this is simpler. */ + .prpl-task-content-active { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: 1.5rem; + width: 100%; + + .prpl-onboarding-task { + display: flex; + flex-direction: column; + flex-grow: 1; + + .prpl-onboarding-task-form, + .tour-content { + display: flex; + flex-direction: column; + flex-grow: 1; + } + + .prpl-onboarding-task-form { + justify-content: space-between; + gap: 3rem; + } + } + } + + /* Task buttons. */ + .prpl-task-buttons { + display: flex; + justify-content: flex-end; + gap: 1rem; + width: 100%; + + .prpl-btn { + margin-top: 0; + margin-bottom: 0; + + &.prpl-task-close-btn { + background-color: var(--prpl-background-banner); + color: var(--prpl-color-text); + } + } + } + } + + /* File drop zone. */ + .prpl-file-drop-zone { + width: 100%; + border-radius: 12px; + padding: 40px; + text-align: center; + color: var(--prpl-color-text); + transition: background 0.2s, border-color 0.2s; + cursor: pointer; + border: 2px dashed var(--prpl-color-field-border); + + svg { + + path { + stroke: var(--prpl-color-ui-icon); + } + } + } + + .prpl-file-drop-zone.dragover { + background-color: var(--prpl-background-field-active); + border-color: var(--prpl-color-field-border-active); + } + + /* When an image has been uploaded. */ + .prpl-file-drop-zone.has-image { + border-color: var(--prpl-color-field-border-active); + background-color: var(--prpl-background-field-active); + + & > .prpl-icon-image, + & > p, + & > .prpl-file-upload-hints { + display: none; + } + + .prpl-file-preview img { + border: 2px solid var(--prpl-color-field-border); + border-radius: 8px; + padding: 8px; + background: var(--prpl-color-background-white); + } + } + + .prpl-file-browse-link { + color: var(--prpl-color-link); + text-decoration: underline; + cursor: pointer; + } + + .prpl-file-remove-btn { + border: none; + background: none; + cursor: pointer; + padding: 0; + margin: 12px 0 0; + font-size: 16px; + color: var(--prpl-color-link); + text-decoration: underline; + } + + .prpl-file-upload-hints { + display: flex; + flex-direction: column; + font-size: 14px; + color: var(--prpl-color-ui-icon); + } + + /* WIP */ + #prpl-upload-status { + margin-top: 10px; + font-family: monospace; + } + + .prpl-file-preview { + display: none; /* Hidden by default. */ + margin-top: 10px; + margin-left: auto; + margin-right: auto; + max-width: 200px; + height: auto; + } +} + +@media (max-width: 1023px) { + + .prpl-popover-onboarding { + --prpl-mobile-nav-height: 60px; + max-width: 90vw; + inset: 0 0 var(--prpl-mobile-nav-height) 0; /* TODO: Adjust this for smallest screen sizes. */ + margin: auto auto var(--prpl-mobile-nav-height) auto; /* Center in available space above nav */ + + /* Hide graphics */ + .prpl-hide-on-mobile { + display: none !important; + } + + /* Quit confirmation */ + .prpl-quit-actions { + flex-direction: column; + gap: 1rem; + } + + /* Naviation section */ + .prpl-onboarding-navigation { + position: fixed; + left: 0; + bottom: 0; + width: 100%; + height: var(--prpl-mobile-nav-height); + z-index: 1000; + + flex-direction: row; + gap: 1rem; + justify-content: center; + align-items: center; + padding: 0.5rem; + + .prpl-step-list { + display: flex; + gap: 0.5rem; + + .prpl-nav-step-item { + margin: 0; + } + + .prpl-step-label { + display: none; + } + } + + #prpl-onboarding-mobile-step-label { + display: block; + } + } + + /* Content section */ + .prpl-columns-wrapper-flex { + flex-direction: column; + } + + .prpl-setting-footer, /* On the settings steps */ + .tour-footer { + flex-direction: column; /* So the info / error message is on top of the button. */ + } + + /* Badges step */ + .prpl-gauge-wrapper { + max-width: 400px; + margin-left: auto; + margin-right: auto; + } + } +} diff --git a/assets/css/page-widgets/suggested-tasks.css b/assets/css/page-widgets/suggested-tasks.css index 4f83b716c1..8fa3f87a79 100644 --- a/assets/css/page-widgets/suggested-tasks.css +++ b/assets/css/page-widgets/suggested-tasks.css @@ -33,14 +33,14 @@ background: none; border: none; padding: 0; - color: var(--wp-admin-theme-color, #2271b1); + color: var(--prpl-color-link); text-decoration: underline; cursor: pointer; font-size: inherit; font-family: inherit; &:hover { - color: var(--wp-admin-theme-color-darker-10, #135e96); + color: var(--prpl-color-link-hover); } &:disabled { @@ -495,3 +495,119 @@ } } } + +/*------------------------------------*\ + Page select setting. +\*------------------------------------*/ +.prpl-pages-item { + + &:has(input[type="radio"][value="yes"]:checked), + &:has(input[type="radio"][value="no"]:checked) { + + h3 { + + .icon-exclamation-circle { + display: block; + } + + .icon-check-circle { + display: none; + } + } + } + + &:has(option[value=""]:not(:checked)):has(input[type="radio"][value="yes"]:checked), + &:has(input[type="radio"][value="not-applicable"]:checked) { + + h3 { + + .icon-check-circle { + display: block; + } + + .icon-exclamation-circle { + display: none; + } + } + } + + .item-actions, + .prpl-select-page { + display: flex; + align-items: center; + gap: 1rem; + } + + .remind-button, + .assign-button { + + svg { + width: 1rem; + height: 1rem; + } + } + + h3 { + font-size: 1.15rem; + margin: 0; + + display: flex; + align-items: center; + gap: 0.5rem; + + .icon { + width: 1em; + height: 1em; + display: none; + } + } + + p { + margin-block-start: 0.5rem; + margin-block-end: 1rem; + } + + .radios { + margin-bottom: 1rem; + } + + .prpl-radio-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + + [data-action="select"], + [data-action="create"] { + visibility: hidden; + } + + &:has(input[type="radio"]:checked) { + + [data-action="select"], + [data-action="create"] { + visibility: visible; + } + } + + &:has(input[type="radio"][value="not-applicable"]) { + padding-top: 0.25rem; + + /* Add bit height, because we dont have button or select */ + } + } +} + +/*------------------------------------*\ + Post types selection. +\*------------------------------------*/ +.prpl-post-types-selection { + + label { + display: block; + margin-top: 0.75rem; + + &:first-child { + margin-top: 0; + } + } +} diff --git a/assets/css/settings-page.css b/assets/css/settings-page.css deleted file mode 100644 index 70d431ed64..0000000000 --- a/assets/css/settings-page.css +++ /dev/null @@ -1,350 +0,0 @@ -/* stylelint-disable-next-line selector-class-pattern */ -.progress-planner_page_progress-planner-settings { - - #wpwrap { - background-color: var(--prpl-background); - } - - ul#adminmenu { - - a.wp-has-current-submenu, - > li.current > a.current { - - &::after { - border-right-color: var(--prpl-background) !important; - } - } - } - - .prpl-settings-wrap { - - h1 { - display: flex; - align-items: center; - padding: 1.2rem; - margin-bottom: 2rem; - - span { - font-weight: 600; - } - } - - #prpl-settings { - - .prpl-widget-wrapper { - padding: var(--prpl-settings-page-gap) var(--prpl-settings-page-gap) 2rem var(--prpl-settings-page-gap); - } - } - } - - .prpl-settings-form-wrap { - background-color: var(--prpl-background-paper); - - border: 1px solid var(--prpl-color-border); - border-radius: var(--prpl-border-radius); - padding: var(--prpl-padding); - box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.07), -2px 0 6px rgba(0, 0, 0, 0.07); - } - - .prpl-settings-section-wrapper { - border: 1px solid var(--prpl-color-border); - border-radius: var(--prpl-border-radius); - padding: var(--prpl-padding); - flex-grow: 1; - } - - .prpl-settings-section-title { - display: flex; - align-items: center; - gap: 0.5rem; - background: var(--prpl-background-monthly); - padding: 1.2rem; - border-radius: 0.5rem; - margin-bottom: var(--prpl-padding); - - &:first-child { - margin-top: 0; - } - - .icon { - width: 1.25em; - height: 1.25em; - } - } - - .prpl-pages-item { - border: 1px solid var(--prpl-color-border); - border-radius: var(--prpl-border-radius); - padding: var(--prpl-padding); - flex-grow: 1; - width: 45%; - - &:has(input[type="radio"][value="yes"]:checked), - &:has(input[type="radio"][value="no"]:checked) { - - h3 { - - .icon-exclamation-circle { - display: block; - } - - .icon-check-circle { - display: none; - } - } - } - - &:has(option[value=""]:not(:checked)):has(input[type="radio"][value="yes"]:checked), - &:has(input[type="radio"][value="not-applicable"]:checked) { - - h3 { - - .icon-check-circle { - display: block; - } - - .icon-exclamation-circle { - display: none; - } - } - } - - .item-actions, - .prpl-select-page { - display: flex; - align-items: center; - gap: 1rem; - } - - .remind-button, - .assign-button { - - svg { - width: 1rem; - height: 1rem; - } - } - - h3 { - font-size: 1.15rem; - margin: 0; - - display: flex; - align-items: center; - gap: 0.5rem; - - .icon { - width: 1em; - height: 1em; - display: none; - } - } - - p { - margin-block-start: 0.5rem; - margin-block-end: 1rem; - } - - .radios { - margin-bottom: 1rem; - } - - .prpl-radio-wrapper { - display: flex; - justify-content: space-between; - align-items: center; - - [data-action="select"], - [data-action="create"] { - visibility: hidden; - } - - &:has(input[type="radio"]:checked) { - - [data-action="select"], - [data-action="create"] { - visibility: visible; - } - } - - &:has(input[type="radio"][value="not-applicable"]) { - padding-top: 0.25rem; /* Add bit height, because we dont have button or select */ - } - } - } - - .prpl-column-pages { - margin-bottom: var(--prpl-gap); - - .prpl-settings-section-title { - background: var(--prpl-background-setting-pages); - - .icon { - - path { - fill: var(--prpl-color-setting-pages-icon); - } - } - } - } - - .prpl-pages-list { - display: flex; - flex-wrap: wrap; - gap: var(--prpl-settings-page-gap); - - .item-description { - - h3 { - margin-bottom: 2rem; - } - - & > p { - display: none; - } - } - - .radios { - display: flex; - flex-direction: column; - gap: 0.5rem; - } - } - - .prpl-button { - color: var(--prpl-color-gray-7); - text-decoration: none; - border: 1px solid var(--prpl-color-border); - border-radius: var(--prpl-border-radius); - padding: 0.5em 0.5em; - font-size: 14px; /* It matches wrapper. + selectPageWrapper.style.visibility = 'hidden'; + } + + // Show only select and edit button. + if ( 'yes' === value ) { + // Show wrapper. - itemRadiosWrapperEl.querySelector( - '.prpl-select-page' - ).style.visibility = 'hidden'; - } - - // Show only select and edit button. - if ( 'yes' === value ) { - // Show + /> + labels->name ); // @phpstan-ignore-line property.nonObject ?> + + + + print_submit_button( \__( 'Set', 'progress-planner' ) ); + } + /** * Add task actions specific to this task. * @@ -130,7 +240,7 @@ public function is_task_completed( $task_id = '' ) { public function add_task_actions( $data = [], $actions = [] ) { $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Go to the settings page', 'progress-planner' ) . '', + 'html' => '' . \esc_html__( 'Set', 'progress-planner' ) . '', ]; return $actions; diff --git a/classes/suggested-tasks/providers/class-settings-saved.php b/classes/suggested-tasks/providers/class-settings-saved.php deleted file mode 100644 index 5fe62f14cf..0000000000 --- a/classes/suggested-tasks/providers/class-settings-saved.php +++ /dev/null @@ -1,88 +0,0 @@ -get_settings()->get( 'include_post_types' ); - } - - /** - * Add task actions specific to this task. - * - * @param array $data The task data. - * @param array $actions The existing actions. - * - * @return array - */ - public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Go to the settings page', 'progress-planner' ) . '', - ]; - - return $actions; - } -} diff --git a/classes/suggested-tasks/providers/class-site-icon.php b/classes/suggested-tasks/providers/class-site-icon.php index 7bf34c85f4..7dfd56f88d 100644 --- a/classes/suggested-tasks/providers/class-site-icon.php +++ b/classes/suggested-tasks/providers/class-site-icon.php @@ -154,9 +154,42 @@ protected function get_enqueue_data() { public function add_task_actions( $data = [], $actions = [] ) { $actions[] = [ 'priority' => 10, - 'html' => '' . \esc_html__( 'Set site icon', 'progress-planner' ) . '', + 'html' => '' . \esc_html( $this->get_task_action_label() ) . '', ]; return $actions; } + + /** + * Get the task action label. + * + * @return string + */ + public function get_task_action_label() { + return \__( 'Set site icon', 'progress-planner' ); + } + + /** + * Complete the task. + * + * @param array $args The task data. + * @param string $task_id The task ID. + * + * @return bool + */ + public function complete_task( $args = [], $task_id = '' ) { + + if ( ! $this->capability_required() ) { + return false; + } + + if ( ! isset( $args['post_id'] ) ) { + return false; + } + + // update_option will return false if the option value is the same as the one being set. + \update_option( 'site_icon', \sanitize_text_field( $args['post_id'] ) ); + + return true; + } } diff --git a/classes/suggested-tasks/providers/class-tasks-interactive.php b/classes/suggested-tasks/providers/class-tasks-interactive.php index 9e3c3a54fe..a82278f776 100644 --- a/classes/suggested-tasks/providers/class-tasks-interactive.php +++ b/classes/suggested-tasks/providers/class-tasks-interactive.php @@ -164,6 +164,11 @@ protected function get_allowed_interactive_options() { * @return void */ public function add_popover() { + + // Don't add the popover if the task is not published. + if ( ! $this->is_task_published() ) { + return; + } ?>
the_popover_content(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> @@ -256,6 +261,11 @@ public function enqueue_scripts( $hook ) { return; } + // Don't enqueue the script if the task is not published. + if ( ! $this->is_task_published() ) { + return; + } + // Enqueue the web component. \progress_planner()->get_admin__enqueue()->enqueue_script( 'progress-planner/recommendations/' . $this->get_provider_id(), @@ -271,4 +281,19 @@ public function enqueue_scripts( $hook ) { protected function get_enqueue_data() { return []; } + + /** + * Check if the task is published. + * + * @return bool + */ + public function is_task_published() { + $tasks = \progress_planner()->get_suggested_tasks_db()->get_tasks_by( + [ + 'provider' => $this->get_provider_id(), + 'post_status' => 'publish', + ] + ); + return ! empty( $tasks ); + } } diff --git a/classes/suggested-tasks/providers/class-tasks.php b/classes/suggested-tasks/providers/class-tasks.php index b49857957a..eed1b097c7 100644 --- a/classes/suggested-tasks/providers/class-tasks.php +++ b/classes/suggested-tasks/providers/class-tasks.php @@ -267,20 +267,37 @@ public function get_external_link_url() { /** * Get the task ID. * - * @param array $task_data Optional data to include in the task ID. - * @return string + * Generates a unique task ID by combining the provider ID with optional task-specific data. + * For repetitive tasks, includes the current year-week (YW format) to create weekly instances. + * + * Example task IDs: + * - Non-repetitive: "update-core" + * - With post target: "update-post-123" + * - With term target: "update-term-5-category" + * - Repetitive weekly: "create-post-2025W42" + * + * @param array $task_data { + * Optional data to include in the task ID. + * + * @type int $target_post_id The ID of the post this task targets. + * @type int $target_term_id The ID of the term this task targets. + * @type string $target_taxonomy The taxonomy slug for term-based tasks. + * } + * @return string The generated task ID (e.g., "provider-id-123-202542"). */ public function get_task_id( $task_data = [] ) { $parts = [ $this->get_provider_id() ]; // Order is important here, new parameters should be added at the end. + // This ensures existing task IDs remain consistent when new fields are added. $parts[] = $task_data['target_post_id'] ?? false; $parts[] = $task_data['target_term_id'] ?? false; $parts[] = $task_data['target_taxonomy'] ?? false; - // If the task is repetitive, add the date as the last part. + // If the task is repetitive, add the date as the last part (format: YYYYWW, e.g., 202542 for week 42 of 2025). + // This creates a new task instance each week for repetitive tasks. $parts[] = $this->is_repetitive() ? \gmdate( 'YW' ) : false; - // Remove empty parts. + // Remove empty parts to keep IDs clean. $parts = \array_filter( $parts ); return \implode( '-', $parts ); @@ -303,8 +320,19 @@ public function get_data_collector() { /** * Get the title with data. * - * @param array $task_data Optional data to include in the task. - * @return string + * Allows child classes to generate dynamic task titles based on task-specific data. + * For example, "Update post: {post_title}" where {post_title} comes from $task_data. + * + * @param array $task_data { + * Optional data to include in the task title. + * + * @type int $target_post_id The ID of the post this task targets. + * @type string $target_post_title The title of the post this task targets. + * @type int $target_term_id The ID of the term this task targets. + * @type string $target_term_name The name of the term this task targets. + * @type string $target_taxonomy The taxonomy slug for term-based tasks. + * } + * @return string The task title. */ protected function get_title_with_data( $task_data = [] ) { return $this->get_title(); @@ -313,8 +341,18 @@ protected function get_title_with_data( $task_data = [] ) { /** * Get the description with data. * - * @param array $task_data Optional data to include in the task. - * @return string + * Allows child classes to generate dynamic task descriptions based on task-specific data. + * + * @param array $task_data { + * Optional data to include in the task description. + * + * @type int $target_post_id The ID of the post this task targets. + * @type string $target_post_title The title of the post this task targets. + * @type int $target_term_id The ID of the term this task targets. + * @type string $target_term_name The name of the term this task targets. + * @type string $target_taxonomy The taxonomy slug for term-based tasks. + * } + * @return string The task description. */ protected function get_description_with_data( $task_data = [] ) { return $this->get_description(); @@ -323,8 +361,17 @@ protected function get_description_with_data( $task_data = [] ) { /** * Get the URL with data. * - * @param array $task_data Optional data to include in the task. - * @return string + * Allows child classes to generate dynamic task URLs based on task-specific data. + * For example, a link to edit a specific post: "post.php?post={post_id}&action=edit". + * + * @param array $task_data { + * Optional data to include in generating the task URL. + * + * @type int $target_post_id The ID of the post this task targets. + * @type int $target_term_id The ID of the term this task targets. + * @type string $target_taxonomy The taxonomy slug for term-based tasks. + * } + * @return string The task URL (escaped and ready to use). */ protected function get_url_with_data( $task_data = [] ) { return $this->get_url(); @@ -389,11 +436,25 @@ public function is_task_relevant() { } /** - * Evaluate a task. + * Evaluate a task to check if it has been completed. * - * @param string $task_id The task ID. + * This method determines whether a task should be marked as completed and earn points. + * It handles both non-repetitive tasks (one-time) and repetitive tasks (weekly). + * + * Non-repetitive tasks: + * - Checks if the task belongs to this provider + * - Verifies completion status via is_task_completed() + * - Returns the task object if completed, false otherwise * - * @return \Progress_Planner\Suggested_Tasks\Task|false The task data or false if the task is not completed. + * Repetitive tasks: + * - Must be completed within the same week they were created (using YW format: year + week number) + * - For example, a task created in week 42 of 2025 must be completed in 2025W42 + * - This prevents tasks from previous weeks being marked as complete + * - Allows child classes to add completion data (e.g., post_id for "create post" tasks) + * + * @param string $task_id The task ID to evaluate. + * + * @return \Progress_Planner\Suggested_Tasks\Task|false The task object if completed, false otherwise. */ public function evaluate_task( $task_id ) { // Early bail if the user does not have the capability to manage options. @@ -407,6 +468,7 @@ public function evaluate_task( $task_id ) { return false; } + // Handle non-repetitive (one-time) tasks. if ( ! $this->is_repetitive() ) { // Collaborator tasks have custom task_ids, so strpos check does not work for them. if ( ! $task->post_name || ( 0 !== \strpos( $task->post_name, $this->get_task_id() ) && 'collaborator' !== $this->get_provider_id() ) ) { @@ -415,10 +477,13 @@ public function evaluate_task( $task_id ) { return $this->is_task_completed( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $task->post_name ) ) ? $task : false; } + // Handle repetitive (weekly) tasks. + // These tasks must be completed in the same week they were created. if ( $task->provider && $task->provider->slug === $this->get_provider_id() && \DateTime::createFromFormat( 'Y-m-d H:i:s', $task->post_date ) && + // Check if the task was created in the current week (YW format: e.g., 202542 = week 42 of 2025). \gmdate( 'YW' ) === \gmdate( 'YW', \DateTime::createFromFormat( 'Y-m-d H:i:s', $task->post_date )->getTimestamp() ) && // @phpstan-ignore-line $this->is_task_completed( \progress_planner()->get_suggested_tasks()->get_task_id_from_slug( $task->post_name ) ) ) { @@ -590,11 +655,40 @@ public function are_dependencies_satisfied() { } /** - * Get task actions. + * Get task actions HTML buttons/links for display in the UI. * - * @param array $data The task data. + * Generates an array of HTML action buttons that users can interact with for each task. + * Actions are ordered by priority (lower numbers appear first). * - * @return array + * Standard actions include: + * - Complete button (priority 20): Marks task as complete and awards points + * - Snooze button (priority 30): Postpones task for specified duration (1 week to forever) + * - Info/External link (priority 40): Educational content about the task + * - Custom actions: Child classes can add via add_task_actions() + * + * Priority system (0-100, lower = higher priority): + * - 0-19: Reserved for critical actions + * - 20: Complete action + * - 30: Snooze action + * - 40: Information/educational links + * - 50+: Custom provider-specific actions + * - 1000: Default for actions without explicit priority + * + * @param array $data { + * The task data from the REST API response. + * + * @type int $id The WordPress post ID of the task. + * @type string $slug The task slug (post_name). + * @type array $title { + * @type string $rendered The rendered task title. + * } + * @type array $content { + * @type string $rendered The rendered task description/content. + * } + * @type array $meta Task metadata (presence checked before processing). + * } + * + * @return array Array of HTML strings for action buttons/links, ordered by priority. */ public function get_task_actions( $data = [] ) { $actions = []; @@ -602,6 +696,7 @@ public function get_task_actions( $data = [] ) { return $actions; } + // Add "Mark as complete" button for dismissable tasks (except user-created tasks). if ( $this->capability_required() && $this->is_dismissable() && 'user' !== static::PROVIDER_ID ) { $actions[] = [ 'priority' => 20, @@ -609,9 +704,13 @@ public function get_task_actions( $data = [] ) { ]; } + // Add "Snooze" button with duration options for snoozable tasks. if ( $this->capability_required() && $this->is_snoozable() ) { + // Build snooze dropdown with custom web component (prpl-tooltip). $snooze_html = ''; $snooze_html .= '
' . \esc_html__( 'Snooze this task?', 'progress-planner' ) . '
'; + + // Generate radio buttons for snooze duration options. foreach ( [ '1-week' => \esc_html__( '1 week', 'progress-planner' ), @@ -630,6 +729,8 @@ public function get_task_actions( $data = [] ) { ]; } + // Add educational/informational links. + // Prefer external links if provided, otherwise show task description in tooltip. if ( $this->get_external_link_url() ) { $actions[] = [ 'priority' => 40, @@ -642,9 +743,11 @@ public function get_task_actions( $data = [] ) { ]; } - // Add action links only if the user has the capability to perform the task. + // Allow child classes to add custom actions (e.g., "Edit Post" for content tasks). if ( $this->capability_required() ) { $actions = $this->add_task_actions( $data, $actions ); + + // Ensure all actions have priority set and remove empty actions. foreach ( $actions as $key => $action ) { $actions[ $key ]['priority'] = $action['priority'] ?? 1000; if ( ! isset( $action['html'] ) || '' === $action['html'] ) { @@ -653,9 +756,10 @@ public function get_task_actions( $data = [] ) { } } - // Order actions by priority. + // Sort actions by priority (ascending: lower priority values appear first). \usort( $actions, fn( $a, $b ) => $a['priority'] - $b['priority'] ); + // Extract just the HTML strings (discard priority metadata). $return_actions = []; foreach ( $actions as $action ) { $return_actions[] = $action['html']; @@ -676,6 +780,15 @@ public function add_task_actions( $data = [], $actions = [] ) { return $actions; } + /** + * Get the task action label. + * + * @return string + */ + public function get_task_action_label() { + return \__( 'Do it', 'progress-planner' ); + } + /** * Check if the task has activity. * @@ -697,4 +810,16 @@ public function task_has_activity( $task_id = '' ) { return ! empty( $activity ); } + + /** + * Complete the task. + * + * @param array $args The task data. + * @param string $task_id The task ID. + * + * @return bool + */ + public function complete_task( $args = [], $task_id = '' ) { + return false; + } } diff --git a/classes/suggested-tasks/providers/class-update-term-description.php b/classes/suggested-tasks/providers/class-update-term-description.php index c5c671e23e..455a5675b7 100644 --- a/classes/suggested-tasks/providers/class-update-term-description.php +++ b/classes/suggested-tasks/providers/class-update-term-description.php @@ -389,7 +389,7 @@ public function print_popover_form_contents() {

', diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-archive-author.php b/classes/suggested-tasks/providers/integrations/aioseo/class-archive-author.php index d572a10ab3..20cc606e49 100644 --- a/classes/suggested-tasks/providers/integrations/aioseo/class-archive-author.php +++ b/classes/suggested-tasks/providers/integrations/aioseo/class-archive-author.php @@ -8,12 +8,17 @@ namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\AIOSEO; use Progress_Planner\Suggested_Tasks\Data_Collector\Post_Author; +use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder; +use Progress_Planner\Suggested_Tasks\Providers\Traits\Ajax_Security_AIOSEO; /** * Add task for All in One SEO: disable the author archive. */ class Archive_Author extends AIOSEO_Interactive_Provider { + use Task_Action_Builder; + use Ajax_Security_AIOSEO; + /** * The minimum number of posts with a post format to add the task. * @@ -148,19 +153,13 @@ public function print_popover_form_contents() { * @return void */ public function handle_interactive_task_specific_submit() { - if ( ! \function_exists( 'aioseo' ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] ); - } + $this->verify_aioseo_active_or_fail(); + $this->verify_nonce_or_fail(); - // Check the nonce. - if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } - - \aioseo()->options->searchAppearance->archives->author->show = false; + \aioseo()->options->searchAppearance->archives->author->show = false; // @phpstan-ignore-line // Update the option. - \aioseo()->options->save(); + \aioseo()->options->save(); // @phpstan-ignore-line \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); } @@ -174,11 +173,6 @@ public function handle_interactive_task_specific_submit() { * @return array */ public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Noindex', 'progress-planner' ) . '', - ]; - - return $actions; + return $this->add_popover_action( $actions, \__( 'Noindex', 'progress-planner' ) ); } } diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-archive-date.php b/classes/suggested-tasks/providers/integrations/aioseo/class-archive-date.php index 86ed48d7b8..2b60d55fc6 100644 --- a/classes/suggested-tasks/providers/integrations/aioseo/class-archive-date.php +++ b/classes/suggested-tasks/providers/integrations/aioseo/class-archive-date.php @@ -7,11 +7,17 @@ namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\AIOSEO; +use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder; +use Progress_Planner\Suggested_Tasks\Providers\Traits\Ajax_Security_AIOSEO; + /** * Add task for All in One SEO: disable the date archive. */ class Archive_Date extends AIOSEO_Interactive_Provider { + use Task_Action_Builder; + use Ajax_Security_AIOSEO; + /** * The provider ID. * @@ -134,19 +140,13 @@ public function print_popover_form_contents() { * @return void */ public function handle_interactive_task_specific_submit() { - if ( ! \function_exists( 'aioseo' ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] ); - } + $this->verify_aioseo_active_or_fail(); + $this->verify_nonce_or_fail(); - // Check the nonce. - if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } - - \aioseo()->options->searchAppearance->archives->date->show = false; + \aioseo()->options->searchAppearance->archives->date->show = false; // @phpstan-ignore-line // Update the option. - \aioseo()->options->save(); + \aioseo()->options->save(); // @phpstan-ignore-line \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); } @@ -160,11 +160,6 @@ public function handle_interactive_task_specific_submit() { * @return array */ public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Noindex', 'progress-planner' ) . '', - ]; - - return $actions; + return $this->add_popover_action( $actions, \__( 'Noindex', 'progress-planner' ) ); } } diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-authors.php b/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-authors.php index ddcfcc05ab..74297316e5 100644 --- a/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-authors.php +++ b/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-authors.php @@ -8,12 +8,17 @@ namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\AIOSEO; use Progress_Planner\Suggested_Tasks\Data_Collector\Post_Author; +use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder; +use Progress_Planner\Suggested_Tasks\Providers\Traits\Ajax_Security_AIOSEO; /** * Add task for All in One SEO: disable author RSS feeds. */ class Crawl_Settings_Feed_Authors extends AIOSEO_Interactive_Provider { + use Task_Action_Builder; + use Ajax_Security_AIOSEO; + /** * The minimum number of posts with a post format to add the task. * @@ -145,19 +150,13 @@ public function print_popover_form_contents() { * @return void */ public function handle_interactive_task_specific_submit() { - if ( ! \function_exists( 'aioseo' ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] ); - } + $this->verify_aioseo_active_or_fail(); + $this->verify_nonce_or_fail(); - // Check the nonce. - if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } - - \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->authors = false; + \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->authors = false; // @phpstan-ignore-line // Update the option. - \aioseo()->options->save(); + \aioseo()->options->save(); // @phpstan-ignore-line \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); } @@ -171,11 +170,6 @@ public function handle_interactive_task_specific_submit() { * @return array */ public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . '', - ]; - - return $actions; + return $this->add_popover_action( $actions, \__( 'Disable', 'progress-planner' ) ); } } diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-comments.php b/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-comments.php index a0a42e7e53..b942ac4a4c 100644 --- a/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-comments.php +++ b/classes/suggested-tasks/providers/integrations/aioseo/class-crawl-settings-feed-comments.php @@ -7,11 +7,17 @@ namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\AIOSEO; +use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder; +use Progress_Planner\Suggested_Tasks\Providers\Traits\Ajax_Security_AIOSEO; + /** * Add task for All in One SEO: disable global comment RSS feeds. */ class Crawl_Settings_Feed_Comments extends AIOSEO_Interactive_Provider { + use Task_Action_Builder; + use Ajax_Security_AIOSEO; + /** * The provider ID. * @@ -113,14 +119,8 @@ public function print_popover_form_contents() { * @return void */ public function handle_interactive_task_specific_submit() { - if ( ! \function_exists( 'aioseo' ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] ); - } - - // Check the nonce. - if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } + $this->verify_aioseo_active_or_fail(); + $this->verify_nonce_or_fail(); // Global comment feed. if ( \aioseo()->options->searchAppearance->advanced->crawlCleanup->feeds->globalComments ) { // @phpstan-ignore-line @@ -147,11 +147,6 @@ public function handle_interactive_task_specific_submit() { * @return array */ public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . '', - ]; - - return $actions; + return $this->add_popover_action( $actions, \__( 'Disable', 'progress-planner' ) ); } } diff --git a/classes/suggested-tasks/providers/integrations/aioseo/class-media-pages.php b/classes/suggested-tasks/providers/integrations/aioseo/class-media-pages.php index c4a445c42d..93b22f6461 100644 --- a/classes/suggested-tasks/providers/integrations/aioseo/class-media-pages.php +++ b/classes/suggested-tasks/providers/integrations/aioseo/class-media-pages.php @@ -7,11 +7,17 @@ namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\AIOSEO; +use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder; +use Progress_Planner\Suggested_Tasks\Providers\Traits\Ajax_Security_AIOSEO; + /** * Add task for All in One SEO: redirect media/attachment pages. */ class Media_Pages extends AIOSEO_Interactive_Provider { + use Task_Action_Builder; + use Ajax_Security_AIOSEO; + /** * The provider ID. * @@ -120,19 +126,13 @@ public function print_popover_form_contents() { * @return void */ public function handle_interactive_task_specific_submit() { - if ( ! \function_exists( 'aioseo' ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] ); - } + $this->verify_aioseo_active_or_fail(); + $this->verify_nonce_or_fail(); - // Check the nonce. - if ( ! \check_ajax_referer( 'progress_planner', 'nonce', false ) ) { - \wp_send_json_error( [ 'message' => \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); - } - - \aioseo()->dynamicOptions->searchAppearance->postTypes->attachment->redirectAttachmentUrls = 'attachment'; + \aioseo()->dynamicOptions->searchAppearance->postTypes->attachment->redirectAttachmentUrls = 'attachment'; // @phpstan-ignore-line // Update the option. - \aioseo()->options->save(); + \aioseo()->options->save(); // @phpstan-ignore-line \wp_send_json_success( [ 'message' => \esc_html__( 'Setting updated.', 'progress-planner' ) ] ); } @@ -146,11 +146,6 @@ public function handle_interactive_task_specific_submit() { * @return array */ public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Redirect', 'progress-planner' ) . '', - ]; - - return $actions; + return $this->add_popover_action( $actions, \__( 'Redirect', 'progress-planner' ) ); } } diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-archive-author.php b/classes/suggested-tasks/providers/integrations/yoast/class-archive-author.php index 367d98ac3d..b4bf9ee3e4 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-archive-author.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-archive-author.php @@ -8,12 +8,15 @@ namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast; use Progress_Planner\Suggested_Tasks\Data_Collector\Post_Author; +use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder; /** * Add task for Yoast SEO: disable the author archive. */ class Archive_Author extends Yoast_Interactive_Provider { + use Task_Action_Builder; + /** * The minimum number of posts with a post format to add the task. * @@ -137,11 +140,6 @@ public function print_popover_form_contents() { * @return array */ public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . '', - ]; - - return $actions; + return $this->add_popover_action( $actions, \__( 'Disable', 'progress-planner' ) ); } } diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-archive-date.php b/classes/suggested-tasks/providers/integrations/yoast/class-archive-date.php index 48173c5f78..0c2b5f2dd0 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-archive-date.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-archive-date.php @@ -7,11 +7,15 @@ namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast; +use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder; + /** * Add task for Yoast SEO: disable the date archive. */ class Archive_Date extends Yoast_Interactive_Provider { + use Task_Action_Builder; + /** * The provider ID. * @@ -124,11 +128,6 @@ public function print_popover_form_contents() { * @return array */ public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . '', - ]; - - return $actions; + return $this->add_popover_action( $actions, \__( 'Disable', 'progress-planner' ) ); } } diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-archive-format.php b/classes/suggested-tasks/providers/integrations/yoast/class-archive-format.php index 0041f168c3..f7aad926d1 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-archive-format.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-archive-format.php @@ -8,12 +8,15 @@ namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast; use Progress_Planner\Suggested_Tasks\Data_Collector\Archive_Format as Archive_Format_Data_Collector; +use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder; /** * Add task for Yoast SEO: disable the format archives. */ class Archive_Format extends Yoast_Interactive_Provider { + use Task_Action_Builder; + /** * The provider ID. * @@ -137,11 +140,6 @@ public function print_popover_form_contents() { * @return array */ public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . '', - ]; - - return $actions; + return $this->add_popover_action( $actions, \__( 'Disable', 'progress-planner' ) ); } } diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-emoji-scripts.php b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-emoji-scripts.php index 9867a75bc3..72abfc4358 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-emoji-scripts.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-emoji-scripts.php @@ -7,11 +7,15 @@ namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast; +use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder; + /** * Add task for Yoast SEO: Remove emoji scripts. */ class Crawl_Settings_Emoji_Scripts extends Yoast_Interactive_Provider { + use Task_Action_Builder; + /** * The provider ID. * @@ -116,11 +120,6 @@ public function print_popover_form_contents() { * @return array */ public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Remove', 'progress-planner' ) . '', - ]; - - return $actions; + return $this->add_popover_action( $actions, \__( 'Remove', 'progress-planner' ) ); } } diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-authors.php b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-authors.php index ff550f62e6..d68d98caf8 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-authors.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-authors.php @@ -8,12 +8,15 @@ namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast; use Progress_Planner\Suggested_Tasks\Data_Collector\Post_Author; +use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder; /** * Add task for Yoast SEO: Remove post authors feeds. */ class Crawl_Settings_Feed_Authors extends Yoast_Interactive_Provider { + use Task_Action_Builder; + /** * The minimum number of posts with a post format to add the task. * @@ -148,11 +151,6 @@ public function print_popover_form_contents() { * @return array */ public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Remove', 'progress-planner' ) . '', - ]; - - return $actions; + return $this->add_popover_action( $actions, \__( 'Remove', 'progress-planner' ) ); } } diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-global-comments.php b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-global-comments.php index 95af3c30ea..c28b88230c 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-global-comments.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-crawl-settings-feed-global-comments.php @@ -7,11 +7,15 @@ namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast; +use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder; + /** * Add task for Yoast SEO: Remove global comment feeds. */ class Crawl_Settings_Feed_Global_Comments extends Yoast_Interactive_Provider { + use Task_Action_Builder; + /** * The provider ID. * @@ -116,11 +120,6 @@ public function print_popover_form_contents() { * @return array */ public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Remove', 'progress-planner' ) . '', - ]; - - return $actions; + return $this->add_popover_action( $actions, \__( 'Remove', 'progress-planner' ) ); } } diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-media-pages.php b/classes/suggested-tasks/providers/integrations/yoast/class-media-pages.php index 64371fa8ea..64e4387c46 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-media-pages.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-media-pages.php @@ -7,11 +7,15 @@ namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast; +use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder; + /** * Add task for Yoast SEO: disable the media pages. */ class Media_Pages extends Yoast_Interactive_Provider { + use Task_Action_Builder; + /** * The provider ID. * @@ -110,11 +114,6 @@ public function print_popover_form_contents() { * @return array */ public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Disable', 'progress-planner' ) . '', - ]; - - return $actions; + return $this->add_popover_action( $actions, \__( 'Disable', 'progress-planner' ) ); } } diff --git a/classes/suggested-tasks/providers/integrations/yoast/class-organization-logo.php b/classes/suggested-tasks/providers/integrations/yoast/class-organization-logo.php index e32e20352e..899aab77f6 100644 --- a/classes/suggested-tasks/providers/integrations/yoast/class-organization-logo.php +++ b/classes/suggested-tasks/providers/integrations/yoast/class-organization-logo.php @@ -7,11 +7,15 @@ namespace Progress_Planner\Suggested_Tasks\Providers\Integrations\Yoast; +use Progress_Planner\Suggested_Tasks\Providers\Traits\Task_Action_Builder; + /** * Add task for Yoast SEO: set your organization logo. */ class Organization_Logo extends Yoast_Interactive_Provider { + use Task_Action_Builder; + /** * The provider ID. * @@ -228,11 +232,6 @@ protected function get_enqueue_data() { * @return array */ public function add_task_actions( $data = [], $actions = [] ) { - $actions[] = [ - 'priority' => 10, - 'html' => '' . \esc_html__( 'Set logo', 'progress-planner' ) . '', - ]; - - return $actions; + return $this->add_popover_action( $actions, \__( 'Set logo', 'progress-planner' ) ); } } diff --git a/classes/suggested-tasks/providers/traits/class-ajax-security-aioseo.php b/classes/suggested-tasks/providers/traits/class-ajax-security-aioseo.php new file mode 100644 index 0000000000..0f0f40cd40 --- /dev/null +++ b/classes/suggested-tasks/providers/traits/class-ajax-security-aioseo.php @@ -0,0 +1,53 @@ + \esc_html__( 'AIOSEO is not active.', 'progress-planner' ) ] ); + } + } + + /** + * Perform complete AIOSEO AJAX security checks. + * + * Runs AIOSEO active check, capability check, and nonce verification. + * This is a convenience method for AIOSEO interactive tasks. + * + * @param string $capability The capability to require (default: 'manage_options'). + * @param string $action The nonce action to verify (default: 'progress_planner'). + * @param string $field The POST field containing the nonce (default: 'nonce'). + * + * @return void Exits with wp_send_json_error() if any check fails. + */ + protected function verify_aioseo_ajax_security( $capability = 'manage_options', $action = 'progress_planner', $field = 'nonce' ) { + $this->verify_aioseo_active_or_fail(); + $this->verify_ajax_security( $capability, $action, $field ); + } +} diff --git a/classes/suggested-tasks/providers/traits/class-ajax-security-base.php b/classes/suggested-tasks/providers/traits/class-ajax-security-base.php new file mode 100644 index 0000000000..6526022904 --- /dev/null +++ b/classes/suggested-tasks/providers/traits/class-ajax-security-base.php @@ -0,0 +1,72 @@ + \esc_html__( 'Invalid nonce.', 'progress-planner' ) ] ); + } + } + + /** + * Verify user capabilities or send JSON error and exit. + * + * Checks if the current user has the specified capability and terminates + * execution with a JSON error response if they don't. + * + * @param string $capability The capability to check (default: 'manage_options'). + * + * @return void Exits with wp_send_json_error() if user lacks capability. + */ + protected function verify_capability_or_fail( $capability = 'manage_options' ) { + if ( ! \current_user_can( $capability ) ) { + \wp_send_json_error( + [ 'message' => \esc_html__( 'You do not have permission to perform this action.', 'progress-planner' ) ] + ); + } + } + + /** + * Perform all standard AJAX security checks. + * + * Runs nonce verification and capability check in one call. + * Useful for most AJAX handlers that require both checks. + * + * @param string $capability The capability to require (default: 'manage_options'). + * @param string $action The nonce action to verify (default: 'progress_planner'). + * @param string $field The POST field containing the nonce (default: 'nonce'). + * + * @return void Exits with wp_send_json_error() if any check fails. + */ + protected function verify_ajax_security( $capability = 'manage_options', $action = 'progress_planner', $field = 'nonce' ) { + $this->verify_capability_or_fail( $capability ); + $this->verify_nonce_or_fail( $action, $field ); + } +} diff --git a/classes/suggested-tasks/providers/traits/class-ajax-security-yoast.php b/classes/suggested-tasks/providers/traits/class-ajax-security-yoast.php new file mode 100644 index 0000000000..2f7714a06c --- /dev/null +++ b/classes/suggested-tasks/providers/traits/class-ajax-security-yoast.php @@ -0,0 +1,55 @@ + \esc_html__( 'Yoast SEO is not active.', 'progress-planner' ) ] ); + } + } + + /** + * Perform complete Yoast SEO AJAX security checks. + * + * Runs Yoast active check, capability check, and nonce verification. + * This is a convenience method for Yoast interactive tasks. + * + * @param string $capability The capability to require (default: 'manage_options'). + * @param string $action The nonce action to verify (default: 'progress_planner'). + * @param string $field The POST field containing the nonce (default: 'nonce'). + * + * @return void Exits with wp_send_json_error() if any check fails. + */ + protected function verify_yoast_ajax_security( $capability = 'manage_options', $action = 'progress_planner', $field = 'nonce' ) { + $this->verify_yoast_active_or_fail(); + $this->verify_ajax_security( $capability, $action, $field ); + } +} diff --git a/classes/suggested-tasks/providers/traits/class-task-action-builder.php b/classes/suggested-tasks/providers/traits/class-task-action-builder.php new file mode 100644 index 0000000000..2649d24c07 --- /dev/null +++ b/classes/suggested-tasks/providers/traits/class-task-action-builder.php @@ -0,0 +1,68 @@ + $priority, + 'html' => $this->generate_popover_button_html( $label ), + ]; + } + + /** + * Generate the HTML for a popover trigger button. + * + * @param string $label The text to display for the action link. + * + * @return string The HTML for the popover trigger button. + */ + protected function generate_popover_button_html( $label ) { + return \sprintf( + '%2$s', + \esc_attr( static::POPOVER_ID ), + \esc_html( $label ) + ); + } + + /** + * Add a popover action to the actions array. + * + * Convenience method that adds a popover action and returns the modified array. + * + * @param array $actions The existing actions array. + * @param string $label The text to display for the action link. + * @param int $priority The priority of the action (default: 10). + * + * @return array The modified actions array. + */ + protected function add_popover_action( $actions, $label, $priority = 10 ) { + $actions[] = $this->create_popover_action( $label, $priority ); + return $actions; + } +} diff --git a/classes/ui/class-chart.php b/classes/ui/class-chart.php index 71f2b3a05e..587bd452ee 100644 --- a/classes/ui/class-chart.php +++ b/classes/ui/class-chart.php @@ -15,8 +15,11 @@ class Chart { /** * Build a chart for the stats. * - * @param array $args The arguments for the chart. - * See `get_chart_data` for the available parameters. + * @param array $args { + * The arguments for the chart. See `get_chart_data` for all available parameters. + * + * @type string $type Chart type (e.g., 'line', 'bar'). + * } * * @return void */ @@ -28,23 +31,49 @@ public function the_chart( $args = [] ) { /** * Get data for the chart. * - * @param array $args The arguments for the chart. - * ['items_callback'] The callback to get items. - * ['filter_results'] The callback to filter the results. Leave empty/null to skip filtering. - * ['dates_params'] The dates parameters for the query. - * ['start_date'] The start date for the chart. - * ['end_date'] The end date for the chart. - * ['frequency'] The frequency for the chart nodes. - * ['format'] The format for the label. + * Normalized charts: + * When $args['normalized'] is true, the chart implements a "decay" algorithm that carries + * forward previous period's activities into the current period with decaying values. + * This creates a rolling momentum effect where past activities continue to contribute + * to current scores, gradually diminishing over time. + * + * Example: If a user published 10 posts in January, the normalized chart for February + * will include both February's new posts plus a decayed value from January's posts. + * This encourages consistent activity by showing how past work continues to have impact. + * + * @param array $args { + * The arguments for the chart. + * + * @type callable $items_callback Callback to fetch items for a date range. + * Signature: function( DateTime $start_date, DateTime $end_date ): array + * @type callable|null $filter_results Optional callback to filter results after fetching. + * Signature: function( array $activities ): array + * @type array $dates_params { + * Date range and frequency parameters. * - * @return array + * @type DateTime $start_date The start date for the chart. + * @type DateTime $end_date The end date for the chart. + * @type string $frequency The frequency for chart nodes (e.g., 'day', 'week', 'month'). + * @type string $format The label format (e.g., 'Y-m-d', 'M j'). + * } + * @type bool $normalized Whether to use normalized scoring with decay from previous periods. + * Default false. + * @type callable $color Callback to determine bar/line color. + * Signature: function(): string (hex color code) + * @type callable $count_callback Callback to calculate score from activities. + * Signature: function( array $activities, DateTime|null $date ): int|float + * @type int|null $max Optional maximum value for chart scaling. + * @type string $type Chart type: 'line' or 'bar'. Default 'line'. + * @type array $return_data Which data fields to return in output. + * Default ['label', 'score', 'color']. + * } + * + * @return array Array of chart data points, each containing requested fields (label, score, color, etc). */ public function get_chart_data( $args = [] ) { $activities = []; - /* - * Set default values for the arguments. - */ + // Set default values for the arguments. $args = \wp_parse_args( $args, [ @@ -61,7 +90,7 @@ public function get_chart_data( $args = [] ) { ] ); - // Get the periods for the chart. + // Get the periods for the chart (e.g., months, weeks, days based on frequency). $periods = \progress_planner()->get_utils__date()->get_periods( $args['dates_params']['start_date'], $args['dates_params']['end_date'], @@ -69,15 +98,25 @@ public function get_chart_data( $args = [] ) { ); /* - * "Normalized" charts decay the score of previous months activities, - * and add them to the current month score. - * This means that for "normalized" charts, we need to get activities - * for the month prior to the first period. + * For "normalized" charts, implement a decay algorithm: + * - Previous period's activities "decay" and carry forward into current period + * - This creates momentum: past productivity continues to boost current scores + * - We need to fetch activities from the month BEFORE the chart starts + * - These previous activities will be added (with decay) to the first period's score + * + * Example: For a chart starting Feb 1, fetch Jan 1-31 activities to contribute + * to February's normalized score. */ $previous_period_activities = []; if ( $args['normalized'] ) { - $previous_month_start = ( clone $periods[0]['start_date'] )->modify( '-1 month' ); - $previous_month_end = ( clone $periods[0]['start_date'] )->modify( '-1 day' ); + /** + * The start date of the first period. + * + * @var \DateTime $first_period_start + */ + $first_period_start = $periods[0]['start_date']; + $previous_month_start = ( clone $first_period_start )->modify( '-1 month' ); + $previous_month_end = ( clone $first_period_start )->modify( '-1 day' ); $previous_period_activities = $args['items_callback']( $previous_month_start, $previous_month_end ); if ( $args['filter_results'] ) { $activities = $args['filter_results']( $activities ); @@ -92,7 +131,8 @@ public function get_chart_data( $args = [] ) { $previous_period_activities = $period_data['previous_period_activities']; $period_data_filtered = []; foreach ( $args['return_data'] as $key ) { - $period_data_filtered[ $key ] = $period_data[ $key ]; + $key_string = (string) $key; // @phpstan-ignore offsetAccess.invalidOffset + $period_data_filtered[ $key_string ] = $period_data[ $key_string ]; // @phpstan-ignore offsetAccess.invalidOffset } $data[] = $period_data_filtered; } @@ -101,30 +141,55 @@ public function get_chart_data( $args = [] ) { } /** - * Get the data for a period. + * Get the data for a single period in the chart. * - * @param array $period The period. - * @param array $args The arguments for the chart. - * @param array $previous_period_activities The activities for the previous month. + * For normalized charts, this implements the decay algorithm: + * 1. Calculate score from current period's activities (normal scoring) + * 2. Add decayed score from previous period's activities (normalized bonus) + * 3. Save current activities to decay into next period * - * @return array + * The decay is handled by the count_callback, which typically reduces scores + * based on how old the activities are relative to the current period. + * + * @param array $period { + * The time period being processed. + * + * @type DateTime $start_date Period start date. + * @type DateTime $end_date Period end date. + * @type string $label Human-readable label for this period. + * } + * @param array $args The chart arguments (see get_chart_data). + * @param array $previous_period_activities Activities from the previous period to apply decay to. + * + * @return array { + * Period data with score and metadata. + * + * @type string $label Period label (e.g., "Jan 2025"). + * @type int|float $score Calculated score for this period. + * @type string $color Color for this data point. + * @type array $previous_period_activities Activities to carry forward to next period. + * } */ public function get_period_data( $period, $args, $previous_period_activities ) { - // Get the activities for the period. + // Get the activities for the current period. $activities = $args['items_callback']( $period['start_date'], $period['end_date'] ); - // Filter the results if a callback is provided. + + // Apply optional filtering callback. if ( $args['filter_results'] ) { $activities = $args['filter_results']( $activities ); } - // Calculate the score for the period. + // Calculate the base score from current period's activities. $period_score = $args['count_callback']( $activities, $period['start_date'] ); - // If this is a "normalized" chart, we need to calculate the score for the previous month activities. + // For normalized charts, apply decay algorithm. if ( $args['normalized'] ) { - // Add the previous month activities to the current month score. + // Add decayed score from previous period's activities to current score. + // The count_callback determines the decay rate based on activity age. $period_score += $args['count_callback']( $previous_period_activities, $period['start_date'] ); - // Update the previous month activities for the next iteration of the loop. + + // Save current activities to decay into the next period. + // This creates a rolling momentum effect across time periods. $previous_period_activities = $activities; } diff --git a/classes/update/class-update-1100.php b/classes/update/class-update-1100.php new file mode 100644 index 0000000000..d21a1503f4 --- /dev/null +++ b/classes/update/class-update-1100.php @@ -0,0 +1,50 @@ +delete_redirect_on_login_user_meta(); + } + + /** + * Delete the prpl_redirect_on_login user meta for all users. + * + * The settings page that allowed users to set their login destination + * has been removed. This migration deletes the user meta to prevent + * users from being redirected to Progress Planner after login. + * + * @return void + */ + private function delete_redirect_on_login_user_meta() { + global $wpdb; + + // Delete the user meta for all users directly from the database. + // This is more efficient than looping through all users. + $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->usermeta, // @phpstan-ignore-line property.nonObject + [ 'meta_key' => 'prpl_redirect_on_login' ], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + [ '%s' ] + ); + } +} diff --git a/classes/update/class-update-140.php b/classes/update/class-update-140.php index 9a25b2c968..aeb83429c4 100644 --- a/classes/update/class-update-140.php +++ b/classes/update/class-update-140.php @@ -41,10 +41,12 @@ private function rename_tasks_option() { // This is to ensure that we don't lose any tasks, and at the same time we don't have duplicate tasks. $tasks = []; foreach ( $new_tasks as $new_task ) { - $tasks[ isset( $new_task['task_id'] ) ? $new_task['task_id'] : \md5( \maybe_serialize( $new_task ) ) ] = $new_task; + $key = isset( $new_task['task_id'] ) ? (string) $new_task['task_id'] : \md5( \maybe_serialize( $new_task ) ); // @phpstan-ignore offsetAccess.invalidOffset + $tasks[ $key ] = $new_task; } foreach ( $old_tasks as $old_task ) { - $tasks[ isset( $old_task['task_id'] ) ? $old_task['task_id'] : \md5( \maybe_serialize( $old_task ) ) ] = $old_task; + $key = isset( $old_task['task_id'] ) ? (string) $old_task['task_id'] : \md5( \maybe_serialize( $old_task ) ); // @phpstan-ignore offsetAccess.invalidOffset + $tasks[ $key ] = $old_task; } // Set the tasks option. diff --git a/classes/utils/class-color-customizer.php b/classes/utils/class-color-customizer.php deleted file mode 100644 index 6097b86fba..0000000000 --- a/classes/utils/class-color-customizer.php +++ /dev/null @@ -1,574 +0,0 @@ -register_hooks(); - } - - /** - * Register the hooks. - * - * @return void - */ - private function register_hooks() { - \add_action( 'admin_menu', [ $this, 'add_page' ] ); - \add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); - \add_action( 'admin_init', [ $this, 'handle_form_submission' ] ); - \add_action( 'admin_head', [ $this, 'add_inline_css' ] ); - } - - /** - * Add the admin page (hidden from menu). - * - * @return void - */ - public function add_page() { - // Add the page but don't show it in the menu. - \add_submenu_page( - 'progress-planner', - 'Color Customizer', - 'Color Customizer', - 'manage_options', - 'progress-planner-color-customizer', - [ $this, 'render_page' ] - ); - } - - - /** - * Enqueue scripts and styles. - * - * @param string $hook The current admin page. - * - * @return void - */ - public function enqueue_assets( $hook ) { - if ( 'progress-planner_page_progress-planner-color-customizer' !== $hook ) { - return; - } - - // Enqueue the variables-color.css first. - \progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/variables-color' ); - \progress_planner()->get_admin__enqueue()->enqueue_style( 'progress-planner/admin' ); - - // Enqueue the color customizer JavaScript. - \progress_planner()->get_admin__enqueue()->enqueue_script( 'color-customizer' ); - - // Add custom CSS for the color picker page. - \wp_add_inline_style( 'progress-planner/admin', $this->get_customizer_css() ); - } - - /** - * Handle form submission. - * - * @return void - */ - public function handle_form_submission() { - if ( ! isset( $_POST['progress_planner_color_customizer_nonce'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - return; - } - - $nonce = \sanitize_text_field( \wp_unslash( $_POST['progress_planner_color_customizer_nonce'] ) ); - if ( ! \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $nonce ) ), 'progress_planner_color_customizer' ) ) { - return; - } - - if ( ! \current_user_can( 'manage_options' ) ) { - return; - } - - $action = \sanitize_text_field( \wp_unslash( $_POST['action'] ?? '' ) ); - - switch ( $action ) { - case 'save_colors': - $this->save_colors(); - break; - case 'reset_colors': - $this->reset_colors(); - break; - case 'export_colors': - $this->export_colors(); - break; - case 'import_colors': - $this->import_colors(); - break; - } - } - - /** - * Save color settings. - * - * @return void - */ - private function save_colors() { - $colors = []; - $color_variables = $this->get_color_variables(); - - foreach ( $color_variables as $section => $variables ) { - foreach ( $variables as $variable => $default_value ) { - $key = "color_{$variable}"; - if ( isset( $_POST[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing - $post_value = isset( $_POST[ $key ] ) ? \sanitize_text_field( \wp_unslash( $_POST[ $key ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing - $color_value = \sanitize_text_field( \wp_unslash( $post_value ) ); - if ( ! empty( $color_value ) ) { - $colors[ $variable ] = $color_value; - } - } - } - } - - \update_option( self::OPTION_NAME, $colors ); - \add_action( - 'admin_notices', - function () { - echo '

Colors saved successfully!

'; - } - ); - } - - /** - * Reset color settings. - * - * @return void - */ - private function reset_colors() { - \delete_option( self::OPTION_NAME ); - \add_action( - 'admin_notices', - function () { - echo '

Colors reset to defaults!

'; - } - ); - } - - /** - * Export color settings. - * - * @return void - */ - private function export_colors() { - $colors = \get_option( self::OPTION_NAME, [] ); - $export_data = [ - 'version' => '1.0', - 'colors' => $colors, - 'exported_at' => \current_time( 'mysql' ), - ]; - - \header( 'Content-Type: application/json' ); - \header( 'Content-Disposition: attachment; filename="progress-planner-colors.json"' ); - echo \wp_json_encode( $export_data, JSON_PRETTY_PRINT ); - exit; - } - - /** - * Import color settings. - * - * @return void - */ - private function import_colors() { - if ( ! isset( $_FILES['color_file'] ) || $_FILES['color_file']['error'] !== UPLOAD_ERR_OK ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated - \add_action( - 'admin_notices', - function () { - echo '

Error uploading file!

'; - } - ); - return; - } - - $file_content = \file_get_contents( $_FILES['color_file']['tmp_name'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - $import_data = \json_decode( $file_content, true ); - - if ( ! $import_data || ! isset( $import_data['colors'] ) ) { - \add_action( - 'admin_notices', - function () { - echo '

Invalid file format!

'; - } - ); - return; - } - - \update_option( self::OPTION_NAME, $import_data['colors'] ); - \add_action( - 'admin_notices', - function () { - echo '

Colors imported successfully!

'; - } - ); - } - - /** - * Add inline CSS to override default colors. - * - * @return void - */ - public function add_inline_css() { - // Hide menu item on all pages. - echo ''; - - // Only add inline CSS on the PP pages. - $current_screen = \get_current_screen(); - if ( ! $current_screen || - ( 'toplevel_page_progress-planner' !== $current_screen->id && 'progress-planner_page_progress-planner-settings' !== $current_screen->id ) - ) { - return; - } - - $colors = \get_option( self::OPTION_NAME, [] ); - if ( empty( $colors ) ) { - return; - } - - $css = ':root {'; - foreach ( $colors as $variable => $value ) { - $css .= "\n\t--prpl-{$variable}: {$value};"; - } - $css .= "\n}"; - - echo ''; - } - - /** - * Render the admin page. - * - * @return void - */ - public function render_page() { - $colors = \get_option( self::OPTION_NAME, [] ); - $color_variables = $this->get_color_variables(); - - ?> -
-

Progress Planner Color Customizer

-

Customize the colors used throughout the Progress Planner interface. Changes will be applied after you save.

- -
- - - $variables ) : ?> - -
-

-
- $default_value ) : ?> - -
- - - - -
- -
-
- - -
- - - 'this.form.action.value = "reset_colors"; return confirm("Are you sure you want to reset all colors to defaults?");' ] ); ?> -
-
- -
-

Import / Export

-
-
- - - -
- -
- - - - -
-
-
-
- [ - 'background' => '#f6f7f9', - 'background-banner' => '#f9b23c', - ], - 'Paper' => [ - 'background-paper' => '#fff', - 'color-border' => '#d1d5db', - 'color-divider' => '#d1d5db', - 'color-shadow-paper' => '#000', - ], - 'Graph' => [ - 'color-gauge-main' => '#e1e3e7', - 'graph-color-1' => '#f43f5e', - 'graph-color-2' => '#faa310', - 'graph-color-3' => '#14b8a6', - 'graph-color-4' => '#534786', - ], - 'Table' => [ - 'background-table' => '#f6f7f9', - 'background-top-task' => '#fff9f0', - 'color-border-top-task' => '#faa310', - 'color-border-next-top-task' => '#534786', - 'color-selection-controls-inactive' => '#9ca3af', - 'color-selection-controls' => '#9ca3af', - 'color-ui-icon' => '#6b7280', - 'color-ui-icon-hover' => '#1e40af', - 'color-ui-icon-hover-fill' => '#effbfe', - 'color-ui-icon-hover-delete' => '#e73136', - 'background-point' => '#f9b23c', - 'text-point' => '#38296d', - 'background-point-inactive' => '#d1d5db', - 'text-point-inactive' => '#38296d', - ], - 'Text' => [ - 'color-text' => '#4b5563', - 'color-text-hover' => '#1e40af', - 'color-headings' => '#38296d', - 'color-subheadings' => '#38296d', - 'color-link' => '#1e40af', - 'color-link-hover' => '#4b5563', - 'color-link-visited' => '#534786', - ], - 'Topics' => [ - 'color-monthly' => '#faa310', - 'color-streak' => '#faa310', - 'color-content-badge' => '#faa310', - 'background-monthly' => '#fff9f0', - 'background-content' => '#f6f5fb', - 'background-activity' => '#f2faf9', - 'background-streak' => '#fff6f7', - 'background-content-badge' => '#effbfe', - ], - 'Alert Success' => [ - 'color-alert-success' => '#16a34a', - 'color-alert-success-text' => '#14532d', - 'background-alert-success' => '#f0fdf4', - ], - 'Alert Error' => [ - 'color-alert-error' => '#e73136', - 'color-alert-error-text' => '#7f1d1d', - 'background-alert-error' => '#fdeded', - ], - 'Alert Warning' => [ - 'color-alert-warning' => '#eab308', - 'color-alert-warning-text' => '#713f12', - 'background-alert-warning' => '#fefce8', - ], - 'Alert Info' => [ - 'color-alert-info' => '#2563eb', - 'color-alert-info-text' => '#1e3a8a', - 'background-alert-info' => '#eff6ff', - ], - 'Button' => [ - 'color-button-primary' => '#dd324f', - 'color-button-primary-hover' => '#cf2441', - 'color-button-primary-shadow' => '#000', - 'color-button-primary-border' => 'none', - 'color-button-primary-text' => '#fff', - ], - 'Settings Page' => [ - 'color-setting-pages-icon' => '#faa310', - 'color-setting-posts-icon' => '#534786', - 'color-setting-login-icon' => '#14b8a6', - 'background-setting-pages' => '#fff9f0', - 'background-setting-posts' => '#f6f5fb', - 'background-setting-login' => '#f2faf9', - 'color-border-settings' => '#d1d5db', - ], - 'Input Field Dropdown' => [ - 'color-field-border' => '#d1d5db', - 'color-text-placeholder' => '#6b7280', - 'color-text-dropdown' => '#4b5563', - 'color-field-shadow' => '#000', - ], - ]; - } - - /** - * Normalize color value to 6-digit hex format. - * - * @param string $color_value The color value to normalize. - * - * @return string - */ - private function normalize_color_value( $color_value ) { - // Handle null or empty values. - if ( empty( $color_value ) ) { - return '#000000'; - } - - // Handle special cases. - if ( 'none' === $color_value ) { - return '#000000'; - } - - // If it's already a 6-digit hex, return as is. - if ( \preg_match( '/^#[0-9A-Fa-f]{6}$/', $color_value ) ) { - return \strtolower( $color_value ); - } - - // Convert 3-digit hex to 6-digit. - if ( \preg_match( '/^#[0-9A-Fa-f]{3}$/', $color_value ) ) { - $hex = \substr( $color_value, 1 ); - return '#' . \strtolower( $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2] ); - } - - // If it's not a valid hex color, return black as fallback. - return '#000000'; - } - - /** - * Get custom CSS for the color customizer page. - * - * @return string - */ - private function get_customizer_css() { - return ' - .color-section { - margin-bottom: 30px; - padding: 20px; - background: #fff; - border: 1px solid #ddd; - border-radius: 4px; - } - - .color-section h2 { - margin-top: 0; - color: var(--prpl-color-headings); - border-bottom: 2px solid var(--prpl-color-border); - padding-bottom: 10px; - } - - .color-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 20px; - margin-top: 20px; - } - - .color-field { - display: flex; - flex-direction: column; - gap: 8px; - } - - .color-field label { - font-weight: 600; - color: var(--prpl-color-headings); - } - - .default-value { - font-weight: normal; - font-size: 12px; - color: var(--prpl-color-ui-icon); - display: block; - } - - .color-picker { - width: 60px; - height: 40px; - border: 1px solid var(--prpl-color-border); - border-radius: 4px; - cursor: pointer; - } - - .color-text-input { - padding: 8px; - border: 1px solid var(--prpl-color-border); - border-radius: 4px; - font-family: monospace; - font-size: 12px; - } - - .form-actions { - margin: 30px 0; - padding: 20px; - background: #fff; - border: 1px solid #ddd; - border-radius: 4px; - } - - .import-export-section { - margin-top: 30px; - padding: 20px; - background: #fff; - border: 1px solid #ddd; - border-radius: 4px; - } - - .import-export-section h2 { - margin-top: 0; - color: var(--prpl-color-headings); - } - - .import-export-actions { - display: flex; - align-items: center; - gap: 20px; - } - - .import-export-actions input[type="file"] { - margin-right: 10px; - } - '; - } -} diff --git a/classes/utils/class-date.php b/classes/utils/class-date.php index 42f6cba79a..2810fa8401 100644 --- a/classes/utils/class-date.php +++ b/classes/utils/class-date.php @@ -18,10 +18,7 @@ class Date { * @param \DateTime $start_date The start date. * @param \DateTime $end_date The end date. * - * @return array [ - * 'start_date' => \DateTime, - * 'end_date' => \DateTime, - * ]. + * @return array > */ public function get_range( $start_date, $end_date ) { $dates = \iterator_to_array( new \DatePeriod( $start_date, new \DateInterval( 'P1D' ), $end_date ), false ); @@ -38,7 +35,7 @@ public function get_range( $start_date, $end_date ) { * @param \DateTime $end_date The end date. * @param string $frequency The frequency. Can be 'daily', 'weekly', 'monthly'. * - * @return array + * @return array */ public function get_periods( $start_date, $end_date, $frequency ) { $end_date->modify( '+1 day' ); @@ -71,8 +68,15 @@ public function get_periods( $start_date, $end_date, $frequency ) { if ( empty( $date_ranges ) ) { return []; } - if ( $end_date->format( 'z' ) !== \end( $date_ranges )['end_date']->format( 'z' ) ) { - $final_end = clone \end( $date_ranges )['end_date']; + $last_range = \end( $date_ranges ); + /** + * The end date of the last range. + * + * @var \DateTime $last_end_date + */ + $last_end_date = $last_range['end_date']; + if ( $end_date->format( 'z' ) !== $last_end_date->format( 'z' ) ) { + $final_end = clone $last_end_date; $date_ranges[] = $this->get_range( $final_end->modify( '+1 day' ), $end_date ); } diff --git a/classes/utils/class-debug-tools.php b/classes/utils/class-debug-tools.php index 9a416d9497..6b42a5ca69 100644 --- a/classes/utils/class-debug-tools.php +++ b/classes/utils/class-debug-tools.php @@ -48,13 +48,11 @@ public function __construct() { \add_action( 'init', [ $this, 'check_toggle_migrations' ] ); \add_action( 'init', [ $this, 'check_delete_single_task' ] ); \add_action( 'init', [ $this, 'check_toggle_recommendations_ui' ] ); + \add_action( 'init', [ $this, 'check_delete_onboarding_progress' ] ); if ( \defined( '\IS_PLAYGROUND_PREVIEW' ) && \constant( '\IS_PLAYGROUND_PREVIEW' ) === true ) { \add_action( 'init', [ $this, 'check_toggle_placeholder_demo' ] ); } - // Initialize color customizer. - $this->get_color_customizer(); - \add_filter( 'progress_planner_tasks_show_ui', [ $this, 'filter_tasks_show_ui' ] ); } @@ -101,17 +99,9 @@ public function add_toolbar_items( $admin_bar ) { $this->add_toggle_recommendations_ui_submenu_item( $admin_bar ); - // Add color customizer item. - $admin_bar->add_node( - [ - 'id' => 'prpl-color-customizer', - 'parent' => 'prpl-debug', - 'title' => 'Color Customizer', - 'href' => \admin_url( 'admin.php?page=progress-planner-color-customizer' ), - ] - ); - $this->add_placeholder_demo_submenu_item( $admin_bar ); + + $this->add_onboarding_submenu_item( $admin_bar ); } /** @@ -528,7 +518,7 @@ protected function add_more_info_submenu_item( $admin_bar ) { ); // Free license info. - $prpl_free_license_key = \get_option( 'progress_planner_license_key', false ); + $prpl_free_license_key = \progress_planner()->get_license_key(); $admin_bar->add_node( [ 'id' => 'prpl-free-license', @@ -728,19 +718,6 @@ public function check_toggle_placeholder_demo() { exit; } - /** - * Get color customizer instance. - * - * @return \Progress_Planner\Utils\Color_Customizer - */ - public function get_color_customizer() { - static $color_customizer = null; - if ( null === $color_customizer ) { - $color_customizer = new Color_Customizer(); - } - return $color_customizer; - } - /** * Filter the tasks show UI. * @@ -753,4 +730,74 @@ public function filter_tasks_show_ui( $show_ui ) { } return $show_ui; } + + /** + * Add Onboarding submenu to the debug menu. + * + * @param \WP_Admin_Bar $admin_bar The WordPress admin bar object. + * @return void + */ + protected function add_onboarding_submenu_item( $admin_bar ) { + $admin_bar->add_node( + [ + 'id' => 'prpl-onboarding', + 'parent' => 'prpl-debug', + 'title' => 'Onboarding', + ] + ); + + // Start onboarding. + $admin_bar->add_node( + [ + 'id' => 'prpl-start-onboarding', + 'parent' => 'prpl-onboarding', + 'title' => 'Start Onboarding', + 'href' => '#', + 'meta' => [ + 'onclick' => 'window.prplOnboardWizard.startOnboarding(); return false;', + ], + ] + ); + + // Delete onboarding progress. + $admin_bar->add_node( + [ + 'id' => 'prpl-delete-onboarding-progress', + 'parent' => 'prpl-onboarding', + 'title' => 'Delete Onboarding Progress', + 'href' => \add_query_arg( 'prpl_delete_onboarding_progress', '1', $this->current_url ), + ] + ); + } + + /** + * Check and process the delete onboarding progress action. + * + * Deletes onboarding progress if the appropriate query parameter is set + * and user has required capabilities. + * + * @return void + */ + public function check_delete_onboarding_progress() { + if ( + ! isset( $_GET['prpl_delete_onboarding_progress'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $_GET['prpl_delete_onboarding_progress'] !== '1' || // phpcs:ignore WordPress.Security.NonceVerification.Recommended + ! \current_user_can( 'manage_options' ) + ) { + return; + } + + // Verify nonce for security. + $this->verify_nonce(); + + // Delete the onboarding progress. + \Progress_Planner\Onboard_Wizard::delete_progress(); + + // Delete the license key. + \delete_option( 'progress_planner_license_key' ); + + // Redirect to the same page without the parameter. + \wp_safe_redirect( \remove_query_arg( [ 'prpl_delete_onboarding_progress', '_wpnonce' ] ) ); + exit; + } } diff --git a/classes/utils/class-deprecations.php b/classes/utils/class-deprecations.php index b072229003..656ce4dc29 100644 --- a/classes/utils/class-deprecations.php +++ b/classes/utils/class-deprecations.php @@ -81,7 +81,6 @@ class Deprecations { 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Sample_Page' => [ 'Progress_Planner\Suggested_Tasks\Providers\Sample_Page', '1.4.0' ], 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Search_Engine_Visibility' => [ 'Progress_Planner\Suggested_Tasks\Providers\Search_Engine_Visibility', '1.4.0' ], 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Set_Valuable_Post_Types' => [ 'Progress_Planner\Suggested_Tasks\Providers\Set_Valuable_Post_Types', '1.4.0' ], - 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Settings_Saved' => [ 'Progress_Planner\Suggested_Tasks\Providers\Settings_Saved', '1.4.0' ], 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\One_Time\Site_Icon' => [ 'Progress_Planner\Suggested_Tasks\Providers\Site_Icon', '1.4.0' ], 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Repetitive\Core_Update' => [ 'Progress_Planner\Suggested_Tasks\Providers\Core_Update', '1.4.0' ], 'Progress_Planner\Suggested_Tasks\Local_Tasks\Providers\Repetitive\Create' => [ 'Progress_Planner\Suggested_Tasks\Providers\Repetitive\Create', '1.4.0' ], diff --git a/classes/utils/class-onboard.php b/classes/utils/class-onboard.php index 25ab718902..37655789da 100644 --- a/classes/utils/class-onboard.php +++ b/classes/utils/class-onboard.php @@ -29,7 +29,7 @@ public function __construct() { // Detect domain changes. \add_action( 'shutdown', [ $this, 'detect_site_url_changes' ] ); - if ( \get_option( 'progress_planner_license_key' ) ) { + if ( \progress_planner()->get_license_key() ) { return; } @@ -194,7 +194,7 @@ public function detect_site_url_changes() { return; } - $saved_license_key = \get_option( 'progress_planner_license_key', false ); + $saved_license_key = \progress_planner()->get_license_key(); // Bail early if the license key is not set, or if the site URL has not changed. if ( ! $saved_license_key || $saved_site_url === $current_site_url ) { diff --git a/classes/utils/class-playground.php b/classes/utils/class-playground.php index f201cde65b..130a004315 100644 --- a/classes/utils/class-playground.php +++ b/classes/utils/class-playground.php @@ -28,10 +28,9 @@ public function __construct() { * @return void */ public function register_hooks() { - if ( ! \get_option( 'progress_planner_license_key', false ) && ! \get_option( 'progress_planner_demo_data_generated', false ) ) { + if ( ! \progress_planner()->get_license_key() && ! \get_option( 'progress_planner_demo_data_generated', false ) ) { $this->generate_data(); \update_option( 'progress_planner_license_key', \str_replace( ' ', '-', $this->create_random_string( 20 ) ) ); - \update_option( 'progress_planner_force_show_onboarding', false ); \update_option( 'progress_planner_todo', [ @@ -48,7 +47,6 @@ public function register_hooks() { \update_option( 'progress_planner_demo_data_generated', true ); } \add_action( 'progress_planner_admin_page_header_before', [ $this, 'show_header_notice' ] ); - \add_action( 'wp_ajax_progress_planner_hide_onboarding', [ $this, 'hide_onboarding' ] ); \add_action( 'wp_ajax_progress_planner_show_onboarding', [ $this, 'show_onboarding' ] ); \progress_planner()->get_settings()->set( 'activation_date', ( new \DateTime() )->modify( '-2 months' )->format( 'Y-m-d' ) ); @@ -80,48 +78,23 @@ public function enable_debug_tools() { } /** - * Toggle the onboarding visibility in the Playground environment. - * - * @param string $action Either 'show' or 'hide'. + * Show the onboarding in the Playground environment. * * @return void */ - private function toggle_onboarding( $action ) { - $nonce_action = "progress_planner_{$action}_onboarding"; - \check_ajax_referer( $nonce_action, 'nonce' ); + public function show_onboarding() { + \check_ajax_referer( 'progress_planner_show_onboarding', 'nonce' ); if ( ! \current_user_can( 'manage_options' ) ) { \wp_die( \esc_html__( 'You do not have sufficient permissions to access this page.', 'progress-planner' ) ); } - if ( $action === 'hide' ) { - \add_option( 'progress_planner_license_key', \str_replace( ' ', '-', $this->create_random_string( 20 ) ) ); - $message = \esc_html__( 'Onboarding hidden successfully', 'progress-planner' ); - } else { - \delete_option( 'progress_planner_license_key' ); - $message = \esc_html__( 'Onboarding shown successfully', 'progress-planner' ); - } - \update_option( 'progress_planner_force_show_onboarding', $action !== 'hide' ); - - \wp_send_json_success( [ 'message' => $message ] ); - } - - /** - * Hide the onboarding in the Playground environment. - * - * @return void - */ - public function hide_onboarding() { - $this->toggle_onboarding( 'hide' ); - } + // Delete onboarding progress to trigger fresh onboarding. + \Progress_Planner\Onboard_Wizard::delete_progress(); + // Delete the license key to trigger onboarding (privacy not accepted). + \delete_option( 'progress_planner_license_key' ); - /** - * Show the onboarding in the Playground environment. - * - * @return void - */ - public function show_onboarding() { - $this->toggle_onboarding( 'show' ); + \wp_send_json_success( [ 'message' => \esc_html__( 'Onboarding shown successfully', 'progress-planner' ) ] ); } /** @@ -135,10 +108,7 @@ public function show_header_notice() { return; } - $show_onboarding = \get_option( 'progress_planner_force_show_onboarding', false ); - $button_text = $show_onboarding ? \__( 'Hide onboarding', 'progress-planner' ) : \__( 'Show onboarding', 'progress-planner' ); - $action = $show_onboarding ? 'hide' : 'show'; - $nonce = \wp_create_nonce( "progress_planner_{$action}_onboarding" ); + $nonce = \wp_create_nonce( 'progress_planner_show_onboarding' ); ?>
@@ -150,16 +120,16 @@ public function show_header_notice() {

-

'; + $result = $this->widget_instance->get_range(); + $this->assertStringNotContainsString( ''; + $result = $this->widget_instance->get_frequency(); + $this->assertStringNotContainsString( ''; + $reflection = new \ReflectionClass( $this->mock_instance ); + $method = $reflection->getMethod( 'get_sanitized_get' ); + $method->setAccessible( true ); + $result = $method->invoke( $this->mock_instance, 'test_key' ); + $this->assertStringNotContainsString( ''; + $reflection = new \ReflectionClass( $this->mock_instance ); + $method = $reflection->getMethod( 'get_sanitized_post' ); + $method->setAccessible( true ); + $result = $method->invoke( $this->mock_instance, 'test_key' ); + $this->assertStringNotContainsString( '', '', '', 'normal value' ]; + + $result = $this->mock_class->public_get_sanitized_post_array( 'test_key' ); + + $this->assertIsArray( $result ); + $this->assertStringNotContainsString( ''; + + $result = $this->mock_class->public_get_sanitized_request( 'test_key' ); + + $this->assertStringNotContainsString( '', '' ]; + + $result = $this->mock_class->public_get_sanitized_post_array( 'test_key' ); + + $this->assertIsArray( $result ); + $this->assertCount( 4, $result ); + $this->assertEquals( 'text', $result[0] ); + $this->assertEquals( '123', $result[1] ); + $this->assertStringNotContainsString( '' ); + + // Should not contain unescaped script tags. + $this->assertStringNotContainsString( ' diff --git a/views/onboarding/email-frequency.php b/views/onboarding/email-frequency.php new file mode 100644 index 0000000000..56fa275a59 --- /dev/null +++ b/views/onboarding/email-frequency.php @@ -0,0 +1,102 @@ +display_name ?? ''; +$prpl_user_email = $prpl_current_user->user_email ?? ''; +?> + + + diff --git a/views/onboarding/first-task.php b/views/onboarding/first-task.php new file mode 100644 index 0000000000..03aa65e390 --- /dev/null +++ b/views/onboarding/first-task.php @@ -0,0 +1,39 @@ + + + + diff --git a/views/onboarding/form-inputs/checkbox.php b/views/onboarding/form-inputs/checkbox.php new file mode 100644 index 0000000000..f8abf8b017 --- /dev/null +++ b/views/onboarding/form-inputs/checkbox.php @@ -0,0 +1,40 @@ + + +
+
+ + + + + +
+
diff --git a/views/onboarding/form-inputs/radio.php b/views/onboarding/form-inputs/radio.php new file mode 100644 index 0000000000..deb6a46e4b --- /dev/null +++ b/views/onboarding/form-inputs/radio.php @@ -0,0 +1,40 @@ + + +
+
+ + + + + +
+
diff --git a/views/onboarding/more-tasks.php b/views/onboarding/more-tasks.php new file mode 100644 index 0000000000..25ac10302d --- /dev/null +++ b/views/onboarding/more-tasks.php @@ -0,0 +1,80 @@ + + + + diff --git a/views/onboarding/quit-confirmation.php b/views/onboarding/quit-confirmation.php new file mode 100644 index 0000000000..b3f4c5e903 --- /dev/null +++ b/views/onboarding/quit-confirmation.php @@ -0,0 +1,46 @@ + + + + + diff --git a/views/onboarding/settings.php b/views/onboarding/settings.php new file mode 100644 index 0000000000..fa407bd9f0 --- /dev/null +++ b/views/onboarding/settings.php @@ -0,0 +1,206 @@ + [ + 'id' => 'homepage', + 'title' => __( 'Home page', 'progress-planner' ), + 'description' => \esc_html__( 'Help us understand your site a little better so we can give you more useful recommendations. Let\'s start with the home page.', 'progress-planner' ), + 'note' => __( 'A Home page is important. We\'ll remind you to make one at a later time.', 'progress-planner' ), + ], + 'about' => [ + 'id' => 'about', + 'title' => __( 'About page', 'progress-planner' ), + 'description' => \esc_html__( 'Next up, pick the page you use as your about page.', 'progress-planner' ), + 'note' => __( 'An About page is important. We\'ll remind you to make one at a later time.', 'progress-planner' ), + ], + 'contact' => [ + 'id' => 'contact', + 'title' => __( 'Contact page', 'progress-planner' ), + 'description' => \esc_html__( 'Now choose the page you use as your contact page.', 'progress-planner' ), + 'note' => __( 'A Contact page is important. We\'ll remind you to make one at a later time.', 'progress-planner' ), + ], + 'faq' => [ + 'id' => 'faq', + 'title' => __( 'FAQ page', 'progress-planner' ), + 'description' => \esc_html__( 'Next, pick the page you use as your FAQ page.', 'progress-planner' ), + 'note' => __( 'An FAQ page is important. We\'ll remind you to make one at a later time.', 'progress-planner' ), + ], +]; + +// Get post types for the post types sub-step. +$prpl_saved_settings = \progress_planner()->get_settings()->get_post_types_names(); +$prpl_post_types = \progress_planner()->get_settings()->get_public_post_types(); + +$prpl_total_number_of_steps = 5; +$prpl_current_step_number = 0; + +?> + + + diff --git a/views/onboarding/tasks/core-blogdescription.php b/views/onboarding/tasks/core-blogdescription.php new file mode 100644 index 0000000000..2b5ac9b2ce --- /dev/null +++ b/views/onboarding/tasks/core-blogdescription.php @@ -0,0 +1,35 @@ + + +
+
+

+ +

+

+ +

+
+
+ + +
+
diff --git a/views/onboarding/tasks/core-siteicon.php b/views/onboarding/tasks/core-siteicon.php new file mode 100644 index 0000000000..eb4ad338ee --- /dev/null +++ b/views/onboarding/tasks/core-siteicon.php @@ -0,0 +1,52 @@ + +
+

+ +

+

+ +

+
+ +
+ + the_file( 'assets/images/onboarding/icon_image.svg' ); ?> + +

+ ', + '' + ); + ?> +

+ + PNG, ICO, WEBP +
+

+ + +
+
+ +
+ +
+
diff --git a/views/onboarding/tasks/select-locale.php b/views/onboarding/tasks/select-locale.php new file mode 100644 index 0000000000..4aea6c9402 --- /dev/null +++ b/views/onboarding/tasks/select-locale.php @@ -0,0 +1,60 @@ + + +
+

+ +

+

+ Venenatis parturient suspendisse massa cursus litora dapibus auctor, et vestibulum blandit condimentum quis ultrices sagittis aliquam. +

+
+ 'language', + 'id' => 'language', + 'selected' => $prpl_locale, + 'languages' => $prpl_languages, + 'translations' => $prpl_translations, + 'show_available_translations' => \current_user_can( 'install_languages' ) && \wp_can_install_language_pack(), + 'echo' => true, + ] + ); + ?> + +
+
diff --git a/views/onboarding/tasks/select-timezone.php b/views/onboarding/tasks/select-timezone.php new file mode 100644 index 0000000000..583f7073eb --- /dev/null +++ b/views/onboarding/tasks/select-timezone.php @@ -0,0 +1,34 @@ + + +
+

+ +

+

+ Venenatis parturient suspendisse massa cursus litora dapibus auctor, et vestibulum blandit condimentum quis ultrices sagittis aliquam. +

+
+ + +
+
diff --git a/views/onboarding/welcome.php b/views/onboarding/welcome.php new file mode 100644 index 0000000000..9008432178 --- /dev/null +++ b/views/onboarding/welcome.php @@ -0,0 +1,83 @@ + + + + diff --git a/views/onboarding/whats-what.php b/views/onboarding/whats-what.php new file mode 100644 index 0000000000..890e885e67 --- /dev/null +++ b/views/onboarding/whats-what.php @@ -0,0 +1,58 @@ + + + + diff --git a/views/page-settings/pages.php b/views/page-settings/pages.php deleted file mode 100644 index 990f84b17d..0000000000 --- a/views/page-settings/pages.php +++ /dev/null @@ -1,33 +0,0 @@ - - -
-

- - the_asset( 'images/icon_pages.svg' ); ?> - - - - -

-

- -

-
- get_admin__page_settings()->get_settings() as $prpl_setting ) { - \progress_planner()->the_view( "setting/{$prpl_setting['type']}.php", [ 'prpl_setting' => $prpl_setting ] ); - } - ?> -
-
diff --git a/views/page-settings/post-types.php b/views/page-settings/post-types.php deleted file mode 100644 index cbd19d561e..0000000000 --- a/views/page-settings/post-types.php +++ /dev/null @@ -1,52 +0,0 @@ -get_settings()->get_post_types_names(); -$prpl_post_types = \progress_planner()->get_settings()->get_public_post_types(); - -// Early exit if there are no public post types. -if ( empty( $prpl_post_types ) ) { - return; -} - -// We use it in order to change grid layout when there are more than 5 valuable post types. -$prpl_data_attributes = 5 < \count( $prpl_post_types ) ? 'data-has-many-valuable-post-types' : ''; -?> - -
> -

- - the_asset( 'images/icon_copywriting.svg' ); ?> - - - - -

-
-

- -

-
- - - -
-
-
diff --git a/views/page-settings/settings.php b/views/page-settings/settings.php deleted file mode 100644 index 928c1149d6..0000000000 --- a/views/page-settings/settings.php +++ /dev/null @@ -1,38 +0,0 @@ - - - diff --git a/views/setting/page-select.php b/views/setting/page-select.php index fbae1937d7..405b703bc0 100644 --- a/views/setting/page-select.php +++ b/views/setting/page-select.php @@ -9,13 +9,12 @@ if ( ! \defined( 'ABSPATH' ) ) { exit; } - $prpl_setting_value = isset( $prpl_setting['value'] ) ? $prpl_setting['value'] : ''; +$prpl_context = isset( $context ) ? $context : ''; // Default value for the radio button. $prpl_radio_value = '_no_page_needed' === $prpl_setting_value ? 'not-applicable' : 'no'; $prpl_radio_value = \is_numeric( $prpl_setting_value ) && 0 < $prpl_setting_value ? 'yes' : $prpl_radio_value; - ?>