# Elementor Dynamic Tag Ajax Select

> Let an Elementor control (in a Dynamic Tag or a widget)

- **Type:** Skill
- **Install:** `agentstack add skill-lonsdale201-wp-agent-skills-elementor-dynamic-tag-ajax-select`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [Lonsdale201](https://agentstack.voostack.com/s/lonsdale201)
- **Installs:** 0
- **Category:** [Agent Skills](https://agentstack.voostack.com/c/agent-skills)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [Lonsdale201](https://github.com/Lonsdale201)
- **Source:** https://github.com/Lonsdale201/wp-agent-skills/tree/main/elementor/elementor-dynamic-tag-ajax-select

## Install

```sh
agentstack add skill-lonsdale201-wp-agent-skills-elementor-dynamic-tag-ajax-select
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# Elementor: AJAX item picker for tags & widgets (large datasets)

When a Dynamic Tag or widget setting must point at **one specific record** out of many — "this product", "that landing page", "this author" — you need a searchable picker. On a small set a preloaded `SELECT2` is fine. On a 20k-product store it is a trap: Elementor renders every option into the panel on load and the editor hangs. This skill is the AJAX-search alternative and how to degrade it when Elementor Pro is absent.

## The misconception (and why the editor freezes)

> "I'll list the products in a `SELECT2` so the user can search them."

```php
// ANTI-PATTERN at scale — every product becomes a preloaded 
$options = [];
foreach ( wc_get_products( [ 'limit' => -1 ] ) as $p ) {
    $options[ $p->get_id() ] = $p->get_name();   // add_control( 'product_id', [
    'type'    => \Elementor\Controls_Manager::SELECT2,
    'options' => $options,
] );
```

A preloaded `SELECT2` ships all options to the editor up front. That is exactly what the reference plugin's `ProductAttributes` tag does — but only because attribute taxonomies are a handful ([ProductAttributes.php:62-74](ProductAttributes.php)). The same shape over products/posts is what locks the panel. **Preloaded `SELECT2` is correct only for small, bounded option sets** (a dozen statuses, a few taxonomies).

## The fix — Elementor Pro's AJAX query control

The query control is a `SELECT2` whose options are fetched **on demand, by search term**, over AJAX. Catalog size is irrelevant because nothing is queried until the user types.

```php
use ElementorPro\Modules\QueryControl\Module as QueryControlModule;

$this->add_control( 'product_id', [
    'label'        => esc_html__( 'Product', 'myplugin' ),
    'type'         => QueryControlModule::QUERY_CONTROL_ID,   // 'query'
    'options'      => [],            // empty — filled by AJAX
    'label_block'  => true,
    'autocomplete' => [
        'object'  => QueryControlModule::QUERY_OBJECT_POST,   // what to search
        'query'   => [ 'post_type' => 'product' ],            // scope (search term is added server-side)
        'display' => 'minimal',                               // or 'detailed'
    ],
] );
```

Verified contract:

- `QUERY_CONTROL_ID = 'query'`; the control class `Query extends Control_Select2` ([controls/query.php:12-16](query.php)).
- `'autocomplete'['object']` is one of ([module.php:34-39](module.php)): `QUERY_OBJECT_POST` (`'post'`), `QUERY_OBJECT_TAX` (`'tax'`), `QUERY_OBJECT_AUTHOR` (`'author'` — users who authored content), `QUERY_OBJECT_USER` (`'user'` — all users), `QUERY_OBJECT_ATTACHMENT` (`'attachment'`), `QUERY_OBJECT_LIBRARY_TEMPLATE`.
- `'query'` is merged into the WP query scope; `'display'` is `'minimal'` or `'detailed'`; `'by_field' => 'ID'` stores the chosen post ID.
- The server-side AJAX handler is **entirely Pro's** — registered on `elementor/ajax/register_actions` (`pro_panel_posts_control_filter_autocomplete`, `query_control_value_titles`). Your code writes **no** `wp_ajax_` handler; you only declare the `autocomplete` config and Pro does the search and the saved-value label resolution.

### Why it doesn't freeze (verified)

The autocomplete handler **returns early with a `WP_Error` when the search term is empty** ([module.php:211](module.php)) — so nothing runs until the user types. When they do, `autocomplete_query_for_post()` sets `$query['s'] = $data['q']` ([module.php:242](module.php)); the `'posts_per_page' => -1` alongside it ([module.php:241](module.php)) is harmless because the `s` search term bounds the result set. This is the inverse of the preloaded `SELECT2`: the query is small and on-demand, not large and upfront.

## Using it inside a Dynamic Tag

The query control works in a `Tag`/`Data_Tag` exactly as in a widget — store the chosen ID in a setting and resolve it in `render()` / `get_value()`. Pro's `Internal_URL` data tag is the canonical example: a `type` selector plus per-type query controls (`post_id`, `taxonomy_id`, `attachment_id`, `author_id`), each with its own `autocomplete['object']` and a `condition` ([internal-url.php:72-142](internal-url.php)):

```php
$this->add_control( 'post_id', [
    'label'        => esc_html__( 'Search & Select', 'myplugin' ),
    'type'         => QueryModule::QUERY_CONTROL_ID,
    'options'      => [],
    'label_block'  => true,
    'autocomplete' => [
        'object'  => QueryModule::QUERY_OBJECT_POST,
        'display' => 'detailed',
        'query'   => [ 'post_type' => 'any' ],
    ],
    'condition'    => [ 'type' => 'post' ],
] );

// …then resolve in get_value()/render():
$url = get_permalink( (int) $this->get_settings( 'post_id' ) );
```

Note: in the bundled reference plugin, **no dynamic tag uses the AJAX query control** — every `SELECT2` tag (`ProductAttributes`, membership-plan tags) preloads a small, bounded set. The AJAX pattern there lives in the **`DynamicAddToCartWidget`**. For a tag, Pro's `Internal_URL` is the reference.

## Graceful degradation when Pro is absent (required)

`QueryControlModule` only exists with Pro. Feature-detect and fall back to a manual ID field — the exact pattern in `DynamicAddToCartWidget`:

```php
use Elementor\Controls_Manager;
use ElementorPro\Modules\QueryControl\Module as QueryControlModule;

private function has_query_control_support(): bool {
    return class_exists( QueryControlModule::class );   // Pro present?
}

private function product_control_type(): string {
    return $this->has_query_control_support()
        ? QueryControlModule::QUERY_CONTROL_ID
        : Controls_Manager::TEXT;                        // manual ID entry
}

// …building the control:
$control = [ 'label' => esc_html__( 'Product', 'myplugin' ), 'type' => $this->product_control_type() ];

if ( $this->has_query_control_support() ) {
    $control['autocomplete'] = [
        'object'   => QueryControlModule::QUERY_OBJECT_POST,
        'query'    => [ 'post_type' => 'product' ],
        'display'  => 'minimal',
        'by_field' => 'ID',
    ];
} else {
    $control['description'] = esc_html__( 'Enter the product ID manually. Activate Elementor Pro for the search picker.', 'myplugin' );
}
$this->add_control( 'product_id', $control );
```

Verified at [DynamicAddToCartWidget.php:43-53,181-198](DynamicAddToCartWidget.php). Either way the stored value is a post ID, so `render()`/`get_value()` resolves it identically regardless of which control produced it.

## Critical rules

- **Never preload a `SELECT2` from an unbounded query** (products/posts/users). Preloaded options are for small, fixed sets only.
- **Use `QUERY_CONTROL_ID` + `autocomplete` for large datasets.** It is search-scoped server-side; size doesn't matter.
- **The query control is Pro-only.** Always `class_exists( QueryControlModule::class )` and degrade to a `Controls_Manager::TEXT` manual-ID field (or a deliberately bounded `SELECT2`).
- **Write no AJAX handler.** Pro owns `pro_panel_posts_control_filter_autocomplete` / `query_control_value_titles`; you only declare `autocomplete`.
- **`'options' => []`** for a query control — options arrive via AJAX; preloading defeats the purpose.
- **Pick the right `object`**: `author` = users who authored content, `user` = all users; `post` needs a `query.post_type` scope; `tax` searches terms.
- **The stored value is an ID** — cast and resolve it (`get_permalink( (int) $id )`, `wc_get_product( (int) $id )`) and handle a missing/invalid ID.

## Common mistakes

```php
// WRONG — query control but options preloaded (pointless + slow)
$this->add_control( 'id', [
    'type'    => QueryControlModule::QUERY_CONTROL_ID,
    'options' => $all_products,           // add_control( 'id', [ 'type' => QueryControlModule::QUERY_CONTROL_ID, /* … */ ] );
// (no class_exists guard → fatal/empty when Pro inactive)

// WRONG — writing your own ajax handler for it
add_action( 'wp_ajax_my_product_search', /* … */ );   // <-- unnecessary; Pro handles query control AJAX

// RIGHT — feature-detect, degrade, let Pro do the AJAX
$type = class_exists( QueryControlModule::class )
    ? QueryControlModule::QUERY_CONTROL_ID
    : \Elementor\Controls_Manager::TEXT;
```

## Cross-references

- Run **`elementor-dynamic-tag-fields`** for the control types, `Tag` vs `Data_Tag`, and reading settings back.
- Run **`elementor-dynamic-tag-register`** for registering the tag and the Pro-feature reality.
- Run **`wc-product-search-select`** for the WooCommerce-native product search/select control (a non-Elementor alternative for product pickers).

## What this skill does NOT cover

- **The query control's full option surface** (custom `query` args per object type, `include_type`, sorting) — read [module.php](module.php) `autocomplete_query_for_*`.
- **Building a bespoke AJAX select without Pro** — possible via a custom control + `wp_ajax_`, but out of scope; prefer the manual-ID degrade.
- **Query-control *filtering* for loop/posts widgets** (the `Group_Control` query side) — this skill is about single-item pickers.
- **Caching/transients for the search results** — Pro's handler runs uncached per keystroke; debounce/min-length is Pro's select2 default.

## References

- Query control module: [wp-content/plugins/elementor-pro/modules/query-control/module.php](module.php) — `QUERY_CONTROL_ID`/`QUERY_OBJECT_*` (29,34-39), empty-term early return (211), `autocomplete_query_for_post` sets `s` (233-242), AJAX action registration (~1012-1024).
- Query control class: [wp-content/plugins/elementor-pro/modules/query-control/controls/query.php:12-16](query.php) — `class Query extends Control_Select2`.
- AJAX query control in a dynamic tag: [wp-content/plugins/elementor-pro/modules/dynamic-tags/tags/internal-url.php:72-142](internal-url.php).
- Graceful-degradation widget: [wp-content/plugins/dynamic-elementor-extension-main/modules/widgets/dynamic/DynamicAddToCartWidget.php:43-53,181-198](DynamicAddToCartWidget.php).
- Bounded preloaded SELECT2 (correct small-set use): [wp-content/plugins/dynamic-elementor-extension-main/dynamic-tags/woo-tags/ProductAttributes.php:62-74](ProductAttributes.php).

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [Lonsdale201](https://github.com/Lonsdale201)
- **Source:** [Lonsdale201/wp-agent-skills](https://github.com/Lonsdale201/wp-agent-skills)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-lonsdale201-wp-agent-skills-elementor-dynamic-tag-ajax-select
- Seller: https://agentstack.voostack.com/s/lonsdale201
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
