phone number extensions

This commit is contained in:
Yuri Kuznetsov
2024-04-02 13:47:23 +03:00
parent 79c8d25a80
commit 89ff3dcf15
12 changed files with 276 additions and 45 deletions

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,64 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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];
}
}

View File

@@ -287,6 +287,7 @@ return [
'cleanupDeletedRecords' => true,
'phoneNumberNumericSearch' => true,
'phoneNumberInternational' => true,
'phoneNumberExtensions' => false,
'phoneNumberPreferredCountryList' => ['us', 'de'],
'adminUpgradeDisabled' => false,
'wysiwygCodeEditorDisabled' => false,

View File

@@ -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",

View File

@@ -65,7 +65,7 @@
"label": "Phone Numbers",
"rows": [
[{"name": "phoneNumberInternational"}, {"name": "phoneNumberPreferredCountryList"}],
[{"name": "phoneNumberNumericSearch"}, false]
[{"name": "phoneNumberNumericSearch"}, {"name": "phoneNumberExtensions"}]
]
},
{

View File

@@ -706,6 +706,9 @@
"phoneNumberInternational": {
"type": "bool"
},
"phoneNumberExtensions": {
"type": "bool"
},
"phoneNumberPreferredCountryList": {
"type": "multiEnum",
"view": "views/settings/fields/phone-number-preferred-country-list"

View File

@@ -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;

View File

@@ -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 === '') {

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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 */