Compare commits

..

46 Commits

Author SHA1 Message Date
Yuri Kuznetsov 0b6fd83b14 9.1.4 2025-05-27 09:21:27 +03:00
Yuri Kuznetsov 3631f17a77 fix ff dd shadows 2025-05-26 20:51:32 +03:00
Yuri Kuznetsov 4100af5ad3 fix sa 2025-05-26 15:32:39 +03:00
Yuri Kuznetsov b16024f205 error ref 2025-05-26 15:22:13 +03:00
Yuri Kuznetsov a0dbef3b78 cleanup 2025-05-26 12:12:04 +03:00
Yuri Kuznetsov 12c24098c3 update frontend build tools 2025-05-26 12:06:00 +03:00
Yuri Kuznetsov 8d03944e68 schema docs fix 2025-05-25 11:35:28 +03:00
Yuri Kuznetsov 3b5207871b link field fromField usage 2025-05-24 17:04:17 +03:00
Yuri Kuznetsov 0f2689deee fix kb article move 2025-05-24 13:53:01 +03:00
Yuri Kuznetsov 37d437dbae ref 2025-05-24 11:42:07 +03:00
Yuri Kuznetsov bfe109e9e4 stream user link color 2025-05-24 09:20:54 +03:00
Yuri Kuznetsov d8f7127528 comment 2025-05-23 20:06:43 +03:00
Yuri Kuznetsov 986af05002 fix user detail layout 2025-05-23 16:17:58 +03:00
Yuri Kuznetsov bdbfbdabf1 complex text table css fix 2025-05-23 11:24:30 +03:00
Yuri Kuznetsov 04f74150ac complex text table style 2025-05-23 11:20:18 +03:00
Yuri Kuznetsov 74ce903cb7 element check 2025-05-23 09:19:14 +03:00
Yuri Kuznetsov 666dd116bd link one empty search fix 2025-05-20 23:15:09 +03:00
Yuri Kuznetsov e0d262e733 portal user same account select 2025-05-20 23:05:57 +03:00
Yuri Kuznetsov 128d7ec94d 9.1.3 2025-05-20 14:58:12 +03:00
Yuri Kuznetsov e0277038cd fix email store 2025-05-20 13:57:55 +03:00
Yuri Kuznetsov 845a50a99f fix tree list view open link 2025-05-18 13:07:34 +03:00
Yuri Kuznetsov a30a353156 markdown match fix 2025-05-17 11:33:15 +03:00
Yuri Kuznetsov 27a7e2f6f5 mass remove form web hook queue 2025-05-16 20:48:49 +03:00
Yuri Kuznetsov febf78dab6 webhook in menu 2025-05-16 15:49:09 +03:00
Yuri Kuznetsov fab0efa5e5 webhook event queue item list view 2025-05-16 15:47:48 +03:00
Yuri Kuznetsov 72794768af webhook queue items panel 2025-05-16 15:33:27 +03:00
Yuri Kuznetsov 75c71d4b28 skip starred and followers in webhook 2025-05-16 15:20:32 +03:00
Yuri Kuznetsov 81364eefb3 layout change 2025-05-16 15:17:01 +03:00
Yuri Kuznetsov 07a09dd08e email sender: reply-to after addresses 2025-05-15 19:56:36 +03:00
SuchAFuriousDeath 673370b9a5 fixes TIMESTAMPDIFF_MONTH error for postgresql (#3369) 2025-05-15 08:54:12 +03:00
Yuri Kuznetsov c398ab0a40 text field: prevent scroll chrome woe with other solution 2025-05-14 19:24:12 +03:00
Yuri Kuznetsov 86f3801513 use setRangeText 2025-05-14 19:08:06 +03:00
Yuri Kuznetsov 678be304c5 cleanup 2025-05-14 18:32:01 +03:00
Yuri Kuznetsov 0243a2a5a0 9.1.2 2025-05-14 17:04:33 +03:00
Yuri Kuznetsov 15d95ddac9 select single category fix 2025-05-14 15:48:39 +03:00
Yuri Kuznetsov 33a15bdbd6 attachment file name fix 2025-05-14 14:09:51 +03:00
Yuri Kuznetsov b71a237cad postgresql delete alias fix 2025-05-14 13:58:35 +03:00
Yuri Kuznetsov 11d9d2aee4 autocomplete max height 2025-05-13 22:12:31 +03:00
Yuri Kuznetsov 6a1a3b67c8 fix lead catpture list layout 2025-05-13 15:19:37 +03:00
Yuri Kuznetsov d1bb6be354 fix record panels container multiple 2025-05-13 13:26:48 +03:00
Yuri Kuznetsov 55a387c785 fix 2025-05-13 12:49:23 +03:00
Yuri Kuznetsov 6efa058293 skip re-render email body on quick save 2025-05-13 12:00:49 +03:00
Yuri Kuznetsov 8f054a475c compose initial fields ref 2025-05-13 12:00:29 +03:00
Yuri Kuznetsov 5b1919b760 google map id 2025-05-12 17:36:42 +03:00
Yuri Kuznetsov 50ded4b933 new google maps api 2025-05-12 17:22:45 +03:00
Yuri Kuznetsov c4b0629e4f google maps warning fix 2025-05-12 17:15:35 +03:00
59 changed files with 647 additions and 188 deletions
+2 -3
View File
@@ -31,11 +31,10 @@ namespace Espo\Controllers;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Controllers\RecordBase;
use Espo\Core\Controllers\Record;
use stdClass;
class Webhook extends RecordBase
class Webhook extends Record
{
protected function checkAccess(): bool
{
@@ -0,0 +1,61 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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\Controllers;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Controllers\RecordBase;
use Espo\Core\Exceptions\Forbidden;
use stdClass;
/**
* @noinspection PhpUnused
*/
class WebhookEventQueueItem extends RecordBase
{
protected function checkAccess(): bool
{
if (!$this->user->isAdmin()) {
return false;
}
return true;
}
public function postActionCreate(Request $request, Response $response): stdClass
{
throw new Forbidden();
}
public function putActionUpdate(Request $request, Response $response): stdClass
{
throw new Forbidden();
}
}
@@ -47,7 +47,6 @@ use Espo\Core\Utils\Log;
use Espo\Core\Field\DateTime as DateTimeField;
use Espo\Entities\EmailFilter;
use Espo\Entities\Email;
use Espo\Entities\GroupEmailFolder;
use Espo\Entities\InboundEmail;
use Espo\ORM\Collection;
use Espo\ORM\EntityManager;
+11 -13
View File
@@ -46,6 +46,7 @@ use Espo\ORM\EntityManager;
use Laminas\Mail\Headers;
use Laminas\Mail\Message as LaminasMessage;
use RuntimeException;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\Transport\TransportInterface;
@@ -292,8 +293,8 @@ class Sender
$this->applyHeaders($message);
$this->applyFrom($email, $message, $params);
$this->applyReplyTo($email, $message, $params);
$this->addRecipientAddresses($email, $message);
$this->applyReplyTo($email, $message, $params);
$this->applySubject($email, $message);
$this->applyBody($email, $message);
$this->applyMessageId($email, $message);
@@ -355,7 +356,7 @@ class Sender
$part = new DataPart(
body: $contents,
filename: $this->prepareAttachmentFileName($attachment),
filename: $attachment->getName() ?? '',
contentType: $attachment->getType(),
);
@@ -470,15 +471,6 @@ class Sender
}
}
private function prepareAttachmentFileName(mixed $attachment): string
{
$namePart = base64_encode($attachment->getName() ?? '');
return "=?utf-8?B?$namePart?=";
}
/**
* @param array<string, mixed> $params
* @throws NoSmtp
@@ -618,11 +610,17 @@ class Sender
return;
}
$parts = preg_split("/\R\R/", $message->toString(), 2);
if (!is_array($parts) || count($parts) < 2) {
throw new RuntimeException("Could not split email.");
}
/** @noinspection PhpMultipleClassDeclarationsInspection */
$this->laminasMessage
->setHeaders(
Headers::fromString($message->getPreparedHeaders()->toString())
Headers::fromString($parts[0])
)
->setBody($message->getBody()->toString());
->setBody($parts[1]);
}
}
@@ -53,6 +53,9 @@ class Manager
/** @var string[] */
protected $skipAttributeList = [
Field::IS_FOLLOWED,
Field::IS_STARRED,
Field::FOLLOWERS . 'Ids',
Field::FOLLOWERS . 'Names',
Field::MODIFIED_AT,
Field::MODIFIED_BY,
Field::STREAM_UPDATED_AT,
@@ -53,15 +53,16 @@ class GoogleMaps
return;
}
if (!$entity->get('enabled') || !$entity->get('apiKey')) {
$this->configWriter->set('googleMapsApiKey', null);
$apiKey = $entity->get('apiKey');
$mapId = $entity->get('mapId');
$this->configWriter->save();
return;
if (!$entity->isEnabled()) {
$apiKey = null;
$mapId = null;
}
$this->configWriter->set('googleMapsApiKey', $entity->get('apiKey'));
$this->configWriter->set('googleMapsApiKey', $apiKey);
$this->configWriter->set('googleMapsMapId', $mapId);
$this->configWriter->save();
}
@@ -35,6 +35,7 @@ use Espo\Core\Api\Request;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\Core\Select\SearchParams;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\Core\Utils\Json;
use Espo\Modules\Crm\Tools\KnowledgeBase\Service as KBService;
@@ -105,15 +106,9 @@ class KnowledgeBaseArticle extends Record
throw new BadRequest();
}
$where = null;
$searchParams = $this->fetchSearchParams($request);
if (!empty($data->where)) {
$where = WhereItem::fromRawAndGroup(
Json::decode(Json::encode($data->where), true)
);
}
$this->getArticleService()->moveToTop($data->id, $where);
$this->getArticleService()->moveToTop($data->id, $searchParams);
return true;
}
@@ -132,15 +127,9 @@ class KnowledgeBaseArticle extends Record
throw new BadRequest();
}
$where = null;
$searchParams = $this->fetchSearchParams($request);
if (!empty($data->where)) {
$where = WhereItem::fromRawAndGroup(
Json::decode(Json::encode($data->where), true)
);
}
$this->getArticleService()->moveUp($data->id, $where);
$this->getArticleService()->moveUp($data->id, $searchParams);
return true;
}
@@ -159,15 +148,9 @@ class KnowledgeBaseArticle extends Record
throw new BadRequest();
}
$where = null;
$searchParams = $this->fetchSearchParams($request);
if (!empty($data->where)) {
$where = WhereItem::fromRawAndGroup(
Json::decode(Json::encode($data->where), true)
);
}
$this->getArticleService()->moveDown($data->id, $where);
$this->getArticleService()->moveDown($data->id, $searchParams);
return true;
}
@@ -186,15 +169,9 @@ class KnowledgeBaseArticle extends Record
throw new BadRequest();
}
$where = null;
$searchParams = $this->fetchSearchParams($request);
if (!empty($data->where)) {
$where = WhereItem::fromRawAndGroup(
Json::decode(Json::encode($data->where), true)
);
}
$this->getArticleService()->moveToBottom($data->id, $where);
$this->getArticleService()->moveToBottom($data->id, $searchParams);
return true;
}
@@ -203,4 +180,19 @@ class KnowledgeBaseArticle extends Record
{
return $this->injectableFactory->create(KBService::class);
}
private function fetchSearchParams(Request $request): SearchParams
{
$body = $request->getParsedBody();
$searchParams = SearchParams::create();
if ($body->whereGroup ?? null) {
$rawWhere = Json::decode(Json::encode($body->whereGroup), true);
$searchParams = SearchParams::fromRaw(['where' => $rawWhere]);
}
return $searchParams;
}
}
@@ -30,6 +30,7 @@
namespace Espo\Modules\Crm\Tools\KnowledgeBase;
use Espo\Core\Acl;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
@@ -46,25 +47,13 @@ use Espo\Tools\Attachment\FieldData;
class Service
{
private EntityManager $entityManager;
private AttachmentAccessChecker $attachmentAccessChecker;
private ServiceContainer $serviceContainer;
private SelectBuilderFactory $selectBuilderFactory;
private Acl $acl;
public function __construct(
EntityManager $entityManager,
AttachmentAccessChecker $attachmentAccessChecker,
ServiceContainer $serviceContainer,
SelectBuilderFactory $selectBuilderFactory,
Acl $acl
) {
$this->entityManager = $entityManager;
$this->attachmentAccessChecker = $attachmentAccessChecker;
$this->serviceContainer = $serviceContainer;
$this->selectBuilderFactory = $selectBuilderFactory;
$this->acl = $acl;
}
private EntityManager $entityManager,
private AttachmentAccessChecker $attachmentAccessChecker,
private ServiceContainer $serviceContainer,
private SelectBuilderFactory $selectBuilderFactory,
private Acl $acl,
) {}
/**
* Copy article attachments for re-using (e.g. in an email).
@@ -132,8 +121,9 @@ class Service
* @throws NotFound
* @throws Forbidden
* @throws Error
* @throws BadRequest
*/
public function moveUp(string $id, ?WhereItem $where = null): void
public function moveUp(string $id, SearchParams $params): void
{
/** @var ?KnowledgeBaseArticle $entity */
$entity = $this->entityManager->getEntityById(KnowledgeBaseArticle::ENTITY_TYPE, $id);
@@ -152,12 +142,6 @@ class Service
throw new Error();
}
$params = SearchParams::create();
if ($where) {
$params = $params->withWhere($where);
}
$query = $this->selectBuilderFactory
->create()
->from(KnowledgeBaseArticle::ENTITY_TYPE)
@@ -194,8 +178,9 @@ class Service
* @throws NotFound
* @throws Forbidden
* @throws Error
* @throws BadRequest
*/
public function moveDown(string $id, ?WhereItem $where = null): void
public function moveDown(string $id, SearchParams $params): void
{
/** @var ?KnowledgeBaseArticle $entity */
$entity = $this->entityManager->getEntityById(KnowledgeBaseArticle::ENTITY_TYPE, $id);
@@ -214,12 +199,6 @@ class Service
throw new Error();
}
$params = SearchParams::create();
if ($where) {
$params = $params->withWhere($where);
}
$query = $this->selectBuilderFactory
->create()
->from(KnowledgeBaseArticle::ENTITY_TYPE)
@@ -256,8 +235,9 @@ class Service
* @throws NotFound
* @throws Forbidden
* @throws Error
* @throws BadRequest
*/
public function moveToTop(string $id, ?WhereItem $where = null): void
public function moveToTop(string $id, SearchParams $params): void
{
/** @var ?KnowledgeBaseArticle $entity */
$entity = $this->entityManager->getEntityById(KnowledgeBaseArticle::ENTITY_TYPE, $id);
@@ -276,12 +256,6 @@ class Service
throw new Error();
}
$params = SearchParams::create();
if ($where) {
$params = $params->withWhere($where);
}
$query = $this->selectBuilderFactory
->create()
->from(KnowledgeBaseArticle::ENTITY_TYPE)
@@ -315,8 +289,9 @@ class Service
* @throws NotFound
* @throws Forbidden
* @throws Error
* @throws BadRequest
*/
public function moveToBottom(string $id, ?WhereItem $where = null): void
public function moveToBottom(string $id, SearchParams $params): void
{
/** @var ?KnowledgeBaseArticle $entity */
$entity = $this->entityManager->getEntityById(KnowledgeBaseArticle::ENTITY_TYPE, $id);
@@ -335,12 +310,6 @@ class Service
throw new Error();
}
$params = SearchParams::create();
if ($where) {
$params = $params->withWhere($where);
}
$query = $this->selectBuilderFactory
->create()
->from(KnowledgeBaseArticle::ENTITY_TYPE)
@@ -333,7 +333,7 @@ class PostgresqlQueryComposer extends BaseQueryComposer
return "EXTRACT(YEAR FROM $to - $from)";
case 'TIMESTAMPDIFF_MONTH':
return "EXTRACT(MONTH FROM $to) - $from)";
return "EXTRACT(MONTH FROM $to - $from)";
case 'TIMESTAMPDIFF_WEEK':
return "FLOOR(EXTRACT(DAY FROM $to - $from) / 7)";
@@ -617,4 +617,40 @@ class PostgresqlQueryComposer extends BaseQueryComposer
return null;
}
protected function composeDeleteQuery(
string $table,
?string $alias,
string $where,
?string $joins,
?string $order,
?int $limit
): string {
$sql = "DELETE ";
$sql .= "FROM " . $this->quoteIdentifier($table);
if ($alias) {
$sql .= " AS " . $this->quoteIdentifier($alias);
}
if ($joins) {
$sql .= " $joins";
}
if ($where) {
$sql .= " WHERE $where";
}
if ($order) {
$sql .= " ORDER BY $order";
}
if ($limit !== null) {
$sql = $this->limit($sql, null, $limit);
}
return $sql;
}
}
@@ -58,6 +58,7 @@
"LayoutSet": "Layout Set",
"Webhook": "Webhook",
"WebhookQueueItem": "Webhook Queue Item",
"WebhookEventQueueItem": "Webhook Event Queue Item",
"Mass Action": "Mass Action",
"WorkingTimeCalendar": "Working Time Calendar",
"WorkingTimeRange": "Working Time Exception",
@@ -116,6 +117,7 @@
"LayoutSet": "Layout Sets",
"Webhook": "Webhooks",
"WebhookQueueItem": "Webhook Queue Items",
"WebhookEventQueueItem": "Webhook Event Queue Items",
"WorkingTimeCalendar": "Working Time Calendars",
"WorkingTimeRange": "Working Time Exceptions",
"AuthenticationProvider": "Authentication Providers",
@@ -7,7 +7,8 @@
"apiKey": "API Key",
"siteKey": "Site Key",
"secretKey": "Secret Key",
"scoreThreshold": "Score Threshold"
"scoreThreshold": "Score Threshold",
"mapId": "Map ID"
},
"titles": {
"GoogleMaps": "Google Maps",
@@ -12,6 +12,7 @@
"secretKey": "Secret Key"
},
"links": {
"user": "User"
"user": "User",
"queueItems": "Queue Items"
}
}
@@ -0,0 +1,8 @@
{
"fields": {
"event": "Event",
"target": "Target",
"data": "Data",
"isProcessed": "Is Processed"
}
}
@@ -1,5 +1,5 @@
[
{"name":"name", "link": true},
{"name":"isActive", "widthPx": "100"},
{"name":"campaign", "width": "30"}
{"name": "name", "link": true},
{"name": "isActive", "widthPx": 120},
{"name": "campaign", "width": 25}
]
@@ -0,0 +1,3 @@
[
"queueItems"
]
@@ -0,0 +1,17 @@
[
{
"rows": [
[
{"name": "event"},
{"name": "isProcessed"}
],
[
{"name": "target"},
false
],
[
{"name": "data"}
]
]
}
]
@@ -0,0 +1,17 @@
[
{
"rows": [
[
{"name": "event"},
{"name": "isProcessed"}
],
[
{"name": "target"},
false
],
[
{"name": "data"}
]
]
}
]
@@ -0,0 +1,6 @@
[
"event",
"target",
"isProcessed",
"createdAt"
]
@@ -0,0 +1,18 @@
[
{
"name": "event",
"link": true
},
{
"name": "isProcessed",
"width": 12
},
{
"name": "target",
"width": 20
},
{
"name": "createdAt",
"width": 14
}
]
@@ -16,6 +16,9 @@
[
{"name": "attempts"},
{"name": "processAt"}
],
[
{"name": "data"}
]
]
}
@@ -1,6 +1,7 @@
[
"status",
"webhook",
"event",
"createdAt",
"processedAt"
]
@@ -0,0 +1,17 @@
[
{
"name": "target"
},
{
"name": "status",
"width": 20
},
{
"name": "createdAt",
"width": 20
},
{
"name": "processedAt",
"width": 20
}
]
@@ -107,6 +107,9 @@
"rowActionsView": "crm:views/record/row-actions/relationship-target",
"layout": "listForTarget",
"view": "crm:views/record/panels/target-lists"
},
"contact": {
"selectHandler": "handlers/user/select-contact"
}
},
"layoutDefaultSidePanelDisabled": true,
@@ -11,8 +11,21 @@
"labelTranslation": "Global.scopeNamesPlural.WebhookQueueItem",
"link": "#WebhookQueueItem",
"aclScope": "WebhookQueueItem"
},
{
"labelTranslation": "Global.scopeNamesPlural.WebhookEventQueueItem",
"link": "#WebhookEventQueueItem",
"aclScope": "WebhookEventQueueItem"
}
]
}
},
"relationshipPanels": {
"queueItems": {
"unlinkDisabled": true,
"createDisabled": true,
"selectDisabled": true,
"layout": "listForWebhook"
}
}
}
@@ -0,0 +1,19 @@
{
"controller": "controllers/record",
"createDisabled": true,
"editDisabled": true,
"mergeDisabled": true,
"exportDisabled": true,
"textFilterDisabled": true,
"menu": {
"list": {
"dropdown": [
{
"labelTranslation": "Global.scopeNamesPlural.Webhook",
"link": "#Webhook",
"aclScope": "Webhook"
}
]
}
}
}
@@ -4,5 +4,16 @@
"editDisabled": true,
"mergeDisabled": true,
"exportDisabled": true,
"textFilterDisabled": true
"textFilterDisabled": true,
"menu": {
"list": {
"dropdown": [
{
"labelTranslation": "Global.scopeNamesPlural.Webhook",
"link": "#Webhook",
"aclScope": "Webhook"
}
]
}
}
}
@@ -69,6 +69,12 @@
"type": "belongsTo",
"entity": "User"
},
"queueItems": {
"type": "hasMany",
"entity": "WebhookQueueItem",
"foreign": "webhook",
"readOnly": true
},
"createdBy": {
"type": "belongsTo",
"entity": "User"
@@ -7,7 +7,8 @@
"event": {
"type": "varchar",
"maxLength": 100,
"required": true
"required": true,
"view": "views/webhook/fields/event"
},
"target": {
"type": "linkParent"
@@ -7,7 +7,8 @@
"event": {
"type": "varchar",
"maxLength": 100,
"required": true
"required": true,
"view": "views/webhook/fields/event"
},
"webhook": {
"type": "link"
@@ -51,7 +52,8 @@
"webhook": {
"type": "belongsTo",
"entity": "Webhook",
"foreignName": "id"
"foreignName": "id",
"foreign": "queueItems"
}
},
"collection": {
@@ -4,6 +4,11 @@
"type": "varchar",
"maxLength": 255,
"required": true
},
"mapId": {
"type": "varchar",
"maxLength": 64,
"required": false
}
},
"allowUserAccounts": false,
@@ -0,0 +1,7 @@
{
"massActions": {
"delete": {
"allowed": true
}
}
}
@@ -0,0 +1,7 @@
{
"massActions": {
"delete": {
"allowed": true
}
}
}
+8 -8
View File
@@ -303,10 +303,10 @@ class SendService
try {
$this->groupAccountService->storeSentMessage($id, $message);
} catch (Exception $e) {
$this->log->error(
"Email sending: Could not store sent email (Group Email Account {$groupAccount->getId()}): " .
$e->getMessage() . "."
);
$text = "Could not store sent email; group account {$groupAccount->getId()}); " .
$e->getMessage() . ".";
$this->log->error($text, ['exception' => $e]);
}
}
@@ -320,10 +320,10 @@ class SendService
try {
$this->personalAccountService->storeSentMessage($id, $message);
} catch (Exception $e) {
$this->log->error(
"Email sending: Could not store sent email (Email Account {$personalAccount->getId()}): " .
$e->getMessage() . "."
);
$text = "Could not store sent email; personal account {$personalAccount->getId()}; " .
$e->getMessage() . ".";
$this->log->error($text, ['exception' => $e]);
}
}
}
@@ -71,7 +71,7 @@ class MoveActionHandler extends RowActionHandler {
Espo.Ajax.postRequest('KnowledgeBaseArticle/action/moveToTop', {
id: model.id,
where: this.collection.getWhere(),
whereGroup: this.collection.getWhere(),
}).then(() => {
this.collection.fetch()
.then(() => Espo.Ui.notify(false));
@@ -89,7 +89,7 @@ class MoveActionHandler extends RowActionHandler {
Espo.Ajax.postRequest('KnowledgeBaseArticle/action/moveUp', {
id: model.id,
where: this.collection.getWhere(),
whereGroup: this.collection.getWhere(),
}).then(() => {
this.collection.fetch()
.then(() => Espo.Ui.notify(false));
@@ -107,7 +107,7 @@ class MoveActionHandler extends RowActionHandler {
Espo.Ajax.postRequest('KnowledgeBaseArticle/action/moveDown', {
id: model.id,
where: this.collection.getWhere(),
whereGroup: this.collection.getWhere(),
}).then(() => {
this.collection.fetch()
.then(() => Espo.Ui.notify(false));
@@ -125,7 +125,7 @@ class MoveActionHandler extends RowActionHandler {
Espo.Ajax.postRequest('KnowledgeBaseArticle/action/moveToBottom', {
id: model.id,
where: this.collection.getWhere(),
whereGroup: this.collection.getWhere(),
}).then(() => {
this.collection.fetch()
.then(() => Espo.Ui.notify(false));
@@ -1,5 +1,13 @@
<div>
<div class="top-group-string-container">
{{{conditionGroup}}}
{{#if isNotEmpty}}
<div>
<div class="top-group-string-container">
{{{conditionGroup}}}
</div>
</div>
</div>
{{else}}
{{#if isSet}}
<span class="none-value">{{translate 'None'}}</span>
{{else}}
<span class="loading-value"></span>
{{/if}}
{{/if}}
+9
View File
@@ -112,6 +112,15 @@ class TreeCollection extends Collection {
return super.fetch(options);
}
clone(options = {}) {
options = {...options};
// Prevents recurring clone.
options.withModels = false;
return super.clone(options);
}
}
export default TreeCollection;
@@ -58,7 +58,7 @@ class GoogleMapsRenderer extends MapRenderer {
// noinspection SpellCheckingInspection
window.mapapiloaded = () => this.initMapGoogle(addressData);
let src = 'https://maps.googleapis.com/maps/api/js?callback=mapapiloaded';
let src = 'https://maps.googleapis.com/maps/api/js?callback=mapapiloaded&loading=async&v=weekly&libraries=marker';
const apiKey = this.view.getConfig().get('googleMapsApiKey');
if (apiKey) {
@@ -67,7 +67,7 @@ class GoogleMapsRenderer extends MapRenderer {
const scriptElement = document.createElement('script');
scriptElement.setAttribute('async', 'async');
scriptElement.setAttribute('defer', 'defer');
scriptElement.src = src;
document.head.appendChild(scriptElement);
@@ -81,12 +81,15 @@ class GoogleMapsRenderer extends MapRenderer {
const geocoder = new google.maps.Geocoder();
let map;
const mapId = this.view.getConfig().get('googleMapsMapId') || 'DEMO_MAP_ID';
try {
// noinspection SpellCheckingInspection,JSUnresolvedReference
map = new google.maps.Map(this.view.$el.find('.map').get(0), {
zoom: 15,
center: {lat: 0, lng: 0},
scrollwheel: false,
mapId: mapId,
});
}
catch (e) {
@@ -146,7 +149,7 @@ class GoogleMapsRenderer extends MapRenderer {
map.setCenter(results[0].geometry.location);
// noinspection JSUnresolvedReference
new google.maps.Marker({
new google.maps.marker.AdvancedMarkerElement({
map: map,
position: results[0].geometry.location,
});
@@ -0,0 +1,55 @@
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 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 SelectRelatedHandler from 'handlers/select-related';
export default class extends SelectRelatedHandler {
/**
* @param {import('model').default} model
*/
async getFilters(model) {
/** @type {string[]|null} */
const accountIds = model.attributes.accountsIds;
if (accountIds && accountIds.length) {
return {
advanced: {
accounts: {
field: 'accounts',
type: 'linkedWith',
value: accountIds,
data: {nameHash: model.attributes.accountsNames || {}},
},
},
};
}
return {};
}
}
+1
View File
@@ -117,6 +117,7 @@ class Autocomplete {
autoSelectFirst: options.autoSelectFirst,
appendTo: $modalBody.length ? $modalBody : 'body',
forceFixPosition: true,
maxHeight: 308,
formatResult: item => {
if (options.formatResult) {
return options.formatResult(item);
@@ -28,11 +28,21 @@
import BaseFieldView from 'views/fields/base';
/**
* Important. Used in extensions.
*/
export default class extends BaseFieldView {
detailTemplate = 'admin/field-manager/fields/dynamic-logic-conditions/detail'
editTemplate = 'admin/field-manager/fields/dynamic-logic-conditions/edit'
data() {
return {
isSet: this.model.has(this.name),
isNotEmpty: this.conditionGroup && this.conditionGroup.length,
};
}
setup() {
this.addActionHandler('editConditions', () => this.edit());
+25 -14
View File
@@ -36,23 +36,33 @@ class EmailComposeRecordView extends EditRecordView {
isWide = true
sideView = false
/**
* @private
* @type {string|null}
*/
initialBody
/**
* @private
* @type {boolean|null}
*/
initialIsHtml
setupBeforeFinal() {
super.setupBeforeFinal();
this.initialBody = null;
this.initialIsHtml = null;
this.initialBody = this.model.attributes.body;
this.initialIsHtml = this.model.attributes.isHtml;
if (!this.model.get('isHtml') && this.getPreferences().get('emailReplyForceHtml')) {
const body = (this.model.get('body') || '').replace(/\n/g, '<br>');
this.model.set('body', body, {silent: true});
this.model.set('isHtml', true, {silent: true});
if (
!this.model.attributes.isHtml &&
this.getPreferences().get('emailReplyForceHtml')
) {
this.initialBody = (this.initialBody || '').replace(/\n/g, '<br>') || null;
this.initialIsHtml = true;
}
if (this.model.get('body')) {
this.initialBody = this.model.get('body');
this.initialIsHtml = this.model.get('isHtml');
}
let body = this.initialBody;
if (!this.options.signatureDisabled && this.hasSignature()) {
let addSignatureMethod = 'prependSignature';
@@ -61,10 +71,11 @@ class EmailComposeRecordView extends EditRecordView {
addSignatureMethod = 'appendSignature';
}
const body = this[addSignatureMethod](this.model.get('body') || '', this.model.get('isHtml'));
this.model.set('body', body, {silent: true});
body = this[addSignatureMethod](body || '', this.initialIsHtml) || null;
}
this.model.set('body', body, {silent: true});
this.model.set('isHtml', this.initialIsHtml, {silent: true});
}
setup() {
+14
View File
@@ -905,6 +905,10 @@ class BaseFieldView extends View {
return;
}
if (this.isEditMode() && this.toSkipReRenderOnChange()) {
return;
}
const reRender = () => {
if (!this.isRendered() && !this.isBeingRendered()) {
return;
@@ -1688,6 +1692,16 @@ class BaseFieldView extends View {
fetchSearchType() {
return this.$el.find('select.search-type').val();
}
/**
* To skip re-render on change in edit mode.
*
* @protected
* @since 9.1.2
*/
toSkipReRenderOnChange() {
return false;
}
}
export default BaseFieldView;
+22 -5
View File
@@ -33,10 +33,24 @@ class LinkOneFieldView extends LinkFieldView {
searchTypeList = ['is', 'isEmpty', 'isNotEmpty', 'isOneOf']
fetchSearch() {
let type = this.$el.find('select.search-type').val();
let value = this.$el.find('[data-name="' + this.idName + '"]').val();
const type = this.$el.find('select.search-type').val();
const value = this.$el.find('[data-name="' + this.idName + '"]').val();
if (['isOneOf'].includes(type) && !this.searchData.oneOfIdList.length) {
return {
type: 'isNotNull',
attribute: 'id',
data: {
type: type,
},
};
}
if (type === 'isOneOf') {
if (!value) {
return false;
}
return {
type: 'linkedWith',
field: this.name,
@@ -48,7 +62,8 @@ class LinkOneFieldView extends LinkFieldView {
},
};
}
else if (type === 'is' || !type) {
if (type === 'is' || !type) {
if (!value) {
return false;
}
@@ -63,7 +78,8 @@ class LinkOneFieldView extends LinkFieldView {
},
};
}
else if (type === 'isEmpty') {
if (type === 'isEmpty') {
return {
type: 'isNotLinked',
data: {
@@ -71,7 +87,8 @@ class LinkOneFieldView extends LinkFieldView {
},
};
}
else if (type === 'isNotEmpty') {
if (type === 'isNotEmpty') {
return {
type: 'isLinked',
data: {
+1 -1
View File
@@ -424,7 +424,7 @@ class LinkFieldView extends BaseFieldView {
this.model.set({
...attributes,
...(await handler.getAttributes(model)),
});
}, {fromField: this.name});
});
}
+3 -6
View File
@@ -30,7 +30,6 @@
import BaseFieldView from 'views/fields/base';
import MailtoHelper from 'helpers/misc/mailto';
import Modal from 'views/modal';
import TextPreviewModalView from 'views/modals/text-preview';
/**
@@ -639,14 +638,12 @@ class TextFieldView extends BaseFieldView {
const after = value.substring(selectionEnd);
// Last line, a list item syntax.
const match = before.match(/(^|\n)( *[-*]|\d+\.)([^\n]*)$/);
const match = before.match(/(^|\n)( *[-*]|\d+\.)([^*\-\n]*)$/);
if (!match) {
// Prevent unwanted scroll applied by the browser on enter.
event.preventDefault();
target.value = before + "\n" + after;
target.selectionStart = target.selectionEnd = before.length + 1;
const previousWindowScroll = window.scrollY;
setTimeout(() => window.scrollTo({top: previousWindowScroll}), 0);
this.controlTextareaHeight();
+6 -4
View File
@@ -282,10 +282,12 @@ class VarcharFieldView extends BaseFieldView {
mainElement.classList.remove('hidden');
}
if (['anyOf', 'noneOf'].includes(type)) {
multiSelectContainer.classList.remove('hidden');
} else {
multiSelectContainer.classList.add('hidden');
if (multiSelectContainer) {
if (['anyOf', 'noneOf'].includes(type)) {
multiSelectContainer.classList.remove('hidden');
} else {
multiSelectContainer.classList.add('hidden');
}
}
}
+12
View File
@@ -977,6 +977,18 @@ class WysiwygFieldView extends TextFieldView {
this.$summernote.summernote('insertText', text);
}
}
toSkipReRenderOnChange() {
if (!this.element || !this.element.contains(document.activeElement)) {
return false;
}
if (!this.model.hasChanged(this.name)) {
return true;
}
return false;
}
}
export default WysiwygFieldView;
@@ -146,6 +146,10 @@ class SelectCategoryTreeRecordsModalView extends SelectRecordsModalView {
buttonsDisabled: true,
}, listView => {
listView.once('select', models => {
if (!Array.isArray(models)) {
models = [models];
}
this.trigger('select', models);
if (this.options.onSelect) {
+2 -2
View File
@@ -1404,10 +1404,10 @@ class DetailRecordView extends BaseRecordView {
}
afterRender() {
this.$middle = this.$el.find('.middle');
this.$middle = this.$el.find('.middle').first();
if (this.bottomView) {
this.$bottom = this.$el.find('.bottom');
this.$bottom = this.$el.find('.bottom').first();
}
this.initElementReferences();
+4
View File
@@ -77,6 +77,10 @@ class PostNoteStreamView extends NoteStreamView {
}
});
if (this.messageName === 'postThis') {
this.messageTemplate = '{user}';
}
if (this.model.get('parentId')) {
this.createMessage();
+2 -2
View File
@@ -346,8 +346,8 @@ class UserDetailRecordView extends DetailRecordView {
"label": "Portal",
"name": "portal",
"rows": [
[{"name":"portals"}, {"name":"contact"}],
[{"name":"portalRoles"}, {"name":"accounts"}],
[{"name": "portals"}, {"name": "accounts"}],
[{"name": "portalRoles"}, {"name": "contact"}],
]
});
+30 -8
View File
@@ -175,9 +175,13 @@ class UserEditRecordView extends EditRecordView {
getGridLayout(callback) {
this.getHelper().layoutManager
.get(this.model.entityType, this.options.layoutName || this.layoutName, simpleLayout => {
/** @type {module:views/record/detail~panelDefs[]} */
const layout = Espo.Utils.cloneDeep(simpleLayout);
layout.push({
/** @type {module:views/record/detail~panelDefs[]} */
const panels = [];
panels.push({
"label": "Teams and Access Control",
"name": "accessControl",
"rows": [
@@ -187,17 +191,17 @@ class UserEditRecordView extends EditRecordView {
]
});
layout.push({
panels.push({
"label": "Portal",
"name": "portal",
"rows": [
[{"name": "portals"}, {"name": "contact"}],
[{"name": "portalRoles"}, {"name": "accounts"}]
[{"name": "portals"}, {"name": "accounts"}],
[{"name": "portalRoles"}, {"name": "contact"}]
]
});
if (this.getUser().isAdmin() && this.model.isPortal()) {
layout.push({
panels.push({
"label": "Misc",
"name": "portalMisc",
"rows": [
@@ -207,7 +211,7 @@ class UserEditRecordView extends EditRecordView {
}
if (this.model.isAdmin() || this.model.isRegular()) {
layout.push({
panels.push({
"label": "Misc",
"name": "misc",
"rows": [
@@ -221,7 +225,7 @@ class UserEditRecordView extends EditRecordView {
this.getUser().isAdmin() &&
!this.model.isApi()
) {
layout.push({
panels.push({
label: 'Password',
rows: [
[
@@ -273,7 +277,7 @@ class UserEditRecordView extends EditRecordView {
}
if (this.getUser().isAdmin() && this.model.isApi()) {
layout.push({
panels.push({
"name": "auth",
"rows": [
[{"name": "authMethod"}, false]
@@ -281,6 +285,24 @@ class UserEditRecordView extends EditRecordView {
});
}
let hasTab = false;
for (const [i, panel] of layout.entries()) {
if (panel.tabBreak && i > 0) {
layout.splice(i, 0, ...panels);
hasTab = true;
break;
}
}
if (!hasTab) {
layout.push(...panels);
}
this.detailLayout = layout;
const gridLayout = {
type: 'record',
layout: this.convertDetailLayout(layout),
+38 -1
View File
@@ -2305,6 +2305,28 @@ td > span.color-icon {
text-align: center;
margin-right: var(--6px);
}
[data-key="user"] {
> a {
&,
&:hover,
&:focus {
color: var(--btn-text-color);
}
}
}
.message {
a {
&[data-scope="User"] {
&,
&:hover,
&:focus {
color: var(--btn-text-color);
}
}
}
}
}
.stream-post-container,
@@ -2767,7 +2789,7 @@ table.table td.cell .html-container {
font-weight: normal;
}
h5, h6 {
h5, h6 {
margin-top: calc(var(--line-height-computed) / 2);
margin-bottom: calc(var(--line-height-computed) / 2);
}
@@ -2835,6 +2857,21 @@ table.table td.cell .html-container {
ul, ol {
padding-left: var(--30px);
}
table {
table-layout: fixed;
width: 100%;
td, th {
padding: var(--table-cell-padding-half) var(--table-cell-padding);
border: var(--1px) solid var(--default-border-color);
vertical-align: top;
}
+ table {
margin-top: var(--line-height-computed);
}
}
}
.field > .html-container {
+6 -5
View File
@@ -36,12 +36,13 @@
.selectize-dropdown,
.selectize-dropdown.form-control,
.autocomplete-suggestions,
.ui-timepicker-wrapper,
.ui-timepicker-wrapper {
.dropdown-border();
}
// Separately intentionally as this one is not supported in FF.
select.form-control::picker(select) {
border: var(--dropdown-border-width) solid var(--dropdown-border);
box-shadow: var(--dropdown-box-shadow);
border-radius: var(--dropdown-border-radius);
border-width: var(--dropdown-border-width);
.dropdown-border();
}
.popover {
+7
View File
@@ -213,3 +213,10 @@
border-bottom-right-radius: var(--panel-border-radius);
}
}
.dropdown-border() {
border: var(--dropdown-border-width) solid var(--dropdown-border);
box-shadow: var(--dropdown-box-shadow);
border-radius: var(--dropdown-border-radius);
border-width: var(--dropdown-border-width);
}
+7 -7
View File
@@ -1,12 +1,12 @@
{
"name": "espocrm",
"version": "9.1.1",
"version": "9.1.4",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "espocrm",
"version": "9.1.1",
"version": "9.1.4",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"dependencies": {
@@ -55,7 +55,7 @@
"devDependencies": {
"archiver": "^5.3.0",
"chromedriver": "^127.0.1",
"espo-frontend-build-tools": "github:espocrm/frontend-build-tools#0.1.1",
"espo-frontend-build-tools": "github:espocrm/frontend-build-tools#0.2.0",
"fstream": ">=1.0.12",
"glob": "^10.2.6",
"grunt": "^1.5.3",
@@ -3270,8 +3270,8 @@
}
},
"node_modules/espo-frontend-build-tools": {
"version": "0.1.1",
"resolved": "git+ssh://git@github.com/espocrm/frontend-build-tools.git#7f4723b205cd35c602dc5c5aa81a21ad0a7ce0c1",
"version": "0.2.0",
"resolved": "git+ssh://git@github.com/espocrm/frontend-build-tools.git#bcbd295c2728b391bebd446ef4ad3ca873d1f6eb",
"dev": true,
"dependencies": {
"@babel/cli": "^7.25.0",
@@ -9713,9 +9713,9 @@
}
},
"espo-frontend-build-tools": {
"version": "git+ssh://git@github.com/espocrm/frontend-build-tools.git#7f4723b205cd35c602dc5c5aa81a21ad0a7ce0c1",
"version": "git+ssh://git@github.com/espocrm/frontend-build-tools.git#bcbd295c2728b391bebd446ef4ad3ca873d1f6eb",
"dev": true,
"from": "espo-frontend-build-tools@github:espocrm/frontend-build-tools#0.1.1",
"from": "espo-frontend-build-tools@github:espocrm/frontend-build-tools#0.2.0",
"requires": {
"@babel/cli": "^7.25.0",
"@babel/core": "^7.25.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "espocrm",
"version": "9.1.1",
"version": "9.1.4",
"description": "Open-source CRM.",
"repository": {
"type": "git",
@@ -21,7 +21,7 @@
"devDependencies": {
"archiver": "^5.3.0",
"chromedriver": "^127.0.1",
"espo-frontend-build-tools": "github:espocrm/frontend-build-tools#0.1.1",
"espo-frontend-build-tools": "github:espocrm/frontend-build-tools#0.2.0",
"fstream": ">=1.0.12",
"glob": "^10.2.6",
"grunt": "^1.5.3",
+1 -1
View File
@@ -70,7 +70,7 @@
"type": "object",
"description": "A scope level.",
"additionalProperties": {
"description": "A scope. True enables access to scopes that don't have actions. False fully restricts access to a scope. A string value makes the framework to use roles from another scope and apply it to our scope. It can be useful to have roles only for one parent scope, when child scopes uses roles of the parent scope. `boolean:{scope}` format will convert a foreign scope data to the boolean type.",
"description": "A scope. True enables access to scopes that don't have actions. False fully restricts access to the scope. A string value makes the framework to use roles from another scope and apply them to our scope. It can be useful to have roles only for one parent scope, when child scopes use roles of the parent scope. `boolean:{scope}` format will convert a foreign scope data to the boolean type.",
"anyOf": [
{
"type": "object",
@@ -347,4 +347,23 @@ class PostgresqlQueryComposerTest extends \PHPUnit\Framework\TestCase
$this->assertEquals($expectedSql, $sql);
}
public function testDeleteWithAlias()
{
$query = $this->queryBuilder
->delete()
->from('Account', 'a')
->where([
'a.name' => 'test',
])
->build();
$sql = $this->queryComposer->compose($query);
$expectedSql =
"DELETE FROM \"account\" AS \"a\" " .
"WHERE \"a\".\"name\" = 'test'";
$this->assertEquals($expectedSql, $sql);
}
}