Skip to content

Bump version v2.10.2 - #208

Merged
RishadAlam merged 15 commits into
mainfrom
redesign/settings-doc-support
Aug 4, 2026
Merged

Bump version v2.10.2#208
RishadAlam merged 15 commits into
mainfrom
redesign/settings-doc-support

Conversation

@RishadAlam

Copy link
Copy Markdown
Member

Description

Reworks the Bit CRM action so its fields come from the connected Bit CRM site instead of a hardcoded list, adds Bit CRM Pro custom-field mapping and Won/Lost closing dates, masks secrets in the Connections form, adds a Pending status to EmailOctopus, shortens nested trigger-data labels, and cuts the 2.10.2 release.

Motivation & Context

The hardcoded Bit CRM field lists had drifted and covered only part of each module (no address fields; missing industry, annual_revenue, secondary_email, mobile, date_of_birth, probability, amount, cost_price). {Module}Service::fields() already merges shipped fields, site label/required overrides and Pro custom fields, so the action now reads from there. Convert Lead also promised less than LeadConvertService does — it always converts contacts and companies — so those are now locked on. Alongside that, connection secrets were rendered in plain text, and deeply nested trigger fields produced unreadable multi-level labels.

Type of Change

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

Key Changes

Bit CRM (Action)

  • Added BitCrmFieldService — action fields are fetched from Bit CRM and routed by type (select, record picker, field map row)
  • Added BitCrmCustomField — Bit CRM Pro custom fields map through cf::<field_key> into the customFieldsValues payload; required comes from the field attributes blob
  • Added automatic closing date when a deal moves to the Won or Lost stage
  • Updated Convert Lead — Contact and Company are preselected, cannot be deselected, and are re-added server-side
  • Updated values now live under conf.fieldValues keyed by Bit CRM's own field key, dropping the conf-key-to-CRM-key translation table (old flat keys dropped, not migrated — the integration shipped in no release tag, so no saved flow can carry them)

Frontend

  • Added SecretInput + EyeIcn — masked credential fields with a reveal toggle, wired into API, OAuth1 and OAuth2 connection forms
  • Added EmailOctopus "Pending" contact status
  • Removed unused authorization logic and token helper from IntegrationHelpers / GoogleIntegrationHelpers

Core

  • Updated Helper::prepareFetchFormatFields() — readable label paths: deep paths collapse to root + ... + tail, repeated parent words drop, list indexes glue to their key (Items 0)
  • Fixed Bit Form base URL read from the wrong connection key (domainNameapp_domain)
  • Removed the stale Bit CRM entry from AllTriggersName

Release

  • Updated version to 2.10.2 across bitwpfi.php, Config.php, readme.txt
  • Updated readme changelog and the in-app changelog (ChangelogToggle.jsx)

Checklist

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

Changelog

  • Feature: Bit CRM — custom fields created in Bit CRM now appear in action field mapping
  • Feature: Bit CRM — action fields load directly from your site, adding address, mobile, date of birth, industry, annual revenue, secondary email, deal amount, probability, cost price and more, and respecting your own labels and required settings
  • Feature: Bit CRM — deal actions can set the closing date automatically on the Won or Lost stage
  • Feature: EmailOctopus — contacts can be added or updated with the "Pending" status
  • Feature: Connections — API keys, secrets and tokens are masked, with an eye button to reveal them
  • Improvement: Trigger data — nested field names are shorter and easier to read
  • Improvement: Bit CRM (Convert Lead) — Contact and Company are always created, matching what Bit CRM actually does
  • Fix: Bit Form — site address not read from the connection, which stopped some actions from running

Bit CRM's LeadConvertService always runs convertToCompanies() and
convertToContacts(); only the deal is gated on convertTo. Preselect both,
block deselecting them, and re-add them server-side so a stored option
list can never claim less than the conversion does.
Bit CRM Pro lets a site define extra fields per module. They live in their
own tables and never appear on the entity model, so field map rows keyed
cf::<field_key> route to the customFieldsValues payload instead of the
entity's columns. Required comes from the field's attributes blob, which is
where Bit CRM folds everything except label and status.
The hardcoded field lists had drifted and covered only part of each
module: no address fields, and missing industry, annual_revenue,
secondary_email, mobile, date_of_birth, probability, amount, cost_price
and more. {Module}Service::fields() already merges the shipped fields,
the site's label/required overrides and Pro custom fields, so read the
list from there and route each reported field by type — select, record
picker, or field map row.

Values now live under conf.fieldValues keyed by Bit CRM's own field key,
which removes the conf-key-to-CRM-key translation table. The old flat
keys are dropped rather than migrated: the integration ships in no
release tag, so no saved flow can carry them.
Copilot AI lite review requested due to automatic review settings August 4, 2026 06:24

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.

@RishadAlam RishadAlam changed the title feat(bit-crm): dynamic action fields, custom fields and 2.10.2 release Bump version v2.10.2 Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 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

Replace wp_date() with gmdate() plus the site GMT offset, since wp_date()
requires WordPress 5.3 while the plugin supports 5.1.

Silence the dynamic hook name prefix warning for the Bit CRM custom field
save hook, which belongs to the Bit CRM plugin namespace.
Copilot AI review requested due to automatic review settings August 4, 2026 06:31

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.

@abdul-kaioum abdul-kaioum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Requesting changes — one blocking item (custom-field type routing, spans PHP + JS), plus two correctness/behavior notes and one confirm. File-by-file inline below.

  • Blocking: custom fields whose CRM type is select/lookup_* get misrouted in the field UI (BitCrmFieldService.php + BitCrmCommonFunc.js).
  • Confirm: update_deal_stage now hard-requires a closing date on won/lost stages — regresses any pre-existing flow that lacks one.
  • Note: timezone double-shift in the dealClosingDate free-form fallback.
  • Confirm (non-blocking): Bit Form app_domain cutover.

'isCustom' => false,
];

if (!empty($field['is_custom'])) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking. Custom-field rows return here keeping their raw CRM type (set at L101) and without options/relatedModule. The frontend (BitCrmCommonFunc.js crm*Fields) routes purely by type, so a Pro custom field whose type is select or lookup_* gets pulled into crmSelectFields/crmLookupFields and rendered with options={sel.options} = undefined → empty/broken control, value unpickable.

Your own comment states custom fields are always field-map rows — but nothing enforces that against the type. Fix here: neutralize the type for custom rows before returning, e.g. $row['type'] = 'text';, or fix the frontend to route custom by isCustom (see companion comment). Please verify against the actual CrmPro custom-field type values.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed. Bit CRM Pro custom fields carry their own type — the picker offers text, textarea, number, select, multi-select, radio, checkbox, date, section — so select is a real value and the row was landing in crmSelectFields with options undefined. (section was already dropped earlier in normalizeAll().)

Neutralized at the source, so a custom row is always a field map row:

$row['key'] = BitCrmCustomField::PREFIX . $key;
$row['type'] = self::TYPE_TEXT;
$row['isCustom'] = true;

Nothing is lost by dropping the type here: the real one is read back from the field definition at write time — BitCrmCustomField::values() passes $byKey[$fieldKey]['type'] into formatValue(), so multi-select and checkbox still serialize as JSON lists.

Companion frontend fix applied as well.

const relaxOnUpdate = (fields, action) =>
action?.startsWith('update_') ? fields.map(fld => ({ ...fld, required: false })) : fields

export const crmMapFields = bitCrmConf =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Companion to the BitCrmFieldService::normalize() issue: these three selectors split fields by type only. A custom field (isCustom: true) whose type equals select/lookup is excluded from crmMapFields and lands in crmSelectFields/crmLookupFields with no options.

Make custom always a map row, e.g.

export const crmMapFields = bitCrmConf =>
  relaxOnUpdate(
    crmFieldsOf(bitCrmConf).filter(
      fld => fld.isCustom || (fld.type !== SELECT_TYPE && fld.type !== LOOKUP_TYPE)
    ),
    bitCrmConf?.mainAction
  )

and exclude isCustom from the select/lookup filters.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Applied as suggested, with the same guard on the other two selectors so a custom row cannot be claimed by both sides:

// A custom field is a field map row whatever its own type says, because it never
// carries the options or related module the other two controls need.
export const crmMapFields = bitCrmConf =>
  relaxOnUpdate(
    crmFieldsOf(bitCrmConf).filter(
      fld => fld.isCustom || (fld.type !== SELECT_TYPE && fld.type !== LOOKUP_TYPE)
    ),
    bitCrmConf?.mainAction
  )

export const crmSelectFields = bitCrmConf =>
  relaxOnUpdate(
    crmFieldsOf(bitCrmConf).filter(fld => !fld.isCustom && fld.type === SELECT_TYPE),
    bitCrmConf?.mainAction
  )

export const crmLookupFields = bitCrmConf =>
  relaxOnUpdate(
    crmFieldsOf(bitCrmConf).filter(
      fld => !fld.isCustom && fld.type === LOOKUP_TYPE && lookupSources[fld.relatedModule]
    ),
    bitCrmConf?.mainAction
  ).map(fld => ({ ...fld, ...lookupSources[fld.relatedModule] }))

BitCrmFieldService::normalize() now also stamps custom rows as text, so the routing holds from either end.

}

// Required on a stage that closes the deal, asked for on no other.
if (\in_array($definition['deal_category'] ?? '', ['closed_won', 'closed_lost'], true)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two things:

  1. Confirm. Won/lost stages now hard-require closed_at (L588). Any pre-existing update_deal_stage flow that targets a closing stage without a mapped closing date will start failing at run time. This rests on the "shipped in no release tag → no saved flows" assumption from the PR description — please confirm it holds for update_deal_stage specifically.
  2. When stages can't be read, dealStages() returns [] and $definition is null; $definition['deal_category'] ?? '' silently yields '', so the closing-date requirement is skipped. That's the intended fallback but it's implicit — a one-line comment would prevent a future "why doesn't this validate" head-scratch.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

1. Right to push on it — the assumption does not hold. update_deal_stage shipped in 2.10.1 (released 30 Jul 2026): git show 2.10.1:frontend/src/components/AllIntegrations/BitCrm/staticData.js carries it at line 47, and backend/Actions/BitCrm/ is present in that tag. Saved flows can exist, so the hard requirement is dropped — an unmapped closing date now leaves the column alone instead of failing a stage change that used to work:

if (\in_array($definition['deal_category'] ?? '', ['closed_won', 'closed_lost'], true)) {
    $closedAt = self::dealClosingDate($fieldData['closed_at'] ?? '');

    if ($closedAt !== null) {
        $update['closed_at'] = $closedAt;
    }
}

closingDateField stays required: true on the frontend, so a flow built or edited from now on is still steered to map it — only the run-time hard stop is gone. The PR description's "shipped in no release tag" line will be corrected too.

2. Comment added:

// When the stages cannot be read $definition is null and the category reads
// as '', so this is skipped along with the stage check above rather than
// guessing at a site whose stage list is unreachable.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correcting my previous reply on point 1 — the hard requirement stays. A won or lost stage without a closing date is not a state worth writing, so the action fails rather than closing a deal with no closing date on it:

if (\in_array($definition['deal_category'] ?? '', ['closed_won', 'closed_lost'], true)) {
    $closedAt = self::dealClosingDate($fieldData['closed_at'] ?? '');

    if ($closedAt === null) {
        return ['success' => false, 'message' => __('A closing date is required to move a deal to a won or lost stage.', 'bit-integrations')];
    }

    $update['closed_at'] = $closedAt;
}

The finding behind your question still stands and the PR description will be corrected: update_deal_stage did ship in 2.10.1 (released 30 Jul 2026 — backend/Actions/BitCrm/ is in the tag, and staticData.js:47 carries the action). Its update_deal_stage offered only deal_id, so no 2.10.1 flow can have a closing date mapped, and any of them pointed at a won or lost stage will now fail until the flow is opened and the row filled in. That is accepted deliberately rather than by assumption.

Point 2 is addressed as asked:

// Required on a stage that closes the deal, asked for on no other. When the
// stages cannot be read $definition is null and the category reads as '', so
// this is skipped along with the stage check above.

return;
}

return gmdate('Y-m-d H:i:s', $timestamp + (int) (get_option('gmt_offset') * HOUR_IN_SECONDS));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Note. Timezone double-shift in the free-form fallback: strtotime($value) parses the string in the server timezone → UTC epoch, then this line re-applies WP's gmt_offset. When server TZ ≠ WP offset the stored local time drifts. The three exact regex fast-paths above are correct and cover the shapes the CRM modal submits; only this catch-all is affected. Consider get_date_from_gmt()/wp_date() semantics, or document that the fallback is best-effort.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed — though not quite for the stated reason. WordPress pins PHP's default timezone to UTC in wp-settings.php, so strtotime() on a timezone-less string already yields a UTC epoch and the gmt_offset addition was the single conversion, not a second one. The real defect is that gmt_offset is a fixed number that does not follow daylight saving, so a site on a named timezone drifts by an hour for part of the year.

// WordPress pins PHP's default timezone to UTC, so a string without one of
// its own parses to a UTC instant here. get_date_from_gmt() carries it back
// to site-local through the site's timezone, which follows daylight saving;
// the raw gmt_offset option does not.
return get_date_from_gmt(gmdate('Y-m-d H:i:s', $timestamp), 'Y-m-d H:i:s');

wp_date() is out — the plugin still declares WP 5.1 support and wp_date() landed in 5.3, the same reason SmartTags.php uses date_i18n(). get_date_from_gmt() predates both and returns the bare site-local Y-m-d H:i:s the other three branches produce.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correcting my previous reply — get_date_from_gmt() is the wrong fix here, and a closer look found a bigger problem than the one reported.

The reported cause does not hold: wp-settings.php pins PHP's default timezone to UTC (date_default_timezone_set( 'UTC' )), so strtotime() on a timezone-less string already yields a UTC epoch — the gmt_offset addition was the single conversion, not a second one.

The actual defect is that the conversion runs in the wrong direction for the common case, and it breaks the invariant this function's own docblock states. The three fast paths return a bare Y-m-d H:i:s verbatim, i.e. read as site-local. The fallback took the same wall clock and pushed it forward by the offset:

mapped value old new
2026-08-04 10:00:00 (fast path) 2026-08-04 10:00:00 unchanged
08/04/2026 10:00 (+06:00 site) 2026-08-04 16:00:00 2026-08-04 10:00:00
2026-08-04T10:00:00Z (America/New_York, August) 2026-08-04 05:00:00 2026-08-04 06:00:00

Row 2 is the one that matters — the same intent stored six hours apart depending on which branch matched. get_date_from_gmt() produces 16:00 there too, so it would not have fixed it. Row 3 shows the DST point as a bonus: stale gmt_offset gave EST in the middle of EDT.

Parsing against the site's timezone gets both right — a bare string is read as local, one that names a zone is converted into that same local wall clock:

$timezone = self::siteTimezone();
$datetime = date_create($value, $timezone);

if ($datetime === false) {
    return;
}

return $datetime->setTimezone($timezone)->format('Y-m-d H:i:s');

siteTimezone() uses wp_timezone() where it exists and reproduces its two cases otherwise — the plugin declares Requires at least: 5.1 and wp_timezone()/wp_date() both land in 5.3, the same reason SmartTags.php uses date_i18n(). Half-hour zones are covered (Asia/Kolkata+05:30).

Numbers above are from running both implementations side by side, not read off the source.

'fields' => [
'api_key' => 'value',
'domainName' => 'domainName',
'app_domain' => 'domainName',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Confirm (non-blocking). The rest of this controller already reads requestParams->app_domain (L30/45/61/77), so aligning the connection map key here looks correct — domainName was the outlier. Just confirm no live Bit Form connection still persists the domain under the old domainName key, since this is a hard cutover with no fallback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed — no cutover, and nothing to migrate. In $authConfig['fields'] the key is the property written onto $requestParams and the value is the key read out of the stored auth details:

// CredentialInjector::inject()
$target->{$oldField} = $authDetails[$authKey] ?? '';

So 'app_domain' => 'domainName' still reads the stored domainName key — which is exactly what BitFormAuthorization.jsx saves the field as (name: 'domainName'). Only the destination property changed, from $requestParams->domainName to $requestParams->app_domain, the one this controller actually reads. Existing connections are untouched.

Copilot AI review requested due to automatic review settings August 4, 2026 08:37

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.

@RishadAlam
RishadAlam merged commit ceeb518 into main Aug 4, 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