Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 917dbdd3fe | |||
| f645af4347 | |||
| be228fa48a | |||
| 5d443dda10 | |||
| 74db03090b | |||
| 03ac0a57c8 | |||
| 1f251eecb3 | |||
| 83439e5384 | |||
| 1df75fcece | |||
| 5ce2e0b6dc | |||
| 445ea1f240 | |||
| 94d2f6aabb | |||
| c426a637fe | |||
| 9e78d4cad3 | |||
| 08fecd2dc0 | |||
| 6dfbb08ce6 | |||
| 610c1004aa | |||
| 26ee35cb7d | |||
| e311ac6dbb | |||
| 9901070fb1 | |||
| 379a2d22f3 | |||
| fc7c949cf3 | |||
| f1f0f3ea9a | |||
| c1c4a2bedc | |||
| 876b74aefc | |||
| 6e2416dc56 | |||
| 33451a1118 | |||
| 90d7b99936 | |||
| 86215d0094 | |||
| 36f03f0a76 | |||
| a141a664c6 | |||
| 0a1b7a9817 | |||
| 47b9d35153 | |||
| 169e6b53be | |||
| 9935a6cd11 | |||
| 02f1e94c86 | |||
| 0d4d4dc9ed | |||
| d69cfd8b31 | |||
| 599646c397 | |||
| 4e0d5a2267 |
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -112,7 +112,7 @@ img.image-preview {
|
||||
|
||||
.preset {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden !important;
|
||||
//overflow: hidden !important;
|
||||
}
|
||||
|
||||
.preset > div{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -213,6 +213,10 @@ small,
|
||||
top: var(--minus-1px);
|
||||
}
|
||||
|
||||
.no-select {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
a[role="button"],
|
||||
a[href="javascript:"] {
|
||||
&:not(.selectable) {
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "espocrm",
|
||||
"version": "9.2.4",
|
||||
"version": "9.2.6",
|
||||
"description": "Open-source CRM.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user