web-to-lead forms

This commit is contained in:
Yuri Kuznetsov
2024-11-23 11:05:25 +02:00
parent 56735e9358
commit ec2fc5916c
49 changed files with 2323 additions and 143 deletions
@@ -60,6 +60,7 @@ class ExampleLoader implements Loader
$this->processRequestUrl($entity);
$this->processRequestPayload($entity);
$this->processFormUrl($entity);
}
private function processRequestUrl(LeadCapture $entity): void
@@ -121,4 +122,21 @@ class ExampleLoader implements Loader
$entity->set('exampleRequestPayload', $requestPayload);
}
private function processFormUrl(LeadCapture $entity): void
{
$formId = $entity->getFormId();
$siteUrl = $this->config->getSiteUrl();
if (!$entity->hasFormEnabled() || !$formId) {
/** @noinspection PhpRedundantOptionalArgumentInspection */
$entity->set('formUrl', null);
return;
}
$formUrl = "$siteUrl?entryPoint=leadCaptureForm&id=$formId";
$entity->set('formUrl', $formUrl);
}
}
@@ -46,8 +46,7 @@ class BeforeCreate implements SaveHook
public function process(Entity $entity): void
{
$apiKey = $this->service->generateApiKey();
$entity->setApiKey($apiKey);
$entity->setApiKey($this->service->generateApiKey());
$entity->setFormId($this->service->generateFormId());
}
}
@@ -110,6 +110,24 @@ class LeadCapture extends Record
->getValueMap();
}
/**
* @throws BadRequest
* @throws NotFound
* @throws Forbidden
*/
public function postActionGenerateNewFormId(Request $request): stdClass
{
$data = $request->getParsedBody();
if (empty($data->id)) {
throw new BadRequest();
}
return $this->getLeadCaptureService()
->generateNewFormIdForEntity($data->id)
->getValueMap();
}
/**
* @return stdClass[]
* @throws Forbidden
@@ -166,9 +166,21 @@ class FieldValidationManager
/**
* Check a specific field for a specific validation type.
*
* @param Entity $entity An entity to check.
* @param string $field A field to check.
* @param string $type A validation type.
* @param ?stdClass $data A payload.
* @param mixed $value To override a validation value.
*/
public function check(Entity $entity, string $field, string $type, ?stdClass $data = null): bool
{
public function check(
Entity $entity,
string $field,
string $type,
?stdClass $data = null,
mixed $value = null
): bool {
$data ??= (object) [];
$entityType = $entity->getEntityType();
@@ -178,7 +190,7 @@ class FieldValidationManager
return false;
}
$validationValue = $this->fieldUtil->getEntityTypeFieldParam($entityType, $field, $type);
$validationValue = $value ?? $this->fieldUtil->getEntityTypeFieldParam($entityType, $field, $type);
$isMandatory = in_array($type, $this->getMandatoryValidationList($entityType, $field));
$skip = !$isMandatory && (is_null($validationValue) || $validationValue === false);
@@ -30,21 +30,19 @@
namespace Espo\Core\Loaders;
use Espo\Core\Container\Loader;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Language as LanguageService;
use Espo\Core\Utils\Language;
class BaseLanguage implements Loader
{
public function __construct(private InjectableFactory $injectableFactory, protected Config $config)
{}
public function __construct(
private Language\LanguageFactory $languageFactory,
protected Config $config
) {}
public function load(): LanguageService
public function load(): Language
{
return $this->injectableFactory->createWith(LanguageService::class, [
'language' => $this->getLanguage(),
'useCache' => $this->config->get('useCache') ?? false,
]);
return $this->languageFactory->create($this->getLanguage());
}
protected function getLanguage(): string
@@ -52,19 +52,30 @@ class ActionRenderer
$params->getController(),
$params->getAction(),
$params->getData(),
$params->initAuth()
$params->initAuth(),
$params->getScripts(),
);
$this->clientManager->writeHeaders($response);
$securityParams = new SecurityParams(
frameAncestors: $params->getFrameAncestors(),
);
$this->clientManager->writeHeaders($response, $securityParams);
$response->writeBody($body);
}
/**
* @deprecated Use`write`.
* @param ?array<string, mixed> $data
* @param Script[] $scripts
*/
public function render(string $controller, string $action, ?array $data = null, bool $initAuth = false): string
{
private function render(
string $controller,
string $action,
?array $data,
bool $initAuth,
array $scripts,
): string {
$encodedData = Json::encode($data);
$initAuthPart = $initAuth ? "app.initAuth();" : '';
@@ -73,12 +84,17 @@ class ActionRenderer
"
{$initAuthPart}
app.doAction({
controllerClassName: '{$controller}',
action: '{$action}',
options: {$encodedData},
controllerClassName: '$controller',
action: '$action',
options: $encodedData,
});
";
return $this->clientManager->render($script);
$params = new RenderParams(
runScript: $script,
scripts: $scripts,
);
return $this->clientManager->render($params);
}
}
@@ -29,6 +29,8 @@
namespace Espo\Core\Utils\Client\ActionRenderer;
use Espo\Core\Utils\Client\Script;
/**
* @immutable
*/
@@ -37,6 +39,10 @@ class Params
/** @var ?array<string, mixed> */
private ?array $data;
private bool $initAuth = false;
/** @var string[] */
private array $frameAncestors = [];
/** @var Script[] */
private array $scripts = [];
/**
* @param ?array<string, mixed> $data
@@ -76,6 +82,30 @@ class Params
return $obj;
}
/**
* @param string[] $frameAncestors
* @since 9.0.0
*/
public function withFrameAncestors(array $frameAncestors): self
{
$obj = clone $this;
$obj->frameAncestors = $frameAncestors;
return $obj;
}
/**
* @param Script[] $scripts
* @since 9.0.0
*/
public function withScripts(array $scripts): self
{
$obj = clone $this;
$obj->scripts = $scripts;
return $obj;
}
public function getController(): string
{
return $this->controller;
@@ -98,4 +128,22 @@ class Params
{
return $this->initAuth;
}
/**
* @return string[]
* @since 9.0.0
*/
public function getFrameAncestors(): array
{
return $this->frameAncestors;
}
/**
* @return Script[]
* @since 9.0.0
*/
public function getScripts(): array
{
return $this->scripts;
}
}
@@ -0,0 +1,41 @@
<?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\Utils\Client;
class RenderParams
{
/**
* @param Script[] $scripts
*/
public function __construct(
readonly public ?string $runScript = null,
readonly public array $scripts = [],
) {}
}
@@ -0,0 +1,38 @@
<?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\Utils\Client;
class Script
{
public function __construct(
readonly public string $source,
readonly public bool $cacheBusting = false,
) {}
}
@@ -0,0 +1,40 @@
<?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\Utils\Client;
class SecurityParams
{
/**
* @param string[] $frameAncestors
*/
public function __construct(
readonly public array $frameAncestors = [],
) {}
}
+71 -24
View File
@@ -33,6 +33,9 @@ use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseWrapper;
use Espo\Core\Utils\Client\DevModeJsFileListProvider;
use Espo\Core\Utils\Client\LoaderParamsProvider;
use Espo\Core\Utils\Client\RenderParams;
use Espo\Core\Utils\Client\Script;
use Espo\Core\Utils\Client\SecurityParams;
use Espo\Core\Utils\File\Manager as FileManager;
use Slim\Psr7\Response as Psr7Response;
@@ -62,7 +65,7 @@ class ClientManager
private FileManager $fileManager,
private DevModeJsFileListProvider $devModeJsFileListProvider,
private Module $module,
private LoaderParamsProvider $loaderParamsProvider
private LoaderParamsProvider $loaderParamsProvider,
) {
$this->nonce = Util::generateKey();
}
@@ -80,43 +83,45 @@ class ClientManager
/**
* @todo Move to a separate class.
*/
public function writeHeaders(Response $response): void
public function writeHeaders(Response $response, ?SecurityParams $params = null): void
{
if ($this->config->get('clientSecurityHeadersDisabled')) {
return;
}
$params ??= new SecurityParams();
$response->setHeader('X-Content-Type-Options', 'nosniff');
$this->writeXFrameOptionsHeader($response);
$this->writeContentSecurityPolicyHeader($response);
$this->writeContentSecurityPolicyHeader($response, $params);
$this->writeStrictTransportSecurityHeader($response);
}
private function writeXFrameOptionsHeader(Response $response): void
{
if ($this->config->get('clientXFrameOptionsHeaderDisabled')) {
return;
}
$response->setHeader('X-Frame-Options', 'SAMEORIGIN');
}
private function writeContentSecurityPolicyHeader(Response $response): void
private function writeContentSecurityPolicyHeader(Response $response, SecurityParams $params): void
{
if ($this->config->get('clientCspDisabled')) {
return;
}
$scriptSrc = "script-src 'self' 'nonce-$this->nonce' 'unsafe-eval'";
$string = "script-src 'self' 'nonce-$this->nonce' 'unsafe-eval'";
/** @var string[] $scriptSourceList */
$scriptSourceList = $this->config->get('clientCspScriptSourceList') ?? [];
foreach ($scriptSourceList as $src) {
$scriptSrc .= ' ' . $src;
$string .= ' ' . $src;
}
$response->setHeader('Content-Security-Policy', $scriptSrc);
// Checking the parameter for bc.
if (!$this->config->get('clientXFrameOptionsHeaderDisabled')) {
$string .= '; frame-ancestors';
foreach (["'self'", ...$params->frameAncestors] as $item) {
$string .= ' ' . $item;
}
}
$response->setHeader('Content-Security-Policy', $string);
}
private function writeStrictTransportSecurityHeader(Response $response): void
@@ -137,7 +142,7 @@ class ClientManager
*/
public function display(?string $runScript = null, ?string $htmlFilePath = null, array $vars = []): void
{
$body = $this->render($runScript, $htmlFilePath, $vars);
$body = $this->renderInternal($runScript, $htmlFilePath, $vars);
$response = new ResponseWrapper(new Psr7Response());
@@ -148,10 +153,32 @@ class ClientManager
}
/**
* @param array<string, mixed> $vars
* Render.
*
* @param RenderParams $params Parameters.
* @return string A result HTML.
*/
public function render(?string $runScript = null, ?string $htmlFilePath = null, array $vars = []): string
public function render(RenderParams $params): string
{
return $this->renderInternal(
$params->runScript,
null,
[],
$params->scripts
);
}
/**
* @param array<string, mixed> $vars
* @param Script[] $additionalScripts
*/
private function renderInternal(
?string $runScript = null,
?string $htmlFilePath = null,
array $vars = [],
array $additionalScripts = []
): string {
$runScript ??= $this->runScript;
$htmlFilePath ??= $this->mainHtmlFilePath;
@@ -176,6 +203,10 @@ class ClientManager
array_map(fn ($file) => $this->getScriptItemHtml($file, $appTimestamp), $jsFileList)
);
foreach ($additionalScripts as $it) {
$scriptsHtml .= $this->getScriptItemHtml(null, $appTimestamp, true, $it->cacheBusting, $it->source);
}
$additionalStyleSheetsHtml = implode('',
array_map(fn ($file) => $this->getCssItemHtml($file, $appTimestamp), $cssFileList)
);
@@ -295,12 +326,28 @@ class ClientManager
return $this->config->get('appTimestamp', 0);
}
private function getScriptItemHtml(string $file, int $appTimestamp): string
{
$src = $this->basePath . $file . '?r=' . $appTimestamp;
private function getScriptItemHtml(
?string $file,
int $appTimestamp,
bool $withNonce = false,
bool $cache = true,
?string $source = null,
): string {
$src = $source ?? $this->basePath . $file;
if ($cache) {
$src .= '?r=' . $appTimestamp;
}
$noncePart = '';
if ($withNonce) {
$noncePart = " nonce=\"$this->nonce\"";
}
return $this->getTabHtml() .
"<script type=\"text/javascript\" src=\"$src\" data-base-path=\"$this->basePath\"></script>";
"<script type=\"text/javascript\" src=\"$src\" data-base-path=\"$this->basePath\"$noncePart></script>";
}
private function getCssItemHtml(string $file, int $appTimestamp): string
@@ -0,0 +1,50 @@
<?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\Utils\Language;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Language;
class LanguageFactory
{
public function __construct(
private InjectableFactory $injectableFactory,
private Config $config,
) {}
public function create(string $language): Language
{
return $this->injectableFactory->createWith(Language::class, [
'language' => $language,
'useCache' => $this->config->get('useCache') ?? false,
]);
}
}
@@ -54,4 +54,9 @@ class ThemeManager
{
return $this->metadata->get(['themes', $this->getName(), 'logo']) ?? $this->defaultLogoSrc;
}
public function isDark(): bool
{
return (bool) $this->metadata->get("themes.{$this->getName()}.isDark");
}
}
+69
View File
@@ -31,6 +31,7 @@ namespace Espo\Entities;
use Espo\Core\Name\Field;
use Espo\Core\ORM\Entity;
use stdClass;
class LeadCapture extends Entity
{
@@ -44,6 +45,49 @@ class LeadCapture extends Entity
return $this->get('subscribeToTargetList') && $this->get('subscribeContactToTargetList');
}
public function hasFormCaptcha(): bool
{
return (bool) $this->get('formCaptcha');
}
/**
* @return string[]
*/
public function getFormFrameAncestors(): array
{
return $this->get('formFrameAncestors') ?? [];
}
public function getFormText(): ?string
{
return $this->get('formText');
}
public function getFormSuccessText(): ?string
{
return $this->get('formSuccessText');
}
public function getFormLanguage(): ?string
{
return $this->get('formLanguage');
}
public function getFormSuccessRedirectUrl(): ?string
{
$url = $this->get('formSuccessRedirectUrl');
if (!$url) {
return null;
}
if (!str_contains($url, '://')) {
$url = 'https://' . $url;
}
return $url;
}
/**
* @return string[]
*/
@@ -52,6 +96,16 @@ class LeadCapture extends Entity
return $this->get('fieldList') ?? [];
}
public function isFieldRequired(string $field): bool
{
/** @var stdClass $fieldParams */
$fieldParams = $this->get('fieldParams') ?? (object) [];
/** @var stdClass $itParams */
$itParams = $fieldParams->$field ?? (object) [];
return (bool) ($itParams->required ?? false);
}
public function getOptInConfirmationSuccessMessage(): ?string
{
return $this->get('optInConfirmationSuccessMessage');
@@ -92,6 +146,16 @@ class LeadCapture extends Entity
return (bool) $this->get('subscribeContactToTargetList');
}
public function getFormId(): ?string
{
return $this->get('formId');
}
public function setFormId(string $apiKey): self
{
return $this->set('formId', $apiKey);
}
public function getApiKey(): ?string
{
return $this->get('apiKey');
@@ -146,4 +210,9 @@ class LeadCapture extends Entity
{
return $this->get('phoneNumberCountry');
}
public function hasFormEnabled(): bool
{
return (bool) $this->get('formEnabled');
}
}
@@ -0,0 +1,77 @@
<?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\EntryPoints;
use Espo\Core\Utils\Client\Script;
use Espo\Tools\LeadCapture\FormService;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\EntryPoint\EntryPoint;
use Espo\Core\EntryPoint\Traits\NoAuth;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Utils\Client\ActionRenderer;
/**
* @noinspection PhpUnused
*/
class LeadCaptureForm implements EntryPoint
{
use NoAuth;
public function __construct(
private ActionRenderer $actionRenderer,
private FormService $service,
) {}
/**
* @throws BadRequest
* @throws NotFound
*/
public function run(Request $request, Response $response): void
{
$id = $request->getQueryParam('id');
if (!$id) {
throw new BadRequest("No ID.");
}
[$leadCapture, $data, $captchaScript] = $this->service->getData($id);
$params = new ActionRenderer\Params('controllers/lead-capture-form', 'show', $data);
$params = $params->withFrameAncestors($leadCapture->getFormFrameAncestors());
if ($captchaScript) {
$params = $params->withScripts([new Script(source: $captchaScript)]);
}
$this->actionRenderer->write($response, $params);
}
}
@@ -0,0 +1,62 @@
<?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\Hooks\LeadCapture;
use Espo\Core\Hook\Hook\AfterSave;
use Espo\Core\Utils\DataCache;
use Espo\Entities\LeadCapture;
use Espo\ORM\Entity;
use Espo\ORM\Repository\Option\SaveOptions;
/**
* @implements AfterSave<LeadCapture>
*/
class ClearCache implements AfterSave
{
private const CACHE_KEY_PREFIX = 'leadCaptureForm';
public function __construct(
private DataCache $dataCache,
) {}
public function afterSave(Entity $entity, SaveOptions $options): void
{
if ($entity->isNew()) {
return;
}
$this->dataCache->clear($this->getCacheKey($entity));
}
private function getCacheKey(LeadCapture $leadCapture): string
{
return self::CACHE_KEY_PREFIX . '/' . $leadCapture->getId();
}
}
@@ -177,4 +177,14 @@ class Lead extends Person
return $this;
}
public function setSource(?string $source): self
{
return $this->set('source', $source);
}
public function setCampaign(Link|Campaign|null $campaign): self
{
return $this->setRelatedLinkOrEntity('campaign', $campaign);
}
}
@@ -301,7 +301,7 @@
"authLog": "Login history.",
"appLog": "Application log.",
"appSecrets": "Store sensitive information like API keys, passwords, and other secrets.",
"leadCapture": "API entry points for Web-to-Lead.",
"leadCapture": "Lead capture endpoints and web forms.",
"attachments": "All file attachments stored in the system.",
"systemRequirements": "System Requirements for EspoCRM.",
"apiUsers": "Separate users for integration purposes.",
@@ -4,16 +4,21 @@
"clientId": "Client ID",
"clientSecret": "Client Secret",
"redirectUri": "Redirect URI",
"apiKey": "API Key"
"apiKey": "API Key",
"siteKey": "Site Key",
"secretKey": "Secret Key",
"scoreThreshold": "Score Threshold"
},
"titles": {
"GoogleMaps": "Google Maps"
"GoogleMaps": "Google Maps",
"GoogleReCaptcha": "Google reCAPTCHA"
},
"messages": {
"selectIntegration": "Select an integration from menu.",
"noIntegrations": "No Integrations is available."
},
"help": {
"GoogleReCaptcha": "Obtain the Site Key and Secret Key from [Google](https://www.google.com/recaptcha/).",
"Google": "**Obtain OAuth 2.0 credentials from the Google Developers Console.**\n\nVisit [Google Developers Console](https://console.developers.google.com/project) to obtain OAuth 2.0 credentials such as a Client ID and Client Secret that are known to both Google and EspoCRM application.",
"GoogleMaps": "Obtain API key [here](https://developers.google.com/maps/documentation/javascript/get-api-key)."
}
@@ -23,7 +23,17 @@
"smtpAccount": "SMTP Account",
"inboundEmail": "Group Email Account",
"duplicateCheck": "Duplicate Check",
"phoneNumberCountry": "Telephone country code"
"phoneNumberCountry": "Telephone country code",
"fieldParams": "Field Params",
"formId": "Form ID",
"formEnabled": "Web Form",
"formUrl": "Form URL",
"formSuccessText": "Text to display after form submission",
"formText": "Text to display on form",
"formSuccessRedirectUrl": "URL to redirect to after form submission",
"formLanguage": "Language used on form",
"formFrameAncestors": "Allowed hosts for form embedding",
"formCaptcha": "Use Captcha"
},
"links": {
"targetList": "Target List",
@@ -37,7 +47,9 @@
"Create LeadCapture": "Create Entry Point",
"Generate New API Key": "Generate New API Key",
"Request": "Request",
"Confirm Opt-In": "Confirm Opt-In"
"Confirm Opt-In": "Confirm Opt-In",
"Generate New Form ID": "Generate New Form ID",
"Web Form": "Web Form"
},
"messages": {
"generateApiKey": "Create new API Key",
@@ -45,6 +57,7 @@
"optInIsConfirmed": "Opt-in is confirmed."
},
"tooltips": {
"formCaptcha": "To be able to use Captcha, you need to configure it under **Administration** > **Integrations**.",
"optInConfirmationSuccessMessage": "Markdown is supported."
}
}
@@ -5,9 +5,20 @@
[{"name": "subscribeToTargetList", "inlineEditDisabled": true}, {"name": "campaign"}],
[{"name": "subscribeContactToTargetList"}, {"name": "targetList"}],
[{"name": "targetTeam"}, {"name": "leadSource"}],
[{"name": "fieldList"}],
[{"name": "duplicateCheck"}, {"name": "phoneNumberCountry"}],
[{"name": "apiKey"}, false]
[{"name": "duplicateCheck"}, {"name": "phoneNumberCountry"}]
]
},
{
"rows": [
[{"name": "fieldList"}, {"name": "apiKey"}]
]
},
{
"rows": [
[{"name": "formEnabled"}, false],
[{"name": "formText"}, {"name": "formSuccessText"}],
[{"name": "formSuccessRedirectUrl"}, {"name": "formLanguage"}],
[{"name": "formFrameAncestors"}, {"name": "formCaptcha"}]
]
},
{
@@ -143,6 +143,82 @@
}
]
}
},
"formSuccessText": {
"visible": {
"conditionGroup": [
{
"type": "isTrue",
"attribute": "formEnabled"
}
]
}
},
"formText": {
"visible": {
"conditionGroup": [
{
"type": "isTrue",
"attribute": "formEnabled"
}
]
}
},
"formSuccessRedirectUrl": {
"visible": {
"conditionGroup": [
{
"type": "isTrue",
"attribute": "formEnabled"
}
]
}
},
"formLanguage": {
"visible": {
"conditionGroup": [
{
"type": "isTrue",
"attribute": "formEnabled"
}
]
}
},
"formFrameAncestors": {
"visible": {
"conditionGroup": [
{
"type": "isTrue",
"attribute": "formEnabled"
}
]
}
},
"formCaptcha": {
"visible": {
"conditionGroup": [
{
"type": "isTrue",
"attribute": "formEnabled"
}
]
}
}
},
"panels": {
"form": {
"visible": {
"conditionGroup": [
{
"type": "isNotEmpty",
"attribute": "id"
},
{
"type": "isTrue",
"attribute": "formEnabled"
}
]
}
}
}
},
@@ -152,7 +228,15 @@
"name": "request",
"label": "Request",
"isForm": true,
"view": "views/lead-capture/record/panels/request"
"view": "views/lead-capture/record/panels/request",
"notRefreshable": true
},
{
"name": "form",
"label": "Web Form",
"isForm": true,
"view": "views/lead-capture/record/panels/form",
"notRefreshable": true
}
]
},
@@ -5,27 +5,33 @@
"maxLength": 100
},
"campaign": {
"type": "link"
"type": "link",
"audited": true
},
"isActive": {
"type": "bool",
"default": true
"default": true,
"audited": true
},
"subscribeToTargetList": {
"type": "bool",
"default": true
"default": true,
"audited": true
},
"subscribeContactToTargetList": {
"type": "bool",
"default": true
"default": true,
"audited": true
},
"targetList": {
"type": "link"
"type": "link",
"audited": true
},
"fieldList": {
"type": "multiEnum",
"default": ["firstName", "lastName", "emailAddress"],
"view": "views/lead-capture/fields/field-list",
"displayAsList": true,
"required": true,
"ignoreFieldList": [
"targetList",
@@ -45,47 +51,117 @@
"phoneNumberIsInvalid",
"opportunityAmountCurrency",
"originalEmail"
]
],
"webFormFieldTypeList": [
"varchar",
"email",
"phone",
"text",
"personName",
"enum",
"multiEnum",
"array",
"checklist",
"int",
"float",
"currency",
"date",
"datetime",
"boolean",
"url",
"urlMultiple",
"address"
],
"audited": true
},
"fieldParams": {
"type": "jsonObject",
"audited": true
},
"duplicateCheck": {
"type": "bool",
"default": true
"default": true,
"audited": true
},
"optInConfirmation": {
"type": "bool"
"type": "bool",
"audited": true
},
"optInConfirmationEmailTemplate": {
"type": "link"
"type": "link",
"audited": true
},
"optInConfirmationLifetime": {
"type": "int",
"default": 48,
"min": 1
"min": 1,
"audited": true
},
"optInConfirmationSuccessMessage": {
"type": "text",
"tooltip": true
"tooltip": true,
"audited": true
},
"createLeadBeforeOptInConfirmation": {
"type": "bool"
"type": "bool",
"audited": true
},
"skipOptInConfirmationIfSubscribed": {
"type": "bool"
"type": "bool",
"audited": true
},
"leadSource": {
"type": "enum",
"customizationOptionsDisabled": true,
"optionsPath": "entityDefs.Lead.fields.source.options",
"translation": "Lead.options.source",
"default": "Web Site"
"default": "Web Site",
"audited": true
},
"apiKey": {
"type": "varchar",
"maxLength": 36,
"readOnly": true
},
"formId": {
"type": "varchar",
"maxLength": 17,
"readOnly": true
},
"formEnabled": {
"type": "bool",
"audited": true
},
"formText": {
"type": "text",
"tooltip": "optInConfirmationSuccessMessage"
},
"formSuccessText": {
"type": "text",
"tooltip": "optInConfirmationSuccessMessage"
},
"formSuccessRedirectUrl": {
"type": "url",
"audited": true
},
"formLanguage": {
"type": "enum",
"maxLength": 5,
"view": "views/preferences/fields/language",
"audited": true
},
"formFrameAncestors": {
"type": "urlMultiple",
"audited": true
},
"formCaptcha": {
"type": "bool",
"audited": true,
"tooltip": true
},
"targetTeam": {
"type": "link"
"type": "link",
"audited": true
},
"exampleRequestUrl": {
"type": "varchar",
@@ -109,8 +185,16 @@
"notStorable": true,
"readOnly": true
},
"formUrl": {
"type": "url",
"notStorable": true,
"readOnly": true,
"copyToClipboard": true,
"index": true
},
"inboundEmail": {
"type": "link"
"type": "link",
"audited": true
},
"smtpAccount": {
"type": "base",
@@ -8,6 +8,11 @@
{
"name": "audited",
"type": "bool"
},
{
"name": "onlyPrimary",
"type": "bool",
"hidden": true
}
],
"actualFields": [
@@ -26,6 +26,11 @@
{
"name": "audited",
"type": "bool"
},
{
"name": "onlyPrimary",
"type": "bool",
"hidden": true
}
],
"actualFields": [
@@ -0,0 +1,23 @@
{
"fields": {
"siteKey": {
"type": "varchar",
"maxLength": 255,
"required": true
},
"secretKey": {
"type": "password",
"maxLength": 255,
"required": true
},
"scoreThreshold": {
"type": "float",
"min": 0.0,
"max": 1.0,
"default": 0.3,
"required": true
}
},
"allowUserAccounts": false,
"view": "views/admin/integrations/edit"
}
+6
View File
@@ -78,6 +78,12 @@
"method": "get",
"actionClassName": "Espo\\Tools\\GlobalSearch\\Api\\Get"
},
{
"route": "/LeadCapture/form/:id",
"method": "post",
"actionClassName": "Espo\\Tools\\LeadCapture\\Api\\PostForm",
"noAuth": true
},
{
"route": "/LeadCapture/:apiKey",
"method": "post",
+21 -15
View File
@@ -62,23 +62,15 @@ class LanguageService
/**
* @return array<string, mixed>
*/
public function getDataForFrontend(bool $default = false): array
public function getDataForFrontendFromLanguage(LanguageUtil $language): array
{
if ($default) {
$languageObj = $this->getDefaultLanguage();
} else {
$languageObj = $this->getLanguage();
}
$data = $languageObj->getAll();
$data = $language->getAll();
if ($this->user->isSystem()) {
unset($data['Global']['scopeNames']);
unset($data['Global']['scopeNamesPlural']);
unset($data['Global']['dashlets']);
unset($data['Global']['links']);
unset($data['Global']['fields']);
unset($data['Global']['options']);
foreach ($data as $k => $item) {
if (
@@ -145,20 +137,34 @@ class LanguageService
}
if (!$this->user->isAdmin()) {
$this->prepareDataNonAdmin($data, $languageObj);
$this->prepareDataNonAdmin($data, $language);
}
}
$data['User']['fields'] = $data['User']['fields'] ?? [];
$data['User']['fields']['password'] = $languageObj->translate('password', 'fields', 'User');
$data['User']['fields']['passwordConfirm'] = $languageObj->translate('passwordConfirm', 'fields', 'User');
$data['User']['fields']['newPassword'] = $languageObj->translate('newPassword', 'fields', 'User');
$data['User']['fields']['newPasswordConfirm'] = $languageObj->translate('newPasswordConfirm', 'fields', 'User');
$data['User']['fields']['password'] = $language->translate('password', 'fields', 'User');
$data['User']['fields']['passwordConfirm'] = $language->translate('passwordConfirm', 'fields', 'User');
$data['User']['fields']['newPassword'] = $language->translate('newPassword', 'fields', 'User');
$data['User']['fields']['newPasswordConfirm'] = $language->translate('newPasswordConfirm', 'fields', 'User');
return $data;
}
/**
* @return array<string, mixed>
*/
public function getDataForFrontend(bool $default = false): array
{
if ($default) {
$languageObj = $this->getDefaultLanguage();
} else {
$languageObj = $this->getLanguage();
}
return $this->getDataForFrontendFromLanguage($languageObj);
}
/**
* @param array<string, mixed> $data
*/
+147
View File
@@ -0,0 +1,147 @@
<?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\Tools\Captcha;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Utils\Json;
use Espo\Core\Utils\Log;
use Espo\Entities\Integration;
use Espo\ORM\EntityManager;
use RuntimeException;
class Checker
{
private const URL = 'https://www.google.com/recaptcha/api/siteverify';
private const SCORE_THRESHOLD = 0.2;
private const TIMEOUT = 20;
public function __construct(
private EntityManager $entityManager,
private Log $log,
) {}
/**
* @throws BadRequest
* @throws Forbidden
*/
public function check(string $token, string $action): void
{
[$secret, $scoreThreshold] = $this->getCaptchaSecretKey();
if ($secret && $token === '') {
throw new BadRequest("No captcha token.");
}
if (!$secret) {
throw new Forbidden("Captcha not configured.");
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, self::URL);
curl_setopt($ch, CURLOPT_POST, true);
$data = [
'secret' => $secret,
'response' => $token,
];
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, self::TIMEOUT);
$response = curl_exec($ch);
curl_close($ch);
if (!is_string($response)) {
throw new RuntimeException("Bad CURL response.");
}
$responseData = Json::decode($response, true);
if (!is_array($responseData)) {
throw new RuntimeException("Bad response from ReCaptcha.");
}
$success = $responseData['success'] ?? null;
$score = $responseData['score'] ?? null;
$resultAction = $responseData['action'] ?? null;
if (!$success) {
$this->log->error("Captcha error; action: {action}; response: {response}", [
'action' => $action,
'response' => $response,
]);
throw new Forbidden("ReCaptcha error.");
}
if (!is_string($resultAction)) {
throw new RuntimeException("No or bad action in ReCaptcha response.");
}
if (!is_int($score) && !is_float($score)) {
throw new RuntimeException("No score in ReCaptcha response.");
}
if ($action !== $resultAction) {
throw new Forbidden("ReCaptcha action mismatch.");
}
if ($score < $scoreThreshold) {
throw new Forbidden("ReCaptcha low score.");
}
}
/**
* @return array{?string, ?int}
*/
private function getCaptchaSecretKey(): array
{
$entity = $this->entityManager
->getRepositoryByClass(Integration::class)
->getById('GoogleReCaptcha');
if (!$entity) {
return [null, null];
}
$secretKey = $entity->get('secretKey');
$scoreThreshold = $entity->get('scoreThreshold') ?? self::SCORE_THRESHOLD;
if (!$secretKey) {
return [null, null];
}
return [$secretKey, $scoreThreshold];
}
}
@@ -0,0 +1,60 @@
<?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\Tools\LeadCapture\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
use Espo\Tools\LeadCapture\CaptureService;
/**
* @noinspection PhpUnused
*/
class PostForm implements Action
{
public function __construct(
private CaptureService $service,
) {}
public function process(Request $request): Response
{
$data = $request->getParsedBody();
$id = $request->getRouteParam('id') ?? throw new BadRequest();
$captchaToken = $request->getHeader('X-Captcha-Token');
$result = $this->service->captureForm($id, $data, $captchaToken);
return ResponseComposer::json([
'redirectUrl' => $result->redirectUrl,
]);
}
}
@@ -29,6 +29,10 @@
namespace Espo\Tools\LeadCapture;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Field\Link;
use Espo\Core\FieldValidation\Exceptions\ValidationError;
use Espo\Core\FieldValidation\Failure;
use Espo\Core\Name\Field;
use Espo\Core\PhoneNumber\Sanitizer as PhoneNumberSanitizer;
use Espo\Core\Job\JobSchedulerFactory;
@@ -36,7 +40,7 @@ use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\FieldValidation\FieldValidationManager;
use Espo\Core\FieldValidation\FieldValidationParams;
use Espo\Core\FieldValidation\FieldValidationParams as ValidationParams;
use Espo\Core\HookManager;
use Espo\Core\Job\QueueName;
use Espo\Core\ORM\EntityManager;
@@ -47,7 +51,7 @@ use Espo\Core\Utils\Language;
use Espo\Core\Utils\Log;
use Espo\ORM\Entity;
use Espo\Entities\UniqueId;
use Espo\Entities\LeadCapture as LeadCaptureEntity;
use Espo\Entities\LeadCapture;
use Espo\Entities\LeadCaptureLogRecord;
use Espo\Modules\Crm\Entities\Campaign;
use Espo\Modules\Crm\Entities\TargetList;
@@ -55,6 +59,7 @@ use Espo\Modules\Crm\Tools\Campaign\LogService as CampaignService;
use Espo\Modules\Crm\Entities\Contact;
use Espo\Modules\Crm\Entities\Lead;
use Espo\Tools\LeadCapture\Jobs\OptInConfirmation;
use Espo\Tools\Captcha\Checker as CaptchaChecker;
use stdClass;
use DateTime;
@@ -70,9 +75,42 @@ class CaptureService
private JobSchedulerFactory $jobSchedulerFactory,
private CampaignService $campaignService,
private PhoneNumberSanitizer $phoneNumberSanitizer,
private ServiceContainer $serviceContainer
private ServiceContainer $serviceContainer,
private CaptchaChecker $captchaChecker,
) {}
/**
* Capture a lead from a web form.
*
* @param string $id A form ID.
* @param stdClass $data A payload.
* @param ?string $captchaToken A captcha token.
* @throws BadRequest
* @throws Error
* @throws NotFound
* @throws Forbidden
*/
public function captureForm(string $id, stdClass $data, ?string $captchaToken = null): FormResult
{
$leadCapture = $this->getLeadCaptureByFormId($id);
$apiKey = $leadCapture->getApiKey();
if (!$apiKey) {
throw new Error("No API key.");
}
if ($leadCapture->hasFormCaptcha()) {
$this->captchaChecker->check($captchaToken ?? '', 'leadCaptureSubmit');
}
$this->capture($apiKey, $data);
return new FormResult(
redirectUrl: $leadCapture->getFormSuccessRedirectUrl(),
);
}
/**
* Capture a lead. A main entry method.
*
@@ -84,18 +122,7 @@ class CaptureService
*/
public function capture(string $apiKey, stdClass $data): void
{
/** @var ?LeadCaptureEntity $leadCapture */
$leadCapture = $this->entityManager
->getRDBRepositoryByClass(LeadCaptureEntity::class)
->where([
'apiKey' => $apiKey,
'isActive' => true,
])
->findOne();
if (!$leadCapture) {
throw new NotFound('Api key is not valid.');
}
$leadCapture = $this->getLeadCapture($apiKey);
if (!$leadCapture->optInConfirmation()) {
$this->proceed($leadCapture, $data);
@@ -199,7 +226,7 @@ class CaptureService
* @throws Error
*/
private function proceed(
LeadCaptureEntity $leadCapture,
LeadCapture $leadCapture,
stdClass $data,
?string $leadId = null,
bool $isLogged = false
@@ -307,7 +334,7 @@ class CaptureService
(!$isContactOptedIn || !$leadCapture->subscribeToTargetList()) &&
$leadCapture->subscribeContactToTargetList()
) {
$this->hookManager->process(LeadCaptureEntity::ENTITY_TYPE, 'afterLeadCapture', $leadCapture, [], [
$this->hookManager->process(LeadCapture::ENTITY_TYPE, 'afterLeadCapture', $leadCapture, [], [
'targetId' => $contact->getId(),
'targetType' => Contact::ENTITY_TYPE,
]);
@@ -357,7 +384,7 @@ class CaptureService
if ($toRelateLead || !$leadCapture->subscribeToTargetList()) {
$this->hookManager->process(
LeadCaptureEntity::ENTITY_TYPE,
LeadCapture::ENTITY_TYPE,
'afterLeadCapture',
$leadCapture,
[],
@@ -424,12 +451,12 @@ class CaptureService
return new ConfirmResult(
ConfirmResult::STATUS_EXPIRED,
$this->defaultLanguage
->translateLabel('optInConfirmationExpired', 'messages', LeadCaptureEntity::ENTITY_TYPE)
->translateLabel('optInConfirmationExpired', 'messages', LeadCapture::ENTITY_TYPE)
);
}
/** @var ?LeadCaptureEntity $leadCapture */
$leadCapture = $this->entityManager->getEntityById(LeadCaptureEntity::ENTITY_TYPE, $leadCaptureId);
/** @var ?LeadCapture $leadCapture */
$leadCapture = $this->entityManager->getEntityById(LeadCapture::ENTITY_TYPE, $leadCaptureId);
if (!$leadCapture) {
throw new Error("LeadCapture Confirm: LeadCapture not found.");
@@ -457,10 +484,9 @@ class CaptureService
* @throws BadRequest
* @throws Error
*/
private function getLeadWithPopulatedData(LeadCaptureEntity $leadCapture, stdClass $data): Lead
private function getLeadWithPopulatedData(LeadCapture $leadCapture, stdClass $data): Lead
{
/** @var Lead $lead */
$lead = $this->entityManager->getNewEntity(Lead::ENTITY_TYPE);
$lead = $this->entityManager->getRDBRepositoryByClass(Lead::class)->getNew();
$fieldList = $leadCapture->getFieldList();
@@ -474,21 +500,35 @@ class CaptureService
$this->setFields($fieldList, $data, $lead);
if ($leadCapture->getLeadSource()) {
$lead->set('source', $leadCapture->getLeadSource());
$lead->setSource($leadCapture->getLeadSource());
}
if ($leadCapture->getCampaignId()) {
$lead->set('campaignId', $leadCapture->getCampaignId());
$lead->setCampaign(Link::create($leadCapture->getCampaignId()));
}
if ($leadCapture->getTargetTeamId()) {
$lead->addLinkMultipleId(Field::TEAMS, $leadCapture->getTargetTeamId());
}
$validationParams = FieldValidationParams::create()->withTypeSkipFieldList('required', $fieldList);
$validationParams = ValidationParams::create()->withTypeSkipFieldList('required', $fieldList);
$this->fieldValidationManager->process($lead, $data, $validationParams);
foreach ($fieldList as $field) {
if (!$leadCapture->isFieldRequired($field)) {
continue;
}
$notValid = $this->fieldValidationManager->check($lead, $field, 'required', $data, true);
if (!$notValid) {
$failure = new Failure(Lead::ENTITY_TYPE, $field, 'required');
throw ValidationError::create($failure);
}
}
return $lead;
}
@@ -498,7 +538,7 @@ class CaptureService
* lead: ?Lead,
* }
*/
private function findLeadDuplicates(LeadCaptureEntity $leadCapture, Lead $lead): array
private function findLeadDuplicates(LeadCapture $leadCapture, Lead $lead): array
{
$duplicate = null;
$contact = null;
@@ -585,7 +625,7 @@ class CaptureService
}
private function log(
LeadCaptureEntity $leadCapture,
LeadCapture $leadCapture,
Entity $target,
stdClass $data,
bool $isNew = true
@@ -654,7 +694,7 @@ class CaptureService
}
if ($isEmpty) {
throw new BadRequest('noRequiredFields');
throw new BadRequest('empty');
}
}
@@ -664,7 +704,7 @@ class CaptureService
private function sanitizePhoneNumber(
array $fieldList,
stdClass $data,
LeadCaptureEntity $leadCapture
LeadCapture $leadCapture
): void {
if (
@@ -689,4 +729,44 @@ class CaptureService
unset($data->{Field::PHONE_NUMBER . 'IsInvalid'});
unset($data->{Field::PHONE_NUMBER . 'IsOptedOut'});
}
/**
* @throws NotFound
*/
private function getLeadCapture(string $apiKey): LeadCapture
{
$leadCapture = $this->entityManager
->getRDBRepositoryByClass(LeadCapture::class)
->where([
'apiKey' => $apiKey,
'isActive' => true,
])
->findOne();
if (!$leadCapture) {
throw new NotFound('Form ID is not valid.');
}
return $leadCapture;
}
/**
* @throws NotFound
*/
private function getLeadCaptureByFormId(string $id): LeadCapture
{
$leadCapture = $this->entityManager
->getRDBRepositoryByClass(LeadCapture::class)
->where([
'formId' => $id,
'isActive' => true,
])
->findOne();
if (!$leadCapture) {
throw new NotFound('API key is not valid.');
}
return $leadCapture;
}
}
@@ -0,0 +1,37 @@
<?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\Tools\LeadCapture;
class FormResult
{
public function __construct(
readonly public ?string $redirectUrl,
) {}
}
@@ -0,0 +1,450 @@
<?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\Tools\LeadCapture;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\ORM\Type\FieldType;
use Espo\Core\Utils\Address\CountryDataProvider;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\DataCache;
use Espo\Core\Utils\Language;
use Espo\Core\Utils\Metadata;
use Espo\Core\Utils\ThemeManager;
use Espo\Entities\Integration;
use Espo\Entities\LeadCapture;
use Espo\Modules\Crm\Entities\Lead;
use Espo\ORM\Defs\EntityDefs;
use Espo\ORM\EntityManager;
use Espo\Tools\App\LanguageService;
use RuntimeException;
class FormService
{
private const CACHE_KEY_PREFIX = 'leadCaptureForm';
public function __construct(
private EntityManager $entityManager,
private Config $config,
private Metadata $metadata,
private Language $defaultLanguage,
private CountryDataProvider $countryDataProvider,
private LanguageService $languageService,
private Language\LanguageFactory $languageFactory,
private DataCache $dataCache,
private ThemeManager $themeManager,
) {}
/**
* @return array{LeadCapture, array<string, mixed>, ?string}
* @throws NotFound
*/
public function getData(string $id): array
{
$leadCapture = $this->getLeadCapture($id);
$captchaKey = $this->getCaptchaKey($leadCapture);
$captchaScript = $this->getCaptchaScript($captchaKey);
$data = $this->getDataInternal($leadCapture);
$data['captchaKey'] = $captchaKey;
$data['isDark'] = $this->themeManager->isDark();
return [$leadCapture, $data, $captchaScript];
}
/**
* @return array<string, mixed>
*/
private function getDataInternal(LeadCapture $leadCapture): array
{
$cacheKey = $this->getCacheKey($leadCapture);
if ($this->config->get('useCache') && $this->dataCache->has($cacheKey)) {
return $this->getFromCache($cacheKey);
}
$data = $this->prepareData($leadCapture);
$this->dataCache->store($cacheKey, $data);
return $data;
}
private function getRequestUrl(LeadCapture $leadCapture): string
{
$formId = $leadCapture->getFormId();
if (!$formId) {
throw new RuntimeException("No API key.");
}
return "LeadCapture/form/$formId";
}
/**
* @return string[]
*/
private function getFieldList(LeadCapture $leadCapture): array
{
/** @var string[] $allowedTypeList */
$allowedTypeList = $this->metadata->get("entityDefs.LeadCapture.fields.fieldList.webFormFieldTypeList") ?? [];
$entityDefs = $this->entityManager->getDefs()->getEntity(Lead::ENTITY_TYPE);
$fieldList = [];
foreach ($leadCapture->getFieldList() as $field) {
if (!$entityDefs->hasField($field)) {
continue;
}
$itemDefs = $entityDefs->getField($field);
if (!in_array($itemDefs->getType(), $allowedTypeList)) {
continue;
}
$fieldList[] = $field;
}
return $fieldList;
}
/**
* @param string[] $fieldList
* @param array<string, array<string, mixed>> $languageData
* @return array<string, array<string, mixed>>
*/
private function getFieldDefs(
array $fieldList,
LeadCapture $leadCapture,
array &$languageData,
Language $language
): array {
$entityDefs = $this->entityManager->getDefs()->getEntity(Lead::ENTITY_TYPE);
$fieldDefs = [];
foreach ($fieldList as $field) {
$fieldDefs[$field] = $this->metadata->get("entityDefs.Lead.fields.$field");
if (!$fieldDefs[$field]) {
continue;
}
$this->applyFieldDefsItem(
$leadCapture,
$entityDefs,
$field,
$fieldDefs,
$languageData,
$language
);
}
return $fieldDefs;
}
/**
* @param string[] $fieldList
* @return array<int, mixed>
*/
private function getDetailLayout(array $fieldList): array
{
$rows = [];
foreach ($fieldList as $field) {
$rows[] = [['name' => $field]];
}
return [['rows' => $rows]];
}
/**
* @param string[] $fieldList
* @return array<string, mixed>
*/
private function getMetadataFields(array $fieldList): array
{
$metadataFields = [];
$entityDefs = $this->entityManager->getDefs()->getEntity(Lead::ENTITY_TYPE);
foreach ($fieldList as $field) {
$type = $entityDefs->getField($field)->getType();
if (array_key_exists($type, $metadataFields)) {
continue;
}
$metadataFields[$type] = $this->metadata->get("fields.$type");
}
return $metadataFields;
}
/**
* @return array<string, mixed>
*/
private function getConfig(): array
{
$params = [
'decimalMark',
'thousandSeparator',
'phoneNumberInternational',
'phoneNumberExtensions',
'phoneNumberPreferredCountryList',
'defaultCurrency',
'currencyList',
'currencyDecimalPlaces',
'addressFormat',
];
$data = [];
foreach ($params as $param) {
$data[$param] = $this->config->get($param);
}
return $data;
}
/**
* @return array<string, mixed>
*/
private function getAppParams(): array
{
return [
'addressCountryData' => $this->countryDataProvider->get(),
];
}
/**
* @param array<string, array<string, mixed>> $fieldDefs
* @param array<string, array<string, mixed>> $languageData
*/
private function applyFieldDefsItem(
LeadCapture $leadCapture,
EntityDefs $entityDefs,
string $field,
array &$fieldDefs,
array &$languageData,
Language $language,
): void {
$fieldDefs[$field]['required'] = $leadCapture->isFieldRequired($field);
$itDefs = $entityDefs->getField($field);
$type = $itDefs->getType();
if ($type === FieldType::ADDRESS) {
$subList = [
$field . 'Street',
$field . 'Country',
$field . 'State',
$field . 'PostalCode',
$field . 'City',
];
foreach ($subList as $susField) {
$fieldDefs[$susField] = $this->metadata->get("entityDefs.Lead.fields.$susField");
}
}
if ($type === FieldType::EMAIL) {
if ($leadCapture->optInConfirmation()) {
$fieldDefs[$field]['required'] = true;
}
$fieldDefs[$field]['onlyPrimary'] = true;
}
if ($type === FieldType::PHONE) {
$fieldDefs[$field]['onlyPrimary'] = true;
}
if (
in_array($type, [
FieldType::ENUM,
FieldType::MULTI_ENUM,
FieldType::ARRAY,
FieldType::CHECKLIST,
])
) {
$reference = $itDefs->getParam('optionsReference');
if ($reference) {
[$refEntityType, $refField] = explode('.', $reference);
$options = $this->entityManager
->getDefs()
->tryGetEntity($refEntityType)
?->tryGetField($refField)
?->getParam('options');
$fieldDefs[$field]['options'] = $options;
unset($fieldDefs[$field]['optionsReference']);
$languageData[Lead::ENTITY_TYPE] ??= [];
$languageData[Lead::ENTITY_TYPE]['options'] ??= [];
$languageData[Lead::ENTITY_TYPE]['options'][$field] =
$language->get("$refEntityType.options.$refField");
}
}
}
/**
* @return array<string, mixed>
*/
private function getLanguageData(Language $language): array
{
$data = $this->languageService->getDataForFrontendFromLanguage($language);
$data[Lead::ENTITY_TYPE] = $language->get(Lead::ENTITY_TYPE);
return $data;
}
/**
* @throws NotFound
*/
public function getLeadCapture(string $id): LeadCapture
{
$leadCapture = $this->entityManager
->getRDBRepositoryByClass(LeadCapture::class)
->where(['formId' => $id])
->findOne();
if (!$leadCapture || !$leadCapture->hasFormEnabled()) {
throw new NotFound();
}
return $leadCapture;
}
private function getSuccessText(LeadCapture $leadCapture): string
{
return $leadCapture->getFormSuccessText() ?? $this->defaultLanguage->translateLabel('Posted');
}
private function getCacheKey(LeadCapture $leadCapture): string
{
return self::CACHE_KEY_PREFIX . '/' . $leadCapture->getId();
}
/**
* @return array<string, mixed>
*/
private function getFromCache(string $cacheKey): array
{
/** @var array<string, mixed> */
return $this->dataCache->get($cacheKey);
}
/**
* @return array<string, mixed>
*/
private function prepareData(LeadCapture $leadCapture): array
{
$language = $this->getLanguage($leadCapture);
$languageData = $this->getLanguageData($language);
$fieldList = $this->getFieldList($leadCapture);
$fieldDefs = $this->getFieldDefs($fieldList, $leadCapture, $languageData, $language);
$detailLayout = $this->getDetailLayout($fieldList);
$metadataFields = $this->getMetadataFields($fieldList);
$successText = $this->getSuccessText($leadCapture);
$text = $leadCapture->getFormText();
$config = $this->getConfig();
$appParams = $this->getAppParams();
return [
'requestUrl' => $this->getRequestUrl($leadCapture),
'fieldDefs' => (object) $fieldDefs,
'metadata' => [
'fields' => (object) $metadataFields,
'app' => [
'regExpPatterns' => $this->metadata->get("app.regExpPatterns"),
],
],
'detailLayout' => $detailLayout,
'language' => $languageData,
'successText' => $successText,
'text' => $text,
'config' => (object) $config,
'appParams' => (object) $appParams,
];
}
private function getLanguage(LeadCapture $leadCapture): Language
{
$language = $this->defaultLanguage;
if ($leadCapture->getFormLanguage()) {
$language = $this->languageFactory->create($leadCapture->getFormLanguage());
}
return $language;
}
private function getCaptchaKey(LeadCapture $leadCapture): ?string
{
if (!$leadCapture->hasFormCaptcha()) {
return null;
}
$entity = $this->entityManager
->getRepositoryByClass(Integration::class)
->getById('GoogleReCaptcha');
if (!$entity) {
return null;
}
$siteKey = $entity->get('siteKey');
if (!$siteKey) {
return null;
}
return $siteKey;
}
private function getCaptchaScript(?string $siteKey): ?string
{
if (!$siteKey) {
return null;
}
return 'https://www.google.com/recaptcha/api.js?data-theme=dark&render=' . $siteKey;
}
}
+32 -4
View File
@@ -37,7 +37,6 @@ use Espo\Core\Utils\Util;
use Espo\Entities\InboundEmail;
use Espo\Entities\LeadCapture as LeadCaptureEntity;
use Espo\Entities\User;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use stdClass;
@@ -71,9 +70,9 @@ class Service
* @throws NotFound
* @throws Forbidden
*/
public function generateNewApiKeyForEntity(string $id): Entity
public function generateNewApiKeyForEntity(string $id): LeadCaptureEntity
{
$service = $this->recordServiceContainer->get(LeadCaptureEntity::ENTITY_TYPE);
$service = $this->recordServiceContainer->getByClass(LeadCaptureEntity::class);
$entity = $service->getEntity($id);
@@ -81,7 +80,31 @@ class Service
throw new NotFound();
}
$entity->set('apiKey', $this->generateApiKey());
$entity->setApiKey($this->generateApiKey());
$this->entityManager->saveEntity($entity);
$service->prepareEntityForOutput($entity);
return $entity;
}
/**
* @throws ForbiddenSilent
* @throws NotFound
* @throws Forbidden
*/
public function generateNewFormIdForEntity(string $id): LeadCaptureEntity
{
$service = $this->recordServiceContainer->getByClass(LeadCaptureEntity::class);
$entity = $service->getEntity($id);
if (!$entity) {
throw new NotFound();
}
$entity->setFormId($this->generateFormId());
$this->entityManager->saveEntity($entity);
@@ -95,6 +118,11 @@ class Service
return Util::generateApiKey();
}
public function generateFormId(): string
{
return Util::generateId();
}
/**
* @return stdClass[]
* @throws Forbidden
+10 -3
View File
@@ -1,13 +1,13 @@
<div>
{{#each emailAddressData}}
<div class="input-group email-address-block">
<div class="input-group email-address-block {{#if ../onlyPrimary}} only-primary {{/if}}">
<input
type="email"
class="form-control email-address{{#if optOut}} text-strikethrough{{/if}}{{#if invalid}} text-danger{{/if}}"
value="{{emailAddress}}" autocomplete="espo-{{../name}}"
maxlength={{../itemMaxLength}}
>
{{#unless ../onlyPrimary}}
<span class="input-group-btn">
<button
class="btn btn-default btn-icon email-property{{#if primary}} active{{/if}} hidden"
@@ -54,8 +54,15 @@
<span class="fas fa-times"></span>
</button>
</span>
{{/unless}}
</div>
{{/each}}
</div>
<button class="btn btn-default btn-icon" type="button" data-action="addEmailAddress"><span class="fas fa-plus"></span></button>
{{#unless onlyPrimary}}
<button
class="btn btn-default btn-icon"
type="button"
data-action="addEmailAddress"
><span class="fas fa-plus"></span></button>
{{/unless}}
+13 -3
View File
@@ -1,13 +1,15 @@
<div class="phone-number-block-container">
{{#each phoneNumberData}}
<div class="input-group phone-number-block">
<div class="input-group phone-number-block {{#if ../onlyPrimary}} only-primary {{/if}}">
{{#unless ../onlyPrimary}}
<span class="input-group-item">
<select
data-property-type="type"
class="form-control radius-left"
>{{options ../params.typeList type scope=../scope field=../name}}</select>
</span>
<span class="input-group-item input-group-item-middle">
{{/unless}}
<span class="input-group-item input-group-item-middle input-phone-number-item">
<input
type="text"
class="form-control phone-number numeric-text no-margin-shifting {{#if optOut}} text-strikethrough {{/if}} {{#if invalid}} text-danger {{/if}}"
@@ -16,6 +18,7 @@
maxlength={{../itemMaxLength}}
>
</span>
{{#unless ../onlyPrimary}}
<span class="input-group-btn">
<button
class="btn btn-default btn-icon phone-property{{#if primary}} active{{/if}} hidden"
@@ -62,8 +65,15 @@
<span class="fas fa-times"></span>
</button>
</span>
{{/unless}}
</div>
{{/each}}
</div>
<button class="btn btn-default btn-icon" type="button" data-action="addPhoneNumber"><span class="fas fa-plus"></span></button>
{{#unless onlyPrimary}}
<button
class="btn btn-default btn-icon"
type="button"
data-action="addPhoneNumber"
><span class="fas fa-plus"></span></button>
{{/unless}}
@@ -0,0 +1,59 @@
/************************************************************************
* 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.
************************************************************************/
import Controller from 'controller';
import LeadCaptureFormView from 'views/lead-capture/form';
// noinspection JSUnusedGlobalSymbols
export default class LeadCaptureFormController extends Controller {
// noinspection JSUnusedGlobalSymbols
actionShow(data) {
this.prepareContainer();
const view = new LeadCaptureFormView({formData: data});
view.setSelector('body > .content');
this.viewFactory.prepare(view, () => view.render());
}
/**
* @private
*/
prepareContainer() {
// Prevents recaptcha removal.
const existingContainer = document.body.querySelector('.container');
existingContainer.remove();
const container = document.createElement('div');
container.classList.add('container', 'content');
document.body.prepend(container);
}
}
+11
View File
@@ -317,6 +317,17 @@ class Language {
return pointer;
}
/**
* Do not use.
*
* @param {string} [scope]
* @param {Record} [data]
* @internal
*/
setScopeData(scope, data) {
this.data[scope] = data;
}
}
Object.assign(Language.prototype, Events);
+10
View File
@@ -259,6 +259,16 @@ class Metadata {
return scopeList;
}
/**
* Do not use.
*
* @internal
* @param {Record} data
*/
setData(data) {
this.data = data;
}
}
Object.assign(Metadata.prototype, Events);
+5
View File
@@ -613,6 +613,11 @@ class ArrayFieldView extends BaseFieldView {
return list.join(', ');
}
/**
* @protected
* @param {string} value
* @return {string}
*/
getItemHtml(value) {
// Do not use the `html` method to avoid XSS.
+34 -2
View File
@@ -49,6 +49,7 @@ class EmailFieldView extends VarcharFieldView {
/**
* @typedef {Object} module:views/fields/email~params
* @property {boolean} [required] Required.
* @property {boolean} [onlyPrimary] Only primary.
*/
/**
@@ -270,7 +271,7 @@ class EmailFieldView extends VarcharFieldView {
validateMaxCount() {
/** @type {number|null} */
const maxCount = this.getConfig().get('emailAddressMaxCount');
const maxCount = this.maxCount;
if (!maxCount) {
return false;
@@ -355,6 +356,7 @@ class EmailFieldView extends VarcharFieldView {
}
data.itemMaxLength = this.itemMaxLength;
data.onlyPrimary = this.params.onlyPrimary;
return data;
}
@@ -610,6 +612,7 @@ class EmailFieldView extends VarcharFieldView {
this.erasedPlaceholder = 'ERASED:';
this.emailAddressOptedOutByDefault = this.getConfig().get('emailAddressIsOptedOutByDefault');
this.maxCount = this.getConfig().get('emailAddressMaxCount');
this.itemMaxLength = this.getMetadata()
.get(['entityDefs', 'EmailAddress', 'fields', 'name', 'maxLength']) || 255;
@@ -617,6 +620,15 @@ class EmailFieldView extends VarcharFieldView {
this.validations.push(() => this.validateMaxCount());
}
/**
* @return {{
* emailAddress: string,
* primary: boolean,
* optOut: boolean,
* invalid: boolean,
* lower: string,
* }[]}
*/
fetchEmailAddressData() {
const data = [];
@@ -649,7 +661,27 @@ class EmailFieldView extends VarcharFieldView {
fetch() {
const data = {};
const addressData = this.fetchEmailAddressData() || [];
const addressData = this.fetchEmailAddressData();
if (this.params.onlyPrimary) {
if (addressData.length > 0) {
data[this.name] = addressData[0].emailAddress;
data[this.dataFieldName] = [
{
emailAddress: addressData[0].emailAddress,
lower: addressData[0].lower,
primary: true,
}
];
} else {
data[this.name] = null;
data[this.dataFieldName] = null;
}
return data;
}
data[this.dataFieldName] = addressData;
data[this.name] = null;
+23 -2
View File
@@ -53,6 +53,7 @@ class PhoneFieldView extends VarcharFieldView {
/**
* @typedef {Object} module:views/fields/phone~params
* @property {boolean} [required] Required.
* @property {boolean} [onlyPrimary] Only primary.
*/
/**
@@ -266,7 +267,7 @@ class PhoneFieldView extends VarcharFieldView {
validateMaxCount() {
/** @type {number|null} */
const maxCount = this.getConfig().get('phoneNumberMaxCount');
const maxCount = this.maxCount;
if (!maxCount) {
return false;
@@ -453,6 +454,7 @@ class PhoneFieldView extends VarcharFieldView {
}
data.itemMaxLength = this.itemMaxLength;
data.onlyPrimary = this.params.onlyPrimary;
// noinspection JSValidateTypes
return data;
@@ -655,6 +657,7 @@ class PhoneFieldView extends VarcharFieldView {
this.useInternational = this.getConfig().get('phoneNumberInternational') || false;
this.allowExtensions = this.getConfig().get('phoneNumberExtensions') || false;
this.preferredCountryList = this.getConfig().get('phoneNumberPreferredCountryList') || [];
this.maxCount = this.getConfig().get('phoneNumberMaxCount');
if (this.useInternational && !this.isListMode() && !this.isSearchMode()) {
this._codeNames = intlTelInputGlobals.getCountryData()
@@ -754,7 +757,25 @@ class PhoneFieldView extends VarcharFieldView {
fetch() {
const data = {};
const addressData = this.fetchPhoneNumberData() || [];
const addressData = this.fetchPhoneNumberData();
if (this.params.onlyPrimary) {
if (addressData.length > 0) {
data[this.name] = addressData[0].phoneNumber;
data[this.dataFieldName] = [
{
phoneNumber: addressData[0].phoneNumber,
primary: true,
}
];
} else {
data[this.name] = null;
data[this.dataFieldName] = null;
}
return data;
}
data[this.dataFieldName] = addressData;
data[this.name] = null;
@@ -26,9 +26,74 @@
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
import MultiEnumFieldView from 'views/fields/multi-enum';
import ArrayFieldView from 'views/fields/array';
export default class extends MultiEnumFieldView {
export default class extends ArrayFieldView {
// language=Handlebars
detailTemplateContent = `
{{#unless isEmpty}}
{{#each items}}
<div
class="multi-enum-item-container"
style="{{#if strikeThrough}} text-decoration: line-through; {{/if}}"
>{{label}}{{#if required}} *{{/if}}</div>
{{/each}}
{{else}}
{{#if valueIsSet}}<span class="none-value">{{translate 'None'}}</span>
{{else}}
<span class="loading-value"></span>
{{/if}}
{{/unless}}
`
/**
* @private
* @type {string[]}
*/
webFormNotAllowedFields
data() {
/** @type {string[]|null} */
const items = this.model.get(this.name);
if (!items) {
return super.data();
}
const dataItems = items.map(it => {
return {
label: this.translatedOptions[it] || it,
strikeThrough: this.model.attributes.formEnabled && this.webFormNotAllowedFields.includes(it),
required: this.isFieldRequired(it),
};
})
return {
...super.data(),
items: dataItems,
};
}
getAttributeList() {
return [...super.getAttributeList(), 'formEnabled', 'fieldParams'];
}
setup() {
this.webFormNotAllowedFields = [];
super.setup();
this.listenTo(this.model, 'change:formEnabled', (m, v, o) => {
if (!o.ui || !this.isDetailMode()) {
return;
}
this.reRender();
});
this.addActionHandler('toggleRequired', (e, target) => this.toggleRequired(target.dataset.value));
}
setupOptions() {
this.params.options = [];
@@ -39,7 +104,11 @@ export default class extends MultiEnumFieldView {
/** @type {string[]} */
const ignoreFieldList = this.getMetadata()
.get(['entityDefs', 'LeadCapture', 'fields', 'fieldList', 'ignoreFieldList']) || [];
.get(`entityDefs.LeadCapture.fields.fieldList.ignoreFieldList`) || [];
/** @type {string[]} */
const webFormTypeList = this.getMetadata()
.get(`entityDefs.LeadCapture.fields.fieldList.webFormFieldTypeList`) || [];
for (const field in fields) {
const defs = fields[field];
@@ -52,8 +121,107 @@ export default class extends MultiEnumFieldView {
continue;
}
if (!webFormTypeList.includes(defs.type)) {
this.webFormNotAllowedFields.push(field);
}
this.params.options.push(field);
this.translatedOptions[field] = this.translate(field, 'fields', 'Lead');
}
}
getItemHtml(value) {
const html = super.getItemHtml(value);
const div = document.createElement('div');
div.innerHTML = html;
/** @type {HTMLElement} */
const item = div.querySelector('.list-group-item');
const group = document.createElement('div');
group.classList.add('btn-group', 'pull-right');
const button = document.createElement('button');
button.classList.add('btn', 'btn-link', 'btn-sm', 'dropdown-toggle');
button.innerHTML = `<span class="caret"></span>`;
button.dataset.toggle = 'dropdown';
button.type = 'button';
const ul = document.createElement('ul');
ul.classList.add('dropdown-menu', 'pull-right');
const li = document.createElement('li');
const a = document.createElement('a');
a.dataset.value = value;
a.dataset.action = 'toggleRequired';
a.role = 'button';
a.tabIndex = 0;
if (this.isFieldRequired(value)) {
a.innerHTML += `<span class="check-icon fas fa-check pull-right"></span>`;
}
const textDiv = document.createElement('div');
textDiv.textContent = this.translate('required', 'fields', 'Admin');
a.append(textDiv);
li.append(a);
ul.append(li);
group.append(button, ul);
item.append(group);
if (this.isFieldRequired(value)) {
const text = div.querySelector('.text');
if (text) {
text.innerHTML += ' *';
}
}
return div.innerHTML;
}
/**
* @param {string} field
* @return {boolean}
*/
isFieldRequired(field) {
const params = this.model.attributes.fieldParams || {};
const fieldParams = params[field] || {};
return !!fieldParams.required;
}
/**
* @private
* @param {string} field
*/
toggleRequired(field) {
const params = Espo.Utils.cloneDeep(this.model.attributes.fieldParams || {});
if (!params[field]) {
params[field] = {};
}
if (!('required' in params[field])) {
params[field].required = false;
}
params[field].required = !params[field].required;
const newParams = {};
/** @type {string[]} */
const fieldList = this.model.attributes.fieldList || [];
fieldList.forEach(it => newParams[it] = params[it]);
this.model.set('fieldParams', newParams, {ui: true});
this.reRender();
}
}
+196
View File
@@ -0,0 +1,196 @@
/************************************************************************
* 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.
************************************************************************/
import View from 'view';
import EditRecordView from 'views/record/edit';
import Model from 'model';
export default class LeadCaptureFormView extends View {
// language=Handlebars
templateContent = `
<div class="block-center-md">
{{#if isPosted}}
<div class="panel paned-default">
<div class="panel-body">
<div class="complex-text">{{complexText successText}}</div>
</div>
</div>
{{else}}
{{#if text}}
<div class="panel paned-default">
<div class="panel-body">
<div class="complex-text">{{complexText text}}</div>
</div>
</div>
{{/if}}
<div class="record">{{{record}}}</div>
{{/if}}
</div>
`
isPosted = false
/**
* @param {{
* formData: {
* requestUrl: string,
* detailLayout: module:views/record/detail~panelDefs[],
* fieldDefs: Record,
* metadata: Record,
* language: Record.<string, Record>,
* successText: string,
* text: string|null,
* config: Record,
* appParams: Record,
* captchaKey: boolean,
* isDark: boolean,
* },
* }} options
*/
constructor(options) {
super();
this.formData = options.formData;
}
data() {
return {
isPosted: this.isPosted,
successText: this.formData.successText,
text: this.formData.text,
};
}
setup() {
this.getMetadata().setData(this.formData.metadata);
this.getConfig().setMultiple(this.formData.config);
this.getHelper().appParams.setAll(this.formData.appParams);
this.getHelper().fieldManager.defs = this.getMetadata().get('fields');
if (this.formData.captchaKey) {
// noinspection JSUnresolvedReference
grecaptcha.ready(() => {
// noinspection SpellCheckingInspection
/** @type {HTMLElement|null} */
const badge = document.querySelector('.grecaptcha-badge');
if (badge) {
badge.style.zIndex = '4';
}
});
}
for (const it in this.formData.language) {
this.getLanguage().setScopeData(it, this.formData.language[it]);
}
this.model = new Model({}, {
defs: {fields: this.formData.fieldDefs},
entityType: 'Lead',
});
this.model.url = this.formData.requestUrl;
this.recordView = new EditRecordView({
model: this.model,
detailLayout: this.formData.detailLayout,
buttonList: [
{
name: 'save',
text: this.translate('Submit'),
style: 'primary',
onClick: () => this.actionCreate(),
}
],
sideView: null,
bottomView: null,
isWide: true,
shortcutKeysEnabled: true,
});
this.assignView('record', this.recordView, '.record');
}
async actionCreate() {
if (this.recordView.validate()) {
Espo.Ui.error(this.translate('Not valid'));
return;
}
this.recordView.disableActionItems();
Espo.Ui.notify(' ... ')
const token = await this.processCaptcha();
const headers = token ? {'X-Captcha-Token': token} : undefined;
/** @type {{redirectUrl: string|null}} */
let result;
try {
result = await Espo.Ajax.postRequest(this.model.url, this.model.attributes, {headers: headers});
} catch (e) {
this.recordView.enableActionItems();
return;
}
Espo.Ui.notify();
this.isPosted = true;
this.recordView.remove();
await this.reRender();
if (result.redirectUrl) {
document.location.href = result.redirectUrl;
}
}
/**
* @return {Promise<string|null>}
*/
async processCaptcha() {
if (!this.formData.captchaKey) {
return null;
}
return new Promise(resolve => {
// noinspection JSUnresolvedReference
grecaptcha.ready(async () => {
// noinspection JSUnresolvedReference
const token = await grecaptcha.execute(this.formData.captchaKey, {action: 'leadCaptureSubmit'});
resolve(token);
});
})
}
}
@@ -38,6 +38,12 @@ export default class extends DetailRecordView {
name: 'generateNewApiKey',
onClick: () => this.actionGenerateNewApiKey(),
});
this.addDropdownItem({
label: 'Generate New Form ID',
name: 'generateNewFormId',
onClick: () => this.actionGenerateNewFormId(),
});
}
actionGenerateNewApiKey() {
@@ -45,7 +51,19 @@ export default class extends DetailRecordView {
Espo.Ajax.postRequest('LeadCapture/action/generateNewApiKey', {id: this.model.id})
.then(data => {
this.model.set(data);
Espo.Ui.success(this.translate('Done'));
});
});
}
async actionGenerateNewFormId() {
await this.confirm(this.translate('confirmation', 'messages'));
const data = await Espo.Ajax.postRequest('LeadCapture/action/generateNewFormId', {id: this.model.id});
this.model.set(data);
Espo.Ui.success(this.translate('Done'));
}
}
@@ -0,0 +1,36 @@
/************************************************************************
* 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.
************************************************************************/
import SidePanelView from 'views/record/panels/side';
export default class extends SidePanelView {
fieldList = [
'formUrl'
]
}
+3 -2
View File
@@ -52,8 +52,8 @@ class DetailRecordView extends BaseRecordView {
* @property {boolean} [editModeDisabled]
* @property {boolean} [confirmLeaveDisabled]
* @property {boolean} [isWide]
* @property {string} [sideView]
* @property {string} [bottomView]
* @property {string|null} [sideView]
* @property {string|null} [bottomView]
* @property {string} [inlineEditDisabled] Disable inline edit.
* @property {boolean} [buttonsDisabled] Disable buttons.
* @property {string} [navigateButtonsDisabled]
@@ -64,6 +64,7 @@ class DetailRecordView extends BaseRecordView {
* @property {module:views/record/detail~dropdownItem[]} [dropdownItemList] Dropdown items.
* @property {Object.<string, *>} [dataObject] Additional data.
* @property {Record} [rootData] Data from the root view.
* @property {boolean} [shortcutKeysEnabled] Enable shortcut keys.
*/
/**
+13 -1
View File
@@ -3858,6 +3858,18 @@ body:has(.modal-backdrop) {
}
}
.email-address-block.only-primary,
.phone-number-block.only-primary {
input.form-control {
border-top-left-radius: var(--border-radius);
border-top-right-radius: var(--border-radius);
border-bottom-left-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
}
display: block;
}
.input-group-currency {
display: flex;
@@ -3925,7 +3937,7 @@ body > .autocomplete-suggestions.text-search-suggestions {
}
}
.input-group-item > .iti {
.input-phone-number-item > .iti {
//top: 3px;
position: unset;
display: block;
+6 -4
View File
@@ -390,11 +390,13 @@ body:not([data-navbar="side"]) {
}
}
.stick-sub {
margin-top: var(--navbar-height);
&:has(> #header > #navbar) {
.stick-sub {
margin-top: var(--navbar-height);
@media screen and (max-width: @screen-xs-max) {
margin-top: 0;
@media screen and (max-width: @screen-xs-max) {
margin-top: 0;
}
}
}