Merge branch 'f/notificaiton-grouping'

This commit is contained in:
Yurii
2026-03-10 18:45:02 +02:00
32 changed files with 1514 additions and 130 deletions
@@ -30,6 +30,7 @@
namespace Espo\Controllers;
use Espo\Core\Name\Field;
use Espo\Entities\Notification as NotificationEntity;
use Espo\Tools\Notification\RecordService as Service;
use Espo\Core\Api\Request;
@@ -60,6 +61,7 @@ class Notification extends RecordBase
$maxSize = $searchParamsAux->getMaxSize();
$after = $request->getQueryParam('after');
$beforeNumber = $request->getQueryParam('beforeNumber');
$searchParams = SearchParams
::create()
@@ -76,19 +78,20 @@ class Notification extends RecordBase
->setValue($after)
->build()
);
}
$recordCollection = $this->getNotificationService()->get($this->user, $searchParams);
$recordCollection = $this->getNotificationService()->get($this->user, $searchParams, $beforeNumber);
return $recordCollection->toApiOutput();
}
/**
* @throws BadRequest
* @throws Forbidden
*/
public function getActionNotReadCount(): int
{
$userId = $this->user->getId();
return $this->getNotificationService()->getNotReadCount($userId);
return $this->getNotificationService()->getNotReadCount($this->user);
}
public function postActionMarkAllRead(Request $request): bool
@@ -0,0 +1,74 @@
<?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\Core\Upgrades\Migrations\V9_4;
use Espo\Core\Upgrades\Migration\Script;
use Espo\Entities\Preferences;
use Espo\Entities\User;
use Espo\ORM\EntityManager;
class AfterUpgrade implements Script
{
public function __construct(
private EntityManager $entityManager,
) {}
public function run(): void
{
$this->updatePreferences();
}
private function updatePreferences(): void
{
$users = $this->entityManager
->getRDBRepositoryByClass(User::class)
->sth()
->where([
User::ATTR_IS_ACTIVE => true,
User::ATTR_TYPE => [
User::TYPE_ADMIN,
User::TYPE_REGULAR,
User::TYPE_PORTAL,
]
])
->find();
foreach ($users as $user) {
$preferences = $this->entityManager->getRepositoryByClass(Preferences::class)->getById($user->getId());
if (!$preferences) {
continue;
}
$preferences->set('notificationGrouping', true);
$this->entityManager->saveEntity($preferences);
}
}
}
+10
View File
@@ -64,6 +64,16 @@ class Note extends Entity
public const TYPE_EMAIL_RECEIVED = 'EmailReceived';
public const TYPE_EMAIL_SENT = 'EmailSent';
public const string DATA_ATTR_ADDED_ASSIGNED_USERS = 'addedAssignedUsers';
public const string DATA_ATTR_REMOVED_ASSIGNED_USERS = 'removedAssignedUsers';
public const string DATA_ATTR_ASSIGNED_USERS = 'assignedUsers';
public const string DATA_ATTR_STATUS_VALUE = 'statusValue';
public const string DATA_ATTR_STATUS_FIELD = 'statusField';
public const string DATA_ATTR_ASSIGNED_USER_ID = 'assignedUserId';
public const string DATA_ATTR_ASSIGNED_USER_NAME = 'assignedUserName';
public const string DATA_ATTR_FIELDS = 'fields';
public const string DATA_ATTR_ATTRIBUTES = 'attributes';
private bool $aclIsProcessed = false;
public function isPost(): bool
@@ -54,6 +54,22 @@ class Notification extends Entity
public const ATTR_ACTION_ID = 'actionId';
public const ATTR_NUMBER = 'number';
public const string ATTR_RELATED_TYPE = 'relatedType';
public const string ATTR_RELATED_ID = 'relatedId';
public const string ATTR_RELATED_PARENT_TYPE = 'relatedParentType';
public const string ATTR_RELATED_PARENT_ID = 'relatedParentId';
public const string FIELD_DATA = 'data';
public const string FIELD_MESSAGE = 'message';
public const string FIELD_TYPE = 'type';
public const string FIELD_RELATED_PARENT = 'relatedParent';
public const string FIELD_IS_FEATURED = 'isFeatured';
public const string GROUP_TYPE_NOTE = Notification::TYPE_NOTE;
public const string GROUP_TYPE_EMAIL_RECEIVED = Notification::TYPE_EMAIL_RECEIVED;
public const string DATE_ATTR_NOTE_ID = 'noteId';
public function getType(): ?string
{
return $this->get('type');
@@ -128,6 +144,12 @@ class Notification extends Entity
return $this;
}
public function getRelatedParent(): ?LinkParent
{
/** @var ?LinkParent */
return $this->getValueObject('relatedParent');
}
public function setRelatedParent(LinkParent|Entity|null $relatedParent): self
{
if ($relatedParent instanceof LinkParent) {
@@ -185,8 +207,27 @@ class Notification extends Entity
return $this->get('actionId');
}
/**
* @internal
*/
public function setGroupedCount(?int $groupedCount): self
{
return $this->set('groupedCount', $groupedCount);
}
/**
* @internal
*/
public function getGroupType(): ?string
{
return $this->get('groupType');
}
/**
* @since 9.4.0
*/
public function setIsFeatured(bool $isFeatured): self
{
return $this->set(self::FIELD_IS_FEATURED, $isFeatured);
}
}
@@ -338,7 +338,8 @@
"Reaction Removed": "Reaction Removed",
"Reactions": "Reactions",
"Lock": "Lock",
"Unlock": "Unlock"
"Unlock": "Unlock",
"Assigned": "Assigned"
},
"messages": {
"checkLogsForDetails": "Check logs for details.",
@@ -566,7 +567,12 @@
"emailInbox": "{user} added email {entity} to your inbox",
"userPostReaction": "{user} reacted to your {post}",
"addedToCollaborators": "{user} added you as a collaborator to {entityType} {entity}",
"userPostInParentReaction": "{user} reacted to your {post} in {entityType} {entity}"
"userPostInParentReaction": "{user} reacted to your {post} in {entityType} {entity}",
"groupUpdatesMultiple": "{entityType} {entity} {number} new activities",
"groupUpdatesOne": "{entityType} {entity} new activity",
"groupUpdates": "{entityType} {entity}",
"groupEmailsReceivedNew": "Emails received {number} new",
"groupEmailsReceived": "Emails received"
},
"streamMessages": {
"post": "{user} posted on {entityType} {entity}",
@@ -17,6 +17,7 @@
"assignmentEmailNotificationsIgnoreEntityTypeList": "Email assignment notifications",
"reactionNotifications": "In-app notifications about reactions",
"reactionNotificationsNotFollowed": "Notifications about reactions for non-followed records",
"notificationGrouping": "Group notifications",
"autoFollowEntityTypeList": "Global Auto-Follow",
"signature": "Email Signature",
"dashboardTabList": "Tab List",
@@ -147,6 +147,10 @@
[
{"name": "reactionNotifications"},
{"name": "reactionNotificationsNotFollowed"}
],
[
{"name": "notificationGrouping"},
false
]
]
}
@@ -31,6 +31,10 @@
[
{"name": "reactionNotifications"},
{"name": "reactionNotificationsNotFollowed"}
],
[
{"name": "notificationGrouping"},
false
]
]
}
@@ -2,7 +2,8 @@
"controller": "controllers/notification",
"acl": "acl/notification",
"aclPortal": "acl-portal/notification",
"collection": "collections/note",
"model": "models/notification",
"collection": "collections/notification",
"itemViews": {
"System": "views/notification/items/system",
"EmailInbox": "views/notification/items/email-inbox"
@@ -46,12 +46,33 @@
"readOnly": true,
"index": true
},
"isFeatured": {
"type": "bool",
"readOnly": true
},
"groupedCount": {
"type": "int",
"notStorable": true,
"readOnly": true,
"utility": true
},
"groupType": {
"type": "enum",
"notStorable": true,
"readOnly": true,
"utility": true,
"options": [
"",
"Note",
"EmailReceived"
]
},
"groupedUnreadCount": {
"type": "int",
"notStorable": true,
"readOnly": true,
"utility": true
},
"createdBy": {
"type": "link",
"readOnly": true,
@@ -115,6 +115,10 @@
"type": "bool",
"default": false
},
"notificationGrouping": {
"type": "bool",
"default": true
},
"autoFollowEntityTypeList": {
"type": "multiEnum",
"view": "views/preferences/fields/auto-follow-entity-type-list",
+10
View File
@@ -355,6 +355,16 @@
"method": "get",
"actionClassName": "Espo\\Tools\\Notification\\Api\\GetGroup"
},
{
"route": "/Notification/group",
"method": "get",
"actionClassName": "Espo\\Tools\\Notification\\Api\\GetGroupAll"
},
{
"route": "/Notification/group/:id",
"method": "delete",
"actionClassName": "Espo\\Tools\\Notification\\Api\\DeleteGroup"
},
{
"route": "/EmailTemplate/:id/prepare",
"method": "post",
@@ -0,0 +1,56 @@
<?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\Notification\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
use Espo\Tools\Notification\GroupAllService;
/**
* @noinspection PhpUnused
*/
class DeleteGroup implements Action
{
public function __construct(
private GroupAllService $service,
) {}
public function process(Request $request): Response
{
$id = $request->getRouteParam('id') ?? throw new BadRequest();
$this->service->remove($id);
return ResponseComposer::json(true);
}
}
@@ -0,0 +1,79 @@
<?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\Notification\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Record\SearchParamsFetcher;
use Espo\Core\Select\Where\Item as WhereItem;
use Espo\Entities\Notification;
use Espo\Tools\Notification\GroupAllService;
/**
* @noinspection PhpUnused
*/
class GetGroupAll implements Action
{
public function __construct(
private GroupAllService $service,
private SearchParamsFetcher $searchParamsFetcher,
) {}
public function process(Request $request): Response
{
$type = $request->getQueryParam('type') ?? throw new BadRequest("No `type`.");
$id = $request->getQueryParam('id') ?? throw new BadRequest("No `id`.");
$searchParams = $this->searchParamsFetcher->fetch($request);
$beforeNumber = $request->getQueryParam('beforeNumber');
if ($beforeNumber) {
$searchParams = $searchParams
->withWhereAdded(
WhereItem
::createBuilder()
->setAttribute(Notification::ATTR_NUMBER)
->setType(WhereItem\Type::LESS_THAN)
->setValue($beforeNumber)
->build()
);
}
$collection = $this->service->get($type, $id, $searchParams);
return ResponseComposer::json(
$collection->toApiOutput()
);
}
}
@@ -0,0 +1,281 @@
<?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\Notification;
use Espo\Core\Acl;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Record\Collection as RecordCollection;
use Espo\Core\Select\SearchParams;
use Espo\Core\Select\SelectBuilderFactory;
use Espo\Entities\Notification;
use Espo\Entities\User;
use Espo\ORM\Collection;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\DeleteBuilder;
use Espo\ORM\Query\Part\Condition as Cond;
use Espo\ORM\Query\Part\Expression as Expr;
use Espo\ORM\Query\Select;
use Espo\ORM\Query\SelectBuilder;
class GroupAllService
{
public function __construct(
private EntityManager $entityManager,
private Acl $acl,
private User $user,
private RecordService $recordService,
private SelectBuilderFactory $selectBuilderFactory,
) {}
/**
* @return RecordCollection<Notification>
* @throws BadRequest
* @throws Forbidden
*/
public function get(string $type, string $groupId, SearchParams $searchParams): RecordCollection
{
$collection = null;
if ($type == Notification::GROUP_TYPE_NOTE) {
$collection = $this->getNote($groupId, $searchParams);
} else if ($type == Notification::GROUP_TYPE_EMAIL_RECEIVED) {
$collection = $this->getEmailReceived($searchParams);
}
if (!$collection) {
throw new BadRequest("Bad group.");
}
$this->markAsRead($collection);
return $collection;
}
/**
* @return RecordCollection<Notification>
* @throws BadRequest
* @throws Forbidden
*/
private function getNote(string $groupId, SearchParams $searchParams): RecordCollection
{
if (substr_count($groupId, '_') < 2) {
throw new BadRequest("Bad ID.");
}
[, $entityType, $id] = explode('_', $groupId, 3);
if (!$this->acl->checkScope($entityType)) {
/** @var Collection<Notification> $collection */
$collection = $this->entityManager->getCollectionFactory()->create(Notification::ENTITY_TYPE);
return RecordCollection::create($collection, 0);
}
$builder = $this->prepareBuilder($searchParams);
$query = $builder
->where([
Notification::FIELD_TYPE => Notification::TYPE_NOTE,
Notification::ATTR_RELATED_PARENT_TYPE => $entityType,
Notification::ATTR_RELATED_PARENT_ID => $id,
])
->build();
[$collection, $total] = $this->runQuery($query);
$collection = $this->recordService->prepareCollection($collection, $this->user);
return RecordCollection::create($collection, $total);
}
/**
* @return RecordCollection<Notification>
* @throws BadRequest
* @throws Forbidden
*/
private function getEmailReceived(SearchParams $searchParams): RecordCollection
{
$builder = $this->prepareBuilder($searchParams);
$query = $builder
->where([
Notification::FIELD_TYPE => Notification::TYPE_EMAIL_RECEIVED,
])
->build();
[$collection, $total] = $this->runQuery($query);
$collection = $this->recordService->prepareCollection($collection, $this->user);
return RecordCollection::create($collection, $total);
}
/**
* @return array{0: Collection<Notification>, 1: int}
*/
private function runQuery(Select $query): array
{
$collection = $this->entityManager
->getRDBRepositoryByClass(Notification::class)
->clone($query)
->find();
$total = $this->entityManager
->getRDBRepositoryByClass(Notification::class)
->clone($query)
->count();
return [$collection, $total];
}
/**
* @param string[] $ids
*/
private function markAsReadByIds(array $ids): void
{
if ($ids === []) {
return;
}
$query = $this->entityManager
->getQueryBuilder()
->update()
->in(Notification::ENTITY_TYPE)
->set([Notification::ATTR_READ => true])
->where([Notification::ATTR_USER_ID => $this->user->getId()])
->where(
Cond::in(Expr::column(Attribute::ID), $ids)
)
->build();
$this->entityManager->getQueryExecutor()->execute($query);
}
/**
* @param RecordCollection<Notification> $collection
*/
private function markAsRead(RecordCollection $collection): void
{
$ids = [];
foreach ($collection->getCollection() as $entity) {
$ids[] = $entity->getId();
}
$this->markAsReadByIds($ids);
}
/**
* @return SelectBuilder
* @throws BadRequest
* @throws Forbidden
*/
private function prepareBuilder(SearchParams $searchParams): SelectBuilder
{
return $this->selectBuilderFactory
->create()
->from(Notification::ENTITY_TYPE)
->withSearchParams($searchParams)
->withComplexExpressionsForbidden()
->buildQueryBuilder()
->where([
Notification::ATTR_USER_ID => $this->user->getId(),
]);
}
/**
* @throws BadRequest
*/
public function remove(string $groupId): void
{
if (substr_count($groupId, '_') < 1) {
throw new BadRequest("Bad ID.");
}
[$type,] = explode('_', $groupId, 2);
if ($type == Notification::GROUP_TYPE_NOTE) {
$this->removeNote($groupId);
return;
}
if ($type == Notification::GROUP_TYPE_EMAIL_RECEIVED) {
$this->removeEmailReceived();
return;
}
throw new BadRequest("Bad group type.");
}
/**
* @throws BadRequest
*/
private function removeNote(string $groupId): void
{
if (substr_count($groupId, '_') < 2) {
throw new BadRequest("Bad ID.");
}
[, $entityType, $id] = explode('_', $groupId, 3);
$query = DeleteBuilder::create()
->from(Notification::ENTITY_TYPE)
->where([
Notification::ATTR_USER_ID => $this->user->getId(),
])
->where([
Notification::FIELD_TYPE => Notification::TYPE_NOTE,
Notification::ATTR_RELATED_PARENT_TYPE => $entityType,
Notification::ATTR_RELATED_PARENT_ID => $id,
])
->build();
$this->entityManager->getQueryExecutor()->execute($query);
}
private function removeEmailReceived(): void
{
$query = DeleteBuilder::create()
->from(Notification::ENTITY_TYPE)
->where([
Notification::ATTR_USER_ID => $this->user->getId(),
])
->where([
Notification::FIELD_TYPE => Notification::TYPE_EMAIL_RECEIVED,
])
->build();
$this->entityManager->getQueryExecutor()->execute($query);
}
}
@@ -48,12 +48,35 @@ use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
use Espo\ORM\Query\Part\Condition as Cond;
use Espo\ORM\Query\Part\Expression as Expr;
use Espo\ORM\Query\Part\Order;
use Espo\ORM\Query\Part\Selection;
use Espo\ORM\Query\Part\WhereItem;
use Espo\ORM\Query\SelectBuilder;
use Espo\ORM\Query\Union;
use Espo\ORM\Query\UnionBuilder;
use Espo\ORM\Repository\RDBSelectBuilder;
use Espo\Tools\Stream\NoteAccessControl;
use Espo\Tools\User\PreferencesProvider;
use PDO;
use UnexpectedValueException;
class RecordService
{
private const string COLUMN_GROUP_TYPE = 'groupType';
private const string COLUMN_GROUP_UNREAD_COUNT = 'groupedUnreadCount';
/** @var string[] */
private array $noGroupAttributes = [
Attribute::DELETED,
Field::CREATED_BY . 'Id',
Notification::ATTR_ACTION_ID,
Notification::FIELD_DATA,
Notification::FIELD_MESSAGE,
Notification::FIELD_TYPE,
Notification::ATTR_RELATED_ID,
Notification::ATTR_RELATED_TYPE,
];
public function __construct(
private EntityManager $entityManager,
private Acl $acl,
@@ -61,6 +84,7 @@ class RecordService
private NoteAccessControl $noteAccessControl,
private SelectBuilderFactory $selectBuilderFactory,
private Config $config,
private PreferencesProvider $preferencesProvider,
) {}
/**
@@ -70,20 +94,14 @@ class RecordService
* @throws Error
* @throws BadRequest
* @throws Forbidden
*
* @internal
*/
public function get(User $user, SearchParams $searchParams): RecordCollection
public function get(User $user, SearchParams $searchParams, ?string $beforeNumber = null): RecordCollection
{
$queryBuilder = $this->selectBuilderFactory
->create()
->from(Notification::ENTITY_TYPE)
->withSearchParams($searchParams)
->buildQueryBuilder()
->where([Notification::ATTR_USER_ID => $user->getId()])
->order(Notification::ATTR_NUMBER, SearchParams::ORDER_DESC);
if ($this->isGroupingEnabled()) {
$queryBuilder->where($this->getActionIdWhere($user->getId()));
}
$queryBuilder = $this->isGroupingEnabled($user) ?
$this->prepareGroupingQueryBuilder($user, $searchParams, beforeNumber: $beforeNumber) :
$this->prepareQueryBuilder($user, $searchParams, beforeNumber: $beforeNumber);
$offset = $searchParams->getOffset();
$limit = $searchParams->getMaxSize();
@@ -92,23 +110,16 @@ class RecordService
$queryBuilder->limit($offset, $limit + 1);
}
$ignoreScopeList = $this->getIgnoreScopeList();
if ($ignoreScopeList !== []) {
$queryBuilder->where([
'OR' => [
'relatedParentType' => null,
'relatedParentType!=' => $ignoreScopeList,
],
]);
}
$query = $queryBuilder->build();
if ($query instanceof Union) {
$collection = $this->fetchAndPrepareCollectionFromUnion($query);
} else {
$collection = $this->entityManager
->getRDBRepositoryByClass(Notification::class)
->clone($query)
->find();
}
if (!$collection instanceof EntityCollection) {
throw new Error("Collection is not instance of EntityCollection.");
@@ -116,7 +127,7 @@ class RecordService
$collection = $this->prepareCollection($collection, $user);
$groupedCountMap = $this->getGroupedCountMap($collection, $user->getId());
$groupedCountMap = $this->getActionGroupedCountMap($collection, $user->getId());
$ids = [];
$actionIds = [];
@@ -126,11 +137,19 @@ class RecordService
break;
}
if (!$entity->getGroupType()) {
$ids[] = $entity->getId();
}
$groupedCount = null;
if ($entity->getActionId() && $this->isGroupingEnabled()) {
if ($this->isGroupingEnabled($user) && $entity->getGroupType()) {
$groupedCount = -1;
$entity->loadParentNameField(Notification::FIELD_RELATED_PARENT);
}
if ($entity->getActionId() && $this->isActionGroupingEnabled()) {
$actionIds[] = $entity->getActionId();
$groupedCount = $groupedCountMap[$entity->getActionId()] ?? 0;
@@ -252,33 +271,35 @@ class RecordService
$entity->set('noteData', $note->getValueMap());
}
public function getNotReadCount(string $userId): int
/**
* @throws BadRequest
* @throws Forbidden
*/
public function getNotReadCount(User $user): int
{
$whereClause = [
Notification::ATTR_USER_ID => $userId,
Notification::ATTR_READ => false,
];
$searchParams = SearchParams::create();
$ignoreScopeList = $this->getIgnoreScopeList();
$queryBuilder = $this->isGroupingEnabled($user) ?
$this->prepareGroupingQueryBuilder($user, $searchParams, true) :
$this->prepareQueryBuilder($user, $searchParams, true);
if (count($ignoreScopeList)) {
$whereClause[] = [
'OR' => [
'relatedParentType' => null,
'relatedParentType!=' => $ignoreScopeList,
]
];
$countQuery = $this->entityManager->getQueryBuilder()
->select()
->fromQuery($queryBuilder->build(), 'q')
->select('COUNT:(q.id)', 'c')
->build();
$sth = $this->entityManager->getQueryExecutor()->execute($countQuery);
$row = $sth->fetch(PDO::FETCH_ASSOC);
$count = $row['c'] ?? null;
if (!is_int($count)) {
throw new UnexpectedValueException();
}
$builder = $this->entityManager
->getRDBRepositoryByClass(Notification::class)
->where($whereClause);
if ($this->isGroupingEnabled()) {
$builder->where($this->getActionIdWhere($userId));
}
return $builder->count();
return $count;
}
public function markAllRead(string $userId): bool
@@ -422,9 +443,9 @@ class RecordService
* @param EntityCollection<Notification> $collection
* @return array<string, int>
*/
private function getGroupedCountMap(EntityCollection $collection, string $userId): array
private function getActionGroupedCountMap(EntityCollection $collection, string $userId): array
{
if (!$this->isGroupingEnabled()) {
if (!$this->isActionGroupingEnabled()) {
return [];
}
@@ -464,7 +485,7 @@ class RecordService
return $groupedCountMap;
}
private function isGroupingEnabled(): bool
private function isActionGroupingEnabled(): bool
{
// @todo Param in preferences?
return (bool) ($this->config->get('notificationGrouping') ?? true);
@@ -483,4 +504,394 @@ class RecordService
$entity->set('createdByName', $createdByName);
}
}
private function isGroupingEnabled(User $user): bool
{
return $this->preferencesProvider->tryGet($user->getId())?->get('notificationGrouping') ?? false;
}
/**
* @throws BadRequest
* @throws Forbidden
*/
private function prepareGroupingQueryBuilder(
User $user,
SearchParams $searchParams,
bool $notRead = false,
?string $beforeNumber = null,
): UnionBuilder {
$noteGroupBuilder = $this->prepareNoteGroupBuilder(
searchParams: $searchParams,
user: $user,
notRead: $notRead,
beforeNumber: $beforeNumber,
);
$emailGroupBuilder = $this->prepareEmailGroupBuilder(
searchParams: $searchParams,
user: $user,
notRead: $notRead,
beforeNumber: $beforeNumber,
);
$restBuilder = $this->prepareRestBuilder(
searchParams: $searchParams,
user: $user,
notRead: $notRead,
beforeNumber: $beforeNumber,
);
return UnionBuilder::create()
->query($noteGroupBuilder->build())
->query($emailGroupBuilder->build())
->query($restBuilder->build())
->order(Notification::ATTR_NUMBER, Order::DESC);
}
/**
* @param RDBSelectBuilder<Notification>|SelectBuilder $builder
*/
private function applyRelatedAccess(RDBSelectBuilder|SelectBuilder $builder): void
{
$ignoreScopeList = $this->getIgnoreScopeList();
if ($ignoreScopeList === []) {
return;
}
$builder->where([
'OR' => [
Notification::ATTR_RELATED_PARENT_TYPE => null,
Notification::ATTR_RELATED_PARENT_TYPE . '!=' => $ignoreScopeList,
],
]);
}
/**
* @return EntityCollection<Notification>
*/
private function fetchAndPrepareCollectionFromUnion(Union $query): EntityCollection
{
$sth = $this->entityManager->getQueryExecutor()->execute($query);
/** @var EntityCollection<Notification> $collection */
$collection = $this->entityManager->getCollectionFactory()->create(Notification::ENTITY_TYPE);
while ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
$entity = $this->entityManager->getRDBRepositoryByClass(Notification::class)->getNew();
$entity->setMultiple($row);
$entity->setAsFetched();
$collection[] = $entity;
}
return $collection;
}
/**
* @return Selection[]
*/
private function getNullNoGroupSelections(): array
{
return array_map(function ($attribute) {
return Selection::create(Expr::value(null), $attribute);
}, $this->noGroupAttributes);
}
/**
* @throws BadRequest
* @throws Forbidden
*/
private function prepareQueryBuilder(
User $user,
SearchParams $searchParams,
bool $notRead = false,
?string $beforeNumber = null,
): SelectBuilder {
$builder = $this->selectBuilderFactory
->create()
->from(Notification::ENTITY_TYPE)
->withSearchParams($searchParams)
->buildQueryBuilder()
->where([Notification::ATTR_USER_ID => $user->getId()])
->order(Notification::ATTR_NUMBER, SearchParams::ORDER_DESC);
if ($notRead) {
$builder->where([Notification::ATTR_READ => false]);
}
if ($beforeNumber) {
$builder->where([Notification::ATTR_NUMBER . '<' => $beforeNumber]);
}
$this->applyRelatedAccess($builder);
if ($this->isActionGroupingEnabled()) {
$builder->where($this->getActionIdWhere($user->getId()));
}
return $builder;
}
/**
* @throws BadRequest
* @throws Forbidden
*/
private function prepareNoteGroupBuilder(
SearchParams $searchParams,
User $user,
bool $notRead,
?string $beforeNumber,
): SelectBuilder {
$builder = $this->selectBuilderFactory
->create()
->from(Notification::ENTITY_TYPE)
->withSearchParams($searchParams)
->buildQueryBuilder()
->select([
Selection::create(
Expr::value(Notification::GROUP_TYPE_NOTE),
self::COLUMN_GROUP_TYPE
),
Selection::create(
Expr::concat(
Expr::value(Notification::GROUP_TYPE_NOTE),
Expr::value('_'),
Expr::column(Notification::ATTR_RELATED_PARENT_TYPE),
Expr::value('_'),
Expr::column(Notification::ATTR_RELATED_PARENT_ID),
),
Attribute::ID
),
Selection::create(
Expr::max(Expr::column(Notification::ATTR_NUMBER)),
Notification::ATTR_NUMBER
),
Notification::ATTR_RELATED_PARENT_ID,
Notification::ATTR_RELATED_PARENT_TYPE,
Selection::create(
Expr::switch(
Expr::greater(Expr::sum(Expr::not(Expr::column(Notification::ATTR_READ))), 0),
Expr::value(false),
Expr::value(true),
),
Notification::ATTR_READ
),
Selection::create(
Expr::sum(
Expr::switch(
Expr::column(Notification::ATTR_READ),
Expr::value(0),
Expr::value(1),
),
),
self::COLUMN_GROUP_UNREAD_COUNT
),
Selection::create(
Expr::max(Expr::column(Field::CREATED_AT)),
Field::CREATED_AT,
),
Selection::create(
Expr::switch(
Expr::greater(
Expr::sum(
Expr::and(
Expr::not(Expr::column(Notification::ATTR_READ)),
Expr::column(Notification::FIELD_IS_FEATURED),
)
),
0
),
true,
false,
),
Notification::FIELD_IS_FEATURED
),
...$this->getNullNoGroupSelections(),
])
->where([
Notification::ATTR_RELATED_PARENT_ID . '!=' => null,
Notification::FIELD_TYPE => Notification::TYPE_NOTE,
])
->where([Notification::ATTR_USER_ID => $user->getId()])
->group(Notification::ATTR_RELATED_PARENT_ID)
->group(Notification::ATTR_RELATED_PARENT_TYPE)
->order(Notification::ATTR_NUMBER, Order::DESC);
if ($beforeNumber) {
$builder->having(
Cond::less(
Expr::max(Expr::column(Notification::ATTR_NUMBER)),
Expr::value($beforeNumber)
)
);
}
$this->applyRelatedAccess($builder);
if ($notRead) {
$builder->where([Notification::ATTR_READ => false]);
}
return $builder;
}
/**
* @throws BadRequest
* @throws Forbidden
*/
private function prepareEmailGroupBuilder(
SearchParams $searchParams,
User $user,
bool $notRead,
?string $beforeNumber,
): SelectBuilder {
$builder = $this->selectBuilderFactory
->create()
->from(Notification::ENTITY_TYPE)
->withSearchParams($searchParams)
->buildQueryBuilder()
->select([
Selection::create(
Expr::value(Notification::GROUP_TYPE_EMAIL_RECEIVED),
self::COLUMN_GROUP_TYPE
),
Selection::create(
Expr::concat(
Expr::value(Notification::GROUP_TYPE_EMAIL_RECEIVED),
Expr::value('_'),
),
Attribute::ID
),
Selection::create(
Expr::max(Expr::column(Notification::ATTR_NUMBER)),
Notification::ATTR_NUMBER
),
Selection::create(
Expr::value(null),
Notification::ATTR_RELATED_PARENT_ID,
),
Selection::create(
Expr::value(null),
Notification::ATTR_RELATED_PARENT_TYPE,
),
Selection::create(
Expr::switch(
Expr::greater(Expr::sum(Expr::not(Expr::column(Notification::ATTR_READ))), 0),
Expr::value(false),
Expr::value(true),
),
Notification::ATTR_READ
),
Selection::create(
Expr::sum(
Expr::switch(
Expr::column(Notification::ATTR_READ),
Expr::value(0),
Expr::value(1),
),
),
self::COLUMN_GROUP_UNREAD_COUNT
),
Selection::create(
Expr::max(Expr::column(Field::CREATED_AT)),
Field::CREATED_AT,
),
Selection::create(
Expr::value(null),
Notification::FIELD_IS_FEATURED,
),
...$this->getNullNoGroupSelections(),
])
->where([
Notification::FIELD_TYPE => Notification::TYPE_EMAIL_RECEIVED,
])
->where([Notification::ATTR_USER_ID => $user->getId()])
->group(Notification::FIELD_TYPE)
->order(Notification::ATTR_NUMBER, Order::DESC);
if ($beforeNumber) {
$builder->having(
Cond::less(
Expr::max(Expr::column(Notification::ATTR_NUMBER)),
Expr::value($beforeNumber)
)
);
}
if ($notRead) {
$builder->where([Notification::ATTR_READ => false]);
}
return $builder;
}
/**
* @throws BadRequest
* @throws Forbidden
*/
private function prepareRestBuilder(
SearchParams $searchParams,
User $user,
bool $notRead,
?string $beforeNumber,
): SelectBuilder {
$builder = $this->selectBuilderFactory
->create()
->from(Notification::ENTITY_TYPE)
->withSearchParams($searchParams)
->buildQueryBuilder()
->select([
Selection::create(
Expr::value(null),
self::COLUMN_GROUP_TYPE,
),
Attribute::ID,
Notification::ATTR_NUMBER,
Notification::ATTR_RELATED_PARENT_ID,
Notification::ATTR_RELATED_PARENT_TYPE,
Notification::ATTR_READ,
Selection::create(
Expr::switch(
Expr::column(Notification::ATTR_READ),
Expr::value(0),
Expr::value(1),
),
self::COLUMN_GROUP_UNREAD_COUNT
),
Field::CREATED_AT,
Selection::create(
Expr::value(null),
Notification::FIELD_IS_FEATURED,
),
...$this->noGroupAttributes,
])
->where([
[
'OR' => [
Notification::FIELD_TYPE . '!=' => Notification::TYPE_NOTE,
Notification::ATTR_RELATED_PARENT_ID => null,
],
],
Notification::FIELD_TYPE . '!=' => Notification::TYPE_EMAIL_RECEIVED,
])
->where([Notification::ATTR_USER_ID => $user->getId()])
->order(Notification::ATTR_NUMBER, Order::DESC);
if ($notRead) {
$builder->where([Notification::ATTR_READ => false]);
}
if ($beforeNumber) {
$builder->where([Notification::ATTR_NUMBER . '<' => $beforeNumber]);
}
return $builder;
}
}
@@ -43,6 +43,7 @@ use Espo\Modules\Crm\Entities\CaseObj;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
use Espo\Tools\Notification\HookProcessor\Params;
use stdClass;
class Service
{
@@ -91,7 +92,7 @@ class Service
$collection = $this->entityManager->getCollectionFactory()->create();
$users = $this->entityManager
->getRDBRepository(User::ENTITY_TYPE)
->getRDBRepositoryByClass(User::class)
->select([
Attribute::ID,
User::ATTR_TYPE,
@@ -124,10 +125,11 @@ class Service
$actionId = $params?->actionId;
if (
in_array($note->getType(), [Note::TYPE_ASSIGN, Note::TYPE_CREATE]) &&
($note->getData()->assignedUserId ?? null) === $user->getId()
) {
$isFeatured = false;
if ($this->isUserTargetAssignee($note, $user)) {
$isFeatured = true;
// Do not group notifications about assignment.
$actionId = null;
}
@@ -137,7 +139,7 @@ class Service
$notification
->set(Attribute::ID, $this->idGenerator->generate())
->set(Field::CREATED_AT, $now)
->setData(['noteId' => $note->getId()])
->setData([Notification::DATE_ATTR_NOTE_ID => $note->getId()])
->setType(Notification::TYPE_NOTE)
->setUserId($user->getId())
->setRelated(LinkParent::fromEntity($note))
@@ -145,7 +147,8 @@ class Service
$note->getParentType() && $note->getParentId() ?
LinkParent::create($note->getParentType(), $note->getParentId()) : null
)
->setActionId($actionId);
->setActionId($actionId)
->setIsFeatured($isFeatured);
$collection[] = $notification;
}
@@ -184,4 +187,37 @@ class Service
return true;
}
private function isUserTargetAssignee(Note $note, User $user): bool
{
if (!in_array($note->getType(), [Note::TYPE_ASSIGN, Note::TYPE_CREATE])) {
return false;
}
$noteData = $note->getData();
if (($noteData->{Note::DATA_ATTR_ASSIGNED_USER_ID} ?? null) === $user->getId()) {
return true;
}
$assignedUsers = $noteData->{Note::DATA_ATTR_ASSIGNED_USERS} ??
$noteData->{Note::DATA_ATTR_ADDED_ASSIGNED_USERS} ?? null;
if (!is_array($assignedUsers)) {
return false;
}
foreach ($assignedUsers as $item) {
if (!$item instanceof stdClass) {
continue;
}
if (($item->{Attribute::ID} ?? null) === $user->getId()) {
return true;
}
}
return false;
}
}
+39 -29
View File
@@ -29,6 +29,7 @@
namespace Espo\Tools\Stream;
use Espo\Core\Acl\AssignmentChecker\Helper as AsssignmentHelper;
use Espo\Core\Field\DateTime;
use Espo\Core\Field\LinkMultiple;
use Espo\Core\Field\LinkParent;
@@ -105,7 +106,8 @@ class Service
private SelectBuilderFactory $selectBuilderFactory,
private UserAclManagerProvider $userAclManagerProvider,
private RecordServiceContainer $recordServiceContainer,
private SystemUser $systemUser
private SystemUser $systemUser,
private AsssignmentHelper $assignmentHelper,
) {}
private function getStatusField(string $entityType): ?string
@@ -593,27 +595,30 @@ class Service
$data = [];
if ($entity->get('assignedUserId')) {
$this->loadAssignedUserName($entity);
$data['assignedUserId'] = $entity->get('assignedUserId');
$data['assignedUserName'] = $entity->get('assignedUserName');
} else if (
if (
$entity instanceof CoreEntity &&
$entity->hasLinkMultipleField(self::FIELD_ASSIGNED_USERS) &&
$entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS) !== [] &&
// Exclude for Email as the assignedUsers serves not for direct assignment.
$entity->getEntityType() !== Email::ENTITY_TYPE
$entity->getEntityType() !== Email::ENTITY_TYPE &&
$this->assignmentHelper->hasAssignedUsersField($entity->getEntityType()) &&
$entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS) !== []
) {
/** @var LinkMultiple $users */
$users = $entity->getValueObject(self::FIELD_ASSIGNED_USERS);
$data['assignedUsers'] = array_map(function ($it) {
$data[Note::DATA_ATTR_ASSIGNED_USERS] = array_map(function ($it) {
return [
Attribute::ID => $it->getId(),
'name' => $it->getName(),
Field::NAME => $it->getName(),
];
}, $users->getList());
} else if (
$this->assignmentHelper->hasAssignedUserField($entity->getEntityType()) &&
$entity->get(Field::ASSIGNED_USER . 'Id')
) {
$this->loadAssignedUserName($entity);
$data[Note::DATA_ATTR_ASSIGNED_USER_ID] = $entity->get(Field::ASSIGNED_USER . 'Id');
$data[Note::DATA_ATTR_ASSIGNED_USER_NAME] = $entity->get(Field::ASSIGNED_USER . 'Name');
}
$field = $this->getStatusField($entityType);
@@ -622,12 +627,12 @@ class Service
$value = $entity->get($field);
if ($value) {
$data['statusValue'] = $value;
$data['statusField'] = $field;
$data[Note::DATA_ATTR_STATUS_VALUE] = $value;
$data[Note::DATA_ATTR_STATUS_FIELD] = $field;
}
}
$note->set('data', (object) $data);
$note->setData($data);
$noteOptions = [];
@@ -915,8 +920,8 @@ class Service
$note->setParent(LinkParent::fromEntity($entity));
$note->setData([
'fields' => $updatedFieldList,
'attributes' => [
Note::DATA_ATTR_FIELDS => $updatedFieldList,
Note::DATA_ATTR_ATTRIBUTES => [
'was' => (object) $was,
'became' => (object) $became,
],
@@ -1160,9 +1165,10 @@ class Service
{
if (
$entity instanceof CoreEntity &&
$entity->hasLinkMultipleField(self::FIELD_ASSIGNED_USERS) &&
$entity->getEntityType() !== Email::ENTITY_TYPE &&
// Exclude for Email as the assignedUsers serves not for direct assignment.
$entity->getEntityType() !== Email::ENTITY_TYPE
$this->assignmentHelper->hasAssignedUsersField($entity->getEntityType()) &&
$entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS) !== []
) {
$data = [];
@@ -1179,17 +1185,17 @@ class Service
$removedIds = array_values(array_diff($prevIds, $newIds));
$names = array_merge($prevNames, $newNames);
$data['addedAssignedUsers'] = array_map(function ($id) use ($names) {
$data[Note::DATA_ATTR_ADDED_ASSIGNED_USERS] = array_map(function ($id) use ($names) {
return [
'id' => $id,
'name' => $names[$id] ?? null,
Attribute::ID => $id,
Field::NAME => $names[$id] ?? null,
];
}, $addedIds);
$data['removedAssignedUsers'] = array_map(function ($id) use ($names) {
$data[Note::DATA_ATTR_REMOVED_ASSIGNED_USERS] = array_map(function ($id) use ($names) {
return [
'id' => $id,
'name' => $names[$id] ?? null,
Attribute::ID => $id,
Field::NAME => $names[$id] ?? null,
];
}, $removedIds);
@@ -1198,18 +1204,22 @@ class Service
return;
}
if ($entity->get('assignedUserId')) {
if (
$this->assignmentHelper->hasAssignedUserField($entity->getEntityType()) &&
$entity->get(Field::ASSIGNED_USER . 'Id')
) {
$this->loadAssignedUserName($entity);
$note->setData([
'assignedUserId' => $entity->get('assignedUserId'),
'assignedUserName' => $entity->get('assignedUserName'),
Note::DATA_ATTR_ASSIGNED_USER_ID => $entity->get(Field::ASSIGNED_USER . 'Id'),
Note::DATA_ATTR_ASSIGNED_USER_NAME => $entity->get(Field::ASSIGNED_USER . 'Name'),
]);
return;
}
$note->setData(['assignedUserId' => null]);
$note->setData([Note::DATA_ATTR_ASSIGNED_USER_ID => null]);
}
/**
@@ -1,6 +1,6 @@
<div class="notification-container">{{{notification}}}</div>
{{#if hasGrouped}}
<div class="notification-grouped">
<div class="notification-grouped list-container-panel">
{{#if isGroupExpanded}}
{{{groupedList}}}
{{else}}
+3
View File
@@ -32,6 +32,9 @@ import Collection from 'collection';
class NoteCollection extends Collection {
/**
* @type {boolean}
*/
paginationByNumber = false
/**
+34
View File
@@ -0,0 +1,34 @@
/************************************************************************
* 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.
************************************************************************/
import NoteCollection from 'collections/note';
export default class NotificationCollection extends NoteCollection {
paginationByNumber = true
}
+46
View File
@@ -0,0 +1,46 @@
/************************************************************************
* 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.
************************************************************************/
import Model from 'model';
export default class Notification extends Model {
/**
* @inheritDoc
*/
constructor(attributes, options) {
super(attributes, options);
if (
!options.url &&
this.attributes.groupType
) {
this.urlRoot = 'Notification/group';
}
}
}
+1
View File
@@ -568,6 +568,7 @@ class NotificationBadgeView extends View {
});
this.listenTo(view, 'collection-fetched', () => {
console.log(1);
this.checkUpdates();
this.broadcastNotificationsRead();
});
@@ -28,6 +28,7 @@
import BaseFieldView from 'views/fields/base';
import NotificationListRecordView from 'views/notification/record/list';
import NotificationPanelView from 'views/notification/panel';
class NotificationContainerFieldView extends BaseFieldView {
@@ -59,13 +60,18 @@ class NotificationContainerFieldView extends BaseFieldView {
isGroupExpanded = false
data() {
const count = this.model.attributes.groupedCount ?? 0;
return {
hasGrouped: (this.model.attributes.groupedCount ?? 0) > 1,
hasGrouped: count > 1 || count < 0,
isGroupExpanded: this.isGroupExpanded,
};
}
setup() {
if (this.model.attributes.groupType) {
this.wait(this.processGroup());
} else {
switch (this.model.attributes.type) {
case 'Note':
this.processNote(this.model.attributes.noteData);
@@ -80,6 +86,7 @@ class NotificationContainerFieldView extends BaseFieldView {
default:
this.process();
}
}
this.addActionHandler('showGrouped', () => this.showGrouped());
}
@@ -111,6 +118,32 @@ class NotificationContainerFieldView extends BaseFieldView {
});
}
/**
* @private
*/
async processGroup() {
const groupType = this.model.attributes.groupType;
let viewName;
if (groupType === 'Note') {
viewName = 'views/notification/items/group-note';
} else if (groupType === 'EmailReceived') {
viewName = 'views/notification/items/group-email-received';
}
if (!viewName) {
return;
}
const parentSelector = this.options.containerSelector ?? this.getSelector();
await this.createView('notification', viewName, {
model: this.model,
fullSelector: `${parentSelector} li[data-id="${this.model.id}"]`,
});
}
/**
* @private
* @param {Record} data
@@ -140,6 +173,7 @@ class NotificationContainerFieldView extends BaseFieldView {
fullSelector: `${parentSelector} li[data-id="${this.model.id}"] .cell[data-name="data"]`,
onlyContent: true,
isNotification: true,
isInGroup: this.options.isInGroup ?? false,
});
this.wait(false);
@@ -183,7 +217,14 @@ class NotificationContainerFieldView extends BaseFieldView {
async showGrouped() {
const collection = await this.getCollectionFactory().create('Notification');
if (this.model.attributes.groupType) {
collection.url = `Notification/group?type=${this.model.attributes.groupType}&id=` +
this.model.id;
collection.maxSize = this.getConfig().get('recordsPerPageSmall');
} else {
collection.url = `Notification/${this.model.id}/group`;
}
const button = this.element.querySelector('a[data-action="showGrouped"]');
@@ -221,6 +262,9 @@ class NotificationContainerFieldView extends BaseFieldView {
{
name: 'data',
view: 'views/notification/fields/container',
options: {
isInGroup: true,
},
},
],
],
@@ -235,6 +279,20 @@ class NotificationContainerFieldView extends BaseFieldView {
await this.assignView('groupedList', view);
await this.reRender();
let viewPointer = this;
while (true) {
viewPointer = viewPointer.getParentView();
if (!viewPointer || viewPointer instanceof NotificationPanelView) {
break;
}
}
if (viewPointer instanceof NotificationPanelView) {
viewPointer.trigger('collection-fetched');
}
}
}
@@ -41,12 +41,7 @@ class AssignNotificationItemView extends BaseNotificationItemView {
this.messageData['entityType'] = this.translateEntityType(data.entityType);
this.messageData['entity'] =
$('<a>')
.attr('href', '#' + data.entityType + '/view/' + data.entityId)
.attr('data-id', data.entityId)
.attr('data-scope', data.entityType)
.text(data.entityName);
this.messageData['entity'] = 'field:related';
this.messageData['user'] =
$('<a>')
@@ -0,0 +1,72 @@
/************************************************************************
* 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.
************************************************************************/
import BaseNotificationItemView from 'views/notification/items/base';
// noinspection JSUnusedGlobalSymbols
export default class GroupNoteNotificationItemView extends BaseNotificationItemView {
// language=Handlebars
templateContent = `
<div class="stream-head-container">
<div class="pull-left stream-head-left-icon-container">
<span
class="far fa-envelope text-soft icon"
>
</div>
<div class="stream-head-text-container">
<span class="text-muted message">{{{message}}}</span>
</div>
</div>
<div class="stream-date-container">
<span class="text-muted small">{{{createdAt}}}</span>
</div>
`
data() {
return {
...super.data(),
};
}
setup() {
const newCount = this.model.attributes.groupedUnreadCount ?? 0;
this.messageData['number'] = newCount.toString();
this.messageName = 'groupEmailsReceivedNew';
if (newCount === 0) {
this.messageName = 'groupEmailsReceived';
}
this.createMessage();
}
}
@@ -0,0 +1,102 @@
/************************************************************************
* 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.
************************************************************************/
import BaseNotificationItemView from 'views/notification/items/base';
// noinspection JSUnusedGlobalSymbols
export default class GroupNoteNotificationItemView extends BaseNotificationItemView {
// language=Handlebars
templateContent = `
<div class="stream-head-container">
{{#if iconClass}}
<div class="pull-left stream-head-left-icon-container">
<span
class=" {{iconClass}} text-muted action icon"
style="
cursor: pointer;
{{#if color}} color: {{color}}; {{/if}}
"
title="{{translate 'View'}}"
data-action="quickView"
data-id="{{relatedParentId}}"
data-scope="{{relatedParentType}}"
></span>
</div>
{{/if}}
<div class="stream-head-text-container">
<span class="text-muted message">{{{message}}}</span>
</div>
</div>
{{~#if isFeatured~}}
<div class="stream-post-container">
<span class="label label-default">{{translate 'Assigned'}}</span>
</div>
{{~/if~}}
<div class="stream-date-container">
<span class="text-muted small">{{{createdAt}}}</span>
</div>
`
data() {
const relatedParentType = this.model.attributes.relatedParentType
const iconClass = this.getMetadata().get(`clientDefs.${relatedParentType}.iconClass`);
const color = this.getMetadata().get(`clientDefs.${relatedParentType}.color`);
return {
...super.data(),
relatedParentId: this.model.attributes.relatedParentId,
relatedParentType: this.model.attributes.relatedParentType,
isFeatured: this.model.attributes.isFeatured,
iconClass,
color,
};
}
setup() {
const relatedParentType = this.model.attributes.relatedParentType;
if (relatedParentType) {
this.messageData['entityType'] = this.translateEntityType(relatedParentType);
}
this.messageData['entity'] = 'field:relatedParent';
const newCount = this.model.attributes.groupedUnreadCount ?? 0;
this.messageData['number'] = newCount.toString();
this.messageName = newCount > 1 ? 'groupUpdatesMultiple' : 'groupUpdatesOne';
if (newCount === 0) {
this.messageName = 'groupUpdates';
}
this.createMessage();
}
}
@@ -102,14 +102,8 @@ export default class UserReactionNotificationItemView extends BaseNotificationIt
this.messageData['user'] = userElement;
if (relatedParentId && relatedParentType) {
const relatedParentElement = document.createElement('a');
relatedParentElement.href = `#${relatedParentType}/view/${relatedParentId}`;
relatedParentElement.dataset.id = relatedParentId;
relatedParentElement.dataset.scope = relatedParentType;
relatedParentElement.textContent = data.entityName || relatedParentType;
this.messageData['entityType'] = this.translateEntityType(relatedParentType);
this.messageData['entity'] = relatedParentElement;
this.messageData['entity'] = 'field:relatedParent';
this.messageName = 'userPostInParentReaction';
}
@@ -108,8 +108,21 @@ class NotificationListRecordView extends ListExpandedRecordView {
* @return {Promise}
*/
showNewRecords() {
if (this.isGroupingEnabled()) {
return this.collection.fetch();
}
return this.collection.fetchNew();
}
/**
* @todo Preferences?
* @private
* @return {boolean}
*/
isGroupingEnabled() {
return true;
}
}
export default NotificationListRecordView;
+4
View File
@@ -243,6 +243,10 @@ class NoteStreamView extends View {
id = this.model.attributes.parentId;
}
if (this.options.isInGroup) {
return null;
}
if (this.isThis && this.parentModel && scope === this.parentModel.entityType) {
return null;
}
@@ -58,7 +58,10 @@ class MentionInPostNoteStreamView extends NoteStreamView {
this.messageName = 'mentionInPostTarget';
}
if (!this.isUserStream || this.options.userId !== this.getUser().id) {
if (
(!this.options.isNotification) &&
(!this.isUserStream || this.options.userId !== this.getUser().id)
) {
this.createMessage();
return;
+11 -4
View File
@@ -1421,13 +1421,13 @@ section {
.notification-grouped > .list > .list-group {
> li {
&:first-child {
border-top-left-radius: var(--border-radius);
border-top-right-radius: var(--border-radius);
//border-top-left-radius: var(--border-radius);
//border-top-right-radius: var(--border-radius);
}
&:last-child {
border-bottom-left-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
//border-bottom-left-radius: var(--border-radius);
//border-bottom-right-radius: var(--border-radius);
}
}
@@ -2147,7 +2147,9 @@ td > span.color-icon {
top: var(--minus-1px);
}
&:not(:first-child) {
padding-left: var(--29px);
}
> .icon:first-child {
min-width: var(--16px);
@@ -2236,6 +2238,11 @@ td > span.color-icon {
.stream-head-container {
margin-bottom: var(--4px);
> .stream-head-left-icon-container {
width: var(--26px);
text-align: center;
}
}
.stream-head-container .internal-badge {