Skip to content

Fix: All Popups bulk actions fail when categories/tags exist (#1175)#1176

Closed
marklchaves wants to merge 58 commits intomasterfrom
develop
Closed

Fix: All Popups bulk actions fail when categories/tags exist (#1175)#1176
marklchaves wants to merge 58 commits intomasterfrom
develop

Conversation

@marklchaves
Copy link
Contributor

@marklchaves marklchaves commented Feb 14, 2026

Description

  • Changed the popup filter nonce to use a unique field name so it no longer overwrites core _wpnonce.
  • Fixed inverted nonce-verification logic used when restoring selected filter values.
  • Removed duplicate nonce output for category/tag filters.
  • Added unit tests to cover nonce output and selected-value restoration.

Related Issue: https://github.com//issues/1175

Fixes: Fixes #1175

Types of changes

What I changed

[classes/Admin/Popups.php add_popup_filters() method (lines ~1590-1636)]

  • Use wp_nonce_field( 'pum-popup-filter-nonce', 'pum_filter_nonce' ) (unique field name)
  • Verify $_GET['pum_filter_nonce'] with wp_verify_nonce() (logic corrected)
  • Removed duplicate nonce output for tags/categories

Tests

new unit tests that assert:

  • pum_filter_nonce is output (no _wpnonce collision)
  • selected filter values are restored only when the custom nonce is valid

Types of changes
✅ Bug fix (non-breaking change)

Screenshots

https://jam.dev/c/34f02751-a401-4081-9a9b-7b999be17cb7

This has been tested in the following browsers

  • Chrome
  • Firefox
  • Edge
  • Safari

Merge Checklist

  • This PR passes all automated checks (will appear once pull request is submitted)
  • My code has been tested in the latest version of WordPress.
  • My code does not have any warnings from ESLint.
  • My code does not have any warnings from StyleLint.
  • My code does not have any warnings from PHPCS.
  • My code follows the WordPress coding standards.
  • My code follows the accessibility standards.
  • All new functions and classes have code documentation.

- Fixed the issue where the license key was not deactivating properly.
- Removed unnecessary checks in the license key handling logic.
- Added support for Pro licenses in the license management system.
- Introduced methods to check for active Pro licenses and retrieve Pro license keys and tiers.
- Updated settings and templates to reflect Pro license status, improving user experience by providing relevant notices.
- Adjusted license activation and deactivation logic to skip actions when a Pro license is active.
- Enhanced data structures to include Pro license information in relevant fields.

Testing confirmed:
✅ Pro license checks function correctly
✅ User notices display appropriately based on license status
- Changed the `replace_editor` action to a filter to ensure proper value return.
- Updated the `replace_editor` method to return the original value when not handling specific post types.
- Added entry to CHANGELOG.md for the fix related to the filter usage.

Testing confirmed:
✅ Filter now correctly returns the value as expected.
- Renamed methods related to Pro license checks for consistency and clarity, changing `has_active_pro_license` to `has_pro_license`.
- Updated references throughout the `License.php` class to reflect the new method names.
- Adjusted comments for better understanding of the code's functionality.
- Updated CHANGELOG.md to document the fix regarding license key display issues.

Testing confirmed:
✅ License management functions correctly with updated method names.
Fixed a critical compatibility issue where the popup editor would fail to load when the block editor was enabled in Divi 4. The classic editor is now automatically enforced for Divi 4 users to resolve the conflict.

Changes:
- Updated Divi compatibility controller to force classic editor for Divi 4 (versions 4.0.0-4.99.99)
- Hook at priority 999 to execute after Divi 4's block editor filter
- Disable and auto-check the "Use Classic Editor" setting for Divi 4 users
- Add visible warning explaining Divi 4 compatibility requirement
- Updated changelog with user-friendly description

Fixes the blank popup editor issue that only affects Divi 4 users with block editor enabled.
Adds native support for Newsletter plugin subscription forms:
- PHP integration class extending PUM_Abstract_Integration_Form
- JavaScript handler for AJAX form submission success events
- Registered in core integrations array

Follows modern integration pattern (FluentForms, WSForms, MC4WP)
Remove SUCCESS_PATTERNS matching - client-side validation means
missing inputs are sufficient evidence of successful submission.
feat: Add Newsletter plugin form integration
- Added improvements to the CHANGELOG.md, highlighting the integration of AJAX submission for the Newsletter plugin.
- Refactored block asset loading in the BlockEditor class to ensure block styles are only loaded when necessary, optimizing performance by leveraging WordPress's automatic loading mechanism for block styles.
Configure CodeRabbit to auto-review PRs targeting develop branch.
Since develop is automatically merged to master during releases,
we need PR reviews before merging to develop.
All PRs target develop; master is updated via automated release merges.
- Add unified CI workflow with PHP, JS/TS, and CSS quality checks
- Only lint changed files for faster feedback
- Add build validation to catch webpack errors
- Add dependency tree validation
- Add security scanning with composer/npm audit
- Disable PHPUnit tests until test suite is comprehensive
- Fix PHPUnit dependency conflicts with --ignore-platform-reqs
- Add TypeScript type checking to CI pipeline

Jobs:
- PHP Quality: PHPCS + PHPStan on changed files (PHP 7.4-8.2)
- JS/TS Quality: ESLint + Stylelint + TypeScript checks
- Build Validation: webpack build + dependency tree validation
- Security: composer audit + npm audit
- PHPUnit: Disabled (ready to enable when needed)

Files:
- New: .github/workflows/ci.yml (315 lines, 5 jobs)
- Disabled: phpcs-tests.yml → phpcs-tests.yml.disabled
- Disabled: phpunit-tests.yml → phpunit-tests.yml.disabled
- Updated: composer.json (added lint:changed script)
- Updated: package.json (added lint:js:changed, lint:style:changed)
- Updated Storybook related packages in package.json, package-lock.json, and packages/icons/package.json to version 8.6.14 for improved features and bug fixes.
- Ensured consistency across all relevant files for dependency management.
- Upgrade actions/checkout from v4 to v6
- Upgrade actions/setup-node from v4 to v6
- Replace manual composer caching with built-in cache: composer
- Replace composer update with composer install for deterministic builds
- Add cache: npm to setup-node in security job

All actions now on latest stable versions with Node.js 24 runtime
support. No breaking changes for existing workflow patterns.
Establish automated release foundation with commit message enforcement and standardized tooling.

## Features
- Commitlint + husky for conventional commit enforcement
- AI attribution blocking (local hooks + CI validation)
- Expanded commit types/scopes aligned with issue labels
- Release scripts standardization (version-update, changelog-update, extract-changelog)
- Reusable GitHub Actions cache workflows

## Technical Changes
- Add @commitlint/cli and @commitlint/config-conventional
- Add husky for git hooks management
- New commitlint.yml CI workflow
- Updated labels.json with release-related labels

Closes #1136
Add centralized detect-changes job that gates expensive CI jobs:
- php-quality: only runs when PHP files change
- js-quality: only runs when JS/CSS files change
- build-validation: only runs when JS or npm deps change
- npm-security: only runs when package.json/lock change
- php-security: only runs when composer.json/lock change

This significantly speeds up CI for:
- PHP-only PRs (skip npm ci, build, JS linting)
- JS-only PRs (skip composer, PHP linting)
- Config/docs PRs (skip all expensive checks)
The attribution check is enforced by local git hooks - by the time
a PR comment appears, commits are already pushed and can't be undone.
Removed redundant/unhelpful notice from the PR comment message.
Allows proper nouns, project names, and acronyms in commit subjects.
Only ALL_CAPS is warned against.
* feat(release): Phase 2 - Release Please integration

Add automated version calculation and release PR creation:
- release-please.yml workflow for weekly release PR creation
- Configuration files for semantic versioning from commits
- Integration documentation

Part of automated release process implementation.

* fix(release): use weekly schedule instead of push trigger

Release Please now runs on Monday 9am UTC schedule to aggregate
all commits into a single weekly release PR, rather than updating
on every push to develop.

Manual trigger (workflow_dispatch) available for testing.

* fix(release): address CodeRabbit review items

- Update action to googleapis/release-please-action@v4 (actively maintained)
- Fix version output to avoid malformed '..' when no release created
- Remove extra-files config, use update-versions.js instead (battle-tested)
- Add workflow steps to run npm run version:update on release
- Fix WordPress.org capitalization in docs
- Update docs to reflect weekly schedule behavior
- request-approval.yml: Interactive approve/reject to #dev-releases
- notify-support.yml: Customer changelog to #support-updates
- Both support dry-run testing via workflow_dispatch
Enables Popup Maker conversion tracking and trigger actions for Forminator form submissions.
Enables conversion tracking and trigger actions for Elementor Pro form submissions with support for targeting specific forms.
danieliser and others added 26 commits January 11, 2026 14:38
Enables conversion tracking for Bit Form with custom storage handling and form instance identification.
Enables conversion tracking for HTML Forms with lightweight form submission detection.
Adds form submission tracking and conversion analytics for Beaver Builder page builder forms.

**Supported form types:**
- Contact Form module
- Subscribe Form module  
- Login Form module

**Implementation:**
- JavaScript tracks AJAX form submissions via `ajaxComplete` event
- Detects success responses from `fl_builder_email` and `fl_builder_subscribe_form_submit` actions
- Forms tracked by type + node ID pattern (e.g., `contact_any`, `subscribe_any`)

Closes #1136
- Use form plugins' own text domains for labels with phpcs:ignore
- Convert self:: to $this-> for instance method calls
- Use if ($popup_id) pattern for conversion tracking
- Form submission always fires regardless of popup context
Reduces vulnerabilities: 26 → 14 (high: 15 → 3)
Co-authored-by: Daniel L. Iser <daniel@code-atlantic.com>
Clarify instructions for the Get Element Selector feature - remove reference to non-existent 'ok' button and use more direct language.

Props to @marklchaves.
Allows administrators to sort popups by their enabled/disabled status in the admin list table, making it easier to find and manage active or inactive popups.
Allow admins to enable or disable multiple popups at once from the All Popups list table using the Bulk Actions dropdown. Only published popups can be toggled (matches UI behavior). Displays success notice with count of affected and skipped popups.
Adds comprehensive JSDoc documentation to all functions in the changelog extraction utility script.
- Add lint-staged to run linters on staged files only
- Configure pre-commit hook with lint-staged
- PHP: composer format + lint
- JS/TS/CSS: wp-scripts format + lint --fix
Replaces attribute filters with tag filters for complete script/style ID obfuscation:

- Use script_loader_tag, style_loader_tag, wp_inline_script_attributes filters
- Add generate_obfuscated_handle() for DRY ID generation
- Catches ALL script output: main, -js-extra, -js-before, -js-after, -js-translations
- Default obfuscation method changed from 'random' to 'custom' for predictability
- Consistent defaults across AssetCache.php, Settings.php, and Analytics.php

Closes #1155
feat: add UTM tracking to all upsell upgrade links

Created generate_upgrade_url() helper function for consistent UTM parameter
application across all upsell touchpoints. Updated notice bar messages to use
contextual UTM campaigns (ecommerce-detected, lms-detected, crm-detected,
pro-generic-upgrade, free-generic-upgrade).

All upgrade links now open in new tab with proper security attributes and
route to pricing page with UTM tracking for analytics.

Enables measurement of which upsell messages drive traffic and conversions.
Fixes blank tab display when switching from Click Trigger advanced tab to Time Delay trigger.

Closes #1109
Strips existing protocols from HTTP_HOST before URL construction to prevent malformed URLs like https://http//site.com in pagination and sorting links.

Closes #1092
Adds defensive validation to handle Fluent Forms passing stdClass instead of array during double opt-in submission.

Closes #1094
)

The detect-changes job uses `files_yaml:` which outputs properties
prefixed with the category name (e.g., `php_any_changed`), not just
the category name (e.g., `php`).

Added documentation comments explaining the two tj-actions/changed-files
output patterns used in this workflow:

- `files_yaml:` → outputs are `{category}_any_changed`
- `files:` → outputs are `any_changed` (no prefix)

This was causing all gated jobs to skip because the outputs were
always undefined.
* fix(build): improve CI quality check accuracy

1. PHPCS: Only fail on errors, not warnings
   - Added --warning-severity=0 flag
   - Warnings still shown but don't block merges

2. Separate JS detection for different purposes:
   - `js`: All JS/TS files (for ESLint)
   - `js_src`: Source files only (packages/**/src + assets/js/src)

3. Build/TypeScript only on source changes:
   - Build Validation: triggers on js_src + npm_deps
   - TypeScript check: triggers on js_src only
   - ESLint: triggers on all JS/TS (config, tooling, etc.)

* ci: reduce PHP matrix to min+latest, narrow CSS scope to source files

* ci: remove no-op push trigger and invalid cache input

* chore: fix coderabbit config schema
- js_src: All source JS/TS files (triggers build validation)
- ts_src: TypeScript only (triggers tsc type checking)

Plain JS files now trigger build but not TypeScript checks.
PHPUnit dev dep uses PHP 8+ union type syntax which causes parse
errors when PHP 7.4 tries to load vendor autoloader for PHPCS.
Code compatibility is the same regardless of PHP version running PHPCS.
* fix: exclude mailto/tel URLs from PID parameter tracking

Non-HTTP protocols like mailto:, tel:, and javascript: cannot handle
query parameters. The ?pid= was being appended incorrectly, breaking
these special links.

Added regex check to skip any URL with a protocol that isn't http(s).

* feat: add link click conversion tracking for external links

- Fire beacon analytics for mailto, tel, and external link clicks
- Track link clicks as conversions with eventData.type: 'link_click'
- Add LinkClickTracking service with atomic counter updates
- Preserve existing PID tracking for internal links only

* refactor: use %i placeholder for table identifiers (WP 6.2+)

Modernizes SQL queries to use wpdb::prepare() %i placeholder
for table name identifiers instead of PHP string interpolation.

* docs: add changelog entries for link click tracking feature

* fix: run PID tracking at priority 0 to beat competing plugins

Ensures template_redirect fires before other plugins that might
intercept/redirect, preventing missed tracking events.
* feat: add form conversion tracking service

- Created FormConversionTracking service to track form submissions
- Hooks into pum_integrated_form_submission action
- Tracks both site-wide and per-popup conversion counts
- Stores counts in wp_options (site) and post_meta (popup)
- Registered service in DI container and initialized on plugin load
- Includes helper methods: get_site_count(), get_popup_count()
- Includes reset methods for testing/data cleanup
- TODO: Send to Supabase v4 when tracking system is ready

This enables:
- Milestone-based upsell triggers (e.g., "congrats on first form!")
- Future analytics dashboard showing form conversion rates
- Per-popup performance metrics for power users

Addresses: Quick win for Monday release - form conversion foundation

* fix: only track form conversions captured by popups

- Add early return when popup_id is empty or invalid
- Prevents counting forms submitted outside of popups
- Ensures site-wide count only includes popup-captured submissions
- Simplifies logic by handling popup_id once instead of twice

* docs: update FormConversionTracking version tags and docblock

- Change @SInCE tags from 1.21.3 to X.X.X for release versioning
- Remove opt-in usage tracking reference from class docblock
- Simplify class description to focus on core functionality

* chore: add commitlint as dev dependency

- Auto-installed during commit message validation
- Required for commit message format enforcement

* fix: track AJAX form submissions via analytics beacon

- Add hook to pum_analytics_conversion for AJAX submissions
- Non-AJAX submissions continue via pum_integrated_form_submission
- Ensures all form submissions are tracked regardless of method
- Resolves issue where AJAX forms weren't being counted

* fix: FormConversionTracking now only tracks forms using eventData pattern

- Adopt Pro's eventData structure for form metadata
- Filter AJAX tracking by eventData.type === 'form_submission'
- Prevents counting CTAs and link clicks as form submissions
- Maintains Pro compatibility (Pro removes handler entirely)
- Non-AJAX forms still tracked via pum_integrated_form_submission

* feat: add REST endpoint eventData sanitization and FluentForms integration

- Add eventData parameter to Analytics REST endpoint with JSON decode sanitization
  (matches Pro's Request pattern)
- Implement FluentForms integration with defensive handling of double-event firing
- Clean FormConversionTracking implementation expecting pre-decoded eventData array
- Add TODO in Telemetry for future usage tracking inclusion

This completes the form conversion tracking feature with proper architecture matching
Pro's proven eventData handling approach.

* feat: add popup existence validation before tracking form conversions

Prevents orphaned postmeta entries for deleted/invalid popups.
Logs validation failures only in WP_DEBUG mode without breaking form submissions.
Tracking is non-critical and should fail gracefully.

* chore: sync package-lock.json with package.json

* fix: address CodeRabbit review feedback

- Add defensive validation to track_form_conversion() and track_ajax_conversion()
- Fix race condition: use atomic SQL updates for increment operations
- Add cache flushing after direct wpdb updates
- Set autoload=false for analytical data option
- Remove duplicate commitlint dependency (keep @commitlint/cli)

* chore: regenerate package-lock.json for CI compatibility

* fix: resolve ESLint formatting and console warnings

* refactor: apply CodeRabbit suggestions and LinkClickTracking patterns

- Replace get_post() with pum_get_popup() + pum_is_popup() for proper validation
- Use %i placeholder for table names in SQL queries (modern wpdb pattern)
- Add popup_maker/form_conversion_tracked action after tracking (extensibility)
Changed %i (identifier/backticks) to %s (string/quotes) in SHOW TABLES
LIKE query. The %i placeholder wraps values in backticks which breaks
the SHOW TABLES LIKE syntax that expects a quoted string pattern.
@coderabbitai
Copy link

coderabbitai bot commented Feb 14, 2026

Warning

Ignoring CodeRabbit configuration file changes. For security, only the configuration from the base branch is applied for open source repositories.

📝 Walkthrough

Walkthrough

This pull request introduces comprehensive form submission tracking, analytics enhancements, CI/CD automation, and admin features. Eight form builder integrations (Beaver Builder, Elementor, Forminator, HappyForms, HTML Forms, Kali Forms, Bit Form, Newsletter) enable client-side and server-side form submission tracking. New LinkClickTracking and FormConversionTracking services provide analytics capabilities. Release automation via Release Please and GitHub Actions workflows streamline versioning and code quality checks. Husky hooks and commitlint enforce commit standards. Admin bulk actions, license management improvements, and asset obfuscation enhancements round out the changes.

Changes

Cohort / File(s) Summary
CI/CD Automation
.github/workflows/ci.yml, .github/workflows/commitlint.yml, .github/workflows/release-please.yml, .github/workflows/notify-support.yml, .github/workflows/request-approval.yml, .coderabbit.yaml
Introduces comprehensive GitHub Actions workflows for code quality gates (PHP/JS/TS linting, security audits), commit validation, release automation via Release Please, and Slack notifications for support and approval workflows.
Release Management
.release-please-config.json, .release-please-manifest.json, bin/extract-changelog.js, docs/release-please-integration.md
Adds Release Please configuration, changelog extraction CLI tool, and integration documentation for automated semantic versioning and release workflows.
Commit & Code Standards
.husky/commit-msg, .husky/pre-commit, commitlint.config.js
Establishes pre-commit and commit-msg Husky hooks to prevent AI-generated commits and enforce conventional commit standards via commitlint with project-specific type and scope rules.
Form Integrations (Client-side)
assets/js/src/integration/beaverbuilder.js, assets/js/src/integration/bitform.js, assets/js/src/integration/elementor.js, assets/js/src/integration/forminator.js, assets/js/src/integration/happyforms.js, assets/js/src/integration/htmlforms.js, assets/js/src/integration/kaliForms.js, assets/js/src/integration/newsletter.js, assets/js/src/integration/index.js
Adds client-side JavaScript integrations for eight form builders/plugins, each hooking into respective submission events and reporting form data to central PUM integration handler.
Form Integrations (Server-side)
classes/Integration/Form/BeaverBuilder.php, classes/Integration/Form/BitForm.php, classes/Integration/Form/Elementor.php, classes/Integration/Form/Forminator.php, classes/Integration/Form/HappyForms.php, classes/Integration/Form/HTMLForms.php, classes/Integration/Form/KaliForms.php, classes/Integration/Form/Newsletter.php
Introduces PHP integration classes for form providers enabling form discovery, metadata exposure, and submission handling for conversions and telemetry.
Existing Form Integrations (Enhanced)
assets/js/src/integration/calderaforms.js, assets/js/src/integration/fluentforms.js, assets/js/src/integration/formidableforms.js, assets/js/src/integration/ninjaforms.js, classes/Integration/Form/CalderaForms.php, classes/Integration/Form/ContactForm7.php, classes/Integration/Form/FluentForms.php, classes/Integration/Form/FormidableForms.php, classes/Integration/Form/GravityForms.php, classes/Integration/Form/NinjaForms.php, classes/Integration/Form/WPForms.php
Refactors existing form integrations to use instance methods instead of static calls, adds defensive input validation, guards conversion tracking with popup ID checks, and improves text domain consistency.
Analytics & Tracking Services
classes/Services/LinkClickTracking.php, classes/Services/FormConversionTracking.php, assets/js/src/site/plugins/pum-analytics.js, assets/js/src/site/plugins/pum-url-tracking.js, classes/Analytics.php
Adds new tracking services with atomic database operations for link clicks and form conversions; extends URL tracking with external link click detection and type categorization; enhances analytics payload with event metadata.
Admin Features
classes/Admin/Popups.php, classes/Admin/Settings.php, classes/Admin/Templates.php, classes/Admin/BlockEditor.php
Introduces bulk enable/disable actions, improves license key handling with Pro license detection, adds Pro license UI templates, and restricts block editor JavaScript to admin context.
Compatibility & Controllers
classes/Controllers/Compatibility/Builder/Divi.php, classes/Controllers/PostTypes.php, classes/Controllers/Assets.php, classes/Controllers/CallToActions.php, classes/Controllers/WP/Dashboard.php
Adds Divi 4-specific block editor compatibility; fixes replace_editor filter semantics; updates admin bar CSS instruction text; adjusts template_redirect hook priority for PID tracking; changes dashboard permission checks to use dynamic capabilities.
License & Extension Management
classes/Extension/License.php, classes/Upsell.php
Implements Pro license precedence flow with fallback to extension-specific licenses; adds dynamic upgrade URL generation with UTM tracking; refactors hardcoded upgrade links to use generated URLs with proper attributes.
Asset Management
classes/AssetCache.php
Extends asset caching with frontend ID obfuscation for WordPress script/style tags and inline scripts; supports custom filename schemes alongside random obfuscation; filters only Popup Maker handles.
Database & Utilities
classes/DB/Subscribers.php, classes/ListTable.php, assets/js/src/admin/general/plugins/tabs.js, includes/namespaced/core.php, includes/integrations/class-pum-woocommerce-integration.php
Aligns SQL placeholder types for WordPress 6.2+; fixes URL construction by stripping protocols; improves tab restoration with existence validation; adds generate_upgrade_url helper; refactors WooCommerce condition labels with localized text domain.
Service Registration
classes/Plugin/Core.php, classes/Integrations.php, classes/Telemetry.php
Registers LinkClickTracking and FormConversionTracking services in container and initializes them; adds new form integrations to registration map; includes placeholder for form conversion telemetry.
Dependencies & Configuration
package.json, packages/fields/src/..., packages/types/src/..., packages/icons/package.json, composer.json, phpstan.neon.dist
Updates Storybook to 8.6.14, adds commitlint/husky tooling, bumps WordPress scripts to ^31.2.0; adds lint-staged configuration with PHP/JS/SCSS pipelines; adds lint:changed composer script and prepare-release npm scripts; extends license field props with Pro license flags; adds commented complexity configuration to PHPStan.
Documentation & Metadata
CHANGELOG.md, readme.txt
Adds Unreleased section documenting new form integrations, tracking improvements, and bug fixes; updates WordPress compatibility to 6.9.0 and links form plugin integrations.

Sequence Diagram(s)

sequenceDiagram
    participant User as User Submits Form
    participant Form as Form Plugin<br/>(e.g., Gravity Forms)
    participant JS as Client-side<br/>Integration
    participant Handler as PUM Integration<br/>Handler
    participant Beacon as Analytics<br/>Beacon

    User->>Form: Submit Form
    Form->>JS: Trigger Success Event
    JS->>JS: Extract form ID &<br/>submission data
    JS->>Handler: formSubmission(provider,<br/>formId, data)
    Handler->>Handler: Store form event<br/>in memory
    Handler->>Beacon: Send conversion<br/>beacon payload
    Beacon->>Beacon: Increment form<br/>conversion counts
    Note over Beacon: Site-wide & Per-popup<br/>conversion tracking
Loading
sequenceDiagram
    participant User as User Clicks Link
    participant Page as Web Page
    participant ClickHandler as URL Tracking<br/>Plugin
    participant Beacon as Analytics<br/>Beacon
    participant Analytics as Analytics<br/>Service

    User->>Page: Click Link
    Page->>ClickHandler: Link Click Event
    alt Internal Link
        ClickHandler->>ClickHandler: Append PID param
        ClickHandler->>Page: Navigate with PID
    else External Link
        ClickHandler->>ClickHandler: Categorize link type<br/>(external, mailto, tel, etc.)
        ClickHandler->>Beacon: Send click beacon<br/>(URL, type, PID)
        Beacon->>Analytics: Track link click<br/>conversion
        Analytics->>Analytics: Increment link click<br/>counts
        ClickHandler->>Page: Navigate original URL
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 Eight forms now dance with Popups bright,
Tracking clicks and conversions right,
Release Please automates our flow,
While Husky guards each commit's glow—
From link to form, analytics gleam! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR contains extensive out-of-scope changes unrelated to fixing issue #1175, including major feature additions (form integrations, release automation, CI/CD workflows, analytics tracking) that should not be in a bug-fix commit. Extract the nonce-field fix from classes/Admin/Popups.php into a separate, focused PR for issue #1175. Move feature additions (form integrations, bulk actions UI, license handling, release workflows) to their own PRs with corresponding issue links.
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (45 files):

⚔️ CHANGELOG.md (content)
⚔️ assets/js/src/admin/general/plugins/tabs.js (content)
⚔️ assets/js/src/integration/calderaforms.js (content)
⚔️ assets/js/src/integration/fluentforms.js (content)
⚔️ assets/js/src/integration/formidableforms.js (content)
⚔️ assets/js/src/integration/index.js (content)
⚔️ assets/js/src/integration/ninjaforms.js (content)
⚔️ assets/js/src/site/plugins/pum-analytics.js (content)
⚔️ assets/js/src/site/plugins/pum-url-tracking.js (content)
⚔️ classes/Admin/BlockEditor.php (content)
⚔️ classes/Admin/Popups.php (content)
⚔️ classes/Admin/Settings.php (content)
⚔️ classes/Admin/Templates.php (content)
⚔️ classes/Analytics.php (content)
⚔️ classes/AssetCache.php (content)
⚔️ classes/Controllers/Assets.php (content)
⚔️ classes/Controllers/CallToActions.php (content)
⚔️ classes/Controllers/Compatibility/Builder/Divi.php (content)
⚔️ classes/Controllers/PostTypes.php (content)
⚔️ classes/Controllers/WP/Dashboard.php (content)
⚔️ classes/DB/Subscribers.php (content)
⚔️ classes/Extension/License.php (content)
⚔️ classes/Integration/Form/CalderaForms.php (content)
⚔️ classes/Integration/Form/ContactForm7.php (content)
⚔️ classes/Integration/Form/FluentForms.php (content)
⚔️ classes/Integration/Form/FormidableForms.php (content)
⚔️ classes/Integration/Form/GravityForms.php (content)
⚔️ classes/Integration/Form/NinjaForms.php (content)
⚔️ classes/Integration/Form/WPForms.php (content)
⚔️ classes/Integrations.php (content)
⚔️ classes/ListTable.php (content)
⚔️ classes/Plugin/Core.php (content)
⚔️ classes/Telemetry.php (content)
⚔️ classes/Upsell.php (content)
⚔️ composer.json (content)
⚔️ includes/integrations/class-pum-woocommerce-integration.php (content)
⚔️ includes/namespaced/core.php (content)
⚔️ package-lock.json (content)
⚔️ package.json (content)
⚔️ packages/fields/src/lib/utils.tsx (content)
⚔️ packages/fields/src/types/fields.ts (content)
⚔️ packages/icons/package.json (content)
⚔️ packages/types/src/index.d.ts (content)
⚔️ phpstan.neon.dist (content)
⚔️ readme.txt (content)

These conflicts must be resolved before merging into master.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'Fix: All Popups bulk actions fail when categories/tags exist (#1175)' is clear, concise, and directly describes the primary change—fixing a bug where bulk actions fail when popup categories or tags are present.
Linked Issues check ✅ Passed The PR implements all coding requirements from issue #1175: changed wp_nonce_field to use unique field name 'pum_filter_nonce', removed duplicate nonce output, corrected inverted verification logic, and updated GET parameter checks to use the new field name.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch develop
⚔️ Resolve merge conflicts (beta)
  • Auto-commit resolved conflicts to branch develop
  • Create stacked PR with resolved conflicts
  • Post resolved changes as copyable diffs in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
classes/Controllers/WP/Dashboard.php (1)

40-47: ⚠️ Potential issue | 🟡 Minor

Widget still renders an empty box for users who lack edit_popups capability.

register_dashboard_widgets registers the widget for everyone, but render_basic_analytics_widget returns early for unauthorized users. This leaves an empty "Popup Analytics" widget on the dashboard. Move the capability check to registration so the widget is not added at all for unauthorized users.

Proposed fix
 public function register_dashboard_widgets() {
+    if ( ! current_user_can( $this->container->get_permission( 'edit_popups' ) ) ) {
+        return;
+    }
+
     // Basic Analytics Widget (will be moved to free plugin).
     wp_add_dashboard_widget(
         'pum_analytics_basic',
         __( 'Popup Analytics','popup-maker' ),
         [ $this, 'render_basic_analytics_widget' ]
     );
 }

You can keep the check in render_basic_analytics_widget as defense-in-depth, or remove it once registration is gated.

Also applies to: 74-78

classes/Admin/Popups.php (1)

1590-1636: ⚠️ Potential issue | 🔴 Critical

Critical: The root-cause fix for issue #1175 is missing from add_popup_filters().

This method contains all four bugs described in the linked issue, and none have been addressed:

  1. Lines 1612 & 1633: wp_nonce_field('pum-popup-filter-nonce') omits the second parameter, so it defaults to _wpnonce — overwriting WordPress core's bulk-action nonce and causing the "link expired" error.
  2. Lines 1601 & 1622: Nonce verification is inverted (! wp_verify_nonce(...)) — filter selections are restored only when the nonce is invalid.
  3. Lines 1601 & 1622: Still checks $_GET['_wpnonce'] instead of the custom pum_filter_nonce field.
  4. Duplicate nonce output: Both the category and tag blocks emit their own wp_nonce_field, producing two hidden fields with the same name.

Without these fixes, the new bulk enable/disable actions added in this PR will also fail whenever popup categories or tags exist — the exact scenario from issue #1175.

🐛 Proposed fix
 	public static function add_popup_filters() {
 		global $typenow;
 
 		// Checks if the current post type is 'popup'
 		if ( 'popup' === $typenow ) {
+			$has_filters = false;
+
 			if ( get_taxonomy( 'popup_category' ) ) {
 				$terms = get_terms( 'popup_category' );
 
 				if ( count( $terms ) > 0 ) {
 					$category = '';
 
-					if ( isset( $_GET['_wpnonce'] ) && ! wp_verify_nonce( sanitize_key( wp_unslash( $_GET['_wpnonce'] ) ), 'pum-popup-filter-nonce' ) ) {
+					if ( isset( $_GET['pum_filter_nonce'] ) && wp_verify_nonce( sanitize_key( wp_unslash( $_GET['pum_filter_nonce'] ) ), 'pum-popup-filter-nonce' ) ) {
 						$category = isset( $_GET['popup_category'] ) ? sanitize_key( wp_unslash( $_GET['popup_category'] ) ) : '';
 					}
 
 					echo "<select name='popup_category' id='popup_category' class='postform'>";
 					echo "<option value=''>" . esc_html__( 'Show all categories', 'popup-maker' ) . '</option>';
 					foreach ( $terms as $term ) {
 						$selected = $category === $term->slug ? 'selected="selected"' : '';
 						echo '<option value="' . esc_attr( $term->slug ) . '" ' . esc_attr( $selected ) . '>' . esc_html( $term->name ) . ' (' . esc_html( $term->count ) . ')</option>';
 					}
 					echo '</select>';
-					wp_nonce_field( 'pum-popup-filter-nonce' );
+					$has_filters = true;
 				}
 			}
 
 			if ( get_taxonomy( 'popup_tag' ) ) {
 				$terms = get_terms( 'popup_tag' );
 
 				if ( count( $terms ) > 0 ) {
 					$tag = '';
 
-					if ( isset( $_GET['_wpnonce'] ) && ! wp_verify_nonce( sanitize_key( wp_unslash( $_GET['_wpnonce'] ) ), 'pum-popup-filter-nonce' ) ) {
+					if ( isset( $_GET['pum_filter_nonce'] ) && wp_verify_nonce( sanitize_key( wp_unslash( $_GET['pum_filter_nonce'] ) ), 'pum-popup-filter-nonce' ) ) {
 						$tag = isset( $_GET['popup_tag'] ) ? sanitize_key( wp_unslash( $_GET['popup_tag'] ) ) : '';
 					}
 
 					echo "<select name='popup_tag' id='popup_tag' class='postform'>";
 					echo "<option value=''>" . esc_html__( 'Show all tags', 'popup-maker' ) . '</option>';
 					foreach ( $terms as $term ) {
 						$selected = $tag === $term->slug ? 'selected="selected"' : '';
 						echo '<option value="' . esc_attr( $term->slug ) . '" ' . esc_attr( $selected ) . '>' . esc_html( $term->name ) . ' (' . esc_html( $term->count ) . ')</option>';
 					}
 					echo '</select>';
-					wp_nonce_field( 'pum-popup-filter-nonce' );
+					$has_filters = true;
 				}
 			}
+
+			// Output a single nonce field with a unique name to avoid overwriting core's _wpnonce.
+			if ( $has_filters ) {
+				wp_nonce_field( 'pum-popup-filter-nonce', 'pum_filter_nonce' );
+			}
 		}
 	}
classes/Integration/Form/WPForms.php (1)

76-94: ⚠️ Potential issue | 🟠 Major

Guard pum_integrated_form_submission call with popup_id check for consistency.

The concern is valid: get_popup_id() returns false when the form is not submitted inside a popup, and this false value is passed to pum_integrated_form_submission at line 89. However, increase_conversion() at line 83 is correctly guarded with if ( $popup_id ). The function expects null as the default for popup_id, not false. For consistency and to avoid unexpected behavior in downstream hooks, wrap the pum_integrated_form_submission call with the same guard:

if ( $popup_id ) {
	$this->increase_conversion( $popup_id );
	pum_integrated_form_submission( [...] );
}

Or move the call inside the existing guard block to ensure the function only processes submissions from forms inside popups.

🤖 Fix all issues with AI agents
In @.github/workflows/notify-support.yml:
- Around line 193-203: The current curl invocation uses a single-quoted inline
expansion of steps.payload.outputs.payload which will break if the payload
contains single quotes; update the "Send to `#support-updates`" step to write the
payload from steps.payload.outputs.payload to a temporary file (e.g.,
payload.json) and call curl with -d `@payload.json` using the SLACK_WEBHOOK_URL
env var, then remove the temp file afterwards; reference the step output name
steps.payload.outputs.payload and the env SLACK_WEBHOOK_URL when making the
change.
- Around line 36-65: The step with id "vars" is vulnerable because it injects
user-controlled values directly into heredocs using ${{
github.event.release.body }} and ${{ inputs.release_body }}; instead, set those
values as step environment variables (e.g., env: RELEASE_BODY: ${{
github.event.release.body }} or RELEASE_BODY: ${{ inputs.release_body }} under
the same step) and write them to /tmp/release_body.txt using a safe shell write
like printf '%s\n' "$RELEASE_BODY" > /tmp/release_body.txt (repeat for the
inputs branch using a different env name), leaving all heredocs removed from the
step to eliminate the EOFBODY injection vector.

In @.github/workflows/request-approval.yml:
- Around line 185-195: The "Send Slack approval request" workflow step injects
the payload string directly into curl using '${{ steps.payload.outputs.payload
}}', which breaks on embedded single quotes; instead, write the payload value
from steps.payload.outputs.payload to a temporary file (e.g., via echo or printf
redirected to a temp file) and call curl with -d `@tempfile` against
SLACK_WEBHOOK_URL, then remove the tempfile; update the step that references the
env SLACK_WEBHOOK_URL and the output variable steps.payload.outputs.payload to
use the temp-file approach to avoid shell quoting issues.
- Around line 85-92: The CHANGELOG assignment is unquoted and can break if the
JSON string contains spaces or shell metacharacters; update the workflow step
that sets VERSION/PLUGIN/ARTIFACT/REPO/DOWNLOAD_URL/CHANGELOG/DRY_RUN to either
export CHANGELOG via the GitHub Actions env context (e.g., add CHANGELOG: ${{
steps.changelog.outputs.changelog }} under env) or wrap the assignment value in
proper shell quoting so that CHANGELOG receives the full JSON string from
steps.changelog.outputs.changelog without word-splitting or globbing; adjust the
same pattern for any other multi-word outputs if needed.
- Around line 29-43: The step "Set variables" (id: vars) is vulnerable because
expressions like ${{ github.event.client_payload.version }} are expanded into
the shell; instead move those expression evaluations into the step's env block
(e.g., VERSION: ${{ github.event.client_payload.version }}, PLUGIN: ${{
github.event.client_payload.plugin || github.event.repository.name }}, ARTIFACT:
${{ github.event.client_payload.artifact }}, DRY_RUN: false) and then in the run
script write safe, already-expanded env vars to $GITHUB_OUTPUT (echo
"version=$VERSION" >> $GITHUB_OUTPUT, etc.); do the same for the
workflow_dispatch branch by exposing inputs via env and referencing those env
vars in run to eliminate direct expression interpolation into the shell.

In @.release-please-config.json:
- Line 12: The .release-please-config.json contains an invalid setting
"changelog-type": "wordpress"; update the changelog-type key (or remove it) so
it uses a supported value ("default" or "github") to satisfy Release Please
schema — locate the "changelog-type" entry and either change its value to
"default" or "github" or delete the "changelog-type" field entirely.

In `@assets/js/src/integration/kaliForms.js`:
- Around line 20-82: The integration listens for undocumented events
(kaliformProcessCompleted, kaliFormSuccess) which may not fire and can cause
double submissions; verify and use the documented event
kaliFormProcessConstructed (or the actual event from your Kali Forms version) in
the document.addEventListener and the submit handler, remove the redundant
listener, and add a per-form guard (e.g., a processedSet keyed by formInstanceId
checked in the handlers) to ensure window.PUM.integrations.formSubmission(…) is
called only once per form submission; update references to the listener function
names (successHandler) and the event names to match the verified event and
remove the secondary listener registration/cleanup to eliminate double-firing
risk while keeping the non-AJAX PHP hook (kaliforms_after_form_process_action)
behavior unchanged.

In `@assets/js/src/integration/newsletter.js`:
- Around line 127-131: The 30s setTimeout that calls observer.disconnect() and
deletes form.dataset.pumNewsletterObserved can prematurely stop tracking; update
the logic in the block using setTimeout to either remove the timeout entirely,
increase the delay to a much larger value (e.g., 300000 ms = 5 minutes), or
implement re-arming when the popup opens again by moving the observer setup into
the popup-open handler and ensuring observer.disconnect() only runs when the
form is truly finished; target the setTimeout call, observer.disconnect, and
manipulation of form.dataset.pumNewsletterObserved when making the change.

In `@assets/js/src/site/plugins/pum-url-tracking.js`:
- Around line 157-165: The code currently places the full href into
eventData.url (in the data object built around pid/event/conversion), which can
leak PII for mailto: and tel: links; update the logic that builds data (the
eventData.url field) — in the block constructing data for conversion events and
where self.getLinkType(href) is used — to sanitize or redact sensitive parts:
for mailto: and tel: links either omit eventData.url entirely or replace it with
a non-PII placeholder (e.g., "mailto:REDACTED" / "tel:REDACTED" or only send
linkType), and ensure any beacon/analytics call uses this sanitized value
instead of the raw href.
- Around line 84-102: The shouldTrackClick function currently only skips empty
and CTA-tagged URLs, so anchor-only links and javascript: links still trigger
conversion beacons; update shouldTrackClick (and reference isInternalUrl usage)
to explicitly return false for URLs that are pure anchors or fragment-only
(e.g., '#' or starting with '#') and for URLs that start with 'javascript:'
(case-insensitive, trim whitespace first) so clicks like in-page anchors or
javascript:void(0) do not fire tracking beacons. Ensure you perform the checks
before the CTA check and any other early returns.

In `@bin/extract-changelog.js`:
- Around line 58-71: The regex in extractVersionContent (and similarly in
extractUnreleasedContent and extractLatestVersion) stops at the first line
because the non-greedy [\s\S]*? combined with the multiline flag makes the
lookahead match end-of-line; replace the regex-based single-pass extraction with
an indexOf/substring approach: locate the header start (e.g., find the "##
v{version}" or "## Unreleased" header using indexOf after escaping/version
formatting), then find the next "## " occurrence (or end of string) with indexOf
and return the substring between them trimmed. Update extractVersionContent,
extractUnreleasedContent, and extractLatestVersion to use this header-indexing
logic instead of the complex regex.

In `@classes/Analytics.php`:
- Around line 179-192: sanitize_event_data currently converts JSON or strings to
arrays but leaves individual values unsanitized; update sanitize_event_data to
recursively sanitize leaf values before returning (use sanitize_text_field for
simple scalar leaves or wp_kses_post for HTML-allowed fields as appropriate).
Locate the sanitize_event_data function and after decoding/accepting an array,
apply a recursive sanitizer (e.g., implement a private helper or use
array_walk_recursive) to transform each scalar value, then return the sanitized
array; ensure handlers like pum_analytics_conversion receive sanitized data.

In `@classes/Integration/Form/BeaverBuilder.php`:
- Around line 47-62: BeaverBuilder's get_forms() uses 'ID'/'post_title' instead
of the standard 'id'/'title' and get_form_selectlist() is duplicated; change
get_forms() to return each form as ['id'=>..., 'title'=>...] to match other
integrations, update get_form() to read the new keys (use 'id' for lookups and
return the same normalized shape), and implement get_form_selectlist() to build
its list by iterating over get_forms() (mapping id=>title) so the select list is
always derived from get_forms(); ensure any code expecting legacy keys is
adjusted or mapped inside these methods to keep behavior consistent.

In `@classes/Integration/Form/FluentForms.php`:
- Around line 102-111: The code in classes/Integration/Form/FluentForms.php is
extracting the form id from the wrong property (attributes->id) so $form_id
stays null; inside the FluentForms integration (the method handling the
fluentform/before_submission_confirmation hook where $form is provided) replace
the defensive check that looks for $form->attributes->id with a check for
$form->id (e.g., if (is_object($form) && isset($form->id)) { $form_id =
$form->id; }) so the actual form ID is captured for conversion tracking.

In `@classes/Services/LinkClickTracking.php`:
- Around line 84-91: Replace the raw post checks using get_post() and
get_post_type() with Popup Maker helpers: call pum_get_popup($popup_id) to
retrieve the popup object and use pum_is_popup($popup) (and/or
$popup->is_valid()) to validate it before tracking; update the variable $popup
to hold the pum_get_popup() result and change the conditional that currently
checks get_post_type() to use pum_is_popup($popup) so the same validation logic
as FormConversionTracking is used.
🟡 Minor comments (8)
classes/Controllers/Compatibility/Builder/Divi.php-130-134 (1)

130-134: ⚠️ Potential issue | 🟡 Minor

Stale docblock: priority described as 5 but registered at 999.

The docblock states "This runs at priority 5 (before PostTypes filter at priority 10)" but the filter is actually registered at priority 999 on line 63. The comment on line 61–62 correctly explains the intent. Update the docblock to match.

Proposed fix
-	 * This runs at priority 5 (before PostTypes filter at priority 10)
-	 * to ensure Divi 4's conflicting filters can't override our setting.
+	 * This runs at priority 999 (after Divi 4's priority 100)
+	 * to ensure Divi 4's conflicting filters are overridden.
classes/Controllers/Compatibility/Builder/Divi.php-170-178 (1)

170-178: ⚠️ Potential issue | 🟡 Minor

Potential PHP notice if desc key is missing on the field.

If enable_classic_editor exists but doesn't have a desc key, the .= on line 177 will trigger an undefined index notice. Add a guard or initialize it.

Proposed fix
 		if ( isset( $fields['general']['main']['enable_classic_editor'] ) ) {
 			$field = &$fields['general']['main']['enable_classic_editor'];
 
 			// Disable the field in UI and set to checked.
 			$field['disabled'] = true;
 			// Append Divi 4 note to existing description.
+			if ( ! isset( $field['desc'] ) ) {
+				$field['desc'] = '';
+			}
 			$field['desc'] .= '<br/><strong style="color: `#d63638`;">' . esc_html__( 'Divi 4 requires the classic editor for popup editing. This setting is automatically enforced for compatibility.', 'popup-maker' ) . '</strong>';
 		}
classes/Services/FormConversionTracking.php-101-110 (1)

101-110: ⚠️ Potential issue | 🟡 Minor

popup_maker/form_conversion_tracked hook fires with inconsistent payload shapes.

  • track_form_conversion (line 109) passes the full $args array (containing popup_id, form_provider, form_id, tracked, etc.).
  • track_ajax_conversion (line 173) passes only $event_data (containing type, and whatever the JS beacon sends).

Any consumer hooking popup_maker/form_conversion_tracked will receive structurally different data depending on the code path. Consider normalizing the second argument to a consistent shape, or using distinct hook names if the payloads are intentionally different.

Also applies to: 165-174

classes/AssetCache.php-1308-1332 (1)

1308-1332: ⚠️ Potential issue | 🟡 Minor

Inline script obfuscation only catches -js-extra; misses -js-before, -js-after, and -js-translations.

WordPress generates inline <script> tags with IDs like {handle}-js-before, {handle}-js-after, and {handle}-js-translations (via wp_add_inline_script() and script translations). All of these pass through wp_inline_script_attributes, but the current regex only matches -js-extra, leaving the original handle exposed in those IDs.

♻️ Broaden the regex to catch all inline script suffixes
-	if ( preg_match( '/^(.+)-js-extra$/', $id, $matches ) ) {
+	if ( preg_match( '/^(.+)-js-(extra|before|after|translations)$/', $id, $matches ) ) {
 		$handle = $matches[1];
 
 		if ( self::should_obfuscate_handle( $handle ) ) {
 			$obfuscated       = self::generate_obfuscated_handle( $handle );
-			$attributes['id'] = $obfuscated . '-js-extra';
+			$attributes['id'] = $obfuscated . '-js-' . $matches[2];
 		}
 	}
CHANGELOG.md-24-36 (1)

24-36: ⚠️ Potential issue | 🟡 Minor

The fix for issue #1175 (bulk actions failing when categories/tags exist) appears to be missing from the Fixes section.

The PR specifically fixes the nonce collision that broke bulk actions on the All Popups list. Consider adding an entry like:

  • Fixed All Popups bulk actions failing with "The link you followed has expired" when popup categories or tags exist. Closes #1175.
classes/Integration/Form/HTMLForms.php-90-100 (1)

90-100: ⚠️ Potential issue | 🟡 Minor

Add function_exists() guard for hf_get_form() since enabled() only checks for hf_get_forms().

The enabled() method verifies hf_get_forms() (plural) exists, but get_form() calls hf_get_form() (singular) without a prior existence check. In PHP 7.4+, calling an undefined function throws an Error (not Exception), so the try-catch will not prevent a fatal error. This should be guarded with function_exists( 'hf_get_form' ) to follow the defensive validation pattern used elsewhere in the codebase.

🛡️ Proposed fix
 public function get_form( $id ) {
 	if ( ! $this->enabled() ) {
 		return null;
 	}
 
 	try {
+		if ( ! function_exists( 'hf_get_form' ) ) {
+			return null;
+		}
 		return hf_get_form( $id );
 	} catch ( Exception $e ) {
 		return null;
 	}
 }
classes/Upsell.php-198-207 (1)

198-207: ⚠️ Potential issue | 🟡 Minor

$has_edd is assigned but never read.

The static analysis tool correctly flags this. $has_edd on line 200 is unused — the else branch on line 205 already acts as the implicit EDD fallback. Remove it to avoid confusion.

🧹 Proposed fix
 // Determine which ecommerce platform to link to.
 $has_woocommerce = in_array( 'WooCommerce', $platforms, true );
-$has_edd         = in_array( 'Easy Digital Downloads', $platforms, true );
 
 // Route to specific integration page (WooCommerce preferred if both exist, though rare).
 if ( $has_woocommerce ) {
assets/js/src/integration/newsletter.js-21-22 (1)

21-22: ⚠️ Potential issue | 🟡 Minor

Overly broad selector form[action*="newsletter"] could match unrelated forms.

The substring match action*="newsletter" will match any form whose action URL contains "newsletter" — including forms from other plugins or custom forms. This could cause false-positive submission tracking. Consider tightening this selector or removing it if the class-based selectors (form.tnp-subscription, form.tnp-ajax) are sufficient.

🧹 Nitpick comments (39)
classes/Controllers/PostTypes.php (1)

541-562: Update the docblock to match the new return type and namespace.

The method now returns a value, but the docblock still says @return void. Also, WP_Post should be fully qualified (\WP_Post) since this class is namespaced.

📝 Proposed docblock fix
 	/**
 	 * Replace the editor interface when classic editor is forced.
 	 *
-	 * `@param` bool    $replace   Whether to replace the editor.
-	 * `@param` WP_Post $post      The post object.
-	 * `@return` void
+	 * `@param` bool     $replace Whether to replace the editor.
+	 * `@param` \WP_Post $post    The post object.
+	 * `@return` bool Whether to replace the editor.
 	 */
classes/Admin/BlockEditor.php (1)

68-79: $hook parameter is unused.

PHPMD flags $hook as unused. The enqueue_block_assets action doesn't pass any arguments, so this parameter will always be null. The same pattern exists in register_editor_assets (line 47) — consider cleaning up both if you revisit this area.

includes/integrations/class-pum-woocommerce-integration.php (1)

102-102: Consider reusing $woocommerce_text in condition_sort_order too.

condition_sort_order still has its own inline __( 'WooCommerce', 'woocommerce' ) call with a separate phpcs:ignore. Since both methods are static, you could extract the translated string into a shared class constant or a private static helper to fully centralize it. Minor nit — not blocking.

classes/Integration/Form/NinjaForms.php (1)

95-113: Consider defensive validation on $form_data before accessing keys.

This callback is hooked to a third-party action (ninja_forms_after_submission). If Ninja Forms changes the data structure, the unguarded $form_data['form_id'] access on Line 110 would emit a PHP notice. A simple isset check would make this more resilient.

Suggested guard
 	public function on_success_v3( $form_data ) {
+		if ( ! is_array( $form_data ) || ! isset( $form_data['form_id'] ) ) {
+			return;
+		}
+
 		if ( ! $this->should_process_submission() ) {
 			return;
 		}

As per coding guidelines: "Defensive validation in hook callbacks for graceful handling of third-party plugin parameter changes."

classes/Controllers/Compatibility/Builder/Divi.php (2)

102-103: Missing blank line before docblock.

WordPress coding standards require a blank line before a docblock that starts a new method/function declaration.

 		return $is_divi_available;
 	}
+
 	/**
 	 * Detect if Divi 4 is active (not 5+).

140-140: Avoid strict type hints on WordPress filter callbacks.

divi4_force_classic_editor is hooked to the WordPress core filter use_block_editor_for_post_type. WordPress may pass unexpected types (e.g., null instead of bool). Use defensive validation instead of type-hinted parameters to prevent TypeError in edge cases.

Proposed fix
-	public function divi4_force_classic_editor( $use_block_editor, $post_type ) {
+	public function divi4_force_classic_editor( $use_block_editor = true, $post_type = '' ) {
 		// Only handle our post types.
-		if ( ! in_array( $post_type, [ 'popup', 'popup_theme' ], true ) ) {
+		if ( ! is_string( $post_type ) || ! in_array( $post_type, [ 'popup', 'popup_theme' ], true ) ) {
 			return $use_block_editor;
 		}

As per coding guidelines: "Never use strict PHP types on methods hooked to third-party actions/filters; use defensive validation instead."

classes/Services/LinkClickTracking.php (1)

119-188: Substantial code duplication with FormConversionTracking.

increment_site_count(), increment_popup_count(), get_site_count(), get_popup_count(), reset_site_count(), and reset_popup_count() are structurally identical in both services — only the option/meta key constants differ. This is a DRY violation across ~120 lines.

Consider extracting a shared base class or trait (e.g., TrackingCounterTrait or BaseTracking extends Service) that parameterizes the storage keys, so both services can inherit the counter logic.

classes/AssetCache.php (2)

143-146: Consider conditionally registering obfuscation hooks.

These filters fire on every page (admin + frontend) even when bypass_adblockers is disabled. Each callback bails early, but registering the filters only when bypass is active would avoid the overhead entirely.

♻️ Suggested approach
-		// Hook into WordPress script/style tag filters for ID obfuscation.
-		add_filter( 'script_loader_tag', [ __CLASS__, 'obfuscate_script_tag' ], 10, 3 );
-		add_filter( 'style_loader_tag', [ __CLASS__, 'obfuscate_style_tag' ], 10, 4 );
-		add_filter( 'wp_inline_script_attributes', [ __CLASS__, 'obfuscate_inline_script_id' ], 10, 2 );
+		// Hook into WordPress script/style tag filters for ID obfuscation only when needed.
+		if ( pum_get_option( 'bypass_adblockers', false ) ) {
+			add_filter( 'script_loader_tag', [ __CLASS__, 'obfuscate_script_tag' ], 10, 3 );
+			add_filter( 'style_loader_tag', [ __CLASS__, 'obfuscate_style_tag' ], 10, 4 );
+			add_filter( 'wp_inline_script_attributes', [ __CLASS__, 'obfuscate_inline_script_id' ], 10, 2 );
+		}

Note: The is_admin() guard in callbacks would still be useful as a safety net, but the outer check avoids running three filter callbacks on every enqueued asset when the feature is off.


1205-1222: Add memoization to avoid repeated DB lookups per asset tag.

generate_obfuscated_handle is called for every PUM script and style tag on the page. Each call hits get_site_url() and pum_get_option() (×2). Caching results in a static map would eliminate redundant work.

♻️ Suggested memoization
 public static function generate_obfuscated_handle( $handle ) {
+	static $cache = [];
+	if ( isset( $cache[ $handle ] ) ) {
+		return $cache[ $handle ];
+	}
+
 	$site_url = get_site_url();
 	$method   = pum_get_option( 'adblock_bypass_url_method', 'custom' );
 
 	// Strip popup-maker-/pum- prefix for cleaner custom names.
 	$suffix = preg_replace( '/^(popup-maker-|pum-)/', '', $handle );
 
 	switch ( $method ) {
 		case 'random':
-			return md5( $site_url . $handle );
+			$result = md5( $site_url . $handle );
+			break;
 
 		case 'custom':
 		default:
 			$prefix = pum_get_option( 'adblock_bypass_custom_filename', 'pm' );
 			$prefix = ! empty( $prefix ) ? $prefix : 'pm';
-			return sanitize_html_class( $prefix . '-' . $suffix );
+			$result = sanitize_html_class( $prefix . '-' . $suffix );
+			break;
 	}
+
+	$cache[ $handle ] = $result;
+	return $result;
 }
classes/Extension/License.php (1)

128-195: Align service access pattern with project convention: \PopupMaker\plugin('license') instead of \PopupMaker\plugin()->get('license').

The codebase consistently uses \PopupMaker\plugin('license') throughout (found in Upsell.php, Services/License.php, Admin/Pages.php, Admin/Settings.php, RestAPI/License.php, and Admin/Templates.php). The new helper methods here uniquely use \PopupMaker\plugin()->get('license'). Align the three methods to match the project convention for consistency.

♻️ Suggested diff
 	private function has_pro_license() {
 		if ( ! function_exists( '\PopupMaker\plugin' ) ) {
 			return false;
 		}
 
 		try {
-			$license_service = \PopupMaker\plugin()->get( 'license' );
+			$license_service = \PopupMaker\plugin( 'license' );
 			$license_key     = $license_service->get_license_key();
 			// Check if Pro key exists and is not empty.
 			return ! empty( $license_key );
 		} catch ( \Exception $e ) {
 			return false;
 		}
 	}
 
 	private function get_pro_license_key() {
 		if ( ! function_exists( '\PopupMaker\plugin' ) ) {
 			return '';
 		}
 
 		try {
-			$license_service = \PopupMaker\plugin()->get( 'license' );
+			$license_service = \PopupMaker\plugin( 'license' );
 			return $license_service->get_license_key();
 		} catch ( \Exception $e ) {
 			return '';
 		}
 	}
 
 	private function get_pro_license_tier() {
 		if ( ! $this->has_pro_license() ) {
 			return '';
 		}
 
 		try {
-			$license_service = \PopupMaker\plugin()->get( 'license' );
+			$license_service = \PopupMaker\plugin( 'license' );
 			return $license_service->get_license_tier();
 		} catch ( \Exception $e ) {
 			return '';
 		}
 	}
.husky/pre-commit (1)

1-3: Placeholder is fine, but consider adding a shebang for when commands are added later.

Husky hooks typically need #!/usr/bin/env sh (or #!/bin/sh) at the top to be recognized as executable scripts. Without it, adding commands in a future phase could silently fail depending on the OS/shell.

.coderabbit.yaml (1)

17-18: Minor: Comment is misleading — the setting actually disables collapsing.

The comment says "Collapse file walkthrough" but collapse_walkthrough: false means the walkthrough will be expanded/visible. Consider updating the comment to reflect the actual behavior.

Suggested comment fix
-  # Collapse file walkthrough in PR comments
-  collapse_walkthrough: false
+  # Show file walkthrough expanded (not collapsed) in PR comments
+  collapse_walkthrough: false
assets/js/src/integration/forminator.js (1)

9-30: Consider guarding against missing formId.

If event.target doesn't carry data-form-id (e.g., Forminator triggers the event on a wrapper rather than the <form> element), formId will be undefined and the submission call may produce unexpected analytics data. A guard similar to what beaverbuilder.js does (early return if key data is missing) would be more defensive.

Suggested guard
 		const formId = $form.attr( 'data-form-id' );
+
+		if ( ! formId ) {
+			return;
+		}
+
 		// Extract form instance ID from data-render-id attribute.
 		const formInstanceId = $form.attr( 'data-render-id' );
assets/js/src/integration/beaverbuilder.js (1)

68-69: Nit: formId and formInstanceId are identical.

Both are set to formType + '_' + nodeId. If Beaver Builder doesn't have a separate instance concept, consider just passing one or documenting why they're the same.

classes/Integration/Form/BeaverBuilder.php (1)

102-115: Redundant method overrides — custom_scripts and custom_styles already have identical pass-through implementations in the abstract base class.

These can be safely removed since PUM_Abstract_Integration_Form already defines the same default behavior (see classes/Abstract/Integration/Form.php lines ~30-42).

classes/Integration/Form/KaliForms.php (2)

117-142: Add defensive type check on $args before array access.

Per coding guidelines, hook callbacks should use defensive validation since third-party plugins can change hook signatures. $args is accessed as an array without verifying it is one. If the hook signature changes, this will produce PHP warnings/errors.

🛡️ Proposed fix
 public function on_success( $args ) {
 	if ( ! $this->should_process_submission() ) {
 		return;
 	}

+	// Defensive validation - never assume parameter types.
+	if ( ! is_array( $args ) ) {
+		return;
+	}
+
 	// Get form ID from the data array.
 	$form_id = isset( $args['data']['formId'] ) ? $args['data']['formId'] : null;

As per coding guidelines: "Never use strict PHP types on methods hooked to third-party actions/filters; use defensive validation instead."


182-195: Redundant: custom_scripts and custom_styles are already defined as pass-throughs in the abstract base class.

Same note as BeaverBuilder — these can be removed without behavior change.

bin/extract-changelog.js (1)

47-49: escapeRegex is missing the hyphen character.

While - is only special inside [] character classes and unlikely to cause issues for version strings used in the current patterns, it's good practice to include it for a general-purpose escape function.

♻️ Proposed fix
 function escapeRegex( str ) {
-	return str.replace( /[.+*?^$[\](){}|\\]/g, '\\$&' );
+	return str.replace( /[-.+*?^$[\](){}|\\]/g, '\\$&' );
 }
.github/workflows/ci.yml (3)

7-8: WP_VERSION: 6.1.1 is defined but never referenced in any active job.

The env variable WP_VERSION is unused — the phpunit job that would use it is disabled. Consider removing it to avoid confusion, or add a comment that it's reserved for when phpunit is enabled.


365-368: if: false is flagged by actionlint as a constant condition.

This is intentional (placeholder for future PHPUnit enablement), but consider using a more descriptive approach:

♻️ Alternative: use a workflow-level variable or comment the job out

You could comment out the entire job block instead, or use an env-driven flag:

env:
  PHPUNIT_ENABLED: false
# ...
phpunit:
  if: ${{ env.PHPUNIT_ENABLED == 'true' }}

This avoids the lint warning while keeping the intent clear.


158-165: File paths with spaces will break the tr ' ' '\n' splitting.

Line 163 splits all_changed_files (space-separated) into newline-separated entries. If any PHP file path contains a space, the path will be split across two lines and PHPCS will fail to find the files. While uncommon in PHP projects, the tj-actions/changed-files action also outputs a all_changed_files with JSON-safe encoding available. Consider using the separator input or newline-separated output.

classes/Upsell.php (1)

209-216: Inconsistent UTM construction: manual add_query_arg vs. centralized generate_upgrade_url().

Lines 303 and 326 correctly use \PopupMaker\generate_upgrade_url(), but these three blocks manually build UTM parameters with add_query_arg. Since these target external (non-upgrade) base URLs, the centralized helper may not apply directly, but you could still extract a small helper (e.g., append_utm_args( $base_url, $medium, $campaign )) to eliminate the repeated array construction and keep UTM key naming consistent in one place.

Also applies to: 229-236, 260-267

classes/Integration/Form/Forminator.php (2)

1-9: Missing ABSPATH guard.

Other integration files in this PR (e.g., Newsletter.php) include the standard if ( ! defined( 'ABSPATH' ) ) { exit; } guard. This file omits it.

🛡️ Proposed fix
 <?php
 /**
  * Integration for Forminator
  *
  * `@package`   PopupMaker
  * `@copyright` Copyright (c) 2025, Code Atlantic LLC
  */
 
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
 class PUM_Integration_Form_Forminator extends PUM_Abstract_Integration_Form {

153-166: Simplify get_popup_id() — avoid copying the entire $_POST superglobal.

$form_data = $_POST is unnecessary; you only need to check a single key. This also makes the intent clearer.

♻️ Proposed simplification
 public function get_popup_id() {
 	// There is no raw nonce passed with this endpoint, so we need to check the raw data.
 	// phpcs:ignore WordPress.Security.NonceVerification
 	if ( isset( $_POST['forminator-form-id'] ) ) {
-		// phpcs:ignore WordPress.Security.NonceVerification
-		$form_data = $_POST;
-
-		if ( isset( $form_data['pum_form_popup_id'] ) ) {
-			return absint( $form_data['pum_form_popup_id'] );
+		// phpcs:ignore WordPress.Security.NonceVerification
+		if ( isset( $_POST['pum_form_popup_id'] ) ) {
+			return absint( $_POST['pum_form_popup_id'] );
 		}
 	}
 
 	return parent::get_popup_id();
 }
classes/Integration/Form/Elementor.php (2)

1-9: Missing ABSPATH guard (same as Forminator).

Add the standard direct-access guard for consistency with Newsletter.php and WordPress conventions.

🛡️ Proposed fix
 <?php
 /**
  * Integration for Elementor Pro Forms
  *
  * `@package`   PopupMaker
  * `@copyright` Copyright (c) 2024, Code Atlantic LLC
  */
 
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
 class PUM_Integration_Form_Elementor extends PUM_Abstract_Integration_Form {

174-195: Add defensive check on $ajax_handler->get_current_form().

Per coding guidelines, hook callbacks should use defensive validation for third-party plugin parameters. If Elementor Pro changes this API, get_current_form() could return an unexpected type or not exist.

🛡️ Proposed fix
 public function on_success( $record, $ajax_handler ) {
 	if ( ! $this->should_process_submission() ) {
 		return;
 	}
 
 	// Get element_id to match form selector configuration.
-	$current_form = $ajax_handler->get_current_form();
-	$element_id   = isset( $current_form['id'] ) ? $current_form['id'] : null;
+	$element_id = null;
+	if ( is_object( $ajax_handler ) && method_exists( $ajax_handler, 'get_current_form' ) ) {
+		$current_form = $ajax_handler->get_current_form();
+		$element_id   = isset( $current_form['id'] ) ? $current_form['id'] : null;
+	}
 	$popup_id     = $this->get_popup_id();

As per coding guidelines: "Never use strict PHP types on methods hooked to third-party actions/filters; use defensive validation instead" and "Use defensive validation in hook callbacks for graceful handling of third-party plugin parameter changes".

classes/Integration/Form/HappyForms.php (1)

1-9: Missing ABSPATH guard (same pattern as Forminator and Elementor).

Add the standard direct-access guard for consistency.

🛡️ Proposed fix
 <?php
 /**
  * Integration for HappyForms
  *
  * `@package`   PopupMaker
  * `@copyright` Copyright (c) 2024, Code Atlantic LLC
  */
 
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
 class PUM_Integration_Form_HappyForms extends PUM_Abstract_Integration_Form {
assets/js/src/integration/elementor.js (1)

12-25: $form naming is misleading — it holds a raw DOM element, not a jQuery object.

$( this )[ 0 ] unwraps to a DOM element, but the $ prefix conventionally signals a jQuery object. Consider renaming to form or formEl for clarity, or pass the jQuery object directly if the API accepts it.

♻️ Suggested rename
-		const $form = $( this )[ 0 ];
+		const formEl = $( this )[ 0 ];
 
 		// Get element_id from the widget container.
 		// Elementor form widgets are inside a .elementor-element-{id} container.
@@ -22,7 +22,7 @@
 		const elementId = $widget.length
 			? $widget.attr( 'data-id' )
 			: 'unknown';
 
-		window.PUM.integrations.formSubmission( $form, {
+		window.PUM.integrations.formSubmission( formEl, {
 			formProvider,
 			formId: elementId,
 		} );
classes/Integrations.php (1)

54-54: Inconsistent array key casing: kaliForms vs all-lowercase convention.

All other integration keys use lowercase (ninjaforms, gravityforms, happyforms, bitform, etc.), but kaliForms uses camelCase. This inconsistency could cause issues if any code expects normalized lowercase keys.

.github/workflows/release-please.yml (2)

42-47: Minor: potential script injection in toJSON output.

Using ${{ toJSON(steps.release.outputs) }} directly in a run block is a known GitHub Actions anti-pattern. If any output contained a single quote, it could break out of the shell string. Consider using an environment variable instead.

♻️ Safer logging approach
      - name: Show Release Please outputs
-       run: |
-         echo "Release created: ${{ steps.release.outputs.release_created }}"
-         echo "Tag: ${{ steps.release.outputs.tag_name }}"
-         echo "PR: ${{ steps.release.outputs.pr }}"
-         echo "All outputs:"
-         echo '${{ toJSON(steps.release.outputs) }}'
+       env:
+         RELEASE_OUTPUTS: ${{ toJSON(steps.release.outputs) }}
+       run: |
+         echo "Release created: ${{ steps.release.outputs.release_created }}"
+         echo "Tag: ${{ steps.release.outputs.tag_name }}"
+         echo "PR: ${{ steps.release.outputs.pr }}"
+         echo "All outputs:"
+         echo "$RELEASE_OUTPUTS"

69-76: Consider scoping git add to expected files only.

git add -A stages all changes in the working tree. If npm run version:update or npm ci leaves unexpected artifacts, they'd be committed. Scoping to known version files would be safer.

assets/js/src/integration/kaliForms.js (1)

43-82: Event listener accumulation on repeated submits.

Each submit event adds a new kaliFormSuccess listener (line 76). If the user submits the form multiple times before kaliFormSuccess fires or the 30s timeout elapses, multiple listeners stack up. Each matching kaliFormSuccess event would then trigger formSubmission for every accumulated listener.

Consider using { once: true } on the addEventListener call to auto-remove after first invocation, which also simplifies the manual removeEventListener logic:

♻️ Simplify with `once: true`
-		document.addEventListener( 'kaliFormSuccess', successHandler );
+		document.addEventListener( 'kaliFormSuccess', successHandler, { once: true } );

 		// Cleanup listener after 30 seconds if not fired.
 		setTimeout( function () {
 			document.removeEventListener( 'kaliFormSuccess', successHandler );
 		}, 30000 );

Note: With { once: true }, the manual removeEventListener inside successHandler (lines 69-72) becomes unnecessary and can also be removed.

.github/workflows/commitlint.yml (2)

26-54: GitHub Actions script injection risk in run: blocks.

Lines 30 and 60 interpolate ${{ github.event.pull_request.base.sha }} and ${{ github.event.pull_request.head.sha }} directly into shell scripts. While SHAs are hex-only and practically safe, the recommended pattern is to pass GitHub context values through environment variables to avoid script injection as a general habit.

Proposed fix
             - name: Check for AI attribution
+              env:
+                  BASE_SHA: ${{ github.event.pull_request.base.sha }}
+                  HEAD_SHA: ${{ github.event.pull_request.head.sha }}
               run: |
                   # Check commits for AI attribution
                   if [[ "${{ github.event_name }}" == "pull_request" ]]; then
-                    COMMITS=$(git log --format=%H ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }})
+                    COMMITS=$(git log --format=%H "$BASE_SHA".."$HEAD_SHA")
                   else
                     COMMITS=$(git log --format=%H HEAD~1..HEAD)
                   fi

The same pattern should be applied to the "Validate commits" step on lines 56-64.


66-113: Failure comment is only created, never updated.

If a contributor pushes multiple failing commits, the existing comment check (Line 84) prevents duplicates, which is good. However, the comment content becomes stale — it won't reflect the latest failure details. Consider updating the existing comment instead of skipping, so the developer always sees current guidance.

Also, the indentation of the template literal (Lines 89-112) will produce leading whitespace in the rendered Markdown comment. This may cause unintended code blocks or formatting issues in the PR comment.

assets/js/src/integration/newsletter.js (2)

63-65: Redundant window.PUM guard.

These integration files are bundled with window.PUM, so checking for its existence is unnecessary and creates dead branches. The same applies to the guard at Line 178.

Proposed fix
 	const handleSuccess = ( container, popupId ) => {
 		// Prevent duplicate handling.
 		if ( container.dataset.pumNewsletterHandled ) {
 			return;
 		}
 		container.dataset.pumNewsletterHandled = 'true';
 
-		if ( ! window.PUM || ! window.PUM.integrations ) {
-			return;
-		}
-
 		window.PUM.integrations.formSubmission( $( container ), {

Based on learnings: "In assets/js/src/integration/**/*.js, guards that check for window.PUM (e.g., conditional blocks that run only if window.PUM exists) are redundant because these integration files are bundled with window.PUM."


93-111: Observer sees its own dataset mutation, risking an extra callback cycle.

The observer watches the form with subtree: true. Inside the callback (Line 105), delete form.dataset.pumNewsletterObserved mutates a child attribute of the form, which can trigger the observer again before disconnect() fully takes effect (since disconnect is called synchronously but pending mutation records may already be queued). This is unlikely to cause a visible bug due to the pumNewsletterHandled guard in handleSuccess, but it's worth being aware of. Moving disconnect() before the dataset mutation would be cleaner.

commitlint.config.js (1)

39-73: Scope 'build' and 'docs' appear in both type-enum and scope-enum.

Having build and docs as both a type and a scope is intentional (e.g., build(deps): bump lodash vs ci(build): fix workflow), but it may confuse contributors. A brief note in the contributing guide would help clarify when to use each.

classes/Integration/Form/BitForm.php (2)

165-168: Negative cache with null may not behave as intended with get_transient.

On Line 150, you check false !== $cached to detect a cache hit. Storing null via set_transient (Line 167) will return null from get_transient on hit, which passes the false !== $cached check and returns null — this works correctly.

However, if WordPress object caching drops the value (some backends treat null as a delete), the negative cache will silently fail, falling through to the DB query every time. Consider using a sentinel value like an empty array instead.

Proposed fix
 		if ( ! $form ) {
 			// Cache negative result for 1 hour to avoid repeated failed queries.
-			set_transient( $cache_key, null, HOUR_IN_SECONDS );
+			set_transient( $cache_key, 'not_found', HOUR_IN_SECONDS );
 			return null;
 		}

Then update the cache check:

 		if ( false !== $cached ) {
+			return 'not_found' === $cached ? null : $cached;
-			return $cached;
 		}

89-113: get_forms() hits the database on every call without caching.

Unlike get_form() which uses transient caching, get_forms() queries the database every time. If this is called from admin UI select lists or during page loads, it could add unnecessary DB queries. Consider adding transient caching here as well, similar to the pattern in get_form().

Comment on lines +36 to +65
- name: Set variables
id: vars
run: |
# Use release event or workflow_dispatch inputs
if [ "${{ github.event_name }}" = "release" ]; then
echo "version=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
echo "release_url=${{ github.event.release.html_url }}" >> $GITHUB_OUTPUT
echo "zip_url=${{ github.event.release.zipball_url }}" >> $GITHUB_OUTPUT
echo "dry_run=false" >> $GITHUB_OUTPUT

# Store release body for changelog extraction
cat << 'EOFBODY' > /tmp/release_body.txt
${{ github.event.release.body }}
EOFBODY
else
VERSION="${{ inputs.version }}"
# Add prerelease suffix if checked
if [ "${{ inputs.prerelease }}" = "true" ]; then
VERSION="${VERSION}-beta.1"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "release_url=${{ github.server_url }}/${{ github.repository }}/releases/tag/$VERSION" >> $GITHUB_OUTPUT
echo "zip_url=${{ github.server_url }}/${{ github.repository }}/archive/refs/tags/$VERSION.zip" >> $GITHUB_OUTPUT
echo "dry_run=${{ inputs.dry_run }}" >> $GITHUB_OUTPUT

# Use input body
cat << 'EOFBODY' > /tmp/release_body.txt
${{ inputs.release_body }}
EOFBODY
fi
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Script injection risk: user-controlled release.body is interpolated directly into shell commands.

${{ github.event.release.body }} and ${{ inputs.release_body }} are expanded by GitHub Actions before the shell runs. If the content contains EOFBODY on its own line, it terminates the heredoc early and the remaining text executes as shell commands. This is a known GitHub Actions injection vector.

Use an environment variable instead of direct interpolation:

🔒 Proposed fix: use environment variables to avoid injection
      - name: Set variables
        id: vars
+       env:
+         RELEASE_BODY: ${{ github.event.release.body }}
+         INPUT_BODY: ${{ inputs.release_body }}
        run: |
          if [ "${{ github.event_name }}" = "release" ]; then
            echo "version=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
            echo "release_url=${{ github.event.release.html_url }}" >> $GITHUB_OUTPUT
            echo "zip_url=${{ github.event.release.zipball_url }}" >> $GITHUB_OUTPUT
            echo "dry_run=false" >> $GITHUB_OUTPUT

-           cat << 'EOFBODY' > /tmp/release_body.txt
-         ${{ github.event.release.body }}
-         EOFBODY
+           printf '%s\n' "$RELEASE_BODY" > /tmp/release_body.txt
          else
            VERSION="${{ inputs.version }}"
            if [ "${{ inputs.prerelease }}" = "true" ]; then
              VERSION="${VERSION}-beta.1"
            fi
            echo "version=$VERSION" >> $GITHUB_OUTPUT
            echo "release_url=${{ github.server_url }}/${{ github.repository }}/releases/tag/$VERSION" >> $GITHUB_OUTPUT
            echo "zip_url=${{ github.server_url }}/${{ github.repository }}/archive/refs/tags/$VERSION.zip" >> $GITHUB_OUTPUT
            echo "dry_run=${{ inputs.dry_run }}" >> $GITHUB_OUTPUT

-           cat << 'EOFBODY' > /tmp/release_body.txt
-         ${{ inputs.release_body }}
-         EOFBODY
+           printf '%s\n' "$INPUT_BODY" > /tmp/release_body.txt
          fi
🤖 Prompt for AI Agents
In @.github/workflows/notify-support.yml around lines 36 - 65, The step with id
"vars" is vulnerable because it injects user-controlled values directly into
heredocs using ${{ github.event.release.body }} and ${{ inputs.release_body }};
instead, set those values as step environment variables (e.g., env:
RELEASE_BODY: ${{ github.event.release.body }} or RELEASE_BODY: ${{
inputs.release_body }} under the same step) and write them to
/tmp/release_body.txt using a safe shell write like printf '%s\n'
"$RELEASE_BODY" > /tmp/release_body.txt (repeat for the inputs branch using a
different env name), leaving all heredocs removed from the step to eliminate the
EOFBODY injection vector.

Comment on lines +193 to +203
- name: Send to #support-updates
if: steps.vars.outputs.dry_run != 'true'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_SUPPORT }}
run: |
echo "📤 Sending support notification to Slack..."
curl -X POST "$SLACK_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d '${{ steps.payload.outputs.payload }}'
echo ""
echo "✅ Slack message sent to #support-updates!"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Slack curl payload may break on single quotes in the output.

'${{ steps.payload.outputs.payload }}' is single-quoted in shell, but ${{ }} is expanded by Actions before the shell. If the payload contains a ', the shell command breaks, potentially causing a partial send or error.

Safer approach: write the payload to a file and use curl -d @file``.

🔧 Proposed fix
      - name: Send to `#support-updates`
        if: steps.vars.outputs.dry_run != 'true'
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_SUPPORT }}
+         SLACK_PAYLOAD: ${{ steps.payload.outputs.payload }}
        run: |
          echo "📤 Sending support notification to Slack..."
+         printf '%s' "$SLACK_PAYLOAD" > /tmp/slack_payload.json
          curl -X POST "$SLACK_WEBHOOK_URL" \
            -H "Content-Type: application/json" \
-           -d '${{ steps.payload.outputs.payload }}'
+           -d `@/tmp/slack_payload.json`
          echo ""
          echo "✅ Slack message sent to `#support-updates`!"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Send to #support-updates
if: steps.vars.outputs.dry_run != 'true'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_SUPPORT }}
run: |
echo "📤 Sending support notification to Slack..."
curl -X POST "$SLACK_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d '${{ steps.payload.outputs.payload }}'
echo ""
echo "✅ Slack message sent to #support-updates!"
- name: Send to `#support-updates`
if: steps.vars.outputs.dry_run != 'true'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_SUPPORT }}
SLACK_PAYLOAD: ${{ steps.payload.outputs.payload }}
run: |
echo "📤 Sending support notification to Slack..."
printf '%s' "$SLACK_PAYLOAD" > /tmp/slack_payload.json
curl -X POST "$SLACK_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d `@/tmp/slack_payload.json`
echo ""
echo "✅ Slack message sent to `#support-updates`!"
🤖 Prompt for AI Agents
In @.github/workflows/notify-support.yml around lines 193 - 203, The current
curl invocation uses a single-quoted inline expansion of
steps.payload.outputs.payload which will break if the payload contains single
quotes; update the "Send to `#support-updates`" step to write the payload from
steps.payload.outputs.payload to a temporary file (e.g., payload.json) and call
curl with -d `@payload.json` using the SLACK_WEBHOOK_URL env var, then remove the
temp file afterwards; reference the step output name
steps.payload.outputs.payload and the env SLACK_WEBHOOK_URL when making the
change.

Comment on lines +29 to +43
- name: Set variables
id: vars
run: |
# Use dispatch payload or workflow_dispatch inputs
if [ "${{ github.event_name }}" = "repository_dispatch" ]; then
echo "version=${{ github.event.client_payload.version }}" >> $GITHUB_OUTPUT
echo "plugin=${{ github.event.client_payload.plugin || github.event.repository.name }}" >> $GITHUB_OUTPUT
echo "artifact=${{ github.event.client_payload.artifact }}" >> $GITHUB_OUTPUT
echo "dry_run=false" >> $GITHUB_OUTPUT
else
echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
echo "plugin=${{ inputs.plugin }}" >> $GITHUB_OUTPUT
echo "artifact=test-artifact-${{ inputs.version }}" >> $GITHUB_OUTPUT
echo "dry_run=${{ inputs.dry_run }}" >> $GITHUB_OUTPUT
fi
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Script injection risk: client_payload values are interpolated directly into shell.

${{ github.event.client_payload.version }}, .plugin, and .artifact are expanded by Actions before the shell runs. Anyone who can trigger repository_dispatch (e.g., via a compromised PAT) can inject arbitrary shell commands. Use environment variables instead.

🔒 Proposed fix
      - name: Set variables
        id: vars
+       env:
+         DISPATCH_VERSION: ${{ github.event.client_payload.version }}
+         DISPATCH_PLUGIN: ${{ github.event.client_payload.plugin || github.event.repository.name }}
+         DISPATCH_ARTIFACT: ${{ github.event.client_payload.artifact }}
        run: |
          if [ "${{ github.event_name }}" = "repository_dispatch" ]; then
-           echo "version=${{ github.event.client_payload.version }}" >> $GITHUB_OUTPUT
-           echo "plugin=${{ github.event.client_payload.plugin || github.event.repository.name }}" >> $GITHUB_OUTPUT
-           echo "artifact=${{ github.event.client_payload.artifact }}" >> $GITHUB_OUTPUT
+           echo "version=$DISPATCH_VERSION" >> $GITHUB_OUTPUT
+           echo "plugin=$DISPATCH_PLUGIN" >> $GITHUB_OUTPUT
+           echo "artifact=$DISPATCH_ARTIFACT" >> $GITHUB_OUTPUT
            echo "dry_run=false" >> $GITHUB_OUTPUT
          else
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Set variables
id: vars
run: |
# Use dispatch payload or workflow_dispatch inputs
if [ "${{ github.event_name }}" = "repository_dispatch" ]; then
echo "version=${{ github.event.client_payload.version }}" >> $GITHUB_OUTPUT
echo "plugin=${{ github.event.client_payload.plugin || github.event.repository.name }}" >> $GITHUB_OUTPUT
echo "artifact=${{ github.event.client_payload.artifact }}" >> $GITHUB_OUTPUT
echo "dry_run=false" >> $GITHUB_OUTPUT
else
echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
echo "plugin=${{ inputs.plugin }}" >> $GITHUB_OUTPUT
echo "artifact=test-artifact-${{ inputs.version }}" >> $GITHUB_OUTPUT
echo "dry_run=${{ inputs.dry_run }}" >> $GITHUB_OUTPUT
fi
- name: Set variables
id: vars
env:
DISPATCH_VERSION: ${{ github.event.client_payload.version }}
DISPATCH_PLUGIN: ${{ github.event.client_payload.plugin || github.event.repository.name }}
DISPATCH_ARTIFACT: ${{ github.event.client_payload.artifact }}
run: |
# Use dispatch payload or workflow_dispatch inputs
if [ "${{ github.event_name }}" = "repository_dispatch" ]; then
echo "version=$DISPATCH_VERSION" >> $GITHUB_OUTPUT
echo "plugin=$DISPATCH_PLUGIN" >> $GITHUB_OUTPUT
echo "artifact=$DISPATCH_ARTIFACT" >> $GITHUB_OUTPUT
echo "dry_run=false" >> $GITHUB_OUTPUT
else
echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
echo "plugin=${{ inputs.plugin }}" >> $GITHUB_OUTPUT
echo "artifact=test-artifact-${{ inputs.version }}" >> $GITHUB_OUTPUT
echo "dry_run=${{ inputs.dry_run }}" >> $GITHUB_OUTPUT
fi
🤖 Prompt for AI Agents
In @.github/workflows/request-approval.yml around lines 29 - 43, The step "Set
variables" (id: vars) is vulnerable because expressions like ${{
github.event.client_payload.version }} are expanded into the shell; instead move
those expression evaluations into the step's env block (e.g., VERSION: ${{
github.event.client_payload.version }}, PLUGIN: ${{
github.event.client_payload.plugin || github.event.repository.name }}, ARTIFACT:
${{ github.event.client_payload.artifact }}, DRY_RUN: false) and then in the run
script write safe, already-expanded env vars to $GITHUB_OUTPUT (echo
"version=$VERSION" >> $GITHUB_OUTPUT, etc.); do the same for the
workflow_dispatch branch by exposing inputs via env and referencing those env
vars in run to eliminate direct expression interpolation into the shell.

Comment on lines +85 to +92
run: |
VERSION="${{ steps.vars.outputs.version }}"
PLUGIN="${{ steps.vars.outputs.plugin }}"
ARTIFACT="${{ steps.vars.outputs.artifact }}"
REPO="${{ github.event.repository.name }}"
DOWNLOAD_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
CHANGELOG=${{ steps.changelog.outputs.changelog }}
DRY_RUN="${{ steps.vars.outputs.dry_run }}"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unquoted variable assignment will break on whitespace or special characters.

Line 91: CHANGELOG=${{ steps.changelog.outputs.changelog }} — this is expanded by Actions before the shell. Since the changelog output is a JSON-encoded string (with wrapping quotes from jq -Rs .), if it contains spaces or shell metacharacters outside the wrapping quotes, the assignment will be corrupted.

🔧 Proposed fix: use env variable or proper quoting
      - name: Build Slack payload
        id: payload
+       env:
+         CHANGELOG_JSON: ${{ steps.changelog.outputs.changelog }}
        run: |
          VERSION="${{ steps.vars.outputs.version }}"
          PLUGIN="${{ steps.vars.outputs.plugin }}"
          ARTIFACT="${{ steps.vars.outputs.artifact }}"
          REPO="${{ github.event.repository.name }}"
          DOWNLOAD_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
-         CHANGELOG=${{ steps.changelog.outputs.changelog }}
+         CHANGELOG="$CHANGELOG_JSON"
          DRY_RUN="${{ steps.vars.outputs.dry_run }}"
🤖 Prompt for AI Agents
In @.github/workflows/request-approval.yml around lines 85 - 92, The CHANGELOG
assignment is unquoted and can break if the JSON string contains spaces or shell
metacharacters; update the workflow step that sets
VERSION/PLUGIN/ARTIFACT/REPO/DOWNLOAD_URL/CHANGELOG/DRY_RUN to either export
CHANGELOG via the GitHub Actions env context (e.g., add CHANGELOG: ${{
steps.changelog.outputs.changelog }} under env) or wrap the assignment value in
proper shell quoting so that CHANGELOG receives the full JSON string from
steps.changelog.outputs.changelog without word-splitting or globbing; adjust the
same pattern for any other multi-word outputs if needed.

Comment on lines +185 to +195
- name: Send Slack approval request
if: steps.vars.outputs.dry_run != 'true'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_DEV }}
run: |
echo "📤 Sending approval request to Slack..."
curl -X POST "$SLACK_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d '${{ steps.payload.outputs.payload }}'
echo ""
echo "✅ Slack message sent!"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same curl payload injection risk as notify-support.yml.

'${{ steps.payload.outputs.payload }}' will break if the payload contains single quotes. Write to a temp file and use curl -d @file`` instead, as recommended for the other workflow.

🤖 Prompt for AI Agents
In @.github/workflows/request-approval.yml around lines 185 - 195, The "Send
Slack approval request" workflow step injects the payload string directly into
curl using '${{ steps.payload.outputs.payload }}', which breaks on embedded
single quotes; instead, write the payload value from
steps.payload.outputs.payload to a temporary file (e.g., via echo or printf
redirected to a temp file) and call curl with -d `@tempfile` against
SLACK_WEBHOOK_URL, then remove the tempfile; update the step that references the
env SLACK_WEBHOOK_URL and the output variable steps.payload.outputs.payload to
use the temp-file approach to avoid shell quoting issues.

Comment on lines +58 to +71
function extractVersionContent( content, version ) {
// Escape version string to handle metacharacters like dots and plus signs
const escapedVersion = escapeRegex( version );

// Support multiple version formats: ## v1.0.3, ## 1.0.3, etc.
// Handles both LF and CRLF line endings
const versionPattern = new RegExp(
`^## (?:v)?${ escapedVersion }(?:\\s*-\\s*[0-9]{4}-[0-9]{2}-[0-9]{2})?\\s*\\r?\\n([\\s\\S]*?)(?=\\r?\\n## |\\r?\\n?$)`,
'm'
);

const match = content.match( versionPattern );
return match ? match[ 1 ].trim() : null;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Regex bug: non-greedy [\s\S]*? with multiline $ in the lookahead captures only the first line.

The lookahead (?=\r?\n## |\r?\n?$) with the m flag causes $ to match at the end of every line (not just end of string). Combined with the non-greedy [\s\S]*?, the regex stops at the first line boundary. This means extractVersionContent returns only the first line of a version's changelog instead of the entire section.

The same bug affects extractUnreleasedContent (line 83) and extractLatestVersion (line 114).

A simpler, correct approach is to use string splitting instead of a complex single-pass regex:

🐛 Proposed fix: use indexOf-based extraction
 function extractVersionContent( content, version ) {
 	const escapedVersion = escapeRegex( version );
-	const versionPattern = new RegExp(
-		`^## (?:v)?${ escapedVersion }(?:\\s*-\\s*[0-9]{4}-[0-9]{2}-[0-9]{2})?\\s*\\r?\\n([\\s\\S]*?)(?=\\r?\\n## |\\r?\\n?$)`,
-		'm'
-	);
-
-	const match = content.match( versionPattern );
-	return match ? match[ 1 ].trim() : null;
+	// Find the version heading
+	const headingPattern = new RegExp(
+		`^## (?:v)?${ escapedVersion }(?:\\s*-\\s*[0-9]{4}-[0-9]{2}-[0-9]{2})?\\s*$`,
+		'm'
+	);
+	const headingMatch = headingPattern.exec( content );
+	if ( ! headingMatch ) {
+		return null;
+	}
+
+	// Find the start of content (after the heading line)
+	const startIdx = headingMatch.index + headingMatch[ 0 ].length;
+	// Advance past the newline
+	const contentStart = content.indexOf( '\n', startIdx );
+	if ( contentStart === -1 ) {
+		return null;
+	}
+
+	// Find the next ## heading
+	const nextHeading = content.indexOf( '\n## ', contentStart );
+	const sectionContent = nextHeading === -1
+		? content.slice( contentStart + 1 )
+		: content.slice( contentStart + 1, nextHeading );
+
+	return sectionContent.trim() || null;
 }

Apply the same pattern to extractUnreleasedContent and extractLatestVersion.

🤖 Prompt for AI Agents
In `@bin/extract-changelog.js` around lines 58 - 71, The regex in
extractVersionContent (and similarly in extractUnreleasedContent and
extractLatestVersion) stops at the first line because the non-greedy [\s\S]*?
combined with the multiline flag makes the lookahead match end-of-line; replace
the regex-based single-pass extraction with an indexOf/substring approach:
locate the header start (e.g., find the "## v{version}" or "## Unreleased"
header using indexOf after escaping/version formatting), then find the next "##
" occurrence (or end of string) with indexOf and return the substring between
them trimmed. Update extractVersionContent, extractUnreleasedContent, and
extractLatestVersion to use this header-indexing logic instead of the complex
regex.

Comment on lines +179 to +192
public static function sanitize_event_data( $value ) {
// If already an array, return as-is.
if ( is_array( $value ) ) {
return $value;
}

// Decode JSON string to array (matches Pro's Request::parse_tracking_data).
if ( is_string( $value ) ) {
$decoded = json_decode( $value, true );
return is_array( $decoded ) ? $decoded : [];
}

return [];
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

sanitize_event_data normalizes structure but doesn't sanitize values.

This method converts the input to an array but doesn't sanitize the individual values within it. If any downstream consumer (e.g., a pum_analytics_conversion action handler) stores or renders these values, they could contain unsanitized user input. Consider recursively applying sanitize_text_field to leaf values.

🛡️ Suggested hardening
 public static function sanitize_event_data( $value ) {
 	// If already an array, return as-is.
 	if ( is_array( $value ) ) {
-		return $value;
+		return array_map( 'sanitize_text_field', $value );
 	}
 
 	// Decode JSON string to array (matches Pro's Request::parse_tracking_data).
 	if ( is_string( $value ) ) {
 		$decoded = json_decode( $value, true );
-		return is_array( $decoded ) ? $decoded : [];
+		return is_array( $decoded ) ? array_map( 'sanitize_text_field', $decoded ) : [];
 	}
 
 	return [];
 }

Note: If nested arrays are expected in eventData, use wp_kses_post or a recursive sanitization helper instead of a flat array_map.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public static function sanitize_event_data( $value ) {
// If already an array, return as-is.
if ( is_array( $value ) ) {
return $value;
}
// Decode JSON string to array (matches Pro's Request::parse_tracking_data).
if ( is_string( $value ) ) {
$decoded = json_decode( $value, true );
return is_array( $decoded ) ? $decoded : [];
}
return [];
}
public static function sanitize_event_data( $value ) {
// If already an array, return as-is.
if ( is_array( $value ) ) {
return array_map( 'sanitize_text_field', $value );
}
// Decode JSON string to array (matches Pro's Request::parse_tracking_data).
if ( is_string( $value ) ) {
$decoded = json_decode( $value, true );
return is_array( $decoded ) ? array_map( 'sanitize_text_field', $decoded ) : [];
}
return [];
}
🤖 Prompt for AI Agents
In `@classes/Analytics.php` around lines 179 - 192, sanitize_event_data currently
converts JSON or strings to arrays but leaves individual values unsanitized;
update sanitize_event_data to recursively sanitize leaf values before returning
(use sanitize_text_field for simple scalar leaves or wp_kses_post for
HTML-allowed fields as appropriate). Locate the sanitize_event_data function and
after decoding/accepting an array, apply a recursive sanitizer (e.g., implement
a private helper or use array_walk_recursive) to transform each scalar value,
then return the sanitized array; ensure handlers like pum_analytics_conversion
receive sanitized data.

Comment on lines +47 to +62
public function get_forms() {
return [
[
'ID' => 'contact_any',
'post_title' => __( 'Any Contact Form', 'popup-maker' ),
],
[
'ID' => 'subscribe_any',
'post_title' => __( 'Any Subscribe Form', 'popup-maker' ),
],
[
'ID' => 'login_any',
'post_title' => __( 'Any Login Form', 'popup-maker' ),
],
];
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, locate the BeaverBuilder integration file and examine its content
find . -name "BeaverBuilder.php" -type f 2>/dev/null | head -5

Repository: PopupMaker/Popup-Maker

Length of output: 110


🏁 Script executed:

# Look at the BeaverBuilder.php file to verify the actual implementation
cat -n classes/Integration/Form/BeaverBuilder.php | head -100

Repository: PopupMaker/Popup-Maker

Length of output: 2776


🏁 Script executed:

# Check HTMLForms integration for comparison
cat -n classes/Integration/Form/HTMLForms.php | head -100

Repository: PopupMaker/Popup-Maker

Length of output: 2906


🏁 Script executed:

# Check if there's a KaliForms integration
find . -name "KaliForms.php" -type f 2>/dev/null

Repository: PopupMaker/Popup-Maker

Length of output: 106


🏁 Script executed:

# Search for how get_forms() is used across the codebase
rg -n 'get_forms\(\)' --type=php -B2 -A5

Repository: PopupMaker/Popup-Maker

Length of output: 21973


🏁 Script executed:

# Check for abstract Form class that might define the interface
find . -name "*.php" -type f | xargs grep -l "abstract class.*Form" 2>/dev/null | head -5

Repository: PopupMaker/Popup-Maker

Length of output: 105


🏁 Script executed:

# Look at the abstract Form class to understand expected interface
rg -n "class Form" --type=php -A20 | head -100

Repository: PopupMaker/Popup-Maker

Length of output: 1556


🏁 Script executed:

# Check the abstract Form class to understand if there's an interface contract
cat -n classes/Abstract/Integration/Form.php

Repository: PopupMaker/Popup-Maker

Length of output: 2637


🏁 Script executed:

# Verify how the integrations are actually used - check if there's a unified consumer of get_forms()
rg -n "get_form_selectlist\(\)" --type=php -B2 -A2 | head -60

Repository: PopupMaker/Popup-Maker

Length of output: 3154


🏁 Script executed:

# Check if get_form() is used elsewhere and how it accesses the returned form
rg -n "get_form\(" --type=php -B2 -A2 | head -80

Repository: PopupMaker/Popup-Maker

Length of output: 4395


🏁 Script executed:

# Look at how forms are used in the integration system - check Integrations.php around line 148
sed -n '140,160p' classes/Integrations.php

Repository: PopupMaker/Popup-Maker

Length of output: 626


Align get_forms() key format with other integrations and derive get_form_selectlist() from get_forms() to follow DRY principle.

BeaverBuilder returns ['ID' => ..., 'post_title' => ...] while HTMLForms, GravityForms, and BitForm return ['id' => ..., 'title' => ...]. This inconsistency makes the API unpredictable across integrations. Additionally, get_form_selectlist() is hardcoded separately from get_forms(), creating a maintenance burden if the form list changes.

🔧 Proposed fixes
 public function get_forms() {
 	return [
 		[
-			'ID'         => 'contact_any',
-			'post_title' => __( 'Any Contact Form', 'popup-maker' ),
+			'id'    => 'contact_any',
+			'title' => __( 'Any Contact Form', 'popup-maker' ),
 		],
 		[
-			'ID'         => 'subscribe_any',
-			'post_title' => __( 'Any Subscribe Form', 'popup-maker' ),
+			'id'    => 'subscribe_any',
+			'title' => __( 'Any Subscribe Form', 'popup-maker' ),
 		],
 		[
-			'ID'         => 'login_any',
-			'post_title' => __( 'Any Login Form', 'popup-maker' ),
+			'id'    => 'login_any',
+			'title' => __( 'Any Login Form', 'popup-maker' ),
 		],
 	];
 }

Then update get_form() and derive get_form_selectlist():

 public function get_form( $id ) {
 	$forms = $this->get_forms();
 	foreach ( $forms as $form ) {
-		if ( $form['ID'] === $id ) {
+		if ( $form['id'] === $id ) {
 			return $form;
 		}
 	}
 	return null;
 }

 public function get_form_selectlist() {
-	return [
-		'contact_any'   => __( 'Any Contact Form', 'popup-maker' ),
-		'subscribe_any' => __( 'Any Subscribe Form', 'popup-maker' ),
-		'login_any'     => __( 'Any Login Form', 'popup-maker' ),
-	];
+	$form_selectlist = [];
+	foreach ( $this->get_forms() as $form ) {
+		$form_selectlist[ $form['id'] ] = $form['title'];
+	}
+	return $form_selectlist;
 }
🤖 Prompt for AI Agents
In `@classes/Integration/Form/BeaverBuilder.php` around lines 47 - 62,
BeaverBuilder's get_forms() uses 'ID'/'post_title' instead of the standard
'id'/'title' and get_form_selectlist() is duplicated; change get_forms() to
return each form as ['id'=>..., 'title'=>...] to match other integrations,
update get_form() to read the new keys (use 'id' for lookups and return the same
normalized shape), and implement get_form_selectlist() to build its list by
iterating over get_forms() (mapping id=>title) so the select list is always
derived from get_forms(); ensure any code expecting legacy keys is adjusted or
mapped inside these methods to keep behavior consistent.

Comment on lines +102 to +111

if ( $popup_id ) {
$this->increase_conversion( $popup_id );
}

// Defensive validation - $form is stdClass, not array.
$form_id = null;
if ( is_object( $form ) && isset( $form->attributes ) && is_object( $form->attributes ) && isset( $form->attributes->id ) ) {
$form_id = $form->attributes->id;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash

# First, check the FluentForms.php file to understand context and existing form usage
echo "=== Checking FluentForms.php context ==="
cat -n classes/Integration/Form/FluentForms.php | head -120

# Search for any references to form properties in this file
echo -e "\n=== Searching for form property access patterns ==="
rg -n "form->" classes/Integration/Form/FluentForms.php

# Check if there are any comments or documentation about the form structure
echo -e "\n=== Searching for form-related comments ==="
rg -n -i "form|fluent" classes/Integration/Form/FluentForms.php | head -30

Repository: PopupMaker/Popup-Maker

Length of output: 5569


🏁 Script executed:

#!/bin/bash

# Check how other form integrations handle form_id extraction in hook callbacks
echo "=== HappyForms.php ==="
rg -n -A5 "on_success|form.*id" classes/Integration/Form/HappyForms.php | head -40

echo -e "\n=== CalderaForms.php ==="
rg -n -A5 "on_success|form.*id" classes/Integration/Form/CalderaForms.php | head -40

# Check if there are any other references to Fluent Forms form structure
echo -e "\n=== Other form integration patterns ==="
fd -e php classes/Integration/Form/ --exec basename {} \;

Repository: PopupMaker/Popup-Maker

Length of output: 2032


🏁 Script executed:

#!/bin/bash

# Search for any references or documentation about form structure
echo "=== Searching for form structure clues in codebase ==="
rg -i "attributes" classes/Integration/Form/ -A2 -B2

# Check if there are any tests or documentation files
echo -e "\n=== Looking for test or documentation files ==="
fd -e php -p test classes/Integration/Form/ 2>/dev/null || echo "No test files found"

# Search for any inline documentation about Fluent Forms
echo -e "\n=== Searching for Fluent Forms references ==="
rg -i "fluent" --include="*.php" -A3 -B3 | head -100

Repository: PopupMaker/Popup-Maker

Length of output: 1168


🌐 Web query:

Fluent Forms fluentform/before_submission_confirmation hook documentation form parameter structure

💡 Result:

fluentform/before_submission_confirmation fires after Fluent Forms finishes processing integrations/notifications and is about to return the final AJAX confirmation response. The action is triggered like: do_action('fluentform/before_submission_confirmation', $insertId, $this->formData, $this->form); and your callback receives 3 params: ($entryId, $formData, $form). [1]

$form parameter (structure)

In this hook, $form is the Fluent Forms “Form object” (the form record + settings), with commonly-used properties like: [2]

  • $form->id (form ID)
  • $form->title
  • $form->status
  • $form->form_fields (raw JSON string of the form field definitions)
  • $form->has_payment
  • $form->type
  • $form->conditions
  • $form->created_by, $form->created_at, $form->updated_at
  • $form->settings (array), which includes keys such as:
    • confirmation (e.g. redirectTo, messageToShow, samePageFormBehavior, etc.)
    • restrictions
    • layout
      (Example JSON for the object is shown in the Form Object doc.) [2]

Quick way to inspect the exact shape on your site

add_action('fluentform/before_submission_confirmation', function($entryId, $formData, $form) {
    error_log(print_r($form, true)); // inspect full object
    $fields = json_decode($form->form_fields, true); // field definitions array
}, 20, 3);

Sources: Fluent Forms hook doc for fluentform/before_submission_confirmation [1], Fluent Forms “$form Object” data definition (properties + example JSON) [2].

[1] (fluentforms.com)
[2] (fluentforms.com)

Citations:


Incorrect form ID extraction path — fix to use $form->id instead of $form->attributes->id.

The defensive validation structure is sound, but it checks the wrong property. According to Fluent Forms' official documentation for the fluentform/before_submission_confirmation hook, the form object exposes the ID directly at $form->id, not $form->attributes->id. The code at line 83 already demonstrates this pattern ($form->id and $form->title).

With the current implementation, $form_id will silently remain null even when a valid form ID exists, causing form submission tracking to fail silently.

Replace line 109–110:

if ( is_object( $form ) && isset( $form->id ) ) {
    $form_id = $form->id;
}
🤖 Prompt for AI Agents
In `@classes/Integration/Form/FluentForms.php` around lines 102 - 111, The code in
classes/Integration/Form/FluentForms.php is extracting the form id from the
wrong property (attributes->id) so $form_id stays null; inside the FluentForms
integration (the method handling the fluentform/before_submission_confirmation
hook where $form is provided) replace the defensive check that looks for
$form->attributes->id with a check for $form->id (e.g., if (is_object($form) &&
isset($form->id)) { $form_id = $form->id; }) so the actual form ID is captured
for conversion tracking.

Comment on lines +84 to +91
// Verify popup exists before tracking (prevents orphaned meta).
$popup = get_post( $popup_id );
if ( ! $popup || 'popup' !== get_post_type( $popup ) ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( sprintf( '[Popup Maker] Skipping link click tracking for invalid popup ID: %d', $popup_id ) );
}
return;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Use pum_get_popup() / pum_is_popup() instead of get_post() / get_post_type() for popup validation.

FormConversionTracking (lines 86-87) uses pum_get_popup() + pum_is_popup(), but this service uses raw get_post() + get_post_type(). This is inconsistent and bypasses Popup Maker's popup validation API, which also checks $popup->is_valid().

♻️ Proposed fix
-		// Verify popup exists before tracking (prevents orphaned meta).
-		$popup = get_post( $popup_id );
-		if ( ! $popup || 'popup' !== get_post_type( $popup ) ) {
+		// Verify popup exists before tracking (prevents orphaned meta).
+		$popup = pum_get_popup( $popup_id );
+		if ( ! pum_is_popup( $popup ) ) {

As per coding guidelines: "Use the pum_get_popup($popup_id) … functions to get popup and theme objects rather than directly querying post meta."

🤖 Prompt for AI Agents
In `@classes/Services/LinkClickTracking.php` around lines 84 - 91, Replace the raw
post checks using get_post() and get_post_type() with Popup Maker helpers: call
pum_get_popup($popup_id) to retrieve the popup object and use
pum_is_popup($popup) (and/or $popup->is_valid()) to validate it before tracking;
update the variable $popup to hold the pum_get_popup() result and change the
conditional that currently checks get_post_type() to use pum_is_popup($popup) so
the same validation logic as FormConversionTracking is used.

@danieliser
Copy link
Member

Closing — this PR is developmaster, which is a release merge, not a bug fix PR. The nonce fix commits are already in develop. The fix should be delivered through the normal release process.

@danieliser danieliser closed this Mar 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

All Popups Bulk actions do not work if popups have a category or tag

3 participants