From 624996c0709f1dc41c0d624f44c4adcbf25067f5 Mon Sep 17 00:00:00 2001 From: Rishad Alam Date: Sat, 1 Aug 2026 12:22:43 +0600 Subject: [PATCH 01/14] feat(EmailOctopus): add pending status option for contact actions --- .../Actions/EmailOctopus/RecordApiHelper.php | 2 ++ .../EmailOctopus/EmailOctopusActions.jsx | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/backend/Actions/EmailOctopus/RecordApiHelper.php b/backend/Actions/EmailOctopus/RecordApiHelper.php index 880cf9982..30645b175 100644 --- a/backend/Actions/EmailOctopus/RecordApiHelper.php +++ b/backend/Actions/EmailOctopus/RecordApiHelper.php @@ -60,6 +60,8 @@ public function addContact($selectedTags, $finalData, $selectedList) if (!empty($this->_integrationDetails->actions->status)) { $data['status'] = 'UNSUBSCRIBED'; + } elseif (!empty($this->_integrationDetails->actions->pending)) { + $data['status'] = 'PENDING'; } else { $data['status'] = 'SUBSCRIBED'; } diff --git a/frontend/src/components/AllIntegrations/EmailOctopus/EmailOctopusActions.jsx b/frontend/src/components/AllIntegrations/EmailOctopus/EmailOctopusActions.jsx index 3ae459abf..96d4a4584 100644 --- a/frontend/src/components/AllIntegrations/EmailOctopus/EmailOctopusActions.jsx +++ b/frontend/src/components/AllIntegrations/EmailOctopus/EmailOctopusActions.jsx @@ -38,10 +38,19 @@ export default function EmailOctopusActions({ if (type === 'status') { if (e.target.checked) { newConf.actions.status = true + delete newConf.actions.pending } else { delete newConf.actions.status } } + if (type === 'pending') { + if (e.target.checked) { + newConf.actions.pending = true + delete newConf.actions.status + } else { + delete newConf.actions.pending + } + } setEmailOctopusConf({ ...newConf }) } @@ -81,6 +90,14 @@ export default function EmailOctopusActions({ title={__('Unsubscribe contact', 'bit-integrations')} subTitle={__('Set the contact status to "unsubscribed".', 'bit-integrations')} /> + actionHandler(e, 'pending')} + className="wdt-200 mt-4 mr-2" + value="pending_status" + title={__('Pending contact', 'bit-integrations')} + subTitle={__('Set the contact status to "pending".', 'bit-integrations')} + /> Date: Sat, 1 Aug 2026 15:21:58 +0600 Subject: [PATCH 02/14] refactor(IntegrationHelpers): remove unused authorization logic and token helper --- .../GoogleIntegrationHelpers.js | 48 ------- .../IntegrationHelpers/IntegrationHelpers.js | 124 ------------------ 2 files changed, 172 deletions(-) diff --git a/frontend/src/components/AllIntegrations/IntegrationHelpers/GoogleIntegrationHelpers.js b/frontend/src/components/AllIntegrations/IntegrationHelpers/GoogleIntegrationHelpers.js index 393239f59..01da3ff86 100644 --- a/frontend/src/components/AllIntegrations/IntegrationHelpers/GoogleIntegrationHelpers.js +++ b/frontend/src/components/AllIntegrations/IntegrationHelpers/GoogleIntegrationHelpers.js @@ -1,51 +1,3 @@ -import bitsFetch from '../../../Utils/bitsFetch' -import { __ } from '../../../Utils/i18nwrap' - -const tokenHelper = ( - ajaxInteg, - grantToken, - confTmp, - setConf, - setisAuthorized, - setIsLoading, - setSnackbar, - btcbi -) => { - const tokenRequestParams = { ...grantToken } - tokenRequestParams.clientId = confTmp.clientId - tokenRequestParams.clientSecret = confTmp.clientSecret - // eslint-disable-next-line no-undef - tokenRequestParams.redirectURI = `${btcbi.api}/redirect` - - bitsFetch(tokenRequestParams, `${ajaxInteg}_generate_token`) - .then(result => result) - .then(result => { - if (result && result.success) { - const newConf = { ...confTmp } - newConf.tokenDetails = result.data - setConf(newConf) - setisAuthorized(true) - setSnackbar({ show: true, msg: __('Authorized Successfully', 'bit-integrations') }) - } else if ( - (result && result.data && result.data.data) || - (!result.success && typeof result.data === 'string') - ) { - setSnackbar({ - show: true, - msg: `${__('Authorization failed Cause:', 'bit-integrations')}${ - result.data.data || result.data - }. ${__('please try again', 'bit-integrations')}` - }) - } else { - setSnackbar({ - show: true, - msg: __('Authorization failed. please try again', 'bit-integrations') - }) - } - setIsLoading(false) - }) -} - export const addFieldMap = (i, confTmp, setConf, uploadFields, tab) => { const newConf = { ...confTmp } if (tab) { diff --git a/frontend/src/components/AllIntegrations/IntegrationHelpers/IntegrationHelpers.js b/frontend/src/components/AllIntegrations/IntegrationHelpers/IntegrationHelpers.js index 1de7deffa..5e5aa529f 100644 --- a/frontend/src/components/AllIntegrations/IntegrationHelpers/IntegrationHelpers.js +++ b/frontend/src/components/AllIntegrations/IntegrationHelpers/IntegrationHelpers.js @@ -462,130 +462,6 @@ export const saveActionConf = async ({ } } -export const handleAuthorize = ( - integ, - ajaxInteg, - scopes, - confTmp, - setConf, - setError, - setisAuthorized, - setIsLoading, - setSnackbar, - btcbi -) => { - if (!confTmp.dataCenter || !confTmp.clientId || !confTmp.clientSecret) { - setError({ - dataCenter: !confTmp.dataCenter ? __("Data center can't be empty", 'bit-integrations') : '', - clientId: !confTmp.clientId ? __("Client Id can't be empty", 'bit-integrations') : '', - clientSecret: !confTmp.clientSecret ? __("Secret key can't be empty", 'bit-integrations') : '' - }) - return - } - setIsLoading(true) - const apiEndpoint = `https://accounts.zoho.${ - confTmp.dataCenter - }/oauth/v2/auth?scope=${scopes}&response_type=code&client_id=${ - confTmp.clientId - }&prompt=Consent&access_type=offline&state=${encodeURIComponent( - window.location.href - )}/redirect&redirect_uri=${encodeURIComponent(`${btcbi.api}`)}/redirect` - const authWindow = window.open(apiEndpoint, integ, 'width=400,height=609,toolbar=off') - const popupURLCheckTimer = setInterval(() => { - if (authWindow.closed) { - clearInterval(popupURLCheckTimer) - let grantTokenResponse = {} - let isauthRedirectLocation = false - const bitformsZoho = localStorage.getItem(`__${integ}`) - if (bitformsZoho) { - isauthRedirectLocation = true - grantTokenResponse = JSON.parse(bitformsZoho) - localStorage.removeItem(`__${integ}`) - } - - if ( - !grantTokenResponse.code || - grantTokenResponse.error || - !grantTokenResponse || - !isauthRedirectLocation - ) { - const errorCause = grantTokenResponse.error ? `Cause: ${grantTokenResponse.error}` : '' - setSnackbar({ - show: true, - msg: `${__('Authorization failed', 'bit-integrations')} ${errorCause}. ${__( - 'please try again', - 'bit-integrations' - )}` - }) - setIsLoading(false) - } else { - grantTokenResponse['accounts-server'] = decodeURIComponent(grantTokenResponse['accounts-server']) - const newConf = { ...confTmp } - newConf.accountServer = grantTokenResponse['accounts-server'] - tokenHelper( - ajaxInteg, - grantTokenResponse, - newConf, - setConf, - setisAuthorized, - setIsLoading, - setSnackbar, - btcbi - ) - } - } - }, 500) -} - -const tokenHelper = ( - ajaxInteg, - grantToken, - confTmp, - setConf, - setisAuthorized, - setIsLoading, - setSnackbar, - btcbi -) => { - const tokenRequestParams = { ...grantToken } - tokenRequestParams.dataCenter = confTmp.dataCenter - tokenRequestParams.clientId = confTmp.clientId - tokenRequestParams.clientSecret = confTmp.clientSecret - // tokenRequestParams.redirectURI = `${encodeURIComponent(window.location.href)}/redirect` - tokenRequestParams.redirectURI = `${btcbi.api}/redirect` - - bitsFetch(tokenRequestParams, `${ajaxInteg}_generate_token`) - .then(result => result) - .then(result => { - if (result && result.success) { - const newConf = { ...confTmp } - newConf.tokenDetails = result.data - setConf(newConf) - setisAuthorized(true) - setSnackbar({ - show: true, - msg: __('Authorized Successfully', 'bit-integrations') - }) - } else if ( - (result && result.data && result.data.data) || - (!result.success && typeof result.data === 'string') - ) { - setSnackbar({ - show: true, - msg: `${__('Authorization failed Cause:', 'bit-integrations')}${ - result.data.data || result.data - }. ${__('please try again', 'bit-integrations')}` - }) - } else { - setSnackbar({ - show: true, - msg: __('Authorization failed. please try again', 'bit-integrations') - }) - } - setIsLoading(false) - }) -} - export const addFieldMap = (i, confTmp, setConf, uploadFields, tab) => { const newConf = { ...confTmp } if (tab) { From 9df4ec025e736064e4b56ba371a3b71985f14970 Mon Sep 17 00:00:00 2001 From: Rishad Alam Date: Sat, 1 Aug 2026 16:30:46 +0600 Subject: [PATCH 03/14] feat(SecretInput): add masked input field with reveal toggle functionality --- frontend/src/Icons/EyeIcn.jsx | 37 +++++++++++++ .../components/Connections/ApiConnection.jsx | 6 +-- .../Connections/Oauth1Connection.jsx | 6 +-- .../Connections/Oauth2Connection.jsx | 15 ++++-- .../src/components/Utilities/SecretInput.jsx | 39 ++++++++++++++ frontend/src/resource/sass/app.scss | 53 +++++++++++++++++++ 6 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 frontend/src/Icons/EyeIcn.jsx create mode 100644 frontend/src/components/Utilities/SecretInput.jsx diff --git a/frontend/src/Icons/EyeIcn.jsx b/frontend/src/Icons/EyeIcn.jsx new file mode 100644 index 000000000..7c17aaf07 --- /dev/null +++ b/frontend/src/Icons/EyeIcn.jsx @@ -0,0 +1,37 @@ +/* eslint-disable max-len */ +export default function EyeIcn({ size = 20, stroke = 2, off = false }) { + return ( + + {off ? ( + <> + + + + + + ) : ( + <> + + + + )} + + ) +} diff --git a/frontend/src/components/Connections/ApiConnection.jsx b/frontend/src/components/Connections/ApiConnection.jsx index 597deec61..b40775e24 100644 --- a/frontend/src/components/Connections/ApiConnection.jsx +++ b/frontend/src/components/Connections/ApiConnection.jsx @@ -11,6 +11,7 @@ import { } from '../../Utils/connectionTemplates' import { __ } from '../../Utils/i18nwrap' import LoaderSm from '../Loaders/LoaderSm' +import SecretInput from '../Utilities/SecretInput' const ERROR_TEXT_STYLE = { color: 'red', fontSize: '15px' } @@ -324,12 +325,11 @@ export default function ApiConnection({
{__('Password:', 'bit-integrations')}
- diff --git a/frontend/src/components/Connections/Oauth1Connection.jsx b/frontend/src/components/Connections/Oauth1Connection.jsx index bdcc3915a..cd6851299 100644 --- a/frontend/src/components/Connections/Oauth1Connection.jsx +++ b/frontend/src/components/Connections/Oauth1Connection.jsx @@ -20,6 +20,7 @@ import { __ } from '../../Utils/i18nwrap' import { APP_CONFIG } from '../../config/app' import LoaderSm from '../Loaders/LoaderSm' import CopyText from '../Utilities/CopyText' +import SecretInput from '../Utilities/SecretInput' const ERROR_TEXT_STYLE = { color: 'red', fontSize: '15px' } @@ -379,12 +380,11 @@ export default function Oauth1Connection({
{authDetails?.clientSecretLabel || __('Client Secret:', 'bit-integrations')}
- diff --git a/frontend/src/components/Connections/Oauth2Connection.jsx b/frontend/src/components/Connections/Oauth2Connection.jsx index 818f0be67..f6299fb62 100644 --- a/frontend/src/components/Connections/Oauth2Connection.jsx +++ b/frontend/src/components/Connections/Oauth2Connection.jsx @@ -16,6 +16,7 @@ import { import { __ } from '../../Utils/i18nwrap' import LoaderSm from '../Loaders/LoaderSm' import CopyText from '../Utilities/CopyText' +import SecretInput from '../Utilities/SecretInput' import { APP_CONFIG } from '../../config/app' const ERROR_TEXT_STYLE = { color: 'red', fontSize: '15px' } @@ -349,6 +350,15 @@ export default function Oauth2Connection({ ))} + ) : field.type === 'password' ? ( + ) : ( {__('Client Secret:', 'bit-integrations')} - diff --git a/frontend/src/components/Utilities/SecretInput.jsx b/frontend/src/components/Utilities/SecretInput.jsx new file mode 100644 index 000000000..cdee5ebc0 --- /dev/null +++ b/frontend/src/components/Utilities/SecretInput.jsx @@ -0,0 +1,39 @@ +import { useState } from 'react' +import EyeIcn from '../../Icons/EyeIcn' +import { __ } from '../../Utils/i18nwrap' + +/** + * Masked credential field with a reveal toggle. Layout classes go on the wrapper + * so the button can sit inside the input; everything else is forwarded. + */ +export default function SecretInput({ className = '', disabled, ...inputProps }) { + const [isRevealed, setIsRevealed] = useState(false) + + // Saved credentials are never sent back to the browser, so a disabled field has + // nothing to reveal — the toggle would only ever uncover an empty input. + const canReveal = !disabled + + return ( +
+ + {canReveal && ( + + )} +
+ ) +} diff --git a/frontend/src/resource/sass/app.scss b/frontend/src/resource/sass/app.scss index 1ff3ecdd3..90d0de163 100644 --- a/frontend/src/resource/sass/app.scss +++ b/frontend/src/resource/sass/app.scss @@ -2711,6 +2711,59 @@ $log-ease: cubic-bezier(0.23, 1, 0.32, 1); } } +.btcd-secret-fld { + position: relative; + + // Keeps a long secret from running underneath the toggle. + & > input { + padding-right: 38px !important; + } +} + +.btcd-secret-fld-btn { + position: absolute; + top: 50%; + right: 7px; + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border: 0; + border-radius: 6px; + background: none; + color: #6b6b6b; + cursor: pointer; + transform: translateY(-50%); + transition: + color 160ms ease, + background-color 160ms ease, + transform 160ms cubic-bezier(0.23, 1, 0.32, 1); + + @media (hover: hover) and (pointer: fine) { + &:hover { + color: $dp-txt; + background-color: rgb(0 0 0 / 6%); + } + } + + &:focus-visible { + outline: 2px solid $purple; + outline-offset: 1px; + } + + &:active { + transform: translateY(-50%) scale(0.92); + } + + @media (prefers-reduced-motion: reduce) { + transition: color 160ms ease, background-color 160ms ease; + + &:active { + transform: translateY(-50%); + } + } +} + .integ-fld-wrp { min-width: 800px; max-width: 800px; From 21d39ab6a3aa9e51bc56189007c27e887d124994 Mon Sep 17 00:00:00 2001 From: Rishad Alam Date: Sun, 2 Aug 2026 16:03:54 +0600 Subject: [PATCH 04/14] refactor(AllTriggersName): remove 'Bit CRM' entry from triggers list --- backend/Core/Util/AllTriggersName.php | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/Core/Util/AllTriggersName.php b/backend/Core/Util/AllTriggersName.php index be68d8479..10aa8e9ef 100644 --- a/backend/Core/Util/AllTriggersName.php +++ b/backend/Core/Util/AllTriggersName.php @@ -59,7 +59,6 @@ public static function allTriggersName() 'CreatorLms' => ['name' => 'Creator LMS', 'isPro' => true, 'is_active' => false], 'FluentCart' => ['name' => 'FluentCart', 'isPro' => true, 'is_active' => false], 'FluentPlayer' => ['name' => 'FluentPlayer', 'isPro' => true, 'is_active' => false], - 'BitCrm' => ['name' => 'Bit CRM', 'isPro' => false, 'is_active' => false], 'Wsms' => ['name' => 'WSMS (WP SMS)', 'isPro' => true, 'is_active' => false], 'FluentCrm' => ['name' => 'Fluent CRM', 'isPro' => true, 'is_active' => false], 'FluentCommunity' => ['name' => 'Fluent Community', 'isPro' => true, 'is_active' => false], From 721cb8ee1cafa55c2bf8ed619d41d0c753ae58dc Mon Sep 17 00:00:00 2001 From: Rishad Alam Date: Mon, 3 Aug 2026 11:08:52 +0600 Subject: [PATCH 05/14] feat(BitCrm): lock contact and company into convert lead 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. --- backend/Actions/BitCrm/BitCrmActionHelper.php | 9 +- .../BitCrm/BitCrmCommonFunc.js | 2 +- .../BitCrm/BitCrmIntegLayout.jsx | 85 ++++++++++++++++--- .../AllIntegrations/BitCrm/staticData.js | 4 +- 4 files changed, 84 insertions(+), 16 deletions(-) diff --git a/backend/Actions/BitCrm/BitCrmActionHelper.php b/backend/Actions/BitCrm/BitCrmActionHelper.php index 1f265075f..6a65f9c8f 100644 --- a/backend/Actions/BitCrm/BitCrmActionHelper.php +++ b/backend/Actions/BitCrm/BitCrmActionHelper.php @@ -578,7 +578,14 @@ public static function convertLead($fieldData) } $leadId = (int) $fieldData['lead_id']; - $convertTo = self::csvList($fieldData['convert_to']); + + // LeadConvertService always creates the contact and the company; only the + // deal is gated on convertTo. Older flows could store either one out, so + // put them back and keep the option list honest about what will happen. + $convertTo = array_values(array_unique(array_merge( + ['contact', 'company'], + self::csvList($fieldData['convert_to']) + ))); $options = [ 'convertTo' => $convertTo, 'moveRelatedDataTo' => $fieldData['move_related_data_to'], diff --git a/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js b/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js index b38042cd7..90f44caae 100644 --- a/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js +++ b/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js @@ -46,7 +46,7 @@ export const checkMappedFields = bitCrmConf => { return mappedFields.length === 0 } -const isEmptyValue = value => +export const isEmptyValue = value => value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0) export const missingRequiredSelect = bitCrmConf => { diff --git a/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx b/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx index d8cdbcf09..e5163638f 100644 --- a/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx +++ b/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx @@ -7,7 +7,12 @@ import { __ } from '../../../Utils/i18nwrap' import TableCheckBox from '../../Utilities/TableCheckBox' import { checkIsPro, getProLabel } from '../../Utilities/ProUtilHelpers' import { addFieldMap } from '../IntegrationHelpers/IntegrationHelpers' -import { generateMappedField, refreshBitCrmList, syncRequiredFieldMap } from './BitCrmCommonFunc' +import { + generateMappedField, + isEmptyValue, + refreshBitCrmList, + syncRequiredFieldMap +} from './BitCrmCommonFunc' import BitCrmFieldMap from './BitCrmFieldMap' import { actionDropdowns, @@ -21,6 +26,8 @@ import { export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmConf }) { const { isPro } = useRecoilValue($appConfigState) const [isLoading, setIsLoading] = useState(false) + // Bumped to remount a locked select, see handleSelectChange. + const [lockedSelectKey, setLockedSelectKey] = useState(0) const action = bitCrmConf?.mainAction const bitCrmFields = bitCrmStaticData[action] ?? [] @@ -44,22 +51,70 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon // eslint-disable-next-line react-hooks/exhaustive-deps }, [action]) + // A select whose value Bit CRM enforces anyway carries a default. Seed it here + // too, so a config saved before the default existed still opens with it set. + useEffect(() => { + if (!action) return + + const unseeded = selects.filter( + sel => sel.defaultValue !== undefined && isEmptyValue(bitCrmConf?.[sel.key]) + ) + if (unseeded.length === 0) return + + setBitCrmConf(prevConf => + create(prevConf, draftConf => { + unseeded.forEach(sel => { + draftConf[sel.key] = sel.defaultValue + }) + }) + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [action]) + const setField = (key, val) => setBitCrmConf(prevConf => create(prevConf, draftConf => { draftConf[key] = val - // A dependent list belongs to the value just replaced, so drop it along - // with the selection made from it. - ;[...selects, ...dropdowns] - .filter(item => item.dependsOn === key) - .forEach(item => { - delete draftConf[item.key] - delete draftConf[item.listKey] - }) + // A dependent list belongs to the value just replaced, so drop it along + // with the selection made from it. + ;[...selects, ...dropdowns] + .filter(item => item.dependsOn === key) + .forEach(item => { + delete draftConf[item.key] + delete draftConf[item.listKey] + }) }) ) + // A locked option cannot be clicked off in the menu, but its chip still carries + // a delete button and the clear button wipes the whole select. Put the locked + // values back, then remount the dropdown so its own copy of the value follows: + // it re-reads the prop only when the string changes, and a rejected delete + // leaves that string exactly as it was. + const handleSelectChange = (sel, val) => { + if (!sel.lockedValues) { + setField(sel.key, val) + return + } + + const picked = String(val ?? '') + .split(',') + .filter(Boolean) + const locked = [ + ...sel.lockedValues, + ...picked.filter(item => !sel.lockedValues.includes(item)) + ].join(',') + + if (locked !== val) setLockedSelectKey(prevKey => prevKey + 1) + setField(sel.key, locked) + } + + const selectOptions = sel => + sel.lockedValues + ? sel.options.map(opt => (sel.lockedValues.includes(opt.value) ? { ...opt, disabled: true } : opt)) + : sel.options + const toggleUtility = key => setBitCrmConf(prevConf => create(prevConf, draftConf => { @@ -85,6 +140,9 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon allConfigurableKeys.forEach(key => { if (!keepKeys.has(key)) delete draftConf[key] }) + ; (actionSelects[value] ?? []).forEach(sel => { + if (sel.defaultValue !== undefined) draftConf[sel.key] = sel.defaultValue + }) }) ) } @@ -111,7 +169,9 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon {/* Fixed enum selects */} {selects.map(sel => ( -
+
{sel.label} {sel.required && *}: @@ -120,12 +180,11 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon title={sel.key} defaultValue={bitCrmConf?.[sel.key] ?? null} className="btcd-paper-drpdwn w-5" - options={sel.options} - onChange={val => setField(sel.key, val)} + options={selectOptions(sel)} + onChange={val => handleSelectChange(sel, val)} singleSelect={!sel.multi} closeOnSelect={!sel.multi} /> - {sel.helperText && {sel.helperText}}
))} diff --git a/frontend/src/components/AllIntegrations/BitCrm/staticData.js b/frontend/src/components/AllIntegrations/BitCrm/staticData.js index 7aadd50ff..263c40ad1 100644 --- a/frontend/src/components/AllIntegrations/BitCrm/staticData.js +++ b/frontend/src/components/AllIntegrations/BitCrm/staticData.js @@ -485,7 +485,9 @@ const convertToSel = { label: __('Convert To', 'bit-integrations'), options: convertToOptions, multi: true, - required: true + required: true, + lockedValues: ['contact', 'company'], + defaultValue: 'contact,company', } const moveRelatedSel = { key: 'moveRelatedDataTo', From aa667ef28d9e47db74dc1670461762a466e5bccf Mon Sep 17 00:00:00 2001 From: Rishad Alam Date: Mon, 3 Aug 2026 11:36:28 +0600 Subject: [PATCH 06/14] refactor(BitCrm): trim convert lead comments --- backend/Actions/BitCrm/BitCrmActionHelper.php | 3 +-- .../AllIntegrations/BitCrm/BitCrmIntegLayout.jsx | 11 ++++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/backend/Actions/BitCrm/BitCrmActionHelper.php b/backend/Actions/BitCrm/BitCrmActionHelper.php index 6a65f9c8f..80a129601 100644 --- a/backend/Actions/BitCrm/BitCrmActionHelper.php +++ b/backend/Actions/BitCrm/BitCrmActionHelper.php @@ -580,8 +580,7 @@ public static function convertLead($fieldData) $leadId = (int) $fieldData['lead_id']; // LeadConvertService always creates the contact and the company; only the - // deal is gated on convertTo. Older flows could store either one out, so - // put them back and keep the option list honest about what will happen. + // deal is gated on convertTo. Older flows could store either one out. $convertTo = array_values(array_unique(array_merge( ['contact', 'company'], self::csvList($fieldData['convert_to']) diff --git a/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx b/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx index e5163638f..1394942d5 100644 --- a/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx +++ b/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx @@ -26,7 +26,6 @@ import { export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmConf }) { const { isPro } = useRecoilValue($appConfigState) const [isLoading, setIsLoading] = useState(false) - // Bumped to remount a locked select, see handleSelectChange. const [lockedSelectKey, setLockedSelectKey] = useState(0) const action = bitCrmConf?.mainAction @@ -51,8 +50,7 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon // eslint-disable-next-line react-hooks/exhaustive-deps }, [action]) - // A select whose value Bit CRM enforces anyway carries a default. Seed it here - // too, so a config saved before the default existed still opens with it set. + // A config saved before a select gained its default still opens without it. useEffect(() => { if (!action) return @@ -88,10 +86,9 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon ) // A locked option cannot be clicked off in the menu, but its chip still carries - // a delete button and the clear button wipes the whole select. Put the locked - // values back, then remount the dropdown so its own copy of the value follows: - // it re-reads the prop only when the string changes, and a rejected delete - // leaves that string exactly as it was. + // a delete button and the clear button wipes the whole select. Remount on a + // rejected delete: the dropdown re-reads the prop only when the string changes, + // and putting the value back leaves it exactly as it was. const handleSelectChange = (sel, val) => { if (!sel.lockedValues) { setField(sel.key, val) From f421c8aa0c458c40f357881b04186e4976020960 Mon Sep 17 00:00:00 2001 From: Rishad Alam Date: Mon, 3 Aug 2026 12:58:10 +0600 Subject: [PATCH 07/14] feat: bitCrm support custom fields in actions 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:: 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. --- backend/Actions/BitCrm/BitCrmActionHelper.php | 17 ++ backend/Actions/BitCrm/BitCrmController.php | 14 ++ backend/Actions/BitCrm/BitCrmCustomField.php | 198 ++++++++++++++++++ backend/Actions/BitCrm/Routes.php | 1 + .../BitCrm/BitCrmCommonFunc.js | 43 ++++ .../BitCrm/BitCrmIntegLayout.jsx | 42 +++- .../AllIntegrations/BitCrm/staticData.js | 15 ++ 7 files changed, 325 insertions(+), 5 deletions(-) create mode 100644 backend/Actions/BitCrm/BitCrmCustomField.php diff --git a/backend/Actions/BitCrm/BitCrmActionHelper.php b/backend/Actions/BitCrm/BitCrmActionHelper.php index 80a129601..e45f16779 100644 --- a/backend/Actions/BitCrm/BitCrmActionHelper.php +++ b/backend/Actions/BitCrm/BitCrmActionHelper.php @@ -32,6 +32,7 @@ public static function createLead($fieldData) $payload = [ 'systemDefinedFieldsValues' => $systemValues, + 'customFieldsValues' => BitCrmCustomField::values($fieldData, 'lead'), 'tagIds' => self::toIntArray($fieldData['tag_ids'] ?? []), ]; @@ -53,6 +54,7 @@ public static function updateLead($fieldData) $payload = [ 'id' => (int) $fieldData['lead_id'], 'systemDefinedFieldsValues' => $systemValues, + 'customFieldsValues' => BitCrmCustomField::values($fieldData, 'lead'), ]; return self::result((new \BitApps\Crm\Services\LeadService())->update($payload), __('Lead updated successfully.', 'bit-integrations')); @@ -131,6 +133,7 @@ public static function createContact($fieldData) $payload = [ 'systemDefinedFieldsValues' => $systemValues, + 'customFieldsValues' => BitCrmCustomField::values($fieldData, 'contact'), 'tagIds' => self::toIntArray($fieldData['tag_ids'] ?? []), ]; @@ -152,6 +155,7 @@ public static function updateContact($fieldData) $payload = [ 'id' => (int) $fieldData['contact_id'], 'systemDefinedFieldsValues' => $systemValues, + 'customFieldsValues' => BitCrmCustomField::values($fieldData, 'contact'), ]; return self::result((new \BitApps\Crm\Services\ContactService())->update($payload), __('Contact updated successfully.', 'bit-integrations')); @@ -230,6 +234,7 @@ public static function createCompany($fieldData) $payload = [ 'systemDefinedFieldsValues' => $systemValues, + 'customFieldsValues' => BitCrmCustomField::values($fieldData, 'company'), 'tagIds' => self::toIntArray($fieldData['tag_ids'] ?? []), ]; @@ -251,6 +256,7 @@ public static function updateCompany($fieldData) $payload = [ 'id' => (int) $fieldData['company_id'], 'systemDefinedFieldsValues' => $systemValues, + 'customFieldsValues' => BitCrmCustomField::values($fieldData, 'company'), ]; return self::result((new \BitApps\Crm\Services\CompanyService())->update($payload), __('Company updated successfully.', 'bit-integrations')); @@ -329,6 +335,7 @@ public static function createDeal($fieldData) $payload = [ 'systemDefinedFieldsValues' => $systemValues, + 'customFieldsValues' => BitCrmCustomField::values($fieldData, 'deal'), 'tagIds' => self::toIntArray($fieldData['tag_ids'] ?? []), ]; @@ -350,6 +357,7 @@ public static function updateDeal($fieldData) $payload = [ 'id' => (int) $fieldData['deal_id'], 'systemDefinedFieldsValues' => $systemValues, + 'customFieldsValues' => BitCrmCustomField::values($fieldData, 'deal'), ]; return self::result((new \BitApps\Crm\Services\DealService())->update($payload), __('Deal updated successfully.', 'bit-integrations')); @@ -428,6 +436,7 @@ public static function createProduct($fieldData) $payload = [ 'systemDefinedFieldsValues' => $systemValues, + 'customFieldsValues' => BitCrmCustomField::values($fieldData, 'product'), 'tagIds' => self::toIntArray($fieldData['tag_ids'] ?? []), ]; @@ -473,6 +482,10 @@ public static function updateProduct($fieldData) return ['success' => false, 'message' => __('Failed to update product.', 'bit-integrations')]; } + // Writing the model directly skips the service that would normally store + // these, so they are saved here instead. + BitCrmCustomField::save('product', $productId, BitCrmCustomField::values($fieldData, 'product')); + do_action('bit_crm/product_updated', $product); return self::success(__('Product updated successfully.', 'bit-integrations'), self::normalizeData($product)); @@ -1461,6 +1474,10 @@ private static function systemValues($fieldData, array $drop) { $values = array_diff_key((array) $fieldData, array_flip($drop)); + // Custom fields travel in their own payload key, so they must not reach + // the entity's own columns. + $values = BitCrmCustomField::withoutCustomKeys($values); + return array_filter($values, static function ($v) { return $v !== null && $v !== '' && $v !== []; }); diff --git a/backend/Actions/BitCrm/BitCrmController.php b/backend/Actions/BitCrm/BitCrmController.php index f55f208b0..74b0cce3d 100644 --- a/backend/Actions/BitCrm/BitCrmController.php +++ b/backend/Actions/BitCrm/BitCrmController.php @@ -81,6 +81,20 @@ public static function refreshEntities($data) wp_send_json_success(['options' => self::normalize((new $service())->getEntitiesAsOptions())]); } + /** + * The module's custom fields, as extra rows for the field map. + * + * @param object $data + */ + public static function refreshCustomFields($data) + { + self::isExists(); + + $module = isset($data->module) ? sanitize_text_field($data->module) : ''; + + wp_send_json_success(['fields' => BitCrmCustomField::fieldMapOptions($module)]); + } + public static function refreshLeadTags() { wp_send_json_success(['options' => self::tagOptions('lead')]); diff --git a/backend/Actions/BitCrm/BitCrmCustomField.php b/backend/Actions/BitCrm/BitCrmCustomField.php new file mode 100644 index 000000000..751cbdd5e --- /dev/null +++ b/backend/Actions/BitCrm/BitCrmCustomField.php @@ -0,0 +1,198 @@ + + */ + public static function all(string $module) + { + if (!\in_array($module, self::MODULES, true) || !class_exists('BitApps\CrmPro\Model\CustomField')) { + return []; + } + + try { + $fields = \BitApps\CrmPro\Model\CustomField::where('module', $module)->get(); + } catch (Throwable $th) { + return []; + } + + if (empty($fields)) { + return []; + } + error_log('BitCrmCustomField::all() - ' . $module . ' - ' . print_r($fields->toArray(), true)); + $active = []; + foreach ($fields->toArray() as $field) { + if (empty($field['field_key']) || empty($field['status'])) { + continue; + } + + $active[] = $field; + } + + return $active; + } + + /** + * Custom fields as field map rows, shaped like the entries in bitCrmStaticData. + * + * @return array + */ + public static function fieldMapOptions(string $module) + { + $options = []; + + foreach (self::all($module) as $field) { + $options[] = [ + 'key' => self::PREFIX . $field['field_key'], + 'label' => $field['label'] ?? $field['field_key'], + 'required' => !empty(self::attributes($field)['required']), + ]; + } + + return $options; + } + + /** + * The mapped custom field rows, keyed the way Bit CRM's entity services + * expect: [field_key => ['field_id' => int, 'field_value' => string]]. + * + * Rows whose field no longer exists on the module are dropped, so deleting a + * custom field in Bit CRM cannot break a flow that still maps it. + */ + public static function values(array $fieldData, string $module) + { + $definitions = self::all($module); + + if (empty($definitions)) { + return []; + } + + $byKey = array_column($definitions, null, 'field_key'); + $values = []; + + foreach ($fieldData as $key => $value) { + if (strpos((string) $key, self::PREFIX) !== 0) { + continue; + } + + $fieldKey = substr((string) $key, \strlen(self::PREFIX)); + + if (!isset($byKey[$fieldKey]) || $value === null || $value === '' || $value === []) { + continue; + } + + $values[$fieldKey] = [ + 'field_id' => (int) $byKey[$fieldKey]['id'], + 'field_value' => self::formatValue($value, (string) ($byKey[$fieldKey]['type'] ?? '')), + ]; + } + + return $values; + } + + /** + * Drop the custom field rows from a field map, so they never reach the + * entity's own columns. + */ + public static function withoutCustomKeys(array $values) + { + return array_filter( + $values, + static function ($key) { + return strpos((string) $key, self::PREFIX) !== 0; + }, + ARRAY_FILTER_USE_KEY + ); + } + + /** + * Write custom field values the same way Bit CRM's own entity services do. + * Only needed where a record is written directly instead of through the + * service that fires this action itself. + */ + public static function save(string $module, int $entityId, array $values) + { + if (empty($values) || empty($entityId)) { + return; + } + + do_action(self::SAVE_HOOK, $module, $entityId, $values); + } + + /** + * A custom field keeps everything but its label and status in one JSON blob, + * `required` included. + */ + private static function attributes(array $field) + { + $attributes = $field['attributes'] ?? []; + + if (\is_string($attributes)) { + $attributes = json_decode($attributes, true); + } + + return \is_array($attributes) ? $attributes : []; + } + + /** + * Multi-value custom fields are stored as a JSON list; everything else is a + * plain string. A mapped value arrives comma separated when it comes from a + * trigger token rather than a real array. + * + * @param mixed $value + */ + private static function formatValue($value, string $type) + { + $multiValueTypes = class_exists('BitApps\CrmPro\Model\CustomField') + ? \BitApps\CrmPro\Model\CustomField::MULTI_VALUE_FIELD_TYPES + : ['multi-select', 'checkbox']; + + if (!\in_array($type, $multiValueTypes, true)) { + return \is_array($value) ? implode(', ', $value) : (string) $value; + } + + $items = \is_array($value) ? $value : explode(',', (string) $value); + + $items = array_values( + array_filter( + array_map('trim', array_map('strval', $items)), + static function ($item) { + return $item !== ''; + } + ) + ); + + return (string) wp_json_encode($items); + } +} diff --git a/backend/Actions/BitCrm/Routes.php b/backend/Actions/BitCrm/Routes.php index 111e7567d..7c4337fa7 100644 --- a/backend/Actions/BitCrm/Routes.php +++ b/backend/Actions/BitCrm/Routes.php @@ -14,6 +14,7 @@ Route::post('refresh_bitcrm_companies', [BitCrmController::class, 'refreshCompanies']); Route::post('refresh_bitcrm_users', [BitCrmController::class, 'refreshUsers']); Route::post('refresh_bitcrm_entities', [BitCrmController::class, 'refreshEntities']); +Route::post('refresh_bitcrm_custom_fields', [BitCrmController::class, 'refreshCustomFields']); Route::post('refresh_bitcrm_lead_tags', [BitCrmController::class, 'refreshLeadTags']); Route::post('refresh_bitcrm_contact_tags', [BitCrmController::class, 'refreshContactTags']); Route::post('refresh_bitcrm_company_tags', [BitCrmController::class, 'refreshCompanyTags']); diff --git a/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js b/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js index 90f44caae..50f9f4871 100644 --- a/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js +++ b/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js @@ -34,6 +34,49 @@ export const refreshBitCrmList = (route, listKey, setBitCrmConf, setIsLoading, p .catch(() => setIsLoading(false)) } +// Shares the loading state with the fetched dropdowns, which key it by list. +export const CUSTOM_FIELDS_KEY = 'customFields' + +export const NO_CUSTOM_FIELDS = { fields: [], module: '' } + +/** + * Custom fields are defined per site rather than shipped with Bit CRM, and + * defining them is a Bit CRM Pro feature, so a site without them gets none and + * the field map falls back to the static list. + * + * The module is stored alongside the fields because a required custom field adds + * a locked row to the map, and the previous module's rows must not survive the + * render between switching action and the new list arriving. + */ +export const fetchBitCrmCustomFields = (module, setCustomFields, setIsLoading, notify = false) => { + if (!module) { + setCustomFields(NO_CUSTOM_FIELDS) + return + } + + setIsLoading(CUSTOM_FIELDS_KEY) + + bitsFetch({ module }, 'refresh_bitcrm_custom_fields') + .then(result => { + const fetched = result?.success && Array.isArray(result?.data?.fields) ? result.data.fields : [] + + setCustomFields({ fields: fetched, module }) + setIsLoading(false) + + if (!notify) return + + if (result?.success) { + toast.success(__('Fields refreshed successfully', 'bit-integrations')) + } else { + toast.error(__('Bit CRM field fetch failed. Please try again', 'bit-integrations')) + } + }) + .catch(() => { + setCustomFields({ fields: [], module }) + setIsLoading(false) + }) +} + export const checkMappedFields = bitCrmConf => { const mappedFields = bitCrmConf?.field_map ? bitCrmConf.field_map.filter( diff --git a/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx b/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx index 1394942d5..ddb61ad2f 100644 --- a/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx +++ b/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx @@ -8,13 +8,17 @@ import TableCheckBox from '../../Utilities/TableCheckBox' import { checkIsPro, getProLabel } from '../../Utilities/ProUtilHelpers' import { addFieldMap } from '../IntegrationHelpers/IntegrationHelpers' import { + CUSTOM_FIELDS_KEY, + fetchBitCrmCustomFields, generateMappedField, isEmptyValue, + NO_CUSTOM_FIELDS, refreshBitCrmList, syncRequiredFieldMap } from './BitCrmCommonFunc' import BitCrmFieldMap from './BitCrmFieldMap' import { + actionCustomFieldModules, actionDropdowns, actionSelects, actionUtilities, @@ -27,19 +31,35 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon const { isPro } = useRecoilValue($appConfigState) const [isLoading, setIsLoading] = useState(false) const [lockedSelectKey, setLockedSelectKey] = useState(0) + const [customFields, setCustomFields] = useState(NO_CUSTOM_FIELDS) const action = bitCrmConf?.mainAction const bitCrmFields = bitCrmStaticData[action] ?? [] const dropdowns = actionDropdowns[action] ?? [] const selects = actionSelects[action] ?? [] const utilities = actionUtilities[action] ?? [] + const customFieldModule = actionCustomFieldModules[action] + + const fetchedFields = customFields.module === customFieldModule ? customFields.fields : [] + const mappableFields = fetchedFields.length > 0 ? [...bitCrmFields, ...fetchedFields] : bitCrmFields + + // A custom field can be required too, so the rows the field map locks depend on + // the fetched list as much as the static one. + const requiredKeys = mappableFields + .filter(fld => fld.required === true) + .map(fld => fld.key) + .join(',') + + useEffect(() => { + fetchBitCrmCustomFields(customFieldModule, setCustomFields, setIsLoading) + }, [customFieldModule]) // A config saved before a field became required still lists the old rows, and // the field map renders its required rows by position. useEffect(() => { - if (!action || bitCrmFields.length === 0) return + if (!action || mappableFields.length === 0) return - const synced = syncRequiredFieldMap(bitCrmConf?.field_map ?? [], bitCrmFields) + const synced = syncRequiredFieldMap(bitCrmConf?.field_map ?? [], mappableFields) if (synced === bitCrmConf?.field_map) return setBitCrmConf(prevConf => @@ -48,7 +68,7 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon }) ) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [action]) + }, [action, requiredKeys]) // A config saved before a select gained its default still opens without it. useEffect(() => { @@ -229,8 +249,20 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon {/* Field map (map dynamic form fields onto free-text / identifier fields) */} {action && bitCrmFields.length > 0 && (
-
+
{__('Field Map', 'bit-integrations')} + {customFieldModule && ( + + )}
@@ -250,7 +282,7 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon i={i} field={field} formFields={formFields} - bitCrmFields={bitCrmFields} + bitCrmFields={mappableFields} bitCrmConf={bitCrmConf} setBitCrmConf={setBitCrmConf} /> diff --git a/frontend/src/components/AllIntegrations/BitCrm/staticData.js b/frontend/src/components/AllIntegrations/BitCrm/staticData.js index 263c40ad1..0c4764e7c 100644 --- a/frontend/src/components/AllIntegrations/BitCrm/staticData.js +++ b/frontend/src/components/AllIntegrations/BitCrm/staticData.js @@ -574,6 +574,21 @@ export const actionSelects = { update_portal_access: [portalCapabilitiesSel] } +// A site can define its own fields on these modules, so the field map for these +// actions is the static list plus whatever Bit CRM reports for the module. +export const actionCustomFieldModules = { + create_lead: 'lead', + update_lead: 'lead', + create_contact: 'contact', + update_contact: 'contact', + create_company: 'company', + update_company: 'company', + create_deal: 'deal', + update_deal: 'deal', + create_product: 'product', + update_product: 'product' +} + // Every conf key a select or dropdown can write, so switching action can clear // the ones the new action does not use. Several keys share a Bit CRM field // (status, type, lead source), and a leftover value would otherwise win. From a45b74e66400ff6a912243826897b6e3b0b082fd Mon Sep 17 00:00:00 2001 From: Rishad Alam Date: Mon, 3 Aug 2026 14:57:09 +0600 Subject: [PATCH 08/14] fix: bitform base url domain key --- backend/Actions/BitForm/BitFormController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Actions/BitForm/BitFormController.php b/backend/Actions/BitForm/BitFormController.php index 331b551a8..eec283e85 100644 --- a/backend/Actions/BitForm/BitFormController.php +++ b/backend/Actions/BitForm/BitFormController.php @@ -20,7 +20,7 @@ class BitFormController 'slug' => 'bitform', 'fields' => [ 'api_key' => 'value', - 'domainName' => 'domainName', + 'app_domain' => 'domainName', ], ]; From 5d29b1f3e1d6f7651e12acdd7af120fa95c28f06 Mon Sep 17 00:00:00 2001 From: Rishad Alam Date: Mon, 3 Aug 2026 16:54:33 +0600 Subject: [PATCH 09/14] feat(BitCrm): fetch action fields from Bit CRM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/Actions/BitCrm/BitCrmController.php | 19 +- backend/Actions/BitCrm/BitCrmCustomField.php | 37 +--- backend/Actions/BitCrm/BitCrmFieldService.php | 187 +++++++++++++++++ backend/Actions/BitCrm/RecordApiHelper.php | 111 ++-------- backend/Actions/BitCrm/Routes.php | 2 +- .../AllIntegrations/BitCrm/BitCrm.jsx | 7 +- .../BitCrm/BitCrmCommonFunc.js | 106 +++++++--- .../BitCrm/BitCrmIntegLayout.jsx | 159 +++++++++++---- .../AllIntegrations/BitCrm/EditBitCrm.jsx | 6 +- .../AllIntegrations/BitCrm/options.js | 56 ------ .../AllIntegrations/BitCrm/staticData.js | 190 +++--------------- 11 files changed, 430 insertions(+), 450 deletions(-) create mode 100644 backend/Actions/BitCrm/BitCrmFieldService.php diff --git a/backend/Actions/BitCrm/BitCrmController.php b/backend/Actions/BitCrm/BitCrmController.php index 74b0cce3d..1ae0d31e8 100644 --- a/backend/Actions/BitCrm/BitCrmController.php +++ b/backend/Actions/BitCrm/BitCrmController.php @@ -48,17 +48,17 @@ public static function refreshCompanies() wp_send_json_success(['options' => self::normalize((new \BitApps\Crm\Services\CompanyService())->getEntitiesAsOptions())]); } - /** - * Records of one module, for the pickers that follow a module select. - * - * @param object $data - */ public static function refreshUsers() { self::ensureClass('BitApps\Crm\Services\UserService'); wp_send_json_success(['options' => self::normalize((new \BitApps\Crm\Services\UserService())->getUsersAsOptions())]); } + /** + * Records of one module, for the pickers that follow a module select. + * + * @param object $data + */ public static function refreshEntities($data) { self::isExists(); @@ -82,17 +82,15 @@ public static function refreshEntities($data) } /** - * The module's custom fields, as extra rows for the field map. - * * @param object $data */ - public static function refreshCustomFields($data) + public static function refreshFields($data) { self::isExists(); $module = isset($data->module) ? sanitize_text_field($data->module) : ''; - wp_send_json_success(['fields' => BitCrmCustomField::fieldMapOptions($module)]); + wp_send_json_success(['fields' => BitCrmFieldService::fields($module)]); } public static function refreshLeadTags() @@ -125,13 +123,12 @@ public function execute($integrationData, $fieldValues) $integrationDetails = $integrationData->flow_details; $integId = $integrationData->id; $fieldMap = $integrationDetails->field_map; - $utilities = isset($integrationDetails->utilities) ? $integrationDetails->utilities : []; if (empty($fieldMap)) { return new WP_Error('field_map_empty', __('Field map is empty', 'bit-integrations')); } - return (new RecordApiHelper($integrationDetails, $integId))->execute($fieldValues, $fieldMap, $utilities); + return (new RecordApiHelper($integrationDetails, $integId))->execute($fieldValues, $fieldMap); } private static function ensureClass($class) diff --git a/backend/Actions/BitCrm/BitCrmCustomField.php b/backend/Actions/BitCrm/BitCrmCustomField.php index 751cbdd5e..1b4a04d4a 100644 --- a/backend/Actions/BitCrm/BitCrmCustomField.php +++ b/backend/Actions/BitCrm/BitCrmCustomField.php @@ -50,7 +50,7 @@ public static function all(string $module) if (empty($fields)) { return []; } - error_log('BitCrmCustomField::all() - ' . $module . ' - ' . print_r($fields->toArray(), true)); + $active = []; foreach ($fields->toArray() as $field) { if (empty($field['field_key']) || empty($field['status'])) { @@ -63,26 +63,6 @@ public static function all(string $module) return $active; } - /** - * Custom fields as field map rows, shaped like the entries in bitCrmStaticData. - * - * @return array - */ - public static function fieldMapOptions(string $module) - { - $options = []; - - foreach (self::all($module) as $field) { - $options[] = [ - 'key' => self::PREFIX . $field['field_key'], - 'label' => $field['label'] ?? $field['field_key'], - 'required' => !empty(self::attributes($field)['required']), - ]; - } - - return $options; - } - /** * The mapped custom field rows, keyed the way Bit CRM's entity services * expect: [field_key => ['field_id' => int, 'field_value' => string]]. @@ -150,21 +130,6 @@ public static function save(string $module, int $entityId, array $values) do_action(self::SAVE_HOOK, $module, $entityId, $values); } - /** - * A custom field keeps everything but its label and status in one JSON blob, - * `required` included. - */ - private static function attributes(array $field) - { - $attributes = $field['attributes'] ?? []; - - if (\is_string($attributes)) { - $attributes = json_decode($attributes, true); - } - - return \is_array($attributes) ? $attributes : []; - } - /** * Multi-value custom fields are stored as a JSON list; everything else is a * plain string. A mapped value arrives comma separated when it comes from a diff --git a/backend/Actions/BitCrm/BitCrmFieldService.php b/backend/Actions/BitCrm/BitCrmFieldService.php new file mode 100644 index 000000000..776f263f3 --- /dev/null +++ b/backend/Actions/BitCrm/BitCrmFieldService.php @@ -0,0 +1,187 @@ + 'BitApps\Crm\Services\LeadService', + 'contact' => 'BitApps\Crm\Services\ContactService', + 'company' => 'BitApps\Crm\Services\CompanyService', + 'deal' => 'BitApps\Crm\Services\DealService', + 'product' => 'BitApps\CrmPro\Services\ProductService', + ]; + + private const LOOKUP_TYPES = ['lookup_select', 'lookup_autocomplete']; + + private const GROUP_FIELDS_KEY = 'group_fields'; + + /** + * @return array + */ + public static function fields(string $module) + { + if (!isset(self::SERVICES[$module]) || !class_exists(self::SERVICES[$module])) { + return []; + } + + $service = self::SERVICES[$module]; + + try { + $fields = (new $service())->fields(); + } catch (Throwable $th) { + return []; + } + + return \is_array($fields) ? self::normalizeAll($fields) : []; + } + + /** + * @return array + */ + private static function normalizeAll(array $fields) + { + $normalized = []; + + foreach ($fields as $field) { + $field = (array) $field; + + if (($field['type'] ?? '') === 'section') { + continue; + } + + if (!empty($field[self::GROUP_FIELDS_KEY]) && \is_array($field[self::GROUP_FIELDS_KEY])) { + array_push($normalized, ...self::normalizeAll($field[self::GROUP_FIELDS_KEY])); + + continue; + } + + $row = self::normalize($field); + + if ($row !== null) { + $normalized[] = $row; + } + } + + return $normalized; + } + + /** + * @return null|array + */ + private static function normalize(array $field) + { + $key = (string) ($field['field_key'] ?? ''); + + if ($key === '') { + return; + } + + $label = (string) ($field['label'] ?? ''); + + $row = [ + 'key' => $key, + 'label' => $label === '' ? $key : $label, + 'required' => !empty($field['required']), + 'type' => (string) ($field['type'] ?? 'text'), + 'isCustom' => false, + ]; + + if (!empty($field['is_custom'])) { + // A custom field carries per-record data, so it stays a field map row + // even when it has options of its own. + if (empty($field['status'])) { + return; + } + + $row['key'] = BitCrmCustomField::PREFIX . $key; + $row['isCustom'] = true; + + return $row; + } + + if (\in_array($row['type'], self::LOOKUP_TYPES, true)) { + $row['type'] = self::TYPE_LOOKUP; + $row['relatedModule'] = (string) ($field['related_module'] ?? ''); + + return $row; + } + + $options = self::options($field); + + if (!empty($options)) { + $row['type'] = self::TYPE_SELECT; + $row['options'] = $options; + + $default = $field['default_value'] ?? ''; + + if ($default !== '' && $default !== null && !\is_array($default)) { + $row['defaultValue'] = self::toOptionValue($default); + } + } + + return $row; + } + + /** + * @return array + */ + private static function options(array $field) + { + if (empty($field['options']) || !\is_array($field['options'])) { + return []; + } + + $options = []; + + foreach ($field['options'] as $option) { + $option = (array) $option; + $value = $option['value'] ?? $option['key'] ?? $option['id'] ?? ''; + + if ($value === '' || $value === null || \is_array($value)) { + continue; + } + + $label = $option['label'] ?? $option['name'] ?? $option['title'] ?? $value; + + $options[] = [ + 'label' => \is_array($label) ? self::toOptionValue($value) : self::toOptionValue($label), + 'value' => self::toOptionValue($value), + ]; + } + + return $options; + } + + /** + * Bit CRM casts with boolval(), and boolval('false') is true, so a boolean + * option value has to travel as '1'/'0'. + * + * @param mixed $value + * + * @return string + */ + private static function toOptionValue($value) + { + if (\is_bool($value)) { + return $value ? '1' : '0'; + } + + return (string) $value; + } +} diff --git a/backend/Actions/BitCrm/RecordApiHelper.php b/backend/Actions/BitCrm/RecordApiHelper.php index 312c48de5..2abc07e54 100644 --- a/backend/Actions/BitCrm/RecordApiHelper.php +++ b/backend/Actions/BitCrm/RecordApiHelper.php @@ -14,12 +14,6 @@ */ class RecordApiHelper { - /** - * Scratch key holding emails that matched no WordPress user. Stripped from - * the payload before any action sees it. - */ - private const UNRESOLVED_USERS_KEY = '__unresolved_users'; - private $_integrationID; private $_integrationDetails; @@ -30,7 +24,7 @@ public function __construct($integrationDetails, $integId) $this->_integrationID = $integId; } - public function execute($fieldValues, $fieldMap, $utilities) + public function execute($fieldValues, $fieldMap) { if (!class_exists('BitApps\Crm\Config')) { return ['success' => false, 'message' => __('Bit CRM is not installed or activated', 'bit-integrations')]; @@ -40,22 +34,6 @@ public function execute($fieldValues, $fieldMap, $utilities) $fieldData = static::generateReqDataFromFieldMap($fieldMap, $fieldValues); $fieldData = $this->mergeConfiguredValues($fieldData, $mainAction); - // A mapped email that matches no WordPress user leaves the id unset, which - // would surface downstream as a bare "assigned_to is required". Name the - // real problem instead. - if (!empty($fieldData[self::UNRESOLVED_USERS_KEY])) { - $unresolved = $fieldData[self::UNRESOLVED_USERS_KEY]; - - return [ - 'success' => false, - 'message' => \sprintf( - // translators: %s: comma separated list of email addresses - __('No WordPress user found for: %s. Bit CRM needs a user account to own or be assigned a record.', 'bit-integrations'), - implode(', ', $unresolved) - ), - ]; - } - switch ($mainAction) { case 'create_lead': $response = BitCrmActionHelper::createLead($fieldData); @@ -461,9 +439,10 @@ private static function generateReqDataFromFieldMap($fieldMap, $fieldValues) } /** - * Merge the dropdown/enum selects (conf.selected*) and Utilities (conf.utilities.*) - * into the field-map data, keyed by the CRM field the action handler reads. - * Only non-empty values overwrite, so an unset select never clobbers a mapping. + * Merge the selects and pickers the layout renders, plus the Utilities + * (conf.utilities.*), into the field-map data, keyed by the CRM field the + * action handler reads. Only non-empty values overwrite, so an unset select + * never clobbers a mapping. * * @param array $fieldData * @param string $mainAction @@ -479,20 +458,9 @@ private function mergeConfiguredValues($fieldData, $mainAction) 'selectedCurrency' => 'currency', 'selectedStage' => 'stage', 'selectedTermKey' => 'term_key', - 'selectedContact' => 'contact_id', 'selectedEntity' => 'entity_id', 'selectedAssignee' => 'assigned_to', - 'selectedOwner' => 'owner_id', - 'selectedCompany' => 'company_id', - 'selectedParent' => 'parent_id', 'selectedTags' => 'tag_ids', - 'title' => 'title', - 'leadSource' => 'lead_source', - 'leadStatus' => 'lead_status', - 'dealType' => 'type', - 'dealLeadSource' => 'lead_source', - 'productType' => 'type', - 'productStatus' => 'status', 'module' => 'module', 'convertTo' => 'convert_to', 'moveRelatedDataTo' => 'move_related_data_to', @@ -503,17 +471,10 @@ private function mergeConfiguredValues($fieldData, $mainAction) 'capabilities' => 'capabilities', ]; - // Several conf keys share a CRM field, and switching the action does not - // erase the value the previous one stored. Without this guard a leftover - // `activityStatus` would win over `productStatus` on a later save, because - // it is merged last. Only the key the chosen action actually renders may - // write its CRM field. + // Both of these write `status`, so only the key the chosen action renders + // may do it — a leftover `activityStatus` would otherwise win over + // `invoiceStatus` on a later save, because it is merged first. $exclusive = [ - 'dealType' => ['create_deal', 'update_deal'], - 'productType' => ['create_product', 'update_product'], - 'leadSource' => ['create_lead', 'update_lead', 'create_contact', 'update_contact'], - 'dealLeadSource' => ['create_deal', 'update_deal'], - 'productStatus' => ['create_product', 'update_product'], 'activityStatus' => ['update_task_status', 'update_meeting_status', 'update_call_status'], 'invoiceStatus' => ['update_invoice', 'update_invoice_status'], ]; @@ -528,6 +489,17 @@ private function mergeConfiguredValues($fieldData, $mainAction) } } + // Fields built from Bit CRM's own definition already carry its field key. + if (isset($conf->fieldValues)) { + foreach ((array) $conf->fieldValues as $crmKey => $value) { + if ($value === '' || $value === null || $value === []) { + continue; + } + + $fieldData[$crmKey] = $value; + } + } + // Utilities (booleans) — e.g. is_shared if (isset($conf->utilities) && \is_object($conf->utilities)) { foreach (get_object_vars($conf->utilities) as $utilKey => $utilVal) { @@ -535,51 +507,6 @@ private function mergeConfiguredValues($fieldData, $mainAction) } } - return static::resolveUserFields($fieldData); - } - - /** - * Resolve user-identifier fields supplied as an email into the numeric user id - * the CRM expects. The owner and assignee are picked from a list now, so this - * only serves flows saved before those pickers existed and still mapping - * `owner_email` / `assigned_to_email`. - * - * @param array $fieldData - * - * @return array - */ - private static function resolveUserFields($fieldData) - { - $emailToId = [ - 'owner_email' => 'owner_id', - 'assigned_to_email' => 'assigned_to', - ]; - - $unresolved = []; - - foreach ($emailToId as $emailKey => $idKey) { - if (empty($fieldData[$emailKey])) { - unset($fieldData[$emailKey]); - - continue; - } - - $email = $fieldData[$emailKey]; - $user = get_user_by('email', $email); - - if ($user) { - $fieldData[$idKey] = $user->ID; - } else { - $unresolved[] = $email; - } - - unset($fieldData[$emailKey]); - } - - if (!empty($unresolved)) { - $fieldData[self::UNRESOLVED_USERS_KEY] = $unresolved; - } - return $fieldData; } } diff --git a/backend/Actions/BitCrm/Routes.php b/backend/Actions/BitCrm/Routes.php index 7c4337fa7..2f298babb 100644 --- a/backend/Actions/BitCrm/Routes.php +++ b/backend/Actions/BitCrm/Routes.php @@ -14,7 +14,7 @@ Route::post('refresh_bitcrm_companies', [BitCrmController::class, 'refreshCompanies']); Route::post('refresh_bitcrm_users', [BitCrmController::class, 'refreshUsers']); Route::post('refresh_bitcrm_entities', [BitCrmController::class, 'refreshEntities']); -Route::post('refresh_bitcrm_custom_fields', [BitCrmController::class, 'refreshCustomFields']); +Route::post('refresh_bitcrm_fields', [BitCrmController::class, 'refreshFields']); Route::post('refresh_bitcrm_lead_tags', [BitCrmController::class, 'refreshLeadTags']); Route::post('refresh_bitcrm_contact_tags', [BitCrmController::class, 'refreshContactTags']); Route::post('refresh_bitcrm_company_tags', [BitCrmController::class, 'refreshCompanyTags']); diff --git a/frontend/src/components/AllIntegrations/BitCrm/BitCrm.jsx b/frontend/src/components/AllIntegrations/BitCrm/BitCrm.jsx index 50275c7e4..0b4ca25d4 100644 --- a/frontend/src/components/AllIntegrations/BitCrm/BitCrm.jsx +++ b/frontend/src/components/AllIntegrations/BitCrm/BitCrm.jsx @@ -1,6 +1,6 @@ import { useState } from 'react' import 'react-multiple-select-dropdown-lite/dist/index.css' -import { useNavigate, useParams } from 'react-router' +import { useNavigate } from 'react-router' import BackIcn from '../../../Icons/BackIcn' import { __, sprintf } from '../../../Utils/i18nwrap' import SnackMsg from '../../Utilities/SnackMsg' @@ -12,7 +12,6 @@ import BitCrmIntegLayout from './BitCrmIntegLayout' export default function BitCrm({ formFields, setFlow, flow, allIntegURL, isInfo }) { const navigate = useNavigate() - const { formID } = useParams() const [isLoading, setIsLoading] = useState(false) const [step, setStep] = useState(1) const [snack, setSnackbar] = useState({ show: false }) @@ -82,13 +81,9 @@ export default function BitCrm({ formFields, setFlow, flow, allIntegURL, isInfo minHeight: step === 2 && '500px' }}>

diff --git a/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js b/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js index 50f9f4871..bda5a03c9 100644 --- a/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js +++ b/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js @@ -2,7 +2,11 @@ import { create } from 'mutative' import toast from 'react-hot-toast' import bitsFetch from '../../../Utils/bitsFetch' import { __ } from '../../../Utils/i18nwrap' -import { actionDropdowns, actionSelects } from './staticData' +import { actionDropdowns, actionFieldModules, actionSelects, lookupSources } from './staticData' + +// The two kinds that get their own control; everything else is a field map row. +const SELECT_TYPE = 'select' +const LOOKUP_TYPE = 'lookup' export const handleInput = (e, bitCrmConf, setBitCrmConf) => { const { name, value } = e.target @@ -35,32 +39,29 @@ export const refreshBitCrmList = (route, listKey, setBitCrmConf, setIsLoading, p } // Shares the loading state with the fetched dropdowns, which key it by list. -export const CUSTOM_FIELDS_KEY = 'customFields' - -export const NO_CUSTOM_FIELDS = { fields: [], module: '' } +export const CRM_FIELDS_KEY = 'crmFields' /** - * Custom fields are defined per site rather than shipped with Bit CRM, and - * defining them is a Bit CRM Pro feature, so a site without them gets none and - * the field map falls back to the static list. - * - * The module is stored alongside the fields because a required custom field adds - * a locked row to the map, and the previous module's rows must not survive the - * render between switching action and the new list arriving. + * Fills the selects, record pickers and field map of every action in + * actionFieldModules. Stored on the conf next to the fetched dropdown lists, and + * stamped with the module it describes so the previous module's rows cannot + * survive the render between switching action and the new list arriving. */ -export const fetchBitCrmCustomFields = (module, setCustomFields, setIsLoading, notify = false) => { - if (!module) { - setCustomFields(NO_CUSTOM_FIELDS) - return - } +export const fetchBitCrmFields = (module, setBitCrmConf, setIsLoading, notify = false) => { + if (!module) return - setIsLoading(CUSTOM_FIELDS_KEY) + setIsLoading(CRM_FIELDS_KEY) - bitsFetch({ module }, 'refresh_bitcrm_custom_fields') + bitsFetch({ module }, 'refresh_bitcrm_fields') .then(result => { const fetched = result?.success && Array.isArray(result?.data?.fields) ? result.data.fields : [] - setCustomFields({ fields: fetched, module }) + setBitCrmConf(prevConf => + create(prevConf, draftConf => { + draftConf.crmFields = fetched + draftConf.crmFieldsModule = module + }) + ) setIsLoading(false) if (!notify) return @@ -72,11 +73,48 @@ export const fetchBitCrmCustomFields = (module, setCustomFields, setIsLoading, n } }) .catch(() => { - setCustomFields({ fields: [], module }) + setBitCrmConf(prevConf => + create(prevConf, draftConf => { + draftConf.crmFields = [] + draftConf.crmFieldsModule = module + }) + ) setIsLoading(false) }) } +// Only while the stored fields still describe the module the action writes to. +export const crmFieldsOf = bitCrmConf => { + const module = actionFieldModules[bitCrmConf?.mainAction] + + if (!module || bitCrmConf?.crmFieldsModule !== module) return [] + + return Array.isArray(bitCrmConf?.crmFields) ? bitCrmConf.crmFields : [] +} + +// An unmapped field leaves the column it would have written alone, so nothing +// but the record id is required on an update. +const relaxOnUpdate = (fields, action) => + action?.startsWith('update_') ? fields.map(fld => ({ ...fld, required: false })) : fields + +export const crmMapFields = bitCrmConf => + relaxOnUpdate( + crmFieldsOf(bitCrmConf).filter(fld => fld.type !== SELECT_TYPE && fld.type !== LOOKUP_TYPE), + bitCrmConf?.mainAction + ) + +export const crmSelectFields = bitCrmConf => + relaxOnUpdate( + crmFieldsOf(bitCrmConf).filter(fld => fld.type === SELECT_TYPE), + bitCrmConf?.mainAction + ) + +export const crmLookupFields = bitCrmConf => + relaxOnUpdate( + crmFieldsOf(bitCrmConf).filter(fld => fld.type === LOOKUP_TYPE && lookupSources[fld.relatedModule]), + bitCrmConf?.mainAction + ).map(fld => ({ ...fld, ...lookupSources[fld.relatedModule] })) + export const checkMappedFields = bitCrmConf => { const mappedFields = bitCrmConf?.field_map ? bitCrmConf.field_map.filter( @@ -99,27 +137,29 @@ export const missingRequiredSelect = bitCrmConf => { .filter(field => field.required) .find(field => isEmptyValue(bitCrmConf?.[field.key])) - if (!missing) return null + if (missing) { + // A dependent list cannot be filled before the field it hangs off, so blame + // that one instead of the empty list it leaves behind. + if (missing.dependsOn && isEmptyValue(bitCrmConf?.[missing.dependsOn])) { + const dependency = fields.find(field => field.key === missing.dependsOn) + return dependency?.label ?? missing.label + } - // A dependent list cannot be filled before the field it hangs off, so blame - // that one instead of the empty list it leaves behind. - if (missing.dependsOn && isEmptyValue(bitCrmConf?.[missing.dependsOn])) { - const dependency = fields.find(field => field.key === missing.dependsOn) - return dependency?.label ?? missing.label + return missing.label } - return missing.label + const missingCrmField = [...crmSelectFields(bitCrmConf), ...crmLookupFields(bitCrmConf)] + .filter(field => field.required) + .find(field => isEmptyValue(bitCrmConf?.fieldValues?.[field.key])) + + return missingCrmField ? missingCrmField.label : null } export const isBitCrmConfValid = bitCrmConf => checkMappedFields(bitCrmConf) && !missingRequiredSelect(bitCrmConf) -/** - * The field map renders its required rows positionally, so a config saved before - * a field became required would show one Bit CRM field while holding another. - * Re-key the leading rows onto the current required list, keeping the form field - * each Bit CRM field was already mapped to, and push the rest below. - */ +// The field map renders its required rows positionally, so the leading rows are +// re-keyed onto the current required list and the rest pushed below. export const syncRequiredFieldMap = (fieldMap = [], fields = []) => { const requiredKeys = fields.filter(fld => fld.required === true).map(fld => fld.key) if (requiredKeys.length === 0) return fieldMap diff --git a/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx b/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx index ddb61ad2f..06f50f8c0 100644 --- a/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx +++ b/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx @@ -8,18 +8,20 @@ import TableCheckBox from '../../Utilities/TableCheckBox' import { checkIsPro, getProLabel } from '../../Utilities/ProUtilHelpers' import { addFieldMap } from '../IntegrationHelpers/IntegrationHelpers' import { - CUSTOM_FIELDS_KEY, - fetchBitCrmCustomFields, + CRM_FIELDS_KEY, + crmLookupFields, + crmMapFields, + crmSelectFields, + fetchBitCrmFields, generateMappedField, isEmptyValue, - NO_CUSTOM_FIELDS, refreshBitCrmList, syncRequiredFieldMap } from './BitCrmCommonFunc' import BitCrmFieldMap from './BitCrmFieldMap' import { - actionCustomFieldModules, actionDropdowns, + actionFieldModules, actionSelects, actionUtilities, allConfigurableKeys, @@ -31,31 +33,32 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon const { isPro } = useRecoilValue($appConfigState) const [isLoading, setIsLoading] = useState(false) const [lockedSelectKey, setLockedSelectKey] = useState(0) - const [customFields, setCustomFields] = useState(NO_CUSTOM_FIELDS) const action = bitCrmConf?.mainAction - const bitCrmFields = bitCrmStaticData[action] ?? [] + const staticFields = bitCrmStaticData[action] ?? [] const dropdowns = actionDropdowns[action] ?? [] const selects = actionSelects[action] ?? [] const utilities = actionUtilities[action] ?? [] - const customFieldModule = actionCustomFieldModules[action] + const crmModule = actionFieldModules[action] - const fetchedFields = customFields.module === customFieldModule ? customFields.fields : [] - const mappableFields = fetchedFields.length > 0 ? [...bitCrmFields, ...fetchedFields] : bitCrmFields + const crmSelects = crmSelectFields(bitCrmConf) + const crmLookups = crmLookupFields(bitCrmConf) + const mappableFields = [...staticFields, ...crmMapFields(bitCrmConf)] - // A custom field can be required too, so the rows the field map locks depend on - // the fetched list as much as the static one. const requiredKeys = mappableFields .filter(fld => fld.required === true) .map(fld => fld.key) .join(',') useEffect(() => { - fetchBitCrmCustomFields(customFieldModule, setCustomFields, setIsLoading) - }, [customFieldModule]) + if (!crmModule || bitCrmConf?.crmFieldsModule === crmModule) return - // A config saved before a field became required still lists the old rows, and - // the field map renders its required rows by position. + fetchBitCrmFields(crmModule, setBitCrmConf, setIsLoading) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [crmModule]) + + // The field map renders its required rows by position, so they have to be re-keyed + // when the required list changes. useEffect(() => { if (!action || mappableFields.length === 0) return @@ -70,7 +73,6 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon // eslint-disable-next-line react-hooks/exhaustive-deps }, [action, requiredKeys]) - // A config saved before a select gained its default still opens without it. useEffect(() => { if (!action) return @@ -89,13 +91,34 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon // eslint-disable-next-line react-hooks/exhaustive-deps }, [action]) + // Create only: on an update an unset select leaves the column alone, and + // seeding one would start rewriting it. + useEffect(() => { + if (!action?.startsWith('create_')) return + + const unseeded = crmSelects.filter( + sel => sel.defaultValue !== undefined && isEmptyValue(bitCrmConf?.fieldValues?.[sel.key]) + ) + if (unseeded.length === 0) return + + setBitCrmConf(prevConf => + create(prevConf, draftConf => { + if (!draftConf.fieldValues) draftConf.fieldValues = {} + + unseeded.forEach(sel => { + draftConf.fieldValues[sel.key] = sel.defaultValue + }) + }) + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [action, crmSelects.map(sel => sel.key).join(',')]) + const setField = (key, val) => setBitCrmConf(prevConf => create(prevConf, draftConf => { draftConf[key] = val - // A dependent list belongs to the value just replaced, so drop it along - // with the selection made from it. + // A dependent list belongs to the value just replaced. ;[...selects, ...dropdowns] .filter(item => item.dependsOn === key) .forEach(item => { @@ -105,10 +128,17 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon }) ) - // A locked option cannot be clicked off in the menu, but its chip still carries - // a delete button and the clear button wipes the whole select. Remount on a - // rejected delete: the dropdown re-reads the prop only when the string changes, - // and putting the value back leaves it exactly as it was. + const setCrmField = (key, val) => + setBitCrmConf(prevConf => + create(prevConf, draftConf => { + if (!draftConf.fieldValues) draftConf.fieldValues = {} + draftConf.fieldValues[key] = val + }) + ) + + // A locked option can still be removed by its chip's delete button or by clear. + // Remount on a rejected delete: the dropdown re-reads the prop only when the + // string changes, and putting the value back leaves it unchanged. const handleSelectChange = (sel, val) => { if (!sel.lockedValues) { setField(sel.key, val) @@ -140,9 +170,8 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon }) ) - // Selects are stored flat on conf, and several of them write the same Bit CRM - // field (status, type, lead source). Drop whatever the previous action left - // behind, so a stale value can never be sent with the new action. + // Selects are stored flat on conf and several write the same Bit CRM field, so + // whatever the previous action left behind has to be dropped. const handleMainAction = value => { const keepKeys = new Set( [...(actionSelects[value] ?? []), ...(actionDropdowns[value] ?? [])].map(item => item.key) @@ -153,6 +182,7 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon draftConf.mainAction = value draftConf.field_map = generateMappedField(bitCrmStaticData[value] ?? []) draftConf.utilities = {} + draftConf.fieldValues = {} allConfigurableKeys.forEach(key => { if (!keepKeys.has(key)) delete draftConf[key] @@ -172,7 +202,7 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon ({ label: checkIsPro(isPro, mod.is_pro) ? mod.label : getProLabel(mod.label), @@ -182,9 +212,18 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon singleSelect closeOnSelect /> + {crmModule && ( + + )}
- {/* Fixed enum selects */} {selects.map(sel => (
))} - {/* Fetched dropdowns */} + {crmSelects.map(sel => ( +
+ + {sel.label} + {sel.required && *}: + + setCrmField(sel.key, val)} + singleSelect + closeOnSelect + /> +
+ ))} + + {crmLookups.map(lookup => ( +
+ + {lookup.label} + {lookup.required && *}: + + ({ + label: opt.label, + value: String(opt.value) + }))} + onChange={val => setCrmField(lookup.key, val)} + singleSelect + closeOnSelect + /> + +
+ ))} + {dropdowns.map(dd => (
@@ -246,23 +333,10 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon
))} - {/* Field map (map dynamic form fields onto free-text / identifier fields) */} - {action && bitCrmFields.length > 0 && ( + {action && mappableFields.length > 0 && (
{__('Field Map', 'bit-integrations')} - {customFieldModule && ( - - )}
@@ -299,7 +373,6 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon
)} - {/* Utilities (booleans) */} {utilities.length > 0 && (
diff --git a/frontend/src/components/AllIntegrations/BitCrm/EditBitCrm.jsx b/frontend/src/components/AllIntegrations/BitCrm/EditBitCrm.jsx index 9df388e9d..53c4cfd64 100644 --- a/frontend/src/components/AllIntegrations/BitCrm/EditBitCrm.jsx +++ b/frontend/src/components/AllIntegrations/BitCrm/EditBitCrm.jsx @@ -12,7 +12,7 @@ import BitCrmIntegLayout from './BitCrmIntegLayout' export default function EditBitCrm({ allIntegURL }) { const navigate = useNavigate() - const { id, formID } = useParams() + const { id } = useParams() const [bitCrmConf, setBitCrmConf] = useRecoilState($actionConf) const [flow, setFlow] = useRecoilState($newFlow) @@ -40,13 +40,9 @@ export default function EditBitCrm({ allIntegURL }) { Date: Mon, 3 Aug 2026 17:54:49 +0600 Subject: [PATCH 10/14] feat(BitCrm): set closing date on won/lost stage --- backend/Actions/BitCrm/BitCrmActionHelper.php | 77 ++++++++++++++++--- backend/Actions/BitCrm/BitCrmController.php | 24 +++++- .../BitCrm/BitCrmCommonFunc.js | 42 +++++++++- .../BitCrm/BitCrmIntegLayout.jsx | 11 ++- .../AllIntegrations/BitCrm/staticData.js | 18 ++++- 5 files changed, 155 insertions(+), 17 deletions(-) diff --git a/backend/Actions/BitCrm/BitCrmActionHelper.php b/backend/Actions/BitCrm/BitCrmActionHelper.php index e45f16779..3aea3d6b6 100644 --- a/backend/Actions/BitCrm/BitCrmActionHelper.php +++ b/backend/Actions/BitCrm/BitCrmActionHelper.php @@ -565,11 +565,33 @@ public static function updateDealStage($fieldData) return ['success' => false, 'message' => __('Deal not found!', 'bit-integrations')]; } - if (!self::isKnownDealStage($fieldData['stage'])) { + $stages = self::dealStages(); + $definition = $stages[$fieldData['stage']] ?? null; + + if (!empty($stages) && $definition === null) { return ['success' => false, 'message' => __('This deal stage does not exist in Bit CRM.', 'bit-integrations')]; } - if (!$deal->update(['stage' => $fieldData['stage'], 'updated_by' => get_current_user_id()])) { + $update = ['stage' => $fieldData['stage'], 'updated_by' => get_current_user_id()]; + + // Bit CRM rewrites the probability on every stage change; leaving it would + // keep the odds of the stage before. + if (isset($definition['probability'])) { + $update['probability'] = $definition['probability']; + } + + // Required on a stage that closes the deal, asked for on no other. + 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; + } + + if (!$deal->update($update)) { return ['success' => false, 'message' => __('Failed to update deal stage.', 'bit-integrations')]; } @@ -1429,23 +1451,58 @@ private static function required($field) * * @return bool */ - private static function isKnownDealStage($stage) + /** + * The site's deal stages keyed by stage key. Empty when they cannot be read, + * which leaves the stage unvalidated rather than rejected. + */ + private static function dealStages() { if (!class_exists('BitApps\Crm\Services\DealStageService')) { - return true; + return []; } - $stages = (new \BitApps\Crm\Services\DealStageService())->getStagesAsOptions(); + $stages = []; - foreach ((array) $stages as $option) { - $option = (array) $option; + foreach ((array) (new \BitApps\Crm\Services\DealStageService())->getAllStages() as $stage) { + $stage = (array) $stage; + $key = (string) ($stage['key'] ?? ''); - if ((string) ($option['value'] ?? '') === (string) $stage) { - return true; + if ($key !== '') { + $stages[$key] = $stage; } } - return false; + return $stages; + } + + /** + * Every branch returns a bare site-local `Y-m-d H:i:s`, the shape Bit CRM's + * own stage modal submits. Formatting one input as local and another as UTC + * would store the same instant two ways. Null when nothing usable was mapped. + */ + private static function dealClosingDate($value) + { + $value = trim((string) $value); + + if ($value === '') { + return null; + } + + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { + return $value . ' 00:00:00'; + } + + if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $value)) { + return $value . ':00'; + } + + if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $value)) { + return $value; + } + + $timestamp = strtotime($value); + + return $timestamp === false ? null : wp_date('Y-m-d H:i:s', $timestamp); } private static function csvList($value) diff --git a/backend/Actions/BitCrm/BitCrmController.php b/backend/Actions/BitCrm/BitCrmController.php index 1ae0d31e8..421fe334b 100644 --- a/backend/Actions/BitCrm/BitCrmController.php +++ b/backend/Actions/BitCrm/BitCrmController.php @@ -23,11 +23,31 @@ public static function refreshCurrencies() wp_send_json_success(['options' => self::normalize((new \BitApps\Crm\Services\CurrencyService())->getOtherCurrenciesAsOptions())]); } + // Read whole rather than as bare options: the layout decides a closing date + // by the stage's category. public static function refreshDealStages() { self::ensureClass('BitApps\Crm\Services\DealStageService'); - $stages = (new \BitApps\Crm\Services\DealStageService())->getStagesAsOptions(\BitApps\Crm\Services\DealStageService::STATUS_ACTIVE); - wp_send_json_success(['options' => self::normalize($stages)]); + + $stages = (new \BitApps\Crm\Services\DealStageService())->getAllStages(\BitApps\Crm\Services\DealStageService::STATUS_ACTIVE); + $options = []; + + foreach ((array) $stages as $stage) { + $stage = (array) $stage; + $value = (string) ($stage['key'] ?? ''); + + if ($value === '') { + continue; + } + + $options[] = [ + 'label' => (string) ($stage['name'] ?? $value), + 'value' => $value, + 'category' => (string) ($stage['deal_category'] ?? ''), + ]; + } + + wp_send_json_success(['options' => $options]); } public static function refreshInvoiceTerms() diff --git a/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js b/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js index bda5a03c9..dce962006 100644 --- a/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js +++ b/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js @@ -2,7 +2,15 @@ import { create } from 'mutative' import toast from 'react-hot-toast' import bitsFetch from '../../../Utils/bitsFetch' import { __ } from '../../../Utils/i18nwrap' -import { actionDropdowns, actionFieldModules, actionSelects, lookupSources } from './staticData' +import { + actionDropdowns, + actionFieldModules, + actionSelects, + CLOSING_STAGE_CATEGORIES, + closingDateField, + conditionalFieldKeys, + lookupSources +} from './staticData' // The two kinds that get their own control; everything else is a field map row. const SELECT_TYPE = 'select' @@ -115,6 +123,27 @@ export const crmLookupFields = bitCrmConf => bitCrmConf?.mainAction ).map(fld => ({ ...fld, ...lookupSources[fld.relatedModule] })) +/** + * The rows a field map only carries in some configurations. A closing date + * belongs to a stage that closes the deal and to no other. + * + * A stage list cached before stages carried a category cannot answer that, and + * the action fails at run time without the row, so an unknown category offers it. + */ +export const conditionalFields = bitCrmConf => { + if (bitCrmConf?.mainAction !== 'update_deal_stage') return [] + if (isEmptyValue(bitCrmConf?.selectedStage)) return [] + + const stage = (bitCrmConf?.allStages ?? []).find( + option => String(option.value) === String(bitCrmConf.selectedStage) + ) + + const isClosing = + stage?.category === undefined ? true : CLOSING_STAGE_CATEGORIES.includes(stage.category) + + return isClosing ? [closingDateField] : [] +} + export const checkMappedFields = bitCrmConf => { const mappedFields = bitCrmConf?.field_map ? bitCrmConf.field_map.filter( @@ -158,6 +187,17 @@ export const missingRequiredSelect = bitCrmConf => { export const isBitCrmConfValid = bitCrmConf => checkMappedFields(bitCrmConf) && !missingRequiredSelect(bitCrmConf) +// A conditional row outlives the configuration that asked for it, so it is +// dropped once the current field list no longer offers it. +export const dropStaleConditionalRows = (fieldMap = [], fields = []) => { + const offered = new Set(fields.map(fld => fld.key)) + const pruned = fieldMap.filter( + row => !conditionalFieldKeys.includes(row.bitCrmField) || offered.has(row.bitCrmField) + ) + + return pruned.length === fieldMap.length ? fieldMap : pruned +} + // The field map renders its required rows positionally, so the leading rows are // re-keyed onto the current required list and the rest pushed below. export const syncRequiredFieldMap = (fieldMap = [], fields = []) => { diff --git a/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx b/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx index 06f50f8c0..979a51bf4 100644 --- a/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx +++ b/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx @@ -8,10 +8,12 @@ import TableCheckBox from '../../Utilities/TableCheckBox' import { checkIsPro, getProLabel } from '../../Utilities/ProUtilHelpers' import { addFieldMap } from '../IntegrationHelpers/IntegrationHelpers' import { + conditionalFields, CRM_FIELDS_KEY, crmLookupFields, crmMapFields, crmSelectFields, + dropStaleConditionalRows, fetchBitCrmFields, generateMappedField, isEmptyValue, @@ -43,7 +45,11 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon const crmSelects = crmSelectFields(bitCrmConf) const crmLookups = crmLookupFields(bitCrmConf) - const mappableFields = [...staticFields, ...crmMapFields(bitCrmConf)] + const mappableFields = [ + ...staticFields, + ...conditionalFields(bitCrmConf), + ...crmMapFields(bitCrmConf) + ] const requiredKeys = mappableFields .filter(fld => fld.required === true) @@ -62,7 +68,8 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon useEffect(() => { if (!action || mappableFields.length === 0) return - const synced = syncRequiredFieldMap(bitCrmConf?.field_map ?? [], mappableFields) + const pruned = dropStaleConditionalRows(bitCrmConf?.field_map ?? [], mappableFields) + const synced = syncRequiredFieldMap(pruned, mappableFields) if (synced === bitCrmConf?.field_map) return setBitCrmConf(prevConf => diff --git a/frontend/src/components/AllIntegrations/BitCrm/staticData.js b/frontend/src/components/AllIntegrations/BitCrm/staticData.js index c5439485f..56a8a6035 100644 --- a/frontend/src/components/AllIntegrations/BitCrm/staticData.js +++ b/frontend/src/components/AllIntegrations/BitCrm/staticData.js @@ -283,8 +283,22 @@ const companyTags = tags('refresh_bitcrm_company_tags') const dealTags = tags('refresh_bitcrm_deal_tags') const productTags = tags('refresh_bitcrm_product_tags') -// Bit CRM names the module a lookup points at, not where to read its records -// from. Keyed by `related_module`; a field pointing anywhere else is skipped. +// Joins the field map only for a stage that closes the deal, where Bit CRM +// requires it. +export const closingDateField = { + key: 'closed_at', + label: __('Closing Date (YYYY-MM-DD HH:MM:SS)', 'bit-integrations'), + required: true +} + +export const CLOSING_STAGE_CATEGORIES = ['closed_won', 'closed_lost'] + +// Rows the field map only sometimes carries, so a stale one can be dropped when +// the configuration that asked for it changes. +export const conditionalFieldKeys = [closingDateField.key] + +// Bit CRM names a lookup's module, not where to read its records from. Keyed by +// `related_module`; a field pointing anywhere else is skipped. export const lookupSources = { user: { route: 'refresh_bitcrm_users', listKey: 'allUsers' }, contact: { route: 'refresh_bitcrm_contacts', listKey: 'allContacts' }, From 0eef6215e5c55eef93a0697b4d0d407f21e76578 Mon Sep 17 00:00:00 2001 From: Rishad Alam Date: Tue, 4 Aug 2026 12:04:40 +0600 Subject: [PATCH 11/14] feat: enhance prepareFetchFormatFields with label path handling --- backend/Core/Util/Helper.php | 70 +++++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/backend/Core/Util/Helper.php b/backend/Core/Util/Helper.php index 31b3c7b71..a0e6214ab 100644 --- a/backend/Core/Util/Helper.php +++ b/backend/Core/Util/Helper.php @@ -419,7 +419,7 @@ public static function setTestData($optionKey, $formData, $primaryKey = null, $p } } - public static function prepareFetchFormatFields(array $data, $path = '', $formattedData = []) + public static function prepareFetchFormatFields(array $data, $path = '', $formattedData = [], $labelPath = []) { foreach ($data as $key => $value) { if (\is_string($key) && ctype_upper($key)) { @@ -433,7 +433,8 @@ public static function prepareFetchFormatFields(array $data, $path = '', $format continue; } - $label = ucwords(str_replace('_', ' ', $path ? $currentPath : $key)); + $currentLabelPath = static::appendLabelSegment($labelPath, $path ? $currentKey : $key); + $label = static::shortenLabel($currentLabelPath); if (\is_string($value) && static::isJson($value)) { $value = json_decode($value, true); @@ -447,15 +448,14 @@ public static function prepareFetchFormatFields(array $data, $path = '', $format 'value' => $value, ]; - $formattedData = static::prepareFetchFormatFields((array) $value, $currentPath, $formattedData); + $formattedData = static::prepareFetchFormatFields((array) $value, $currentPath, $formattedData, $currentLabelPath); } else { $labelValue = \is_string($value) && \strlen($value) > 20 ? substr($value, 0, 20) . '...' : $value; - $label = preg_replace("/\b(\w+)\s+\\1\b/i", '$1', $label) . ' (' . $labelValue . ')'; $formattedData[$currentPath] = [ 'name' => $currentPath . '.value', 'type' => static::getVariableType($value), - 'label' => $label, + 'label' => $label . ' (' . $labelValue . ')', 'value' => $value, ]; } @@ -464,6 +464,66 @@ public static function prepareFetchFormatFields(array $data, $path = '', $format return $formattedData; } + /** + * Append one key to the readable label path. List indexes stay glued to the + * key they belong to ("Items 0") instead of eating a whole segment, and a + * key repeating its parent is dropped. + * + * @param array $labelPath + * @param int|string $key + * + * @return array + */ + private static function appendLabelSegment($labelPath, $key) + { + $segment = trim(ucwords(str_replace('_', ' ', (string) $key))); + + if ($segment === '') { + return $labelPath; + } + + if (ctype_digit($segment) && !empty($labelPath)) { + $labelPath[\count($labelPath) - 1] .= ' ' . $segment; + + return $labelPath; + } + + if (end($labelPath) !== $segment) { + $labelPath[] = $segment; + } + + return $labelPath; + } + + /** + * Collapse a deep label path so a nested field stays identifiable without + * printing every ancestor: root + "..." + the last segments. + * + * @param array $labelPath + * @param int $maxSegments + * @param int $maxLength + * + * @return string + */ + private static function shortenLabel($labelPath, $maxSegments = 3, $maxLength = 55) + { + if (\count($labelPath) > $maxSegments) { + $labelPath = array_merge([$labelPath[0], '...'], \array_slice($labelPath, -($maxSegments - 1))); + } + + $label = preg_replace("/\b(\w+)\s+\\1\b/i", '$1', implode(' ', $labelPath)); + + if (mb_strlen($label) <= $maxLength) { + return $label; + } + + // keep the tail (the field itself) and never cut mid word + $tail = mb_substr($label, -($maxLength - 3)); + $spaceAt = mb_strpos($tail, ' '); + + return '...' . ($spaceAt === false ? $tail : mb_substr($tail, $spaceAt + 1)); + } + public static function flattenNestedData($resultArray, $parentKey, $nestedData) { if (\is_object($nestedData)) { From c542a36f20c043d4382b6b2b6d647911f83a9516 Mon Sep 17 00:00:00 2001 From: Rishad Alam Date: Tue, 4 Aug 2026 12:17:31 +0600 Subject: [PATCH 12/14] chore: bump version to 2.10.2 and update changelog --- backend/Config.php | 2 +- bitwpfi.php | 4 +- frontend/src/pages/ChangelogToggle.jsx | 104 ++++++------------------- readme.txt | 17 +++- 4 files changed, 42 insertions(+), 85 deletions(-) diff --git a/backend/Config.php b/backend/Config.php index da37de7df..3dbfb4b3b 100644 --- a/backend/Config.php +++ b/backend/Config.php @@ -24,7 +24,7 @@ class Config public const VAR_PREFIX = 'bit_integrations_'; - public const VERSION = '2.10.1'; + public const VERSION = '2.10.2'; public const DB_VERSION = '1.2'; diff --git a/bitwpfi.php b/bitwpfi.php index 11534d41e..994e6fbb8 100644 --- a/bitwpfi.php +++ b/bitwpfi.php @@ -4,7 +4,7 @@ * Plugin Name: Bit Integrations * Plugin URI: https://bitapps.pro/bit-integrations * Description: Bit Integrations is a platform that integrates with over 300+ different platforms to help with various tasks on your WordPress site, like WooCommerce, Form builder, Page builder, LMS, Sales funnels, Bookings, CRM, Webhooks, Email marketing, Social media and Spreadsheets, etc - * Version: 2.10.1 + * Version: 2.10.2 * Author: Automation & Integration Plugin - Bit Apps * Author URI: https://bitapps.pro * Text Domain: bit-integrations @@ -34,7 +34,7 @@ * * @deprecated 2.7.8 Use Config::VERSION instead. */ -define('BTCBI_VERSION', '2.10.1'); +define('BTCBI_VERSION', '2.10.2'); /** * deprecated since version 2.7.8. * diff --git a/frontend/src/pages/ChangelogToggle.jsx b/frontend/src/pages/ChangelogToggle.jsx index 8bb88281e..571332dcd 100644 --- a/frontend/src/pages/ChangelogToggle.jsx +++ b/frontend/src/pages/ChangelogToggle.jsx @@ -8,7 +8,7 @@ import ExternalLinkIcn from '../Icons/ExternalLinkIcn' import bitsFetch from '../Utils/bitsFetch' import { __, sprintf } from '../Utils/i18nwrap' -const releaseDate = '25th July 2026' +const releaseDate = '4th August 2026' // Example for items: // items: [ @@ -23,52 +23,19 @@ const changeLog = [ label: __('Note', 'bit-integrations'), headClass: 'new-note', itemClass: '', - items: [ - { - label: 'Since 2.10.0, credentials live in reusable Connections. Integrations you set up before that still keep their own credentials, so we call them legacy - their info page shows a short explainer instead of a connection to view or switch.', - desc: '', - isPro: false - }, - { - label: 'They keep working exactly as before, and credentials stay safe on your server (never sent to your browser, which is why the fields look empty). To move one over, just open its settings and authorize the app once.', - desc: '', - isPro: false - } - ] + items: [] }, { label: __('New Triggers', 'bit-integrations'), headClass: 'new-trigger', itemClass: 'integration-list', - items: [ - { - label: 'Bit CRM', - desc: '66 new events added', - isPro: false - }, - { - label: 'Fluent Player', - desc: '12 new events added', - isPro: true - } - ] + items: [] }, { label: __('New Actions', 'bit-integrations'), headClass: 'new-integration', itemClass: 'integration-list', - items: [ - { - label: 'Bit CRM', - desc: '50 new events added', - isPro: false - }, - { - label: 'Fluent Player', - desc: '26 new events added', - isPro: true - } - ] + items: [] }, { label: __('New Features', 'bit-integrations'), @@ -76,82 +43,57 @@ const changeLog = [ itemClass: 'feature-list', items: [ { - label: 'Webhook (Action)', - desc: 'Dynamic URL path variables added to outgoing webhooks, mappable from trigger data.', + label: 'EmailOctopus', + desc: 'Contacts can now be added or updated with the "Pending" status.', isPro: false }, - { - label: 'Webhook (Action)', - desc: 'Smart codes are now available in query parameters, request headers and path variables.', - isPro: true - }, - { - label: 'Connections', - desc: "An action's connection can now be switched from its info page.", - isPro: false - } ] }, { label: __('Improvements', 'bit-integrations'), headClass: 'new-improvement', itemClass: 'feature-list', - items: [] - }, - { - label: __('Bug Fixes', 'bit-integrations'), - headClass: 'fixes', - itemClass: 'fixes-list', items: [ { label: 'Connections', - desc: 'Fixed credentials not resolving for renamed integrations.', + desc: 'API keys, secrets and tokens are now hidden behind dots in the connection form, with an eye button to reveal them when you need to check a value.', isPro: false }, { - label: 'Integration Info', - desc: 'Fixed legacy actions rendering a blank info page.', + label: 'oAuth Redirect', + desc: 'Apps now return to a dedicated callback address on your site after you approve them. Providers that refused the old redirect URL can now be connected without any extra setup.', isPro: false }, { - label: 'Webhook (Action)', - desc: 'Run status is now judged by the response HTTP status code, and path variables sync even when their tab is never opened.', + label: 'Trigger data', + desc: 'Field names taken from nested data are now shorter and easier to read - long paths are trimmed, repeated words removed, and list items are shown as "Items 0" instead of a separate level.', isPro: false }, - { - label: 'ACF', - desc: 'Fixed field reading when the meta value is missing', - isPro: true - }, - { - label: 'WP Post', - desc: 'Fixed trashed and internal posts firing the post created/inserted triggers', - isPro: true - } ] }, { - label: __('Security', 'bit-integrations'), + label: __('Bug Fixes', 'bit-integrations'), headClass: 'fixes', itemClass: 'fixes-list', items: [ { - label: 'Custom Action', - desc: "Closed an administrator gate bypass and confined the custom function file to the plugin's custom-function directory.", + label: 'Bit Form', + desc: 'Fixed the site address not being read from the connection, which stopped some Bit Form actions from running.', isPro: false }, { - label: 'Timeline', - desc: 'Log re-execution now applies the same administrator check as custom action save, update and delete.', - isPro: false - }, - { - label: 'Mail', - desc: 'Recipient addresses and headers are validated in all cases, and header display names are sanitized.', - isPro: false + label: 'Amelia Booking', + desc: 'Appointment triggers now include the appointment location details.', + isPro: true } ] }, + { + label: __('Security', 'bit-integrations'), + headClass: 'fixes', + itemClass: 'fixes-list', + items: [] + }, { label: __('Compatibility & Compliance', 'bit-integrations'), headClass: 'new-improvement', diff --git a/readme.txt b/readme.txt index 81cb91c37..1443ab3a2 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: automation, automator, google sheets integration, form integration, WooCom Requires at least: 5.1 Tested up to: 7.0 Requires PHP: 7.4 -Stable tag: 2.10.1 +Stable tag: 2.10.2 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -469,6 +469,21 @@ Bit Integrations follows WordPress coding standards and best practices to ensure == Changelog == += 2.10.2 = +_Release Date - 4th August 2026_ + +- **New Feature** + - EmailOctopus: Contacts can now be added or updated with the "Pending" status. + +- **Improvements** + - Connections: API keys, secrets and tokens are now hidden behind dots in the connection form, with an eye button to reveal them when you need to check a value. + - Authorization: Apps now return to a dedicated callback address on your site after you approve them. Providers that refused the old redirect URL can now be connected without any extra setup. + - Trigger data: Field names taken from nested data are now shorter and easier to read - long paths are trimmed, repeated words removed, and list items are shown as "Items 0" instead of a separate level. + +- **Bug Fixes** + - Bit Form: Fixed the site address not being read from the connection, which stopped some Bit Form actions from running. + - Amelia Booking: Appointment triggers now include the appointment location details (Pro). + = 2.10.1 = _Release Date - 30th July 2026_ From 88a62042b730e6056b643cd302c7771b5085fea6 Mon Sep 17 00:00:00 2001 From: Rishad Alam Date: Tue, 4 Aug 2026 12:31:06 +0600 Subject: [PATCH 13/14] fix(BitCrm): resolve plugin check warnings 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. --- backend/Actions/BitCrm/BitCrmActionHelper.php | 10 ++++++++-- backend/Actions/BitCrm/BitCrmCustomField.php | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/Actions/BitCrm/BitCrmActionHelper.php b/backend/Actions/BitCrm/BitCrmActionHelper.php index 3aea3d6b6..61b46c5be 100644 --- a/backend/Actions/BitCrm/BitCrmActionHelper.php +++ b/backend/Actions/BitCrm/BitCrmActionHelper.php @@ -1479,13 +1479,15 @@ private static function dealStages() * Every branch returns a bare site-local `Y-m-d H:i:s`, the shape Bit CRM's * own stage modal submits. Formatting one input as local and another as UTC * would store the same instant two ways. Null when nothing usable was mapped. + * + * @param mixed $value */ private static function dealClosingDate($value) { $value = trim((string) $value); if ($value === '') { - return null; + return; } if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { @@ -1502,7 +1504,11 @@ private static function dealClosingDate($value) $timestamp = strtotime($value); - return $timestamp === false ? null : wp_date('Y-m-d H:i:s', $timestamp); + if ($timestamp === false) { + return; + } + + return gmdate('Y-m-d H:i:s', $timestamp + (int) (get_option('gmt_offset') * HOUR_IN_SECONDS)); } private static function csvList($value) diff --git a/backend/Actions/BitCrm/BitCrmCustomField.php b/backend/Actions/BitCrm/BitCrmCustomField.php index 1b4a04d4a..e0d8d9b77 100644 --- a/backend/Actions/BitCrm/BitCrmCustomField.php +++ b/backend/Actions/BitCrm/BitCrmCustomField.php @@ -127,6 +127,7 @@ public static function save(string $module, int $entityId, array $values) return; } + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- hook is owned by the Bit CRM plugin, not this one. do_action(self::SAVE_HOOK, $module, $entityId, $values); } From 91a151d03ac1ec28db5ebd2cc37c95322ec98ba6 Mon Sep 17 00:00:00 2001 From: Rishad Alam Date: Tue, 4 Aug 2026 14:37:26 +0600 Subject: [PATCH 14/14] feat(BitCrm): enhance lead handling and timezone conversion logic --- backend/Actions/BitCrm/BitCrmActionHelper.php | 10 ++++++++-- .../AllIntegrations/BitCrm/BitCrmCommonFunc.js | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/Actions/BitCrm/BitCrmActionHelper.php b/backend/Actions/BitCrm/BitCrmActionHelper.php index 61b46c5be..d691dc45d 100644 --- a/backend/Actions/BitCrm/BitCrmActionHelper.php +++ b/backend/Actions/BitCrm/BitCrmActionHelper.php @@ -580,7 +580,9 @@ public static function updateDealStage($fieldData) $update['probability'] = $definition['probability']; } - // Required on a stage that closes the deal, asked for on no other. + // 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. if (\in_array($definition['deal_category'] ?? '', ['closed_won', 'closed_lost'], true)) { $closedAt = self::dealClosingDate($fieldData['closed_at'] ?? ''); @@ -1508,7 +1510,11 @@ private static function dealClosingDate($value) return; } - return gmdate('Y-m-d H:i:s', $timestamp + (int) (get_option('gmt_offset') * HOUR_IN_SECONDS)); + // 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'); } private static function csvList($value) diff --git a/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js b/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js index dce962006..c14ab9497 100644 --- a/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js +++ b/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js @@ -107,7 +107,9 @@ const relaxOnUpdate = (fields, action) => export const crmMapFields = bitCrmConf => relaxOnUpdate( - crmFieldsOf(bitCrmConf).filter(fld => fld.type !== SELECT_TYPE && fld.type !== LOOKUP_TYPE), + crmFieldsOf(bitCrmConf).filter( + fld => fld.isCustom || (fld.type !== SELECT_TYPE && fld.type !== LOOKUP_TYPE) + ), bitCrmConf?.mainAction )