Compare commits

...

40 Commits

Author SHA1 Message Date
Yurii 917dbdd3fe 9.2.6 2026-01-15 20:35:58 +02:00
Yurii f645af4347 fix url rendering 2026-01-15 19:47:54 +02:00
Yurii be228fa48a url validator util 2026-01-15 19:41:06 +02:00
Yurii 5d443dda10 fix mariadb 12 2026-01-09 12:50:28 +02:00
Yuri Kuznetsov 74db03090b fix linked where filters 2025-12-19 08:50:17 +02:00
Yuri Kuznetsov 03ac0a57c8 fix double shortcut issue 2025-12-17 20:02:39 +02:00
Yuri Kuznetsov 1f251eecb3 fix notification read 2025-12-06 10:43:26 +02:00
Usame Beşir 83439e5384 fix missing value of entityType variable (#3523) 2025-11-17 12:06:19 +02:00
Yuri Kuznetsov 1df75fcece currencyAttribute prop 2025-11-15 13:18:31 +02:00
Yuri Kuznetsov 5ce2e0b6dc 9.2.5 2025-11-14 15:16:05 +02:00
Anton Slyzhko 445ea1f240 Fix incorrect alias in subscriptionExclude join (#3520) 2025-11-14 14:51:47 +02:00
Yuri Kuznetsov 94d2f6aabb array field: override allowCustomOptions false 2025-11-13 18:12:26 +02:00
Yuri Kuznetsov c426a637fe email address add impr 2025-11-13 18:11:02 +02:00
Yuri Kuznetsov 9e78d4cad3 no-select 2025-11-13 18:03:00 +02:00
Yuri Kuznetsov 08fecd2dc0 search menu check icon fix 2025-11-13 17:56:06 +02:00
Yuri Kuznetsov 6dfbb08ce6 Merge branch 'fix' of https://github.com/espocrm/espocrm into fix 2025-11-13 17:25:45 +02:00
Yuri Kuznetsov 610c1004aa jsdoc fix 2025-11-13 17:25:10 +02:00
Shaikh Naasir 26ee35cb7d Fix typo in accessCheckCommand description (#3519)
This pull request fixes a minor typo in the WebSocket schema description:

Changed 'patameter' to 'parameter'.
No functional changes were made. Purely a documentation correction.
2025-11-13 16:07:22 +02:00
Yuri Kuznetsov e311ac6dbb fix width in layouts 2025-11-11 22:00:27 +02:00
Yuri Kuznetsov 9901070fb1 fix layout 2025-11-11 08:14:35 +02:00
Yuri Kuznetsov 379a2d22f3 autocomplete suggestion min width 2025-11-08 20:53:02 +02:00
Yuri Kuznetsov fc7c949cf3 link field: foreign name attribute 2025-11-08 20:31:46 +02:00
Yuri Kuznetsov f1f0f3ea9a process dynamic logic if model default preparator is used 2025-11-07 16:00:16 +02:00
Yuri Kuznetsov c1c4a2bedc controller name check 2025-11-01 16:04:19 +02:00
Yuri Kuznetsov 876b74aefc loader error callback fix 2025-11-01 15:53:04 +02:00
Yuri Kuznetsov 6e2416dc56 fix kanban menu with star 2025-10-30 17:00:57 +02:00
Yuri Kuznetsov 33451a1118 lead capture fields ui fix 2025-10-30 14:45:28 +02:00
bfzgaier 90d7b99936 fix Cannot access protected property id (#3506) 2025-10-30 13:03:44 +02:00
Anton Slyzhko 86215d0094 int getMinValue fix (#3507)
Fix getMinValue logic that overwrote valid minValue due to wrong null comparison
2025-10-30 11:29:47 +02:00
Yuri Kuznetsov 36f03f0a76 fix schema diff for decimal 2025-10-26 12:58:27 +02:00
Yuri Kuznetsov a141a664c6 default decimal to string 2025-10-26 12:38:06 +02:00
Yuri Kuznetsov 0a1b7a9817 create builder method 2025-10-26 11:22:27 +02:00
Yuri Kuznetsov 47b9d35153 default order in relations 2025-10-26 11:13:50 +02:00
Yuri Kuznetsov 169e6b53be default template value 2025-10-26 11:09:06 +02:00
Yuri Kuznetsov 9935a6cd11 default template value 2025-10-26 10:29:05 +02:00
Yuri Kuznetsov 02f1e94c86 fix link category parent field 2025-10-25 17:33:41 +03:00
Yuri Kuznetsov 0d4d4dc9ed category folders fetch on refresh 2025-10-25 17:25:13 +03:00
Yuri Kuznetsov d69cfd8b31 typo 2025-10-25 12:51:26 +03:00
Yuri Kuznetsov 599646c397 metadata aclDependency anyScopeList 2025-10-25 12:47:21 +03:00
Yuri Kuznetsov 4e0d5a2267 schema 2025-10-25 10:22:56 +03:00
55 changed files with 580 additions and 147 deletions
@@ -1358,16 +1358,22 @@ class ItemGeneralConverter implements ItemConverter
$nearKey = $defs->getMidKey();
$middleEntityType = ucfirst($defs->getRelationshipName());
$conditions = [
"$alias.$nearKey:" => Attribute::ID,
"$alias.deleted" => false,
];
foreach ($defs->getConditions() as $k => $v) {
$conditions["$alias.$k"] = $v;
}
// The foreign table is not joined as it would perform much slower.
// Trade off is that if a foreign record is deleted but the middle table
// is not yet deleted, it will give a non-actual result.
$subQuery = QueryBuilder::create()
->select(Attribute::ID)
->from($this->entityType)
->leftJoin($middleEntityType, $alias, [
"$alias.$nearKey:" => Attribute::ID,
"$alias.deleted" => false,
])
->leftJoin($middleEntityType, $alias, $conditions)
->where(["$alias.$key" => null])
->build();
@@ -1410,16 +1416,22 @@ class ItemGeneralConverter implements ItemConverter
$nearKey = $defs->getMidKey();
$middleEntityType = ucfirst($defs->getRelationshipName());
$conditions = [
"$alias.$nearKey:" => Attribute::ID,
"$alias.deleted" => false,
];
foreach ($defs->getConditions() as $k => $v) {
$conditions["$alias.$k"] = $v;
}
// The foreign table is not joined as it would perform much slower.
// Trade off is that if a foreign record is deleted but the middle table
// is not yet deleted, it will give a non-actual result.
$subQuery = QueryBuilder::create()
->select(Attribute::ID)
->from($this->entityType)
->leftJoin($middleEntityType, $alias, [
"$alias.$nearKey:" => Attribute::ID,
"$alias.deleted" => false,
])
->leftJoin($middleEntityType, $alias, $conditions)
->where(["$alias.$key!=" => null])
->build();
@@ -1476,7 +1488,6 @@ class ItemGeneralConverter implements ItemConverter
if ($relationType == Entity::MANY_MANY) {
$key = $defs->getForeignMidKey();
$nearKey = $defs->getMidKey();
// IN-sub-query performs faster than EXISTS on MariaDB when multiple IDs.
// Left-join performs faster than inner-join.
@@ -1489,12 +1500,6 @@ class ItemGeneralConverter implements ItemConverter
->from($this->entityType)
->leftJoin(
Join::create($link, $alias)
->withConditions(
Cond::equal(
Cond::column("$alias.$nearKey"),
Cond::column(Attribute::ID)
)
)
->withOnlyMiddle()
)
->where(["$alias.$key" => $value])
@@ -34,7 +34,7 @@ use Doctrine\DBAL\Platforms\Keywords\MariaDBKeywords;
/**
* 'LEAD' happened to be a reserved words on some environments.
*/
final class MariaDb102Keywords extends MariaDBKeywords
class MariaDb102Keywords extends MariaDBKeywords
{
/** @deprecated */
public function getName(): string
@@ -82,6 +82,14 @@ class Currency implements FieldConverter
->withDbType($dbType)
->withParam(AttributeParam::PRECISION, $precision)
->withParam(AttributeParam::SCALE, $scale);
$defaultValue = $fieldDefs->getParam(AttributeParam::DEFAULT);
if (is_int($defaultValue) || is_float($defaultValue)) {
$defaultValue = number_format($defaultValue, $scale, '.', '');
$amountDefs = $amountDefs->withParam(AttributeParam::DEFAULT, $defaultValue);
}
}
if ($fieldDefs->isNotStorable()) {
@@ -61,6 +61,12 @@ class RelationConverter
RelationParam::INDEXES,
];
/** @var string[] */
private $manyMergeParams = [
RelationParam::ORDER_BY,
RelationParam::ORDER,
];
public function __construct(
private Metadata $metadata,
private InjectableFactory $injectableFactory,
@@ -112,7 +118,7 @@ class RelationConverter
$raw = $convertedEntityDefs->toAssoc();
if (isset($raw[EntityParam::RELATIONS][$name])) {
$this->mergeParams($raw[EntityParam::RELATIONS][$name], $params, $foreignParams ?? []);
$this->mergeParams($raw[EntityParam::RELATIONS][$name], $params, $foreignParams ?? [], $linkType);
$this->correct($raw[EntityParam::RELATIONS][$name]);
}
@@ -172,9 +178,18 @@ class RelationConverter
* @param array<string, mixed> $params
* @param array<string, mixed> $foreignParams
*/
private function mergeParams(array &$relationDefs, array $params, array $foreignParams): void
private function mergeParams(array &$relationDefs, array $params, array $foreignParams, string $linkType): void
{
foreach ($this->mergeParams as $name) {
$mergeParams = $this->mergeParams;
if (
$linkType === RelationType::HAS_MANY ||
$linkType === RelationType::HAS_CHILDREN
) {
$mergeParams = array_merge($mergeParams, $this->manyMergeParams);
}
foreach ($mergeParams as $name) {
$additionalParam = $this->getMergedParam($name, $params, $foreignParams);
if ($additionalParam === null) {
@@ -227,6 +242,13 @@ class RelationConverter
*/
private function correct(array &$relationDefs): void
{
if (
isset($relationDefs[RelationParam::ORDER]) &&
is_string($relationDefs[RelationParam::ORDER])
) {
$relationDefs[RelationParam::ORDER] = strtoupper($relationDefs[RelationParam::ORDER]);
}
if (!isset($relationDefs[RelationParam::ADDITIONAL_COLUMNS])) {
return;
}
@@ -1,5 +1,5 @@
[
{"name":"name","link":true, "width": "40"},
{"name":"name", "link":true, "width": 40},
{"name":"account"},
{"name":"emailAddress", "notSortable": true}
]
@@ -93,4 +93,18 @@ class RelationParam
* Deferred load.
*/
public const DEFERRED_LOAD = 'deferredLoad';
/**
* Default order by. Applied on the entity level.
*
* @since 9.2.5
*/
public const ORDER_BY = 'orderBy';
/**
* Default order. Applied on the entity level.
*
* @since 9.2.5
*/
public const ORDER = 'order';
}
+23 -15
View File
@@ -32,12 +32,14 @@ namespace Espo\ORM\Relation;
use Espo\ORM\BaseEntity;
use Espo\ORM\Defs\Defs;
use Espo\ORM\Defs\Params\RelationParam;
use Espo\ORM\Defs\RelationDefs;
use Espo\ORM\Entity;
use Espo\ORM\EntityCollection;
use Espo\ORM\EntityManager;
use Espo\ORM\Mapper\RDBMapper;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\Part\Order;
use Espo\ORM\Repository\RDBRelationSelectBuilder;
use Espo\ORM\Type\RelationType;
use LogicException;
use RuntimeException;
@@ -293,22 +295,11 @@ class RDBRelations implements Relations
->getEntity($this->getEntity()->getEntityType())
->getRelation($relation);
$orderBy = null;
$order = null;
$builder = $this->entityManager
->getRelation($this->getEntity(), $relation)
->createBuilder();
if ($relationDefs->getParam('orderBy')) {
$orderBy = $relationDefs->getParam('orderBy');
if ($relationDefs->getParam('order')) {
$order = strtoupper($relationDefs->getParam('order')) === Order::DESC ? Order::DESC : Order::ASC;
}
}
$builder = $this->entityManager->getRelation($this->getEntity(), $relation);
if ($orderBy) {
$builder->order($orderBy, $order);
}
$this->applyOrder($relationDefs, $builder);
$collection = $builder->find();
@@ -398,4 +389,21 @@ class RDBRelations implements Relations
return $foreignEntity;
}
private function applyOrder(RelationDefs $relationDefs, RDBRelationSelectBuilder $builder): void
{
$orderBy = $relationDefs->getParam(RelationParam::ORDER_BY);
if (!$orderBy) {
return;
}
$order = $relationDefs->getParam(RelationParam::ORDER);
if ($order !== null) {
$order = strtoupper($order) === Order::DESC ? Order::DESC : Order::ASC;
}
$builder->order($orderBy, $order);
}
}
@@ -51,7 +51,7 @@ use RuntimeException;
/**
* An access point for a specific relation of a record.
*
* @template TEntity of Entity
* @template TEntity of Entity = Entity
*/
class RDBRelation
{
@@ -95,6 +95,18 @@ class RDBRelation
}
}
/**
* Create a select builder.
*
* @return Builder<TEntity>
*
* @since 9.2.5
*/
public function createBuilder(): Builder
{
return $this->createSelectBuilder();
}
/**
* Create a select builder.
*
@@ -53,7 +53,7 @@ use InvalidArgumentException;
/**
* Builds select parameters for related records for RDB repository.
*
* @template TEntity of Entity
* @template TEntity of Entity = Entity
*/
class RDBRelationSelectBuilder
{
@@ -234,7 +234,7 @@ class RDBRepository implements Repository
*/
public function find(): EntityCollection|SthCollection
{
return $this->createSelectBuilder()->find();
return $this->createBuilder()->find();
}
/**
@@ -273,7 +273,7 @@ class RDBRepository implements Repository
*/
public function count(): int
{
return $this->createSelectBuilder()->count();
return $this->createBuilder()->count();
}
/**
@@ -283,7 +283,7 @@ class RDBRepository implements Repository
*/
public function max(string $attribute)
{
return $this->createSelectBuilder()->max($attribute);
return $this->createBuilder()->max($attribute);
}
/**
@@ -293,7 +293,7 @@ class RDBRepository implements Repository
*/
public function min(string $attribute)
{
return $this->createSelectBuilder()->min($attribute);
return $this->createBuilder()->min($attribute);
}
/**
@@ -303,7 +303,7 @@ class RDBRepository implements Repository
*/
public function sum(string $attribute)
{
return $this->createSelectBuilder()->sum($attribute);
return $this->createBuilder()->sum($attribute);
}
/**
@@ -334,7 +334,7 @@ class RDBRepository implements Repository
*/
public function join($target, ?string $alias = null, $conditions = null): RDBSelectBuilder
{
return $this->createSelectBuilder()->join($target, $alias, $conditions);
return $this->createBuilder()->join($target, $alias, $conditions);
}
/**
@@ -348,7 +348,7 @@ class RDBRepository implements Repository
*/
public function leftJoin($target, ?string $alias = null, $conditions = null): RDBSelectBuilder
{
return $this->createSelectBuilder()->leftJoin($target, $alias, $conditions);
return $this->createBuilder()->leftJoin($target, $alias, $conditions);
}
/**
@@ -358,7 +358,7 @@ class RDBRepository implements Repository
*/
public function distinct(): RDBSelectBuilder
{
return $this->createSelectBuilder()->distinct();
return $this->createBuilder()->distinct();
}
/**
@@ -368,7 +368,7 @@ class RDBRepository implements Repository
*/
public function forUpdate(): RDBSelectBuilder
{
return $this->createSelectBuilder()->forUpdate();
return $this->createBuilder()->forUpdate();
}
/**
@@ -378,7 +378,7 @@ class RDBRepository implements Repository
*/
public function sth(): RDBSelectBuilder
{
return $this->createSelectBuilder()->sth();
return $this->createBuilder()->sth();
}
/**
@@ -395,7 +395,7 @@ class RDBRepository implements Repository
*/
public function where($clause = [], $value = null): RDBSelectBuilder
{
return $this->createSelectBuilder()->where($clause, $value);
return $this->createBuilder()->where($clause, $value);
}
/**
@@ -412,7 +412,7 @@ class RDBRepository implements Repository
*/
public function having($clause = [], $value = null): RDBSelectBuilder
{
return $this->createSelectBuilder()->having($clause, $value);
return $this->createBuilder()->having($clause, $value);
}
/**
@@ -432,7 +432,7 @@ class RDBRepository implements Repository
*/
public function order($orderBy = Attribute::ID, $direction = null): RDBSelectBuilder
{
return $this->createSelectBuilder()->order($orderBy, $direction);
return $this->createBuilder()->order($orderBy, $direction);
}
/**
@@ -442,7 +442,7 @@ class RDBRepository implements Repository
*/
public function limit(?int $offset = null, ?int $limit = null): RDBSelectBuilder
{
return $this->createSelectBuilder()->limit($offset, $limit);
return $this->createBuilder()->limit($offset, $limit);
}
/**
@@ -462,7 +462,7 @@ class RDBRepository implements Repository
*/
public function select($select = [], ?string $alias = null): RDBSelectBuilder
{
return $this->createSelectBuilder()->select($select, $alias);
return $this->createBuilder()->select($select, $alias);
}
/**
@@ -479,15 +479,17 @@ class RDBRepository implements Repository
*/
public function group($groupBy): RDBSelectBuilder
{
return $this->createSelectBuilder()->group($groupBy);
return $this->createBuilder()->group($groupBy);
}
/**
* Create a select builder.
*
* @return RDBSelectBuilder<TEntity>
*
* @since 9.2.5
*/
protected function createSelectBuilder(): RDBSelectBuilder
public function createBuilder(): RDBSelectBuilder
{
/** @var RDBSelectBuilder<TEntity> $builder */
$builder = new RDBSelectBuilder($this->entityManager, $this->entityType);
@@ -495,6 +497,19 @@ class RDBRepository implements Repository
return $builder;
}
/**
* Create a select builder.
*
* @return RDBSelectBuilder<TEntity>
*
* @deprecated Since v9.2.5. Use `createBuilder` instead.
* @todo Remove in v10.0.
*/
protected function createSelectBuilder(): RDBSelectBuilder
{
return $this->createBuilder();
}
/**
* Use hooks instead.
*
@@ -1,6 +1,6 @@
[
{"name":"action", "width": "18", "notSortable": true},
{"name":"targetType", "width": "20", "notSortable": true},
{"name":"action", "width": 18, "notSortable": true},
{"name":"targetType", "width": 20, "notSortable": true},
{"name":"target", "notSortable": true},
{"name":"createdAt", "width": "18", "align": "right", "notSortable": true}
]
{"name":"createdAt", "width": 18, "align": "right", "notSortable": true}
]
@@ -1,7 +1,7 @@
[
{"name":"user"},
{"name":"isActive", "widthPx": "100"},
{"name":"ipAddress", "width": "17"},
{"name":"createdAt", "width": "19"},
{"name":"lastAccess", "width": "19"}
{"name":"isActive", "widthPx": 100},
{"name":"ipAddress", "width": 17},
{"name":"createdAt", "width": 19},
{"name":"lastAccess", "width": 19}
]
@@ -1,7 +1,7 @@
[
{"name":"user"},
{"name":"isActive", "widthPx": "100"},
{"name":"ipAddress", "width": "17"},
{"name":"createdAt", "width": "19"},
{"name":"lastAccess", "width": "19"}
{"name":"isActive", "widthPx": 100},
{"name":"ipAddress", "width": 17},
{"name":"createdAt", "width": 19},
{"name":"lastAccess", "width": 19}
]
@@ -1,5 +1,5 @@
[
{"name":"target", "notSortable": true},
{"name":"isCreated", "width": "25", "notSortable": true},
{"name":"createdAt", "width": "30", "notSortable": true}
{"name":"isCreated", "width": 25, "notSortable": true},
{"name":"createdAt", "width": 30, "notSortable": true}
]
@@ -31,10 +31,14 @@ namespace Espo\Tools\App\Metadata;
class AclDependencyItem
{
/**
* @param ?string[] $anyScopeList
*/
public function __construct(
private string $target,
private string $scope,
private ?string $field
private ?string $scope,
private ?string $field,
private ?array $anyScopeList = null,
) {}
/**
@@ -45,7 +49,7 @@ class AclDependencyItem
return $this->target;
}
public function getScope(): string
public function getScope(): ?string
{
return $this->scope;
}
@@ -54,4 +58,13 @@ class AclDependencyItem
{
return $this->field;
}
/**
* @return ?string[]
* @since 9.2.5
*/
public function getAnyScopeList(): ?array
{
return $this->anyScopeList;
}
}
@@ -95,15 +95,13 @@ class AclDependencyProvider
$data = [];
foreach (($this->metadata->get(['app', 'metadata', 'aclDependencies']) ?? []) as $target => $item) {
$anyScopeList = $item['anyScopeList'] ?? null;
$scope = $item['scope'] ?? null;
$field = $item['field'] ?? null;
if (!$scope) {
continue;
}
$data[] = [
'target' => $target,
'anyScopeList' => $anyScopeList,
'scope' => $scope,
'field' => $field,
];
@@ -196,8 +194,14 @@ class AclDependencyProvider
$target = $rawItem['target'] ?? null;
$scope = $rawItem['scope'] ?? null;
$field = $rawItem['field'] ?? null;
$anyScopeList = $rawItem['anyScopeList'] ?? null;
$list[] = new AclDependencyItem($target, $scope, $field);
$list[] = new AclDependencyItem(
target: $target,
scope: $scope,
field: $field,
anyScopeList: $anyScopeList,
);
}
return $list;
+22 -10
View File
@@ -178,20 +178,32 @@ class MetadataService
foreach ($this->aclDependencyProvider->get() as $dependencyItem) {
$aclScope = $dependencyItem->getScope();
$aclField = $dependencyItem->getField();
$anyScopeList = $dependencyItem->getAnyScopeList();
if (!$aclScope) {
continue;
if ($anyScopeList) {
$skip = true;
foreach ($anyScopeList as $itemScope) {
if ($this->acl->tryCheck($itemScope)) {
$skip = false;
break;
}
}
if ($skip) {
continue;
}
}
if (!$this->acl->tryCheck($aclScope)) {
continue;
}
if ($aclScope) {
if (!$this->acl->tryCheck($aclScope)) {
continue;
}
if (
$aclField &&
in_array($aclField, $this->acl->getScopeForbiddenFieldList($aclScope))
) {
continue;
if ($aclField && in_array($aclField, $this->acl->getScopeForbiddenFieldList($aclScope))) {
continue;
}
}
$targetArr = explode('.', $dependencyItem->getTarget());
@@ -114,7 +114,7 @@ class Erasor implements
$this->emailAddressAccessChecker
->checkEdit($this->user, $emailAddress, $entity)
) {
$emailAddress->set(Field::NAME, 'ERASED:' . $emailAddress->id);
$emailAddress->set(Field::NAME, 'ERASED:' . $emailAddress->getId());
$emailAddress->set('optOut', true);
$this->entityManager->saveEntity($emailAddress);
}
@@ -132,7 +132,7 @@ class Erasor implements
$this->phoneNumberAccessChecker
->checkEdit($this->user, $phoneNumber, $entity)
) {
$phoneNumber->set(Field::NAME, 'ERASED:' . $phoneNumber->id);
$phoneNumber->set(Field::NAME, 'ERASED:' . $phoneNumber->getId());
$this->entityManager->saveEntity($phoneNumber);
}
@@ -121,7 +121,11 @@ class RecordService
$ids = [];
$actionIds = [];
foreach ($collection as $entity) {
foreach ($collection as $i => $entity) {
if ($i === $limit) {
break;
}
$ids[] = $entity->getId();
$groupedCount = null;
@@ -389,7 +389,7 @@ class UserRecordService
[
'entityType:' => 'parentType',
'entityId:' => 'parentId',
'subscription.userId' => $user->getId(),
'subscriptionExclude.userId' => $user->getId(),
]
)
->where([
@@ -48,7 +48,7 @@ class UrlValidator
{
$siteUrl = rtrim($this->config->get('siteUrl') ?? '', '/');
if (str_starts_with($url, $siteUrl)) {
if (UrlValidatorUtil::validate($url, $siteUrl)) {
return;
}
@@ -60,7 +60,7 @@ class UrlValidator
foreach ($portals as $portal) {
$siteUrl = rtrim($portal->getUrl() ?? '', '/');
if (str_starts_with($url, $siteUrl)) {
if (UrlValidatorUtil::validate($url, $siteUrl)) {
return;
}
}
@@ -0,0 +1,59 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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\UserSecurity\Password\Recovery;
use const FILTER_VALIDATE_URL;
use const PHP_URL_HOST;
/**
* @internal
*/
class UrlValidatorUtil
{
public static function validate(string $url, string $siteUrl): bool
{
$host = parse_url($url, PHP_URL_HOST);
$siteHost = parse_url($siteUrl, PHP_URL_HOST);
if ($host !== $siteHost) {
return false;
}
if (!filter_var($url, FILTER_VALIDATE_URL)) {
return false;
}
if (!str_starts_with($url, $siteUrl)) {
return false;
}
return true;
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
<div>
{{#if isFirst}}
{{#unless rowActionsDisabled}}
<div class="pull-right item-menu-container fix-overflow">{{{../itemMenu}}}</div>
<div class="pull-right item-menu-container fix-position">{{{../itemMenu}}}</div>
{{/unless}}
{{/if}}
<div class="form-group">
+1 -1
View File
@@ -34,7 +34,7 @@
data-action="selectPreset"
>
<div class="{{#if style}}text-{{style}}{{/if}}">
{{#if label}}{{label}}{{else}}{{translate name category='presetFilters' scope=../entityType}}{{/if}}
{{~#if label}}{{label}}{{else}}{{translate name category='presetFilters' scope=../entityType}}{{/if~}}
</div>
</a>
</li>
+8
View File
@@ -691,6 +691,14 @@ class App {
if (!className) {
const module = this.metadata.get(`scopes.${name}.module`);
if (!/^[A-Za-z0-9]+$/.test(name)) {
console.error(`Bad controller name ${name}.`);
this.baseController.error404();
return;
}
className = Utils.composeClassName(module, name, 'controllers');
}
+1 -1
View File
@@ -174,7 +174,7 @@ class Collection {
parentModel
/**
* @param {Model[]|null} [models] Models.
* @param {Model[]|Record<string, *>[]|null} [models] Models.
* @param {{
* entityType?: string,
* model?: Model.prototype,
+7 -2
View File
@@ -763,7 +763,7 @@
}
console.warn(`Could not obtain ${id}.`);
});
}, errorCallback);
return;
}
@@ -792,9 +792,10 @@
* @private
* @param {string} url
* @param {function} callback
* @param {function|null} [errorCallback]
* @return {Promise}
*/
_addScript(url, callback) {
_addScript(url, callback, errorCallback = null) {
const script = document.createElement('script');
script.src = url;
@@ -802,6 +803,10 @@
script.addEventListener('error', e => {
console.error(`Could not load script '${url}'.`, e);
if (errorCallback) {
errorCallback();
}
});
document.head.appendChild(script);
+11 -5
View File
@@ -112,8 +112,14 @@ function uiAppInit() {
const $dashletBody = $(target).closest('.dashlet-body');
if ($dashletBody.length) {
const $body = $dashletBody;
const fixPosition = e.target.parentElement.classList.contains('fix-position');
if ($dashletBody.length || fixPosition) {
let $body = $dashletBody;
if (fixPosition) {
$body = $(window);
}
$(target).removeClass('dropup');
@@ -152,10 +158,10 @@ function uiAppInit() {
right: 'auto',
});
return;
//return;
}
if (e.target.parentElement.classList.contains('fix-overflow')) {
/*if (e.target.parentElement.classList.contains('fix-overflow')) {
$(target).removeClass('dropup');
const isRight = e.target.classList.contains('pull-right');
@@ -187,7 +193,7 @@ function uiAppInit() {
left: left,
right: 'auto',
});
}
}*/
});
}
@@ -420,7 +420,38 @@ class EmailAddressVarcharFieldView extends BaseFieldView {
return true;
}
/**
*
* @param {string} address
* @param {string} name
* @param {string} [type]
* @param {string} [id]
*/
addAddress(address, name, type, id) {
if (name === '') {
const nameHash = this.model.attributes.nameHash ?? {};
if (address in nameHash) {
name = nameHash[address];
}
}
if (type === undefined) {
const typeHash = this.model.attributes.typeHash ?? {};
if (address in typeHash) {
type = typeHash[address];
}
}
if (id === undefined) {
const idHash = this.model.attributes.idHash ?? {};
if (address in idHash) {
id = idHash[address];
}
}
if (this.justAddedAddress) {
this.deleteAddress(this.justAddedAddress);
}
@@ -482,9 +513,9 @@ class EmailAddressVarcharFieldView extends BaseFieldView {
$text.append(
$('<span>').text(name),
' ',
'<span class="no-select"> </span>',
$('<span>').addClass('text-muted middle-dot'),
' '
'<span class="no-select"> </span>'
);
}
+4
View File
@@ -256,6 +256,10 @@ class ArrayFieldView extends BaseFieldView {
this.allowCustomOptions = true;
}
if (this.params.allowCustomOptions === false) {
this.allowCustomOptions = false;
}
if (this.type === 'array') {
this.validations.push('noInputValue')
}
+8 -1
View File
@@ -100,6 +100,13 @@ class CurrencyFieldView extends FloatFieldView {
'range',
]
/**
* @protected
* @type {string}
* @since 9.2.6
*/
currencyAttribute
/** @inheritDoc */
data() {
const currencyValue = this.model.get(this.currencyFieldName) ||
@@ -123,7 +130,7 @@ class CurrencyFieldView extends FloatFieldView {
setup() {
super.setup();
this.currencyFieldName = this.name + 'Currency';
this.currencyFieldName = this.currencyAttribute ?? this.name + 'Currency';
this.defaultCurrency = this.getConfig().get('defaultCurrency');
this.currencyList = this.getConfig().get('currencyList') || [this.defaultCurrency];
this.decimalPlaces = this.getConfig().get('currencyDecimalPlaces');
+3 -9
View File
@@ -298,17 +298,11 @@ class IntFieldView extends BaseFieldView {
* @return {number|null}
*/
getMinValue() {
let minValue = this.model.getFieldParam(this.name, 'min') ?? null;
if (minValue != null) {
minValue = null;
}
if ('min' in this.params) {
minValue = this.params.min;
return this.params.min;
}
return minValue;
return this.model.getFieldParam(this.name, 'min') ?? null;
}
// noinspection JSUnusedGlobalSymbols
@@ -34,6 +34,11 @@ class LinkCategoryTreeFieldView extends LinkFieldView {
autocompleteDisabled = false
getUrl() {
if (this.getMetadata().get(`scopes.${this.entityType}.type`) === 'CategoryTree') {
// Can be used for the 'parent' field of the category entity type.
return super.getUrl();
}
const id = this.model.get(this.idName);
if (!id) {
+28 -9
View File
@@ -217,6 +217,13 @@ class LinkFieldView extends BaseFieldView {
*/
linkClass
/**
* @protected
* @type {string}
* @since 9.2.5
*/
foreignNameAttribute
/** @inheritDoc */
events = {
/** @this LinkFieldView */
@@ -364,6 +371,10 @@ class LinkFieldView extends BaseFieldView {
this.model.getFieldParam(this.name, 'entity') ||
this.model.getLinkParam(this.name, 'entity');
this.foreignNameAttribute = this.model.getLinkParam(this.name, 'foreignName') ??
this.getMetadata().get(`clientDefs.${this.foreignScope}.nameAttribute`) ??
'name';
if ('createDisabled' in this.options) {
this.createDisabled = this.options.createDisabled;
}
@@ -407,12 +418,12 @@ class LinkFieldView extends BaseFieldView {
* @return {Promise|void}
*/
select(model) {
this.$elementName.val(model.get('name') || model.id);
this.$elementId.val(model.get('id'));
this.$elementName.val(model.get(this.foreignNameAttribute) || model.id);
this.$elementId.val(model.id);
if (this.mode === this.MODE_SEARCH) {
this.searchData.idValue = model.get('id');
this.searchData.nameValue = model.get('name') || model.id;
this.searchData.idValue = model.id;
this.searchData.nameValue = model.get(this.foreignNameAttribute) || model.id;
}
this.trigger('change');
@@ -611,7 +622,7 @@ class LinkFieldView extends BaseFieldView {
if (!this.forceSelectAllAttributes) {
const mandatorySelectAttributeList = this.getMandatorySelectAttributeList();
let select = ['id', 'name'];
let select = ['id', this.foreignNameAttribute];
if (mandatorySelectAttributeList) {
select = select.concat(mandatorySelectAttributeList);
@@ -824,12 +835,20 @@ class LinkFieldView extends BaseFieldView {
const list = [];
response.list.forEach(item => {
const name = item[this.foreignNameAttribute] || item.name || item.id;
const attributes = item;
if (this.foreignNameAttribute !== 'name') {
attributes[this.foreignNameAttribute] = name;
}
list.push({
id: item.id,
name: item.name || item.id,
name: name,
data: item.id,
value: item.name || item.id,
attributes: item,
value: name,
attributes: attributes,
});
});
@@ -1365,7 +1384,7 @@ class LinkFieldView extends BaseFieldView {
*/
selectOneOf(models) {
models.forEach(model => {
this.addLinkOneOf(model.id, model.get('name'));
this.addLinkOneOf(model.id, model.get(this.foreignNameAttribute));
});
}
}
@@ -140,7 +140,7 @@ export default class extends ArrayFieldView {
const item = div.querySelector('.list-group-item');
const group = document.createElement('div');
group.classList.add('btn-group', 'pull-right');
group.classList.add('btn-group', 'pull-right', 'item-button');
const button = document.createElement('button');
button.classList.add('btn', 'btn-link', 'btn-sm', 'dropdown-toggle');
+10
View File
@@ -902,6 +902,16 @@ class ListWithCategories extends ListView {
Espo.Ui.notify();
}
/**
* @protected
*/
async actionFullRefresh() {
await Promise.all([
super.actionFullRefresh(),
this.nestedCategoriesCollection?.fetch(),
]);
}
}
export default ListWithCategories;
+1 -1
View File
@@ -42,7 +42,7 @@ class ComposeEmailModalView extends EditModalView {
shortcutKeys = {
/** @this ComposeEmailModalView */
'Control+Enter': function (e) {
if (this.buttonList.findIndex(item => item.name === 'send' && !item.hidden) === -1) {
if (this.buttonList.findIndex(item => item.name === 'send' && !item.hidden && !item.disabled) === -1) {
return;
}
+2 -2
View File
@@ -73,7 +73,7 @@ class EditModalView extends ModalView {
return;
}
if (this.buttonList.findIndex(item => item.name === 'save' && !item.hidden) === -1) {
if (this.buttonList.findIndex(item => item.name === 'save' && !item.hidden && !item.disabled) === -1) {
return;
}
@@ -93,7 +93,7 @@ class EditModalView extends ModalView {
return;
}
if (this.buttonList.findIndex(item => item.name === 'save' && !item.hidden) === -1) {
if (this.buttonList.findIndex(item => item.name === 'save' && !item.hidden && !item.disabled) === -1) {
return;
}
+1 -1
View File
@@ -253,7 +253,7 @@ class RelatedListModalView extends ModalView {
this.$header.append(
title ||
$('<span>').text(
this.getLanguage().translate(this.link, 'links', this.entityType)
this.getLanguage().translate(this.link, 'links', this.scope)
)
);
+5 -1
View File
@@ -148,7 +148,11 @@ class EditRecordView extends DetailRecordView {
this.wait(promise);
// @todo Revise. Possible race condition issues.
promise.then(() => super.setupBeforeFinal());
promise.then(() => {
super.setupBeforeFinal();
this.processDynamicLogic();
});
}
if (this.model.isNew()) {
+1 -1
View File
@@ -938,7 +938,7 @@ class SearchView extends View {
this.$el
.find('ul.filter-menu a.preset[data-name="'+presetName+'"]')
.prepend('<span class="fas fa-check pull-right"></span>');
.prepend('<span class="fas fa-check check-icon pull-right"></span>');
}
manageBoolFilters() {
@@ -139,12 +139,17 @@ export default class extends View {
const url = data.url || this.baseUrl;
const msg = this.translate('passwordChangedByRequest', 'messages', 'User') +
' <a href="' + url + '">' + this.translate('Login', 'labels', 'User') + '</a>.';
const a = document.createElement('a');
a.href = url;
a.innerText = this.translate('Login', 'labels', 'User');
const message = this.translate('passwordChangedByRequest', 'messages', 'User');
const html = this.getHelper().escapeString(message) + ' ' + a.outerHTML;
this.$el.find('.msg-box')
.removeClass('hidden')
.html('<span class="text-success">' + msg + '</span>');
.html('<span class="text-success">' + html + '</span>');
})
.catch(() => {
return $submit.removeClass('disabled');
+1 -1
View File
@@ -112,7 +112,7 @@ img.image-preview {
.preset {
text-overflow: ellipsis;
overflow: hidden !important;
//overflow: hidden !important;
}
.preset > div{
+4 -2
View File
@@ -8,6 +8,8 @@
padding: var(--5px) 0;
user-select: none;
min-width: calc(var(--50px) + var(--10px));
.autocomplete-suggestion.autocomplete-selected {
background-color: var(--dropdown-link-hover-bg) !important;
color: var(--dropdown-link-hover-color) !important;
@@ -204,11 +206,11 @@ ul.dropdown-menu {
margin-right: var(--2px);
}
.fix-overflow {
/*.fix-position {
.btn-group {
position: unset;
}
}
}*/
.dropdown-menu {
.quick-search-list-item {
+6
View File
@@ -708,6 +708,12 @@ select.form-control.native-select {
}
}
&:has(> .item-button.btn-group):has(> .drag-handle) {
> span.text {
width: calc(100% - var(--36px) - var(--26px));
}
}
> a[role="button"] {
margin-top: var(--1px);
margin-left: var(--2px);
+4
View File
@@ -213,6 +213,10 @@ small,
top: var(--minus-1px);
}
.no-select {
user-select: none;
}
a[role="button"],
a[href="javascript:"] {
&:not(.selectable) {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "espocrm",
"version": "9.2.4",
"version": "9.2.6",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "espocrm",
"version": "9.2.4",
"version": "9.2.6",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "espocrm",
"version": "9.2.4",
"version": "9.2.6",
"description": "Open-source CRM.",
"repository": {
"type": "git",
+4 -4
View File
@@ -44,21 +44,21 @@
},
"aclDependencies": {
"type": "object",
"description": "Rules making a metadata sections available for a user when they don't have access to a scope.",
"description": "Rules making a metadata sections available for the user when they don't have access to the scope.",
"additionalProperties": {
"description": "A metadata path, items are separated by dots.",
"properties": {
"scope": {
"type": "string",
"description": "If a user have access to the scope, they will have access to a metadata section defined by a key."
"description": "If the user has access to the scope, they will have access to the metadata section defined by the key."
},
"field": {
"type": "string",
"description": "If a user have access to the field (of a scope), they will have access to a metadata section defined by a key."
"description": "If the user has access to the field (of the scope), they will have access to the metadata section defined by the key."
},
"anyScopeList": {
"type": "array",
"description": "Not supported. TBD.",
"description": "If the user has access to any of the listed scopes, they will have access to the metadata section defined by the key. As of v9.2.5.",
"items": {
"type": "string"
}
+1 -1
View File
@@ -19,7 +19,7 @@
},
"accessCheckCommand": {
"type": "string",
"description": "A console command that will be used to check whether a topic is allowed for a user when they attempting to subscribe. Patameter placeholders starts with the colon :. The userId parameter contains a user ID. If access checking is not needed, omit this parameter."
"description": "A console command that will be used to check whether a topic is allowed for a user when they attempting to subscribe. Parameter placeholders starts with the colon :. The userId parameter contains a user ID. If access checking is not needed, omit this parameter."
}
}
}
+4
View File
@@ -986,6 +986,10 @@
"type": "boolean",
"description": "Disables the ability to customize the field in the Entity Manager tool."
},
"dynamicLogicDisabled": {
"type": "boolean",
"description": "Dynamic logic cannot be customized."
},
"fieldManagerParamList": {
"type": "array",
"items": {
@@ -29,8 +29,10 @@
namespace tests\integration\Espo\ORM;
use Espo\Modules\Crm\Entities\Contact;
use Espo\Modules\Crm\Entities\Lead;
use Espo\Modules\Crm\Entities\Task;
use Espo\ORM\Defs\Params\RelationParam;
use Espo\ORM\EntityCollection;
use Espo\Modules\Crm\Entities\Account;
use Espo\Modules\Crm\Entities\Opportunity;
@@ -232,4 +234,71 @@ class RelationsTest extends BaseTestCase
$this->assertEquals(null, $lead2->get('createdAccountId'));
}
public function testOrder(): void
{
$em = $this->getEntityManager();
$account1 = $em->createEntity(Account::ENTITY_TYPE, ['name' => 'a1']);
$account2 = $em->createEntity(Account::ENTITY_TYPE, ['name' => 'a2']);
$contact = $em->createEntity(Contact::ENTITY_TYPE);
$this->assertInstanceOf(Contact::class, $contact);
$em->getRelation($contact, 'accounts')->relate($account1);
$em->getRelation($contact, 'accounts')->relate($account2);
//
$metadata = $this->getMetadata();
$metadata->set('entityDefs', Contact::ENTITY_TYPE, [
'links' => [
'accounts' => [
RelationParam::ORDER_BY => 'name',
RelationParam::ORDER => 'desc',
]
]
]);
$metadata->save();
$this->reCreateApplication();
$em = $this->getEntityManager();
$contact = $em->getRDBRepositoryByClass(Contact::class)->getById($contact->getId());
$this->assertNotNull($contact);
$accounts = $contact->getAccounts();
$this->assertCount(2, $accounts);
$this->assertEquals('a2', $accounts[0]->getName());
$this->assertEquals('a1', $accounts[1]->getName());
//
$metadata = $this->getMetadata();
$metadata->set('entityDefs', Contact::ENTITY_TYPE, [
'links' => [
'accounts' => [
RelationParam::ORDER_BY => 'name',
RelationParam::ORDER => 'asc',
]
]
]);
$metadata->save();
$this->reCreateApplication();
$em = $this->getEntityManager();
$contact = $em->getRDBRepositoryByClass(Contact::class)->getById($contact->getId());
$this->assertNotNull($contact);
$accounts = $contact->getAccounts();
$this->assertCount(2, $accounts);
$this->assertEquals('a1', $accounts[0]->getName());
$this->assertEquals('a2', $accounts[1]->getName());
}
}
@@ -29,6 +29,7 @@
namespace tests\integration\Espo\Tools\App;
use Espo\Core\Utils\Metadata;
use Espo\Tools\App\MetadataService;
use tests\integration\Core\BaseTestCase;
@@ -36,6 +37,20 @@ class MetadataTest extends BaseTestCase
{
public function testAclDependency(): void
{
$metadata = $this->getContainer()->getByClass(Metadata::class);
$metadata->set('app', 'metadata', [
'aclDependencies' => [
'entityDefs.Campaign' => [
'anyScopeList' => ['Opportunity'],
],
],
]);
$metadata->save();
$this->reCreateApplication();
$this->createUser('tester', [
'data' => [
'Lead' => false,
@@ -55,6 +70,7 @@ class MetadataTest extends BaseTestCase
$data = $this->getInjectableFactory()->create(MetadataService::class)->getDataForFrontend();
$this->assertIsArray($data?->entityDefs?->Lead?->fields?->source?->options);
$this->assertNull($data->entityDefs->Lead->fields?->name ?? null);
$this->assertNull($data->entityDefs->Lead->fields->name ?? null);
$this->assertNotNull($data?->entityDefs->Campaign ?? null);
}
}
@@ -372,9 +372,8 @@ class ConverterTest extends TestCase
[
'test',
$alias,
[$alias . '.localId=:' => 'id'],
null,
[
'noLeftAlias' => true,
'onlyMiddle' => true,
'type' => JoinType::left,
],
@@ -0,0 +1,59 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2026 EspoCRM, Inc.
* 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 tests\unit\Espo\Tools\UserSecurity\Password;
use Espo\Tools\UserSecurity\Password\Recovery\UrlValidatorUtil;
use PHPUnit\Framework\TestCase;
class UrlValidatorUtilTest extends TestCase
{
public function testValidate(): void
{
$this->assertTrue(
UrlValidatorUtil::validate('https://test.com', 'https://test.com')
);
$this->assertTrue(
UrlValidatorUtil::validate('https://test.com/test', 'https://test.com')
);
$this->assertTrue(
UrlValidatorUtil::validate('https://test.com/test', 'https://test.com/test')
);
$this->assertFalse(
UrlValidatorUtil::validate('https://test.com.test', 'https://test.com')
);
$this->assertFalse(
UrlValidatorUtil::validate('https://test.com.test<test', 'https://test.com')
);
}
}