Skip to content

Bump version to v2.10.4 - #219

Merged
RishadAlam merged 64 commits into
mainfrom
redesign/settings-doc-support
Sep 5, 2026
Merged

Bump version to v2.10.4#219
RishadAlam merged 64 commits into
mainfrom
redesign/settings-doc-support

Conversation

@RishadAlam

Copy link
Copy Markdown
Member

Description

Release 2.10.4. Adds a shared "action user" source so WordPress actions can run for the logged-in user or for a user resolved from a mapped email field, adds a Markdown editor with live preview for Google Calendar event descriptions (Pro-gated), and ships a broad hardening pass across integrations, AJAX handling and the admin UI.

Motivation & Context

WordPress-side actions (LMS, membership, affiliate, gamification) previously enrolled/unenrolled whoever the request context happened to belong to — in LearnDash and LifterLMS the unenroll actions used a hardcoded user id, so they removed the wrong person. A single reusable user-source control fixes this consistently across twelve integrations.

Alongside that, a review pass found a set of real defects in the integrations (dropped fields, mislabelled successes, unpaginated fetchers, silent GET webhooks) and two crash paths in activation/checkout. Those are fixed here rather than spread over separate releases.

Related Links: (if applicable)

Type of Change

  • 🐛 Bug fix
  • ✨ New feature
  • 💥 Breaking change
  • 📚 Documentation update
  • ⚡ Improvement
  • 🔄 Code refactor

Key Changes

Action User Resolution

  • Added backend/Core/Util/ActionUser.php plus the UserSourceSelect / UserEmailFieldMap frontend helpers — one control to choose between the logged-in user and a user resolved from a mapped email field.
  • Added user-source support to LearnDash, LifterLMS, Tutor LMS, MasterStudy LMS, Academy LMS, WP Courseware, MemberPress, Paid Memberships Pro, Restrict Content, GamiPress, AffiliateWP and SliceWP.
  • Fixed LearnDash and LifterLMS unenroll actions that operated on a hardcoded user id.

Google Calendar & Markdown Editor

  • Added a Markdown editor for the Google Calendar event description, with an icon toolbar, a visual Preview tab and token insertion that keeps caret and scroll position.
  • Added frontend/src/Utils/markdownToHtml.js for the preview rendering.
  • Updated the Markdown description to be Pro-gated, with the conversion handed off to the Pro plugin.

Integrations

  • Fixed Brevo dropping contacts when mapped fields were empty, and duplicating contacts for +-addressed emails.
  • Fixed Notion checkbox, multi-select and decimal number values.
  • Fixed Systeme.io logging failed runs as successful.
  • Fixed Asana sending null custom fields; added pagination to the project, section and custom-field fetchers.
  • Fixed Zoho Bigin record-response warnings and unsorted layout list.
  • Fixed ActiveCampaign account-details endpoint.
  • Fixed Custom API silently sending webhooks as GET, and the edit screen not showing the saved method.
  • Fixed KonnectzIT saved integrations not matching their action type on open.
  • Improved SendPulse address-book pagination and made its config state updates immutable.
  • Improved Google Sheet error surfacing and Drive listing pagination.

Core & Frontend

  • Added an ErrorBoundary so a render error contains itself instead of blanking the admin app.
  • Added JSON recovery in bitsFetch for admin-ajax responses corrupted by stray PHP output.
  • Fixed activation, customer and checkout crash paths.
  • Improved trigger test-data polling — paced requests, and the spinner always clears on stop.
  • Refactored prefixed strings to build through Config::withPrefix().
  • Added tutorial links to several authorization screens; marked SureContact and BrilliantDirectories as Pro in Select Action.

Release

  • Updated version to 2.10.4 and added the 2.10.4 changelog to readme.txt.

Checklist

  • Code follows project style guidelines
  • Self-review completed
  • Tests added/updated
  • Documentation updated if needed
  • README updated if needed

Changelog

  • Feature: Choose who an action runs for — the logged-in user, or a user resolved from a mapped email field — in LearnDash, LifterLMS, Tutor LMS, MasterStudy LMS, Academy LMS, WP Courseware, MemberPress, Paid Memberships Pro, Restrict Content, GamiPress, AffiliateWP and SliceWP.
  • Feature: Google Calendar — write event descriptions in Markdown with a live Preview tab (Pro).
  • Improvement: Asana loads the full list of projects, sections and custom fields instead of the first batch.
  • Improvement: SendPulse loads all address books, and the selected list no longer resets when its fields fail to load.
  • Improvement: Salesforce connects using the more secure PKCE sign-in flow.
  • Improvement: Custom API actions start on POST and the edit screen shows the saved request method.
  • Improvement: Trigger test-data polling no longer floods the site with requests, and the button always stops spinning.
  • Fix: LearnDash and LifterLMS unenroll actions removed a fixed user instead of the one from the flow.
  • Fix: Brevo dropped contacts with empty mapped fields and duplicated contacts for emails containing "+".
  • Fix: Notion checkboxes always saved as ticked, single-choice values sent to multi-select properties were dropped, and decimals lost their fraction.
  • Fix: Systeme.io logged failed runs as successful.
  • Fix: Asana skipped task creation when some mapped fields were empty.
  • Fix: Zoho Bigin warnings on failed runs and Deals actions saved without a layout; the layout dropdown is now sorted by name.
  • Fix: ActiveCampaign account details failed to load.
  • Fix: Custom API sent an empty GET when the method dropdown was never opened.
  • Fix: KonnectzIT integrations showed a spinner forever when opened.
  • Fix: A render error in the admin app no longer blanks the whole screen.

Three defects on the inline-credential refresh path that legacy flows
still use (CredentialInjector bails when connection_id is 0, so these
flows never reach the connection-based refresh in OAuth2Authorization):

- refreshAccessToken sent client_id, client_secret and refresh_token in
  the query string. A URL carrying them is recorded by access logs and
  forward proxies. Moved to a form-urlencoded POST body, matching what
  OAuth2Authorization::buildRefreshBody already does for every
  connection-based integration.
- refreshTokenDetails stored the `false` that refreshAccessToken returns
  on failure, so the next line fataled on $tokenDetails->access_token
  under PHP 8. The stale token is kept instead, letting Salesforce return
  the real 401 that callers already surface as wp_send_json_error.
- Seven saveRefreshedToken() calls passed $response['organizations'],
  a key refreshTokenDetails never returns, to a method that takes two
  parameters. The argument was discarded and the read emitted an
  undefined-array-key warning on every call.

The success path is unchanged in all three cases.
Salesforce blocks the web server flow outright when a Connected App
enables "Require Proof Key for Code Exchange (PKCE) Extension", which is
the default for External Client Apps. Switching the declared grant to
authorization_code_pkce makes Oauth2Connection attach code_challenge and
code_challenge_method=S256 on authorize, then code_verifier on exchange.

Apps that do not require PKCE ignore the challenge, so both configurations
work. Existing integrations are unaffected: Oauth2Connection normalizes
the stored grant back to authorization_code, the refresh path never reads
PKCE fields, EditSalesforce does not render the authorization step, and
reauthorizeConnection has no caller. Only new connections take this path.
Reverts 19cd39f. The change belongs on fix/salesforce-pkce-token-refresh,
not on this branch; it was pushed here by mistake.
Reverts cf98361. The change belongs on fix/salesforce-pkce-token-refresh,
not on this branch; it was pushed here by mistake.
The Tutor LMS action always ran against get_current_user_id(), so it only
worked for logged-in users. resolveUserId() now reads the flow's userSource:
when it is "email", the mapped user_email value is looked up with
get_user_by(), and an invalid or unknown email returns a WP_Error that is
logged and aborts the action. Flows saved before this change carry no
userSource, so they keep running against the logged-in user.

enrollCourse(), completeLesson(), completeCourse() and resetCourse() now take
the resolved user id instead of looking it up themselves.
Adds the user_email field definition, a generator for its field_map entry,
and the mapped/incomplete checks used to gate saving. TutorLmsFieldMap
renders the single locked user_email row with form field, custom value and
smart code sources, matching the field map used by the other LMS actions.
The integration layout gains a "Run Action For" select. Choosing "User
Matched by Email" reveals the user email field map and swaps the note to
explain that the user must already exist; the default stays on the logged-in
user so existing integrations render unchanged.

The wizard's Next button and the edit screen's Update button are disabled
while the email source is selected without a mapped email, and the edit
screen now passes form fields down to the layout.
Tutor LMS grew a userSource toggle that resolves the action's target user
from a mapped email. The same limitation exists across the other WordPress
plugin actions, so the pieces move somewhere reusable before being ported.

ActionUser::resolve() takes the flow details, the trigger values and the
field_map property an integration uses for its action-side field name, and
returns either a user id or a WP_Error. On the frontend, UserSourceSelect and
UserEmailFieldMap render the picker and the single locked email row, with the
matching state helpers in userSource.js.

Tutor LMS now uses those instead of its own copies; behaviour is unchanged.
Enroll and unroll ran against get_current_user_id(), so a flow triggered by a
webhook or a guest submission acted on user 0. The controller now resolves the
target through ActionUser, and the layout offers the logged-in / mapped-email
choice with the email field map behind it. Flows without userSource keep
running against the logged-in user.
Most actions already use field_map for their own payload, so adding a
user_email row there would collide with the integration's own mapping UI and
its required-row indexing. The mapped email now lives in its own
flow_details.userEmailField object, which keeps the shared picker independent
of whatever an integration does with field_map.

ActionUser::resolve() drops its field_map key argument and reads
userEmailField instead; UserEmailFieldMap renders that single row. Tutor LMS
and WP Courseware move over.
Adding a commission looked the affiliate up from get_current_user_id(), so a
flow without a logged-in user found no affiliate and silently dropped the
commission. The target user now comes from ActionUser, with the picker and
email field map in the layout.
Both referral actions stamped the referral with get_current_user_id() and, for
"create a referral for the user", derived the affiliate from it too. They now
take the user resolved by ActionUser.

get_user_by() returning false is also handled: the previous code dereferenced
user_login unconditionally, which fatals whenever the resolved id is 0.
Both membership integrations resolved their target with
get_current_user_id(), so a guest-triggered flow wrote a transaction or a
membership level against user 0. They now take the id ActionUser resolves and
offer the logged-in / mapped-email choice.

Paid Memberships Pro's helper had no access to the trigger values, so the
controller resolves the user and passes it into execute().
Adding to and removing from a level both ran against get_current_user_id(),
so a guest-triggered flow created an RCP customer for user 0. The target now
comes from ActionUser.

Note the integration's own field map is still collected and never read by
insertMember(); that is a separate defect and is left alone here.
All six award and revoke actions targeted get_current_user_id(). They now take
the user ActionUser resolves. The achievement award also passed the current
user as the awarding admin, which is now the same resolved id rather than a
second independent lookup.
Same shape as Tutor LMS: all five actions ran against get_current_user_id().
The controller resolves the target through ActionUser and threads it into
enroll, complete lesson, complete course and reset course.
The Pro actions already targeted a mapped user_email while the five free ones
resolved to get_current_user_id(), so one dropdown behaved two different ways.
The free actions now offer the same logged-in / mapped-email choice, resolved
through ActionUser.
Six of the seven actions resolved their target with get_current_user_id() and
now take the id ActionUser returns.

"Unenroll user from a membership" is left as it is: it assigns a fixed user id
rather than looking one up, so it needs its own fix and the picker is hidden
for that action.
Fourteen of the seventeen actions resolved their target with
get_current_user_id(). Each method now takes the id ActionUser returns, and the
layout offers the logged-in / mapped-email choice.

Two actions are excluded from the picker: "send an email to the users group
leaders" has no user target, and "unenroll the user from a course" assigns a
fixed user id rather than looking one up, so it needs its own fix first.
…ctions

"Unenroll the user from a course" assigned $user_id = 5 and "Unenroll user
from a membership" assigned $user_id = 30, so both acted on whoever those ids
belong to on the site rather than on the flow's user. Both now take the id
ActionUser resolves, like the rest of their actions, and the user source
picker is no longer hidden for them.

Two guards that the hardcoded values had masked:

- LifterLMS bailed on `!function_exists(...) && empty($user_id) &&
  empty($membershipId)`, which a non-empty hardcoded id made unreachable, so a
  missing llms_unenroll_student() would have been a fatal. It is `||` now.
- LearnDash returned $apiResponse without initialising it, which is undefined
  when 'any' is chosen and the user has no enrolled courses.
getAllList made a single GET to /addressbooks, so accounts with more
address books than the API's per-request cap only ever saw the first
page in the list dropdown.

Loop with limit/offset until a page returns fewer than the limit, and
bail out on a WP_Error or non-array response instead of iterating over
it.
The list dropdown reverted to its previous value whenever the field
fetch failed: handleInput put listId on a local copy of the config and
left the commit to refreshSendPulseHeader, which only calls the setter
inside its success branch. Selecting the empty option was ignored
entirely. Commit listId right away with a functional update, clear the
previous list's cached fields and mappings, and only fetch when a list
is actually selected.

Both refresh helpers captured the config at call time and wrote the
whole snapshot back once the response arrived, discarding anything the
user changed while the request was in flight. They now apply the
response onto the latest state via mutative.

Neither helper set isLoading, so the loader never appeared and the
refresh buttons never disabled, which let repeated clicks race each
other. They set it now.

The field map handlers shallow-copied the config and then spliced or
assigned into field_map, mutating the array and row objects still held
in state. In the edit screen that state is the $actionConf Recoil atom.
Use the shared mutative-based FieldMapHelper handlers instead, plus a
matching handleCustomValue.

Also default listId and customValue to an empty string so the inputs
stay controlled on first render.
Any notice, warning or deprecation printed before wp_send_json_* lands in
the admin-ajax body ahead of the payload, so JSON.parse throws. The catch
branch tried to salvage it with /{"success":(?:[^{}]*)*}/, but [^{}]
excludes braces, so that pattern can only match an object with no nested
object. Every response here is nested, so the salvage always failed and
the caller got {success: false} with the raw html as data. Components read
that as a permanent failure and left their loading state on forever. The
nested quantifier also backtracked catastrophically on long bodies.

Scan for the first balanced top-level value instead, skipping braces that
appear inside string literals, and retry from the next candidate when the
prefix text itself contained one. Handles prefixes, suffixes and both.

fetch() also rejects on abort and on network failure, and nothing caught
that, so the rejection escaped as an unhandled rejection and stranded the
caller the same way. Return a value in both cases, flagging aborts so the
polling loops can tell them apart from a genuine empty response.
KonnectzIT saves flow_details.type as 'konnectzIT' with a lowercase k, but
the edit switch only listed 'KonnectzIT'. Opening a saved KonnectzIT
integration fell through to the default branch, which renders a loader, so
the settings screen span forever and the action could never be edited.
There was no error boundary anywhere in the app, so a throw inside any one
integration component tore the whole tree down and left an empty page with
no message and nothing pointing at the cause. Wrap the route tree, the new
action screen and the edit screen, keyed so navigating away clears the
caught error.

Both action switches also failed silently when a name did not match a case.
New action screens returned an empty fragment and edit screens returned a
loader, which reads as a blank page and an endless spinner respectively.
Say which action has no screen, and mention Pro on the edit path since a
deactivated Pro plugin is the common way to reach it.
The method dropdown is uncontrolled, so a user who filled in a url and a
body but never opened it saved no actionMethod at all. execute() then fell
back to 'get', and WP puts a GET body in the query string, so the request
arrived at the receiving end as a GET with nothing in it.

Seed POST on new actions and key the dropdown so it shows the saved value
once the config loads on the edit screen. Saved flows keep falling back to
GET rather than changing behaviour under them, but now log why, naming the
dropped body so the cause is visible from the log instead of only from the
receiving end.

processPayload also read $details->body->type with no guard, warning on
every run for an action whose body tab was never opened.
The eight fetch loops re-fired the moment a poll came back empty, with no
delay, so waiting for a test submission sent back-to-back requests to
admin-ajax for the full three minute window.

Wait FETCH_RETRY_DELAY between polls, and do not retry a request the user
aborted by pressing Stop.

The try/catch around each loop is synchronous and never saw a rejected
promise, so a throw inside the .then body skipped stopFetching() and left
the button spinning until the countdown expired. Give the chains a catch,
and let Webhook's own catch clear the state it was missing.
A mapped field the trigger did not send resolved to null and was still put
in the payload, so Brevo got {"email":null,"attributes":{"FIRSTNAME":null}}
and rejected the whole request instead of ignoring the empty parts. Leave
absent values out; a field that is present but blank still goes through as
an empty string.

Fail with a clear message when the email itself resolves to nothing, since
Brevo cannot create a contact without one and the raw api error did not say
which field was at fault.

The email also went into the lookup and update urls unencoded, so a plus
addressed contact was never matched and got duplicated on every run.
custom_fields entries resolved to null for any field the trigger did not
send, and Asana rejects the whole task rather than skipping them, so tasks
never got created and the log only showed nulls. Leave absent values out.

The project, section and custom field fetchers each made one unpaginated
call. Asana caps a page and returns next_page.offset, so accounts past the
first page only ever saw part of their projects and the one they wanted
looked missing. Walk the offsets instead, bounded at MAX_PAGES.

addTask returned nothing when the api failed and a section was configured,
so the caller logged a null response instead of the error. The result check
also read ->data and ->status off responses that can be an array or a
WP_Error, warning on every failed run.

Absent request parameters are now rejected up front rather than
interpolated into the url as empty strings.
The method dropdown is uncontrolled, so a user who filled in a url and a
body but never opened it saved no actionMethod at all. execute() then fell
back to 'get', and WP puts a GET body in the query string, so the request
arrived at the receiving end as a GET with nothing in it.

Seed POST on new actions and key the dropdown so it shows the saved value
once the config loads on the edit screen. Saved flows keep falling back to
GET rather than changing behaviour under them, but now log why, naming the
dropped body so the cause is visible from the log instead of only from the
receiving end.

processPayload also read $details->body->type with no guard, warning on
every run for an action whose body tab was never opened.
The eight fetch loops re-fired the moment a poll came back empty, with no
delay, so waiting for a test submission sent back-to-back requests to
admin-ajax for the full three minute window.

Wait FETCH_RETRY_DELAY between polls, and do not retry a request the user
aborted by pressing Stop.

The try/catch around each loop is synchronous and never saw a rejected
promise, so a throw inside the .then body skipped stopFetching() and left
the button spinning until the countdown expired. Give the chains a catch,
and let Webhook's own catch clear the state it was missing.
A mapped field the trigger did not send resolved to null and was still put
in the payload, so Brevo got {"email":null,"attributes":{"FIRSTNAME":null}}
and rejected the whole request instead of ignoring the empty parts. Leave
absent values out; a field that is present but blank still goes through as
an empty string.

Fail with a clear message when the email itself resolves to nothing, since
Brevo cannot create a contact without one and the raw api error did not say
which field was at fault.

The email also went into the lookup and update urls unencoded, so a plus
addressed contact was never matched and got duplicated on every run.
custom_fields entries resolved to null for any field the trigger did not
send, and Asana rejects the whole task rather than skipping them, so tasks
never got created and the log only showed nulls. Leave absent values out.

The project, section and custom field fetchers each made one unpaginated
call. Asana caps a page and returns next_page.offset, so accounts past the
first page only ever saw part of their projects and the one they wanted
looked missing. Walk the offsets instead, bounded at MAX_PAGES.

addTask returned nothing when the api failed and a section was configured,
so the caller logged a null response instead of the error. The result check
also read ->data and ->status off responses that can be an array or a
WP_Error, warning on every failed run.

Absent request parameters are now rejected up front rather than
interpolated into the url as empty strings.
addContact returned nothing at all when a tag was selected: the tag branch
called addTag and dropped its result on the floor. execute() then tested
isset(null->errors), which is false, so every run logged "successfully" no
matter what the api answered. Return the response, and skip tagging when
the contact was not created rather than tagging id null.

The tag branch was also entered on isset(), which is true for a saved but
empty tag, so an action with the tag cleared took the broken path too.

Mapped fields the trigger did not send were read unguarded, warning on
every run and putting nulls in the payload; leave them out.

An empty email returned a plain array that execute() then logged as a
success. Detect it and log the failure.
settype() returns whether the cast succeeded, not the cast value, so every
checkbox property was written to Notion as true regardless of what the form
sent. Cast the value instead.

multi_select iterated the value with no array check, so a radio or single
select field arrived as a scalar, warned, and the property was dropped
rather than sent as its one option.

number cast to int, silently truncating any decimal the form collected, and
turned non numeric input into 0; send the real number and nothing at all
when the value is not numeric.

The response check read ->object off a value that can be a WP_Error, and
the field map read properties that a saved config need not carry.
Every fetcher tested $response->response->error for failure, but Google
returns error at the top level, so a 401 from an expired token passed the
success check. The spreadsheet listing then read ->files off the error
object, warned, and answered success with an empty list, which reached
users as a spreadsheet dropdown that was simply blank with nothing
explaining why. The worksheet and header fetchers read ->status and
->message off a WP_Error, which carries neither, so they always said
"Unknown". Read the real shape in one place and name reauthorization as
the fix on 401 and 403.

refreshAccessToken returns false on any failure, including invalid_grant,
and all three callers carried on with the token they already knew was
expired, guaranteeing the 401 above. Stop and ask for a reconnect instead.

The drive listing also requested no page size and ignored nextPageToken,
so accounts past the first page could not find their spreadsheet. Walk the
pages and request only the fields being used.

$authorizationHeader was read before assignment when the token was still
fresh, warning into the response body on every call.
The failure check read ->data[0]->status unconditionally, so a WP_Error or
any response without a data array warned on every failed run and the
warning went out in the ajax body.

pLayout was read with no guard, warning whenever a Deals action was saved
before a layout was picked.

The layout list is a plain list, so uksort compared its numeric keys and
never ordered the dropdown by anything visible. Sort on display_label.
Two WP_Error codes added with the earlier action fixes wrote the
bit_integrations_ prefix as a literal instead of going through
Config::withPrefix(), leaving the prefix with more than one definition.

Also drops the inline commentary from those fixes; the pragmas and the
doc blocks stay.
The google fetchers were made to stop with a reconnect message whenever
refreshAccessToken returned false. That is wrong for a config whose
generates_on is unset or stale but whose token still works, and it is
redundant now that the error reader understands google's real shape: an
expired token comes back as a 401 and already reports as a reconnect. Fall
through to the request instead.

The asana list builders read ->gid and ->name off each record with no
guard, so an unexpected shape warned into the ajax body, which is the same
failure the rest of this work is removing. Skip records with no gid and
fall back to it for the label.
get_role('administrator') returns null when a membership or role plugin has
removed or renamed the role, and add_cap() on that is fatal during plugin
activation.

get_userdata() returns false for an id that no longer resolves. On customer
create that produced a warning and an empty payload; on customer delete
in_array() then received null for its haystack, which is a TypeError and so
a hard fatal while deleting a user.

The Store API checkout handler, which runs for every block checkout, read
the legacy $order->id property and built its own WC_Checkout instead of
taking the singleton, so it skipped woocommerce_checkout_init and tripped
_doing_it_wrong inside the checkout response.
Inserting a form field or smart tag from the dropdowns appended the token
at the end of the document and scrolled there, instead of writing at the
caret and staying put.

Two causes stacked. getRange() read el.selectionStart unconditionally, but
opening the select blurs the textarea, and an unfocused controlled textarea
reports its caret at the end once React has replaced its value. And
assigning a new value resets scrollTop and parks the caret at the end.

getRange() now trusts the live caret only while the textarea has focus, and
otherwise falls back to the position remembered from select, keyup, click
and blur. commit() captures scrollTop before pushing the value up and the
pending-selection effect restores it after setSelectionRange.

The scroll restore is skipped in the one case where it would hide the
result: no remembered caret at all, when the dropdown is used before the
textarea has ever been clicked. The token then appends at the end and the
browser is left to scroll to it. appendedAtEndRef carries that decision
from getRange to commit so every toolbar button gets the same handling
without touching their call sites.

Also fixes the same behaviour in the Trello card description editor.
Adds a "Rich Text Description" utility to the Google Calendar action. When
enabled, the description is written in the shared MarkdownEditor instead of
being mapped from a plain text form field, and description is dropped from
the Field Map so it cannot be set twice.

Google Calendar renders HTML in event descriptions, not Markdown, so raw
Markdown would show literal ** and _ in the event. RecordApiHelper converts
the stored Markdown to the small HTML subset Google keeps: headings become
a bold paragraph, quotes and code blocks become paragraphs, since h1-h6,
blockquote and pre are all dropped on their side.

The pipeline is convert, then substitute, then wp_kses. Converting before
substitution keeps a form value containing * or _ from injecting formatting;
running kses after it keeps a value containing markup out of the payload and
the log. Smart tags are shielded behind placeholders during the inline pass
so an underscore in ${first_name} is not read as emphasis, and link targets
are restricted to http, https, mailto and tel.

descRichText is passed as a defaulted argument to executeRecordApi, so
existing saved flows are unaffected.

Google Calendar saves now go through the sanitize_post_content route, which
leaves the Markdown punctuation intact.
Moves the Markdown to HTML conversion out of the free plugin. RecordApiHelper
now fires a filter and uses whatever comes back, instead of converting inline:

    $description = Hooks::apply(
        Config::withPrefix('googlecalendar_rich_description'),
        null,
        $descRichText,
        $fieldValues
    );

The null default is load bearing. With Pro inactive nothing binds the filter,
so the description is left unset rather than sent to Google as literal
Markdown, which is what a free-side fallback conversion would have produced.

Drops markdownToHtml(), inlineMarkdownToHtml() and sanitizeDescription() from
this file; they move to the Pro plugin along with the wp_kses allowlist. The
substitution of form values into the converted HTML moves with them, so the
convert, then substitute, then kses ordering is preserved on the Pro side.
The toggle is what gets gated, not just the editor. Enabling it strips
description from the Field Map, so a free user able to flip it would lose the
plain text mapping and get nothing in its place. It now carries input-disable
and opens ProModal instead, using the same class this file already applies to
the all-day and slot-check exclusion.

The editor itself is wrapped in ActionProFeatureComponent as well, which covers
a flow that already has richTextDesc saved when Pro is later deactivated.
Swaps the text and emoji glyphs for Lucide icons from react-icons, which is
already a dependency. The mix of text labels, punctuation and a full-colour
link emoji never read as one set.

Layout is unchanged — same round icn-btn row, same selects, same textarea. The
per-tool style overrides go away with the text they existed for: bold's font
weight, italic's font style and the numbered list's smaller font size.

The button carries inline-flex centering because .icn-btn sets line-height to
1px and is not a flex container, so an SVG child would otherwise sit off
centre. Kept on the button rather than in app.scss so the shared .icn-btn class
stays as it is for everything else using it.
Adds Write and Preview modes so the description can be read as it will be
rendered instead of only as raw Markdown.

markdownToHtml.js is a JS port of the block parser that already exists in PHP
on the Pro side: headings, emphasis, both list kinds, blockquotes, inline and
fenced code, links. Smart tags are shielded behind placeholders so the
underscore in ${first_name} is not read as emphasis, and link hrefs are limited
to http, https, mailto and tel.

dangerouslySetInnerHTML is safe here because the renderer never passes input
through. Every line is HTML escaped first and the tags are built by the parser,
so markup in the source comes out as text.

The toolbar row is reworked to fit the mode switch without wrapping. The two
150px selects were the widest elements in the row while being the least used,
and squeezing them truncated their labels to "Form Fie..." — worse than no
label at all. They collapse into one Insert dropdown holding both groups, which
is the merge tag pattern these editors usually use, and the reclaimed space
pays for hairline separators splitting the twelve buttons into headings,
emphasis, lists, and links and code.

The row renders in both modes with only the tools behind a write check. Hiding
the whole row in preview would take the mode switch with it and leave no way
back.

Note on fidelity: the preview renders standard Markdown semantics, so a heading
shows as a heading. Google Calendar flattens headings to bold and blockquotes
to paragraphs, so for that action the preview shows structure that only partly
survives. It is accurate for Trello, which renders Markdown natively.
"Rich Text Description" described an editor that no longer exists. The field
takes Markdown with a preview, not rich text, and the name promised WYSIWYG
behaviour the control does not have.

Renames the string in all three places it appears: the utility checkbox, the
Pro modal subject, and the Pro gate overlay on the editor.

The supporting copy moves with it. The subtitle now names the Markdown and
preview the neutral title leaves out, and the helper line points at the Preview
tab, which nothing mentioned before. Both keep their second sentences about the
Field Map and the Google Calendar conversion.

The richTextDesc key is deliberately unchanged. It is persisted in saved flows
and read by the Pro side hook, so renaming it would break existing
integrations.
Copilot AI lite review requested due to automatic review settings September 5, 2026 10:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

✅ WordPress Plugin Check Report

✅ Status: Passed

📊 Report

All checks passed! No errors or warnings found.


🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

@RishadAlam RishadAlam changed the title feat: action user mapping, markdown descriptions and integration fixes Bump version to v2.10.4 Sep 5, 2026
@RishadAlam
RishadAlam merged commit 1426747 into main Sep 5, 2026
1 check passed
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.

3 participants