$npx -y skills add Lonsdale201/wp-agent-skills --skill fluentcrm-rest-optionsRegister a custom AJAX option list for FluentCRM trigger / action / benchmark editor pickers. Pairs 'type' => 'rest_selector', 'option_key' => '...' in a settings field with a server-side add_filter('fluentcrm_ajax_options_{key}', $callback, 10, 3) callback. Filter signature
| 1 | # FluentCRM: register a `rest_selector` option list |
| 2 | |
| 3 | For developers building a custom FluentCRM trigger, action, or benchmark that needs a multi-select / single-select field whose options come from your plugin (target courses, target products, target post types, target whatever). The settings-field side declares `'type' => 'rest_selector', 'option_key' => 'my_things'`; the server side registers `add_filter('fluentcrm_ajax_options_my_things', $callback, 10, 3)`. This is a small focused contract — under 100 lines of code per integration — but it has one subtle "include pre-selected IDs" trap. |
| 4 | |
| 5 | ## API stability note |
| 6 | |
| 7 | The `fluentcrm_ajax_options_*` filter family has been in place since FluentCRM 2.5.9. The 3-argument signature `($options, $search, $includedIds)` and the `[{id, title}, ...]` return shape have not changed. |
| 8 | |
| 9 | In FluentCRM 3.1.8, `OptionsController::index()` guards the comma-separated `fields` dispatcher with reflection: only public, non-static, zero-required-argument methods declared on `OptionsController` itself are invoked. Custom dynamic pickers still go through `getAjaxOptions()` and the `fluentcrm_ajax_options_{option_key}` fallback filter. |
| 10 | |
| 11 | ## Misconception this skill corrects |
| 12 | |
| 13 | > "I'll filter the options by `$search` only — the picker handles the rest." |
| 14 | |
| 15 | Wrong — the picker does NOT separately fetch labels for already-saved IDs. When the admin opens an existing trigger / action whose `course_ids` is `[42, 99]`, the editor calls the same `getAjaxOptions` REST endpoint with `$includedIds = [42, 99]` and an empty `$search`. If your filter callback only honours `$search`, IDs 42 and 99 get a search query of `''` against your filter — which most callbacks treat as "return everything matching empty string" (so the labels are present), but if you've added a `posts_per_page` cap the saved IDs may not appear in the result, and the editor renders the field as bare numbers. |
| 16 | |
| 17 | The correct pattern: when `$includedIds` is non-empty, **bypass `$search` and load those specific IDs unconditionally**, then merge with the search-driven results. The canonical approach is to use `post__in` for CPT lookups so already-saved values always come back regardless of search range: |
| 18 | |
| 19 | ```php |
| 20 | public function get_my_things($options, $search, $includedIds) |
| 21 | { |
| 22 | $includedIds = is_array($includedIds) ? array_filter(array_map('intval', $includedIds)) : []; |
| 23 | |
| 24 | $args = [ |
| 25 | 'post_type' => 'my_thing', |
| 26 | 'post_status' => 'publish', |
| 27 | 'posts_per_page' => 50, |
| 28 | 'orderby' => 'title', |
| 29 | 'order' => 'ASC', |
| 30 | ]; |
| 31 | |
| 32 | if (!empty($search)) { |
| 33 | $args['s'] = (string) $search; |
| 34 | } |
| 35 | if (!empty($includedIds)) { |
| 36 | // CRITICAL — load pre-selected ids unconditionally so the editor |
| 37 | // can render their human labels instead of raw numeric IDs. |
| 38 | $args['post__in'] = $includedIds; |
| 39 | $args['posts_per_page'] = -1; |
| 40 | } |
| 41 | |
| 42 | foreach (get_posts($args) as $thing) { |
| 43 | $options[] = ['id' => $thing->ID, 'title' => $thing->post_title]; |
| 44 | } |
| 45 | |
| 46 | return $options; |
| 47 | } |
| 48 | ``` |
| 49 | |
| 50 | Other AI-prone misconceptions: |
| 51 | |
| 52 | - **"The filter callback returns a `WP_Query` / array of `WP_Post` objects."** No — it must return `array<int, array{id: scalar, title: string}>`. The picker JSON-serialises the result; objects with private fields throw. Use `array_map` if you have model objects. |
| 53 | - **"`option_key` can be anything; the picker just calls my filter."** The filter is dispatched only as the `getAjaxOptions()` fallback case. The controller has built-in handlers for many known option keys (`woo_products`, `woo_categories`, `available_lists`, `tags`, `editable_statuses`, `companies`, etc.). Pick a key prefixed with your plugin's slug to avoid collisions — e.g. `myplugin_things`, NOT just `things`. |
| 54 | - **"`$includedIds` is the se |