diff --git a/application/Espo/Controllers/Notification.php b/application/Espo/Controllers/Notification.php index e48d10bdbd..25cf4d6d14 100644 --- a/application/Espo/Controllers/Notification.php +++ b/application/Espo/Controllers/Notification.php @@ -79,9 +79,7 @@ class Notification extends RecordBase } - $userId = $this->user->getId(); - - $recordCollection = $this->getNotificationService()->get($userId, $searchParams); + $recordCollection = $this->getNotificationService()->get($this->user, $searchParams); return $recordCollection->toApiOutput(); } diff --git a/application/Espo/Entities/Notification.php b/application/Espo/Entities/Notification.php index 21a9146ca9..6b2188daac 100644 --- a/application/Espo/Entities/Notification.php +++ b/application/Espo/Entities/Notification.php @@ -47,6 +47,11 @@ class Notification extends Entity public const TYPE_USER_REACTION = 'UserReaction'; public const TYPE_SYSTEM = 'System'; + public const ATTR_READ = 'read'; + public const ATTR_USER_ID = 'userId'; + public const ATTR_ACTION_ID = 'actionId'; + public const ATTR_NUMBER = 'number'; + public function getType(): ?string { return $this->get('type'); @@ -160,4 +165,12 @@ class Notification extends Entity return $this; } + + /** + * @since 9.2.0 + */ + public function getActionId(): ?string + { + return $this->get('actionId'); + } } diff --git a/application/Espo/Resources/metadata/entityDefs/Notification.json b/application/Espo/Resources/metadata/entityDefs/Notification.json index 3c626ba6a6..f463efd89e 100644 --- a/application/Espo/Resources/metadata/entityDefs/Notification.json +++ b/application/Espo/Resources/metadata/entityDefs/Notification.json @@ -43,7 +43,14 @@ "actionId": { "type": "varchar", "maxLength": 36, - "readOnly": true + "readOnly": true, + "index": true + }, + "groupedCount": { + "type": "int", + "notStorable": true, + "readOnly": true, + "utility": true }, "createdBy": { "type": "link", diff --git a/application/Espo/Resources/routes.json b/application/Espo/Resources/routes.json index b3908fe489..92535577b7 100644 --- a/application/Espo/Resources/routes.json +++ b/application/Espo/Resources/routes.json @@ -345,6 +345,11 @@ "method": "get", "actionClassName": "Espo\\Tools\\Stream\\Api\\GetNoteReactors" }, + { + "route": "/Notification/:id/group", + "method": "get", + "actionClassName": "Espo\\Tools\\Notification\\Api\\GetGroup" + }, { "route": "/EmailTemplate/:id/prepare", "method": "post", diff --git a/application/Espo/Tools/Notification/Api/GetGroup.php b/application/Espo/Tools/Notification/Api/GetGroup.php new file mode 100644 index 0000000000..d0dd7c4592 --- /dev/null +++ b/application/Espo/Tools/Notification/Api/GetGroup.php @@ -0,0 +1,60 @@ +. + * + * 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\EntityProvider; +use Espo\Entities\Notification; +use Espo\Tools\Notification\GroupService; + +class GetGroup implements Action +{ + public function __construct( + private EntityProvider $entityProvider, + private GroupService $service, + ) {} + + public function process(Request $request): Response + { + $id = $request->getRouteParam('id') ?? throw new BadRequest(); + + $notification = $this->entityProvider->getByClass(Notification::class, $id); + + $collection = $this->service->get($notification); + + return ResponseComposer::json( + $collection->toApiOutput() + ); + } +} diff --git a/application/Espo/Tools/Notification/GroupService.php b/application/Espo/Tools/Notification/GroupService.php new file mode 100644 index 0000000000..0c9d41eaaa --- /dev/null +++ b/application/Espo/Tools/Notification/GroupService.php @@ -0,0 +1,75 @@ +. + * + * 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\Record\Collection as RecordCollection; +use Espo\Entities\Notification; +use Espo\Entities\User; +use Espo\ORM\Collection; +use Espo\ORM\EntityManager; +use Espo\ORM\Name\Attribute; + +class GroupService +{ + private const LIMIT = 100; + + public function __construct( + private EntityManager $entityManager, + private User $user, + private RecordService $recordService, + ) {} + + /** + * @return RecordCollection + */ + public function get(Notification $notification): RecordCollection + { + if (!$notification->getActionId()) { + /** @var Collection $collection */ + $collection = $this->entityManager->getCollectionFactory()->create(Notification::ENTITY_TYPE); + + return RecordCollection::create($collection, 0); + } + + $collection = $this->entityManager + ->getRDBRepositoryByClass(Notification::class) + ->where([ + Attribute::ID . '!=' => $notification->getId(), + Notification::ATTR_ACTION_ID => $notification->getActionId(), + ]) + ->limit(0, self::LIMIT) + ->order(Notification::ATTR_NUMBER) + ->find(); + + $collection = $this->recordService->prepareCollection($collection, $this->user); + + return RecordCollection::create($collection, count($collection)); + } +} diff --git a/application/Espo/Tools/Notification/RecordService.php b/application/Espo/Tools/Notification/RecordService.php index 38565c9ca6..25ee28e9a6 100644 --- a/application/Espo/Tools/Notification/RecordService.php +++ b/application/Espo/Tools/Notification/RecordService.php @@ -41,9 +41,14 @@ use Espo\Core\Utils\Metadata; use Espo\Entities\Note; use Espo\Entities\Notification; use Espo\Entities\User; +use Espo\ORM\Collection; use Espo\ORM\EntityCollection; 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\WhereItem; +use Espo\ORM\Query\SelectBuilder; use Espo\Tools\Stream\NoteAccessControl; class RecordService @@ -53,7 +58,7 @@ class RecordService private Acl $acl, private Metadata $metadata, private NoteAccessControl $noteAccessControl, - private SelectBuilderFactory $selectBuilderFactory + private SelectBuilderFactory $selectBuilderFactory, ) {} /** @@ -64,15 +69,53 @@ class RecordService * @throws BadRequest * @throws Forbidden */ - public function get(string $userId, SearchParams $searchParams): RecordCollection + public function get(User $user, SearchParams $searchParams): RecordCollection { $queryBuilder = $this->selectBuilderFactory ->create() ->from(Notification::ENTITY_TYPE) ->withSearchParams($searchParams) ->buildQueryBuilder() - ->where(['userId' => $userId]) - ->order('number', SearchParams::ORDER_DESC); + ->where([Notification::ATTR_USER_ID => $user->getId()]) + ->order(Notification::ATTR_NUMBER, SearchParams::ORDER_DESC); + + if ($this->isGroupingEnabled()) { + $queryBuilder->where($this->getActionIdWhere()); + } + + /*$queryBuilder + ->leftJoin( + Join + ::createWithSubQuery( + SelectBuilder::create() + ->from(Notification::ENTITY_TYPE) + ->select('actionId') + ->select( + Selection::create( + Expr::max(Expr::column('number')), + 'maxNumber' + ) + ) + ->where( + Expr::isNotNull(Expr::column('actionId')) + ) + ->group('actionId') + ->build(), + 'subLatest' + ) + ->withConditions( + Cond::and( + Cond::equal( + Expr::column('notification.actionId'), + Expr::alias('subLatest.actionId') + ), + Cond::equal( + Expr::column('notification.number'), + Expr::alias('subLatest.maxNumber') + ), + ) + ) + );*/ $offset = $searchParams->getOffset(); $limit = $searchParams->getMaxSize(); @@ -81,14 +124,6 @@ class RecordService $queryBuilder->limit($offset, $limit + 1); } - $user = $this->entityManager - ->getRDBRepositoryByClass(User::class) - ->getById($userId); - - if (!$user) { - throw new Error("User not found."); - } - $ignoreScopeList = $this->getIgnoreScopeList(); if ($ignoreScopeList !== []) { @@ -111,31 +146,71 @@ class RecordService throw new Error("Collection is not instance of EntityCollection."); } + $collection = $this->prepareCollection($collection, $user); + + $groupedCountMap = $this->getGroupedCountMap($collection); + $ids = []; + $actionIds = []; - foreach ($collection as $k => $entity) { - if ($k === $limit) { - break; - } - + foreach ($collection as $entity) { $ids[] = $entity->getId(); - $this->prepareListItem($entity, $k, $collection, $limit, $user); + $groupedCount = null; + + if ($entity->getActionId() && $this->isGroupingEnabled()) { + $actionIds[] = $entity->getActionId(); + + $groupedCount = $groupedCountMap[$entity->getActionId()] ?? 0; + } + + $entity->set('groupedCount', $groupedCount); } $collection = new EntityCollection([...$collection], Notification::ENTITY_TYPE); - $this->markAsRead($ids); + $this->markAsRead($user, $ids, $actionIds); return RecordCollection::createNoCount($collection, $limit); } /** - * @param string[] $ids + * @param Collection $collection + * @return EntityCollection */ - private function markAsRead(array $ids): void + public function prepareCollection(Collection $collection, User $user): EntityCollection { - if ($ids === []) { + if (!$collection instanceof EntityCollection) { + $collection = new EntityCollection([...$collection], Notification::ENTITY_TYPE); + } + + $limit = count($collection); + + foreach ($collection as $i => $entity) { + if ($i === $limit) { + break; + } + + $this->prepareListItem( + entity: $entity, + index: $i, + collection: $collection, + count: $limit, + user: $user, + ); + } + + /** @var EntityCollection */ + return new EntityCollection([...$collection], Notification::ENTITY_TYPE); + } + + /** + * @param string[] $ids + * @param string[] $actionIds + */ + private function markAsRead(User $user, array $ids, array $actionIds): void + { + if ($ids === [] && $actionIds === []) { return; } @@ -143,7 +218,14 @@ class RecordService ->getQueryBuilder() ->update() ->in(Notification::ENTITY_TYPE) - ->set(['read' => true]) + ->set([Notification::ATTR_READ => true]) + ->where([Notification::ATTR_USER_ID => $user->getId()]) + ->where( + Cond::or( + Cond::in(Expr::column(Attribute::ID), $ids), + Cond::in(Expr::column(Notification::ATTR_ACTION_ID), $actionIds), + ) + ) ->where([Attribute::ID => $ids]) ->build(); @@ -200,8 +282,8 @@ class RecordService public function getNotReadCount(string $userId): int { $whereClause = [ - 'userId' => $userId, - 'read' => false, + Notification::ATTR_USER_ID => $userId, + Notification::ATTR_READ => false, ]; $ignoreScopeList = $this->getIgnoreScopeList(); @@ -215,10 +297,15 @@ class RecordService ]; } - return $this->entityManager + $builder = $this->entityManager ->getRDBRepositoryByClass(Notification::class) - ->where($whereClause) - ->count(); + ->where($whereClause); + + if ($this->isGroupingEnabled()) { + $builder->where($this->getActionIdWhere()); + } + + return $builder->count(); } public function markAllRead(string $userId): bool @@ -326,4 +413,84 @@ class RecordService $note->loadLinkMultipleField('attachments'); } } + + private function getActionIdWhere(): WhereItem + { + return Cond::or( + Expr::isNull(Expr::column('actionId')), + Cond::and( + Expr::isNotNull(Expr::column('actionId')), + Cond::not( + Cond::exists( + SelectBuilder::create() + ->from(Notification::ENTITY_TYPE, 'sub') + ->select('id') + ->where( + Cond::equal( + Expr::column('sub.actionId'), + Expr::column('notification.actionId') + ) + ) + ->where( + Cond::less( + Expr::column('sub.number'), + Expr::column('notification.number') + ) + ) + ->build() + ) + ) + ) + ); + } + + /** + * @param EntityCollection $collection + * @return array + */ + private function getGroupedCountMap(EntityCollection $collection): array + { + if (!$this->isGroupingEnabled()) { + return []; + } + + $groupedCountMap = []; + + $actionIds = []; + + foreach ($collection as $note) { + if ($note->getActionId()) { + $actionIds[] = $note->getActionId(); + } + } + + $countsQuery = SelectBuilder::create() + ->from(Notification::ENTITY_TYPE) + ->select(Expr::count(Expr::column(Attribute::ID)), 'count') + ->select(Expr::column(Notification::ATTR_ACTION_ID)) + ->where([ + Notification::ATTR_ACTION_ID => $actionIds, + ]) + ->group(Expr::column(Notification::ATTR_ACTION_ID)) + ->build(); + + $rows = $this->entityManager->getQueryExecutor()->execute($countsQuery)->fetchAll(); + + foreach ($rows as $row) { + $actionId = $row[Notification::ATTR_ACTION_ID] ?? null; + + if (!is_string($actionId)) { + continue; + } + + $groupedCountMap[$actionId] = $row['count'] ?? 0; + } + + return $groupedCountMap; + } + + private function isGroupingEnabled(): bool + { + return true; + } } diff --git a/client/res/templates/notification/fields/container.tpl b/client/res/templates/notification/fields/container.tpl index 96134ddbf7..7609ec9c69 100644 --- a/client/res/templates/notification/fields/container.tpl +++ b/client/res/templates/notification/fields/container.tpl @@ -1 +1,14 @@ -{{{notification}}} +
{{{notification}}}
+{{#if hasGrouped}} +
+ {{#if isGroupExpanded}} + {{{groupedList}}} + {{else}} + + {{/if}} +
+{{/if}} diff --git a/client/src/views/notification/fields/container.js b/client/src/views/notification/fields/container.js index d06dddba87..9492d03c2d 100644 --- a/client/src/views/notification/fields/container.js +++ b/client/src/views/notification/fields/container.js @@ -27,6 +27,7 @@ ************************************************************************/ import BaseFieldView from 'views/fields/base'; +import NotificationListRecordView from 'views/notification/record/list'; class NotificationContainerFieldView extends BaseFieldView { @@ -44,23 +45,38 @@ class NotificationContainerFieldView extends BaseFieldView { 'UserReaction', ] - inlineEditDisabled = true; + inlineEditDisabled = true + + /** + * @private + * @type {boolean} + */ + isGroupExpanded = false + + data() { + return { + hasGrouped: (this.model.attributes.groupedCount ?? 0) > 1, + isGroupExpanded: this.isGroupExpanded, + }; + } setup() { switch (this.model.attributes.type) { case 'Note': - this.processNote(this.model.get('noteData')); + this.processNote(this.model.attributes.noteData); break; case 'MentionInPost': - this.processMentionInPost(this.model.get('noteData')); + this.processMentionInPost(this.model.attributes.noteData); break; default: this.process(); } + + this.addActionHandler('showGrouped', () => this.showGrouped()); } process() { @@ -82,12 +98,18 @@ class NotificationContainerFieldView extends BaseFieldView { viewName = 'views/notification/items/' + Espo.Utils.camelCaseToHyphen(type); } + const parentSelector = this.options.containerSelector ?? this.getSelector(); + this.createView('notification', viewName, { model: this.model, - fullSelector: this.options.containerSelector + ' li[data-id="' + this.model.id + '"]', + fullSelector: `${parentSelector} li[data-id="${this.model.id}"]`, }); } + /** + * @private + * @param {Record} data + */ processNote(data) { if (!data) { return; @@ -105,10 +127,12 @@ class NotificationContainerFieldView extends BaseFieldView { viewName = 'views/stream/notes/' + Espo.Utils.camelCaseToHyphen(data.type); } + const parentSelector = this.options.containerSelector ?? this.getSelector(); + this.createView('notification', viewName, { model: model, isUserStream: true, - fullSelector: `${this.options.containerSelector} li[data-id="${this.model.id}"] .cell[data-name="data"]`, + fullSelector: `${parentSelector} li[data-id="${this.model.id}"] .cell[data-name="data"]`, onlyContent: true, isNotification: true, }); @@ -117,6 +141,10 @@ class NotificationContainerFieldView extends BaseFieldView { }); } + /** + * @private + * @param {Record} data + */ processMentionInPost(data) { if (!data) { return; @@ -129,11 +157,13 @@ class NotificationContainerFieldView extends BaseFieldView { const viewName = 'views/stream/notes/mention-in-post'; + const parentSelector = this.options.containerSelector ?? this.getSelector(); + this.createView('notification', viewName, { model: model, userId: this.model.get('userId'), isUserStream: true, - fullSelector: this.options.containerSelector + ' li[data-id="' + this.model.id + '"]', + fullSelector: `${parentSelector} li[data-id="${this.model.id}"]`, onlyContent: true, isNotification: true, }); @@ -141,6 +171,68 @@ class NotificationContainerFieldView extends BaseFieldView { this.wait(false); }); } + + /** + * @private + */ + async showGrouped() { + const collection = await this.getCollectionFactory().create('Notification'); + + collection.url = `Notification/${this.model.id}/group`; + + const button = this.element.querySelector('a[data-action="showGrouped"]'); + + if (button instanceof HTMLElement) { + button.classList.add('disabled'); + } + + Espo.Ui.notifyWait(); + + try { + await collection.fetch(); + } catch (e) { + await this.reRender(); + + return; + } + + Espo.Ui.notify(); + + this.isGroupExpanded = true; + + if (this.model.attributes.read === false) { + collection.models.forEach(model => { + model.set('read', false); + }); + } + + //console.log(this.getSelector()); + + const view = new NotificationListRecordView({ + collection: collection, + showCount: false, + selector: '.notification-grouped', + listLayout: { + rows: [ + [ + { + name: 'data', + view: 'views/notification/fields/container', + }, + ], + ], + right: { + name: 'read', + view: 'views/notification/fields/read', + width: '10px', + }, + }, + }); + + await this.assignView('groupedList', view); + + await this.reRender(); + } } export default NotificationContainerFieldView; diff --git a/client/src/views/notification/list.js b/client/src/views/notification/list.js index 17412b4c86..e39da46c05 100644 --- a/client/src/views/notification/list.js +++ b/client/src/views/notification/list.js @@ -77,7 +77,7 @@ class NotificationListView extends View { 'views/notification/record/list'; const options = { - selector: '.list-container', + selector: '.notification-list', collection: this.collection, showCount: false, listLayout: { diff --git a/client/src/views/notification/record/list.js b/client/src/views/notification/record/list.js index eb9b8eb444..b73515d2d2 100644 --- a/client/src/views/notification/record/list.js +++ b/client/src/views/notification/record/list.js @@ -93,6 +93,17 @@ class NotificationListRecordView extends ListExpandedRecordView { }; } + getCellSelector(model, item) { + const current = this.getSelector(); + const row = this.getRowSelector(model.id); + + if (item.field === 'right') { + return `${current} ${row} > .cell[data-name="${item.field}"]`; + } + + return `${current} ${row} > .expanded-row > .cell[data-name="${item.field}"]`; + } + /** * @return {Promise} */ diff --git a/client/src/views/record/list-expanded.js b/client/src/views/record/list-expanded.js index b19a61d8df..6ed1fc91ba 100644 --- a/client/src/views/record/list-expanded.js +++ b/client/src/views/record/list-expanded.js @@ -43,7 +43,7 @@ class ListExpandedRecordView extends ListRecordView { header = false _internalLayout = null checkedList = null - listContainerEl = '.list > ul' + listContainerEl = '> .list > ul' columnResize = false init() { @@ -192,13 +192,19 @@ class ListExpandedRecordView extends ListRecordView { const rows = internalLayout.rows || []; rows.forEach(row => { - row.forEach(col => { - col.fullSelector = this.getCellSelector(model, col); + row.forEach(cell => { + //cell.fullSelector = this.getCellSelector(model, cell); + + cell.options ??= {}; + cell.options.fullSelector = this.getCellSelector(model, cell) }); }); if (internalLayout.right) { - internalLayout.right.fullSelector = this.getCellSelector(model, internalLayout.right); + //internalLayout.right.fullSelector = this.getCellSelector(model, internalLayout.right); + + internalLayout.right.options ??= {}; + internalLayout.right.options.fullSelector = this.getCellSelector(model, internalLayout.right); } } diff --git a/client/src/views/record/list.js b/client/src/views/record/list.js index f5d726b983..6d2df99859 100644 --- a/client/src/views/record/list.js +++ b/client/src/views/record/list.js @@ -3146,6 +3146,8 @@ class ListRecordView extends View { */ prepareInternalLayout(internalLayout, model) { internalLayout.forEach(item => { + // @todo Revise whether has any effect. + // Has to be in options instead? item.options.fullSelector; item.fullSelector = this.getCellSelector(model, item); if (this.header && item.options && item.options.defs) { diff --git a/frontend/less/espo/custom.less b/frontend/less/espo/custom.less index c332df11ca..2a8ef1a8fd 100644 --- a/frontend/less/espo/custom.less +++ b/frontend/less/espo/custom.less @@ -1622,6 +1622,38 @@ section { white-space: normal; overflow: hidden; } + + li.list-group-item { + &:has(.notification-grouped) { + padding-bottom: 0; + + a[data-action="showGrouped"] { + margin: 0 auto; + display: block; + width: 36px; + } + } + + &:has(.notification-grouped > .list > .list-group) { + .notification-grouped { + margin-top: var(--5px); + } + } + + .notification-grouped > .list > .list-group { + > li { + &:first-child { + border-top-left-radius: var(--border-radius); + border-top-right-radius: var(--border-radius); + } + + &:last-child { + border-bottom: 0; + } + } + + } + } } #notifications-panel .right { @@ -2150,6 +2182,7 @@ textarea.auto-height { overflow-x: hidden; background-color: var(--dropdown-bg); + scrollbar-gutter: stable; ul > li.list-group-item { background-color: var(--dropdown-bg);