diff --git a/application/Espo/Classes/FieldValidators/PhoneType.php b/application/Espo/Classes/FieldValidators/PhoneType.php index 7b352e3c34..c925797494 100644 --- a/application/Espo/Classes/FieldValidators/PhoneType.php +++ b/application/Espo/Classes/FieldValidators/PhoneType.php @@ -31,6 +31,7 @@ namespace Espo\Classes\FieldValidators; use Brick\PhoneNumber\PhoneNumber; use Brick\PhoneNumber\PhoneNumberParseException; +use Espo\Core\PhoneNumber\Util; use Espo\Core\Utils\Config; use Espo\Core\Utils\Metadata; use Espo\ORM\Defs; @@ -203,6 +204,22 @@ class PhoneType return true; } + $ext = null; + + if ($this->config->get('phoneNumberExtensions')) { + [$number, $ext] = Util::splitExtension($number); + } + + if ($ext) { + if (!preg_match('/[0-9]+/', $ext)) { + return false; + } + + if (strlen($ext) > 6) { + return false; + } + } + try { $numberObj = PhoneNumber::parse($number); } diff --git a/application/Espo/Core/PhoneNumber/Sanitizer.php b/application/Espo/Core/PhoneNumber/Sanitizer.php index c89d6d0031..8081fcc683 100644 --- a/application/Espo/Core/PhoneNumber/Sanitizer.php +++ b/application/Espo/Core/PhoneNumber/Sanitizer.php @@ -62,13 +62,25 @@ class Sanitizer private function parsePhoneNumber(string $value, ?string $countryCode): string { + $ext = null; + + if ($this->config->get('phoneNumberExtensions')) { + [$value, $ext] = Util::splitExtension($value); + } + try { $number = PhoneNumber::parse($value, $countryCode); - - return (string) $number; } catch (PhoneNumberParseException) { return $value; } + + $output = (string) $number; + + if ($ext) { + $output .= ' ext. ' . $ext; + } + + return $output; } } diff --git a/application/Espo/Core/PhoneNumber/Util.php b/application/Espo/Core/PhoneNumber/Util.php new file mode 100644 index 0000000000..ded1121fda --- /dev/null +++ b/application/Espo/Core/PhoneNumber/Util.php @@ -0,0 +1,64 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\PhoneNumber; + +class Util +{ + /** + * @internal Do not use in custom code. + * @return array{string, ?string} + */ + public static function splitExtension(string $value): array + { + $ext = null; + + $delimiters = [ + 'ext.', + 'x.', + 'x', + '#', + ]; + + foreach ($delimiters as $delimiter) { + $index = strrpos($value, $delimiter); + + if ($index === false || $index < 2) { + continue; + } + + $ext = trim(substr($value, $index + strlen($delimiter))); + $value = trim(substr($value, 0, $index)); + + break; + } + + return [$value, $ext]; + } +} diff --git a/application/Espo/Resources/defaults/config.php b/application/Espo/Resources/defaults/config.php index 786d9e84d6..b238b0aeb1 100644 --- a/application/Espo/Resources/defaults/config.php +++ b/application/Espo/Resources/defaults/config.php @@ -287,6 +287,7 @@ return [ 'cleanupDeletedRecords' => true, 'phoneNumberNumericSearch' => true, 'phoneNumberInternational' => true, + 'phoneNumberExtensions' => false, 'phoneNumberPreferredCountryList' => ['us', 'de'], 'adminUpgradeDisabled' => false, 'wysiwygCodeEditorDisabled' => false, diff --git a/application/Espo/Resources/i18n/en_US/Settings.json b/application/Espo/Resources/i18n/en_US/Settings.json index 1cee0016fe..afdc4abdc3 100644 --- a/application/Espo/Resources/i18n/en_US/Settings.json +++ b/application/Espo/Resources/i18n/en_US/Settings.json @@ -113,6 +113,7 @@ "textFilterUseContainsForVarchar": "Use 'contains' operator when filtering varchar fields", "phoneNumberNumericSearch": "Numeric phone number search", "phoneNumberInternational": "International phone numbers", + "phoneNumberExtensions": "Phone number extensions", "phoneNumberPreferredCountryList": "Preferred telephone country codes", "authTokenPreventConcurrent": "Only one auth token per user", "scopeColorsDisabled": "Disable scope colors", diff --git a/application/Espo/Resources/layouts/Settings/settings.json b/application/Espo/Resources/layouts/Settings/settings.json index 95442cda7c..149c5297ac 100644 --- a/application/Espo/Resources/layouts/Settings/settings.json +++ b/application/Espo/Resources/layouts/Settings/settings.json @@ -65,7 +65,7 @@ "label": "Phone Numbers", "rows": [ [{"name": "phoneNumberInternational"}, {"name": "phoneNumberPreferredCountryList"}], - [{"name": "phoneNumberNumericSearch"}, false] + [{"name": "phoneNumberNumericSearch"}, {"name": "phoneNumberExtensions"}] ] }, { diff --git a/application/Espo/Resources/metadata/entityDefs/Settings.json b/application/Espo/Resources/metadata/entityDefs/Settings.json index 6723edf33a..8757288393 100644 --- a/application/Espo/Resources/metadata/entityDefs/Settings.json +++ b/application/Espo/Resources/metadata/entityDefs/Settings.json @@ -706,6 +706,9 @@ "phoneNumberInternational": { "type": "bool" }, + "phoneNumberExtensions": { + "type": "bool" + }, "phoneNumberPreferredCountryList": { "type": "multiEnum", "view": "views/settings/fields/phone-number-preferred-country-list" diff --git a/client/src/views/admin/settings.js b/client/src/views/admin/settings.js index 77c9c6214c..de9a6d2bac 100644 --- a/client/src/views/admin/settings.js +++ b/client/src/views/admin/settings.js @@ -26,23 +26,49 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -define('views/admin/settings', ['views/settings/record/edit'], function (Dep) { +import SettingsEditRecordView from 'views/settings/record/edit'; - return Dep.extend({ +class SettingsAdminRecordView extends SettingsEditRecordView { - layoutName: 'settings', + layoutName = 'settings' - saveAndContinueEditingAction: false, + saveAndContinueEditingAction = false - setup: function () { - Dep.prototype.setup.call(this); - - if (this.getHelper().getAppParam('isRestrictedMode') && !this.getUser().isSuperAdmin()) { - this.hideField('cronDisabled'); - this.hideField('maintenanceMode'); - this.setFieldReadOnly('useWebSocket'); - this.setFieldReadOnly('siteUrl'); + dynamicLogicDefs = { + fields: { + phoneNumberPreferredCountryList: { + visible: { + conditionGroup: [ + { + attribute: 'phoneNumberInternational', + type: 'isTrue' + } + ] + } + }, + phoneNumberExtensions: { + visible: { + conditionGroup: [ + { + attribute: 'phoneNumberInternational', + type: 'isTrue' + } + ] + } } - }, - }); -}); + } + } + + setup() { + super.setup(); + + if (this.getHelper().getAppParam('isRestrictedMode') && !this.getUser().isSuperAdmin()) { + this.hideField('cronDisabled'); + this.hideField('maintenanceMode'); + this.setFieldReadOnly('useWebSocket'); + this.setFieldReadOnly('siteUrl'); + } + } +} + +export default SettingsAdminRecordView; diff --git a/client/src/views/fields/phone.js b/client/src/views/fields/phone.js index 99f7d43823..1d01ed62ef 100644 --- a/client/src/views/fields/phone.js +++ b/client/src/views/fields/phone.js @@ -297,7 +297,7 @@ class PhoneFieldView extends VarcharFieldView { item.erased = number.indexOf(this.erasedPlaceholder) === 0; if (!item.erased) { - item.valueForLink = number.replace(/ /g, ''); + item.valueForLink = this.formatForLink(number); if (this.isReadMode()) { item.phoneNumber = this.formatNumber(item.phoneNumber); @@ -312,7 +312,7 @@ class PhoneFieldView extends VarcharFieldView { const o = { phoneNumber: this.formatNumber(number), primary: true, - valueForLink: number.replace(/ /g, ''), + valueForLink: this.formatForLink(number), }; if (this.isReadMode()) { @@ -326,8 +326,6 @@ class PhoneFieldView extends VarcharFieldView { phoneNumberData = [o]; } - - const data = { ...super.data(), phoneNumberData: phoneNumberData, @@ -343,7 +341,7 @@ class PhoneFieldView extends VarcharFieldView { data.isErased = this.model.get(this.name).indexOf(this.erasedPlaceholder) === 0; if (!data.isErased) { - data.valueForLink = this.model.get(this.name).replace(/ /g, ''); + data.valueForLink = this.formatForLink(this.model.get(this.name)); } } @@ -356,6 +354,18 @@ class PhoneFieldView extends VarcharFieldView { return data; } + /** + * @private + * @param {string} number + */ + formatForLink(number) { + if (this.allowExtensions && this.useInternational) { + return number; + } + + return number.replace(/ /g, ''); + } + focusOnLast(cursorAtEnd) { const $item = this.$el.find('input.form-control').last(); @@ -449,7 +459,14 @@ class PhoneFieldView extends VarcharFieldView { return; } - obj.setNumber(obj.getNumber()); + let number = obj.getNumber(); + const ext = obj.getExtension(); + + if (this.allowExtensions && ext) { + number += ' ext. ' + ext; + } + + obj.setNumber(number); }); }); } @@ -529,6 +546,7 @@ class PhoneFieldView extends VarcharFieldView { this.phoneNumberOptedOutByDefault = this.getConfig().get('phoneNumberIsOptedOutByDefault'); this.useInternational = this.getConfig().get('phoneNumberInternational') || false; + this.allowExtensions = this.getConfig().get('phoneNumberExtensions') || false; this.preferredCountryList = this.getConfig().get('phoneNumberPreferredCountryList') || []; if (this.useInternational && !this.isListMode() && !this.isSearchMode()) { @@ -603,6 +621,12 @@ class PhoneFieldView extends VarcharFieldView { if (this.intlTelInputMap.has(inputElement)) { row.phoneNumber = this.intlTelInputMap.get(inputElement).getNumber(); + + const ext = this.intlTelInputMap.get(inputElement).getExtension() || null; + + if (this.allowExtensions && ext) { + row.phoneNumber += ' ext. ' + ext; + } } if (row.phoneNumber === '') { diff --git a/client/src/views/settings/record/edit.js b/client/src/views/settings/record/edit.js index 821be44071..22470f14e2 100644 --- a/client/src/views/settings/record/edit.js +++ b/client/src/views/settings/record/edit.js @@ -26,33 +26,30 @@ * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. ************************************************************************/ -define('views/settings/record/edit', ['views/record/edit'], function (Dep) { +import EditRecordView from 'views/record/edit'; - return Dep.extend({ +class SettingsEditRecordView extends EditRecordView { - saveAndContinueEditingAction: false, + saveAndContinueEditingAction = false - sideView: null, + sideView = null - layoutName: 'settings', + layoutName = 'settings' - setup: function () { - Dep.prototype.setup.call(this); + setup() { + super.setup(); - this.listenTo(this.model, 'after:save', () => { - this.getConfig().set(this.model.getClonedAttributes()); - }); - }, + this.listenTo(this.model, 'after:save', () => { + this.getConfig().set(this.model.getClonedAttributes()); + }); + } - afterRender: function () { - Dep.prototype.afterRender.call(this); - }, + exit(after) { + if (after === 'cancel') { + this.getRouter().navigate('#Admin', {trigger: true}); + } + } +} - exit: function (after) { - if (after === 'cancel') { - this.getRouter().navigate('#Admin', {trigger: true}); - } - }, - }); -}); +export default SettingsEditRecordView; diff --git a/tests/integration/Espo/Record/FieldValidationTest.php b/tests/integration/Espo/Record/FieldValidationTest.php index 2e2d26d2ef..57eb1fdb5d 100644 --- a/tests/integration/Espo/Record/FieldValidationTest.php +++ b/tests/integration/Espo/Record/FieldValidationTest.php @@ -31,10 +31,14 @@ namespace tests\integration\Espo\Record; use Espo\Core\Application; use Espo\Core\Exceptions\BadRequest; +use Espo\Core\Exceptions\Conflict; +use Espo\Core\Exceptions\Forbidden; use Espo\Core\Record\CreateParams; use Espo\Core\Record\ServiceContainer; use Espo\Core\Record\UpdateParams; +use Espo\Core\Utils\Config\ConfigWriter; use Espo\Entities\User; +use Espo\Modules\Crm\Entities\Account; use Espo\Modules\Crm\Entities\Lead; use Espo\ORM\EntityManager; use Espo\Tools\App\SettingsService as SettingsService; @@ -471,4 +475,47 @@ class FieldValidationTest extends BaseTestCase 'opportunityAmount' => 'bad-value', ], CreateParams::create()); } + + public function testPhoneNumber(): void + { + $service = $this->getContainer() + ->getByClass(ServiceContainer::class) + ->getByClass(Account::class); + + $configWriter = $this->getInjectableFactory()->create(ConfigWriter::class); + $configWriter->set('phoneNumberExtensions', true); + $configWriter->save(); + + /** @noinspection PhpUnhandledExceptionInspection */ + $service->create((object)[ + 'name' => 'Test 1', + 'phoneNumberData' => [ + (object)[ + 'phoneNumber' => '+38 09 044 433 22 ext. 001', + ], + ], + ], CreateParams::create()); + + $thrown = false; + + try { + /** @noinspection PhpUnhandledExceptionInspection */ + $service->create((object)[ + 'name' => 'Test 2', + 'phoneNumberData' => [ + (object)[ + 'phoneNumber' => '+38 09 044 433 22 ext. ABC', + ], + (object)[ + 'phoneNumber' => '+38 09 044 433 33 ext. 1234567', + ], + ], + ], CreateParams::create()); + } + catch (BadRequest) { + $thrown = true; + } + + $this->assertTrue($thrown); + } } diff --git a/tests/integration/Espo/Record/SanitizeTest.php b/tests/integration/Espo/Record/SanitizeTest.php index 2355d1614a..2c39fcee17 100644 --- a/tests/integration/Espo/Record/SanitizeTest.php +++ b/tests/integration/Espo/Record/SanitizeTest.php @@ -31,6 +31,7 @@ namespace tests\integration\Espo\Record; use Espo\Core\Record\CreateParams; use Espo\Core\Record\ServiceContainer; +use Espo\Core\Utils\Config\ConfigWriter; use Espo\Entities\User; use Espo\Modules\Crm\Entities\Account; use Espo\Modules\Crm\Entities\Meeting; @@ -108,6 +109,44 @@ class SanitizeTest extends BaseTestCase $this->assertEquals('+380904443322', $numbers[0]); $this->assertEquals('+380904443333', $numbers[1]); + $configWriter = $this->getInjectableFactory()->create(ConfigWriter::class); + $configWriter->set('phoneNumberExtensions', true); + $configWriter->save(); + + /** @var Account $account */ + /** @noinspection PhpUnhandledExceptionInspection */ + $account = $service->create((object) [ + 'name' => 'Test 3', + 'phoneNumberData' => [ + (object) [ + 'phoneNumber' => '+38 09 044 433 22 ext. 0001', + ], + (object) [ + 'phoneNumber' => '+38 09 044 433 33 x. 1001', + ], + (object) [ + 'phoneNumber' => '+380904443344x.1000', + ], + (object) [ + 'phoneNumber' => '+380904443355#1000', + ], + (object) [ + 'phoneNumber' => '+380904443366 # 1000', + ], + ], + ], CreateParams::create()); + + $numbers = $account->getPhoneNumberGroup()->getNumberList(); + $this->assertCount(5, $numbers); + + sort($numbers); + + $this->assertEquals('+380904443322 ext. 0001', $numbers[0]); + $this->assertEquals('+380904443333 ext. 1001', $numbers[1]); + $this->assertEquals('+380904443344 ext. 1000', $numbers[2]); + $this->assertEquals('+380904443355 ext. 1000', $numbers[3]); + $this->assertEquals('+380904443366 ext. 1000', $numbers[4]); + // datetime /** @noinspection PhpUnhandledExceptionInspection */