diff --git a/backend/Actions/BitCrm/BitCrmActionHelper.php b/backend/Actions/BitCrm/BitCrmActionHelper.php index 1f265075f..d691dc45d 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)); @@ -552,11 +565,35 @@ 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. 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'] ?? ''); + + 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')]; } @@ -578,7 +615,13 @@ 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. + $convertTo = array_values(array_unique(array_merge( + ['contact', 'company'], + self::csvList($fieldData['convert_to']) + ))); $options = [ 'convertTo' => $convertTo, 'moveRelatedDataTo' => $fieldData['move_related_data_to'], @@ -1410,23 +1453,68 @@ 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. + * + * @param mixed $value + */ + private static function dealClosingDate($value) + { + $value = trim((string) $value); + + if ($value === '') { + return; + } + + 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); + + if ($timestamp === false) { + return; + } + + // 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) @@ -1455,6 +1543,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..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() @@ -48,17 +68,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(); @@ -81,6 +101,18 @@ public static function refreshEntities($data) wp_send_json_success(['options' => self::normalize((new $service())->getEntitiesAsOptions())]); } + /** + * @param object $data + */ + public static function refreshFields($data) + { + self::isExists(); + + $module = isset($data->module) ? sanitize_text_field($data->module) : ''; + + wp_send_json_success(['fields' => BitCrmFieldService::fields($module)]); + } + public static function refreshLeadTags() { wp_send_json_success(['options' => self::tagOptions('lead')]); @@ -111,13 +143,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 new file mode 100644 index 000000000..e0d8d9b77 --- /dev/null +++ b/backend/Actions/BitCrm/BitCrmCustomField.php @@ -0,0 +1,164 @@ + + */ + 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 []; + } + + $active = []; + foreach ($fields->toArray() as $field) { + if (empty($field['field_key']) || empty($field['status'])) { + continue; + } + + $active[] = $field; + } + + return $active; + } + + /** + * 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; + } + + // 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); + } + + /** + * 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/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 111e7567d..2f298babb 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_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/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', ], ]; 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/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/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], 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)) { 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/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/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 b38042cd7..c14ab9497 100644 --- a/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js +++ b/frontend/src/components/AllIntegrations/BitCrm/BitCrmCommonFunc.js @@ -2,7 +2,19 @@ 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, + 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' +const LOOKUP_TYPE = 'lookup' export const handleInput = (e, bitCrmConf, setBitCrmConf) => { const { name, value } = e.target @@ -34,6 +46,106 @@ 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 CRM_FIELDS_KEY = 'crmFields' + +/** + * 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 fetchBitCrmFields = (module, setBitCrmConf, setIsLoading, notify = false) => { + if (!module) return + + setIsLoading(CRM_FIELDS_KEY) + + bitsFetch({ module }, 'refresh_bitcrm_fields') + .then(result => { + const fetched = result?.success && Array.isArray(result?.data?.fields) ? result.data.fields : [] + + setBitCrmConf(prevConf => + create(prevConf, draftConf => { + draftConf.crmFields = fetched + draftConf.crmFieldsModule = 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(() => { + 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.isCustom || (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] })) + +/** + * 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( @@ -46,7 +158,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 => { @@ -56,27 +168,40 @@ 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. - */ +// 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 = []) => { 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 d8cdbcf09..979a51bf4 100644 --- a/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx +++ b/frontend/src/components/AllIntegrations/BitCrm/BitCrmIntegLayout.jsx @@ -7,10 +7,23 @@ 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 { + conditionalFields, + CRM_FIELDS_KEY, + crmLookupFields, + crmMapFields, + crmSelectFields, + dropStaleConditionalRows, + fetchBitCrmFields, + generateMappedField, + isEmptyValue, + refreshBitCrmList, + syncRequiredFieldMap +} from './BitCrmCommonFunc' import BitCrmFieldMap from './BitCrmFieldMap' import { actionDropdowns, + actionFieldModules, actionSelects, actionUtilities, allConfigurableKeys, @@ -21,19 +34,42 @@ import { export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmConf }) { const { isPro } = useRecoilValue($appConfigState) const [isLoading, setIsLoading] = useState(false) + const [lockedSelectKey, setLockedSelectKey] = useState(0) 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 crmModule = actionFieldModules[action] + + const crmSelects = crmSelectFields(bitCrmConf) + const crmLookups = crmLookupFields(bitCrmConf) + const mappableFields = [ + ...staticFields, + ...conditionalFields(bitCrmConf), + ...crmMapFields(bitCrmConf) + ] + + const requiredKeys = mappableFields + .filter(fld => fld.required === true) + .map(fld => fld.key) + .join(',') + + useEffect(() => { + 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 || bitCrmFields.length === 0) return + if (!action || mappableFields.length === 0) return - const synced = syncRequiredFieldMap(bitCrmConf?.field_map ?? [], bitCrmFields) + const pruned = dropStaleConditionalRows(bitCrmConf?.field_map ?? [], mappableFields) + const synced = syncRequiredFieldMap(pruned, mappableFields) if (synced === bitCrmConf?.field_map) return setBitCrmConf(prevConf => @@ -42,24 +78,97 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon }) ) // eslint-disable-next-line react-hooks/exhaustive-deps + }, [action, requiredKeys]) + + 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]) + // 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. - ;[...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. + ;[...selects, ...dropdowns] + .filter(item => item.dependsOn === key) + .forEach(item => { + delete draftConf[item.key] + delete draftConf[item.listKey] + }) }) ) + 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) + 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 => { @@ -68,9 +177,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) @@ -81,10 +189,14 @@ 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] }) + ; (actionSelects[value] ?? []).forEach(sel => { + if (sel.defaultValue !== undefined) draftConf[sel.key] = sel.defaultValue + }) }) ) } @@ -97,7 +209,7 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon ({ label: checkIsPro(isPro, mod.is_pro) ? mod.label : getProLabel(mod.label), @@ -107,11 +219,22 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon singleSelect closeOnSelect /> + {crmModule && ( + + )} - {/* Fixed enum selects */} {selects.map(sel => ( -
+
{sel.label} {sel.required && *}: @@ -120,16 +243,63 @@ 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}}
))} - {/* 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 => (
@@ -170,10 +340,9 @@ 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')}
@@ -194,7 +363,7 @@ export default function BitCrmIntegLayout({ formFields, bitCrmConf, setBitCrmCon i={i} field={field} formFields={formFields} - bitCrmFields={bitCrmFields} + bitCrmFields={mappableFields} bitCrmConf={bitCrmConf} setBitCrmConf={setBitCrmConf} /> @@ -211,7 +380,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 }) { + 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')} + /> { - 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) { 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/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/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; 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_