mirror of
https://github.com/espocrm/espocrm.git
synced 2026-03-03 02:27:01 +00:00
reactions
This commit is contained in:
19
.idea/jsonSchemas.xml
generated
19
.idea/jsonSchemas.xml
generated
@@ -1006,6 +1006,25 @@
|
||||
</SchemaInfo>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="metadata/app/reactions">
|
||||
<value>
|
||||
<SchemaInfo>
|
||||
<option name="generatedName" value="New Schema" />
|
||||
<option name="name" value="metadata/app/reactions" />
|
||||
<option name="relativePathToSchema" value="schema/metadata/app/reactions.json" />
|
||||
<option name="schemaVersion" value="JSON Schema version 7" />
|
||||
<option name="patterns">
|
||||
<list>
|
||||
<Item>
|
||||
<option name="pattern" value="true" />
|
||||
<option name="path" value="*/Resources/metadata/app/reactions.json" />
|
||||
<option name="mappingKind" value="Pattern" />
|
||||
</Item>
|
||||
</list>
|
||||
</option>
|
||||
</SchemaInfo>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="metadata/app/rebuild">
|
||||
<value>
|
||||
<SchemaInfo>
|
||||
|
||||
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@@ -394,6 +394,12 @@
|
||||
],
|
||||
"url": "./schema/metadata/app/portalContainerServices.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"*/Resources/metadata/app/reactions.json"
|
||||
],
|
||||
"url": "./schema/metadata/app/reactions.json"
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"*/Resources/metadata/app/rebuild.json"
|
||||
|
||||
@@ -33,14 +33,21 @@ use Espo\Core\FieldProcessing\Loader;
|
||||
use Espo\Core\FieldProcessing\Loader\Params;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\Tools\Stream\MassNotePreparator;
|
||||
|
||||
/**
|
||||
* @implements Loader<Note>
|
||||
*/
|
||||
class AdditionalFieldsLoader implements Loader
|
||||
{
|
||||
public function __construct(
|
||||
private MassNotePreparator $massNotePreparator,
|
||||
) {}
|
||||
|
||||
public function process(Entity $entity, Params $params): void
|
||||
{
|
||||
$entity->loadAdditionalFields();
|
||||
|
||||
$this->massNotePreparator->prepare([$entity]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Classes\FieldValidators\Settings\AvailableReactions;
|
||||
|
||||
use Espo\Core\FieldValidation\Validator;
|
||||
use Espo\Core\FieldValidation\Validator\Data;
|
||||
use Espo\Core\FieldValidation\Validator\Failure;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\Entities\Settings;
|
||||
use Espo\ORM\Entity;
|
||||
|
||||
/**
|
||||
* @implements Validator<Settings>
|
||||
*/
|
||||
class Valid implements Validator
|
||||
{
|
||||
public function __construct(private Metadata $metadata)
|
||||
{}
|
||||
|
||||
public function validate(Entity $entity, string $field, Data $data): ?Failure
|
||||
{
|
||||
$value = $entity->get($field);
|
||||
|
||||
if (!is_array($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var string[] $allowedList */
|
||||
$allowedList = array_map(fn ($it) => $it['type'], $this->metadata->get("app.reactions.list", []));
|
||||
|
||||
foreach ($value as $it) {
|
||||
if (!in_array($it, $allowedList)) {
|
||||
return Failure::create();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Api\Request;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\Core\Record\SearchParamsFetcher;
|
||||
|
||||
use Espo\Core\Select\SearchParams;
|
||||
@@ -42,6 +43,7 @@ use Espo\Entities\User as UserEntity;
|
||||
use Espo\Tools\Stream\RecordService;
|
||||
|
||||
use Espo\Tools\Stream\UserRecordService;
|
||||
use Espo\Tools\UserReaction\ReactionStreamService;
|
||||
use stdClass;
|
||||
|
||||
class Stream
|
||||
@@ -51,7 +53,8 @@ class Stream
|
||||
public function __construct(
|
||||
private RecordService $service,
|
||||
private UserRecordService $userRecordService,
|
||||
private SearchParamsFetcher $searchParamsFetcher
|
||||
private SearchParamsFetcher $searchParamsFetcher,
|
||||
private ReactionStreamService $reactionStreamService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -73,9 +76,13 @@ class Stream
|
||||
if ($scope === UserEntity::ENTITY_TYPE) {
|
||||
$collection = $this->userRecordService->find($id, $searchParams);
|
||||
|
||||
$reactionsCheckDate = DateTime::createNow();
|
||||
|
||||
return (object) [
|
||||
'total' => $collection->getTotal(),
|
||||
'list' => $collection->getValueMapList(),
|
||||
'reactionsCheckDate' => $reactionsCheckDate->toString(),
|
||||
'updatedReactions' => $this->getReactionUpdates($request, $id),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -195,4 +202,22 @@ class Stream
|
||||
|
||||
return $searchParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequest
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
* @return stdClass[]
|
||||
*/
|
||||
private function getReactionUpdates(Request $request, ?string $id): array
|
||||
{
|
||||
$reactionsAfter = $request->getQueryParam('reactionsAfter');
|
||||
$noteIds = explode(',', $request->getQueryParam('reactionsCheckNoteIds') ?? '');
|
||||
|
||||
if (!$reactionsAfter || !$noteIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->reactionStreamService->getReactionUpdates(DateTime::fromString($reactionsAfter), $noteIds, $id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Core\Upgrades\Migrations\V9_0;
|
||||
|
||||
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
|
||||
{
|
||||
$users = $this->entityManager
|
||||
->getRDBRepositoryByClass(User::class)
|
||||
->sth()
|
||||
->where([
|
||||
'isActive' => true,
|
||||
'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('reactionNotifications', true);
|
||||
$this->entityManager->saveEntity($preferences);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ use Espo\Core\ORM\Entity;
|
||||
use Espo\Core\Field\DateTime;
|
||||
|
||||
use Espo\ORM\Collection;
|
||||
use Espo\ORM\Entity as OrmEntity;
|
||||
use RuntimeException;
|
||||
use stdClass;
|
||||
|
||||
@@ -360,6 +361,11 @@ class Note extends Entity
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getParent(): ?OrmEntity
|
||||
{
|
||||
return $this->relations->getOne('parent');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<Attachment>
|
||||
*/
|
||||
|
||||
@@ -44,6 +44,7 @@ class Notification extends Entity
|
||||
public const TYPE_NOTE = 'Note';
|
||||
public const TYPE_MENTION_IN_POST = 'MentionInPost';
|
||||
public const TYPE_MESSAGE = 'Message';
|
||||
public const TYPE_USER_REACTION = 'UserReaction';
|
||||
public const TYPE_SYSTEM = 'System';
|
||||
|
||||
public function getType(): ?string
|
||||
@@ -93,9 +94,28 @@ class Notification extends Entity
|
||||
return $this->getValueObject('related');
|
||||
}
|
||||
|
||||
public function setRelated(?LinkParent $related): self
|
||||
public function setRelated(LinkParent|Entity|null $related): self
|
||||
{
|
||||
$this->setValueObject('related', $related);
|
||||
if ($related instanceof LinkParent) {
|
||||
$this->setValueObject('related', $related);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->relations->set('related', $related);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setRelatedParent(LinkParent|Entity|null $relatedParent): self
|
||||
{
|
||||
if ($relatedParent instanceof LinkParent) {
|
||||
$this->setValueObject('relatedParent', $relatedParent);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->relations->set('relatedParent', $relatedParent);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
70
application/Espo/Entities/UserReaction.php
Normal file
70
application/Espo/Entities/UserReaction.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Entities;
|
||||
|
||||
use Espo\Core\Field\LinkParent;
|
||||
use Espo\Core\ORM\Entity;
|
||||
|
||||
class UserReaction extends Entity
|
||||
{
|
||||
const ENTITY_TYPE = 'UserReaction';
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->get('type');
|
||||
}
|
||||
|
||||
public function getParent(): LinkParent
|
||||
{
|
||||
/** @var LinkParent */
|
||||
return $this->getValueObject('parent');
|
||||
}
|
||||
|
||||
public function setType(string $type): self
|
||||
{
|
||||
$this->set('type', $type);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setParent(Note $note): self
|
||||
{
|
||||
$this->relations->set('parent', $note);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setUser(User $user): self
|
||||
{
|
||||
$this->relations->set('user', $user);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -308,5 +308,7 @@ return [
|
||||
'authIpAddressWhitelist' => [],
|
||||
'authIpAddressCheckExcludedUsersIds' => [],
|
||||
'authIpAddressCheckExcludedUsersNames' => (object) [],
|
||||
'availableReactions' => ['Like'],
|
||||
'streamReactionsCheckMaxSize' => 50,
|
||||
'isInstalled' => false,
|
||||
];
|
||||
|
||||
@@ -311,7 +311,10 @@
|
||||
"Copy to Clipboard": "Copy to Clipboard",
|
||||
"Copied to clipboard": "Copied to clipboard",
|
||||
"Audit Log": "Audit Log",
|
||||
"View Audit Log": "View Audit Log"
|
||||
"View Audit Log": "View Audit Log",
|
||||
"Reacted": "Reacted",
|
||||
"Reaction Removed": "Reaction Removed",
|
||||
"Reactions": "Reactions"
|
||||
},
|
||||
"messages": {
|
||||
"pleaseWait": "Please wait...",
|
||||
@@ -517,7 +520,9 @@
|
||||
"assign": "{entityType} {entity} has been assigned to you",
|
||||
"emailReceived": "Email received from {from}",
|
||||
"entityRemoved": "{user} removed {entityType} {entity}",
|
||||
"emailInbox": "{user} added email {entity} to your inbox"
|
||||
"emailInbox": "{user} added email {entity} to your inbox",
|
||||
"userPostReaction": "{user} reacted to your {post}",
|
||||
"userPostInParentReaction": "{user} reacted to your {post} in {entityType} {entity}"
|
||||
},
|
||||
"streamMessages": {
|
||||
"post": "{user} posted on {entityType} {entity}",
|
||||
@@ -984,5 +989,15 @@
|
||||
},
|
||||
"strings": {
|
||||
"yesterdayShort": "Yest"
|
||||
},
|
||||
"reactions": {
|
||||
"Smile": "Smile",
|
||||
"Surprise": "Surprise",
|
||||
"Laugh": "Laugh",
|
||||
"Meh": "Meh",
|
||||
"Sad": "Sad",
|
||||
"Love": "Love",
|
||||
"Like": "Like",
|
||||
"Dislike": "Dislike"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"receiveStreamEmailNotifications": "Email notifications about posts and status updates",
|
||||
"assignmentNotificationsIgnoreEntityTypeList": "In-app assignment notifications",
|
||||
"assignmentEmailNotificationsIgnoreEntityTypeList": "Email assignment notifications",
|
||||
"reactionNotifications": "In-app notifications about reactions",
|
||||
"autoFollowEntityTypeList": "Global Auto-Follow",
|
||||
"signature": "Email Signature",
|
||||
"dashboardTabList": "Tab List",
|
||||
|
||||
@@ -172,7 +172,8 @@
|
||||
"quickSearchFullTextAppendWildcard": "Append wildcard in quick search",
|
||||
"authIpAddressCheck": "Restrict access by IP address",
|
||||
"authIpAddressWhitelist": "IP Address Whitelist",
|
||||
"authIpAddressCheckExcludedUsers": "Users excluded from check"
|
||||
"authIpAddressCheckExcludedUsers": "Users excluded from check",
|
||||
"availableReactions": "Available Reactions"
|
||||
},
|
||||
"options": {
|
||||
"authenticationMethod": {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -139,6 +139,10 @@
|
||||
[
|
||||
{"name": "assignmentNotificationsIgnoreEntityTypeList"},
|
||||
{"name": "assignmentEmailNotificationsIgnoreEntityTypeList"}
|
||||
],
|
||||
[
|
||||
{"name": "reactionNotifications"},
|
||||
false
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -27,7 +27,11 @@
|
||||
"label": "Notifications",
|
||||
"name": "notifications",
|
||||
"rows": [
|
||||
[{"name": "receiveStreamEmailNotifications"}, false]
|
||||
[{"name": "receiveStreamEmailNotifications"}, false],
|
||||
[
|
||||
{"name": "reactionNotifications"},
|
||||
false
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -46,9 +46,9 @@
|
||||
"tabBreak": true,
|
||||
"tabLabel": "$label:General",
|
||||
"rows": [
|
||||
[{"name": "followCreatedEntities"}, {"name": "emailAddressIsOptedOutByDefault"}],
|
||||
[{"name": "aclAllowDeleteCreated"}, {"name": "cleanupDeletedRecords"}],
|
||||
[{"name": "exportDisabled"}, {"name": "b2cMode"}],
|
||||
[{"name": "cleanupDeletedRecords"}, {"name": "emailAddressIsOptedOutByDefault"}],
|
||||
[{"name": "aclAllowDeleteCreated"}, {"name": "b2cMode"}],
|
||||
[{"name": "exportDisabled"}, false],
|
||||
[{"name": "pdfEngine"}, false]
|
||||
]
|
||||
|
||||
@@ -80,5 +80,11 @@
|
||||
"rows": [
|
||||
[{"name": "attachmentUploadMaxSize"}, {"name": "attachmentUploadChunkSize"}]
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Stream",
|
||||
"rows": [
|
||||
[{"name": "availableReactions"}, {"name": "followCreatedEntities"}]
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -153,6 +153,9 @@
|
||||
},
|
||||
"authIpAddressCheckExcludedUsers": {
|
||||
"level": "superAdmin"
|
||||
},
|
||||
"availableReactions": {
|
||||
"level": "global"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
36
application/Espo/Resources/metadata/app/reactions.json
Normal file
36
application/Espo/Resources/metadata/app/reactions.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"list": [
|
||||
{
|
||||
"type": "Smile",
|
||||
"iconClass": "far fa-face-smile"
|
||||
},
|
||||
{
|
||||
"type": "Surprise",
|
||||
"iconClass": "far fa-face-surprise"
|
||||
},
|
||||
{
|
||||
"type": "Laugh",
|
||||
"iconClass": "far fa-face-laugh"
|
||||
},
|
||||
{
|
||||
"type": "Meh",
|
||||
"iconClass": "far fa-face-meh"
|
||||
},
|
||||
{
|
||||
"type": "Sad",
|
||||
"iconClass": "far fa-face-frown"
|
||||
},
|
||||
{
|
||||
"type": "Love",
|
||||
"iconClass": "far fa-heart"
|
||||
},
|
||||
{
|
||||
"type": "Like",
|
||||
"iconClass": "far fa-thumbs-up"
|
||||
},
|
||||
{
|
||||
"type": "Dislike",
|
||||
"iconClass": "far fa-thumbs-down"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,5 +11,8 @@
|
||||
},
|
||||
"itemViews": {
|
||||
"Post": "views/stream/notes/post"
|
||||
},
|
||||
"viewSetupHandlers": {
|
||||
"record/detail": ["handlers/note/record-detail-setup"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,20 @@
|
||||
"customizationDisabled": true,
|
||||
"readOnly": true
|
||||
},
|
||||
"reactionCounts": {
|
||||
"type": "jsonObject",
|
||||
"notStorable": true,
|
||||
"readOnly": true,
|
||||
"customizationDisabled": true,
|
||||
"utility": true
|
||||
},
|
||||
"myReactions": {
|
||||
"type": "jsonArray",
|
||||
"notStorable": true,
|
||||
"readOnly": true,
|
||||
"customizationDisabled": true,
|
||||
"utility": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "datetime",
|
||||
"readOnly": true,
|
||||
|
||||
@@ -39,9 +39,18 @@
|
||||
"relatedParent": {
|
||||
"type": "linkParent",
|
||||
"readOnly": true
|
||||
},
|
||||
"createdBy": {
|
||||
"type": "link",
|
||||
"readOnly": true,
|
||||
"view": "views/fields/user"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"createdBy": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"user": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
|
||||
@@ -107,6 +107,10 @@
|
||||
"translation": "Global.scopeNamesPlural",
|
||||
"view": "views/preferences/fields/assignment-email-notifications-ignore-entity-type-list"
|
||||
},
|
||||
"reactionNotifications": {
|
||||
"type": "bool",
|
||||
"default": true
|
||||
},
|
||||
"autoFollowEntityTypeList": {
|
||||
"type": "multiEnum",
|
||||
"view": "views/preferences/fields/auto-follow-entity-type-list",
|
||||
|
||||
@@ -918,6 +918,14 @@
|
||||
"type": "linkMultiple",
|
||||
"entity": "User",
|
||||
"tooltip": true
|
||||
},
|
||||
"availableReactions": {
|
||||
"type": "array",
|
||||
"maxCount": 9,
|
||||
"view": "views/settings/fields/available-reactions",
|
||||
"validatorClassNameList": [
|
||||
"Espo\\Classes\\FieldValidators\\Settings\\AvailableReactions\\Valid"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"fields": {
|
||||
"type": {
|
||||
"type": "varchar",
|
||||
"maxLength": 10
|
||||
},
|
||||
"user": {
|
||||
"type": "link"
|
||||
},
|
||||
"parent": {
|
||||
"type": "linkParent"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "datetime"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"user": {
|
||||
"type": "belongsTo",
|
||||
"entity": "User"
|
||||
},
|
||||
"parent": {
|
||||
"type": "belongsToParent",
|
||||
"entityList": ["Note"]
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"parentUserType": {
|
||||
"unique": true,
|
||||
"columns": [
|
||||
"parentId",
|
||||
"parentType",
|
||||
"userId",
|
||||
"type"
|
||||
]
|
||||
}
|
||||
},
|
||||
"noDeletedAttribute": true
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"entity": true
|
||||
}
|
||||
@@ -324,6 +324,21 @@
|
||||
"method": "post",
|
||||
"actionClassName": "Espo\\Tools\\Attachment\\Api\\PostCopy"
|
||||
},
|
||||
{
|
||||
"route": "/Note/:id/myReactions/:type",
|
||||
"method": "post",
|
||||
"actionClassName": "Espo\\Tools\\Stream\\Api\\PostMyReactions"
|
||||
},
|
||||
{
|
||||
"route": "/Note/:id/myReactions/:type",
|
||||
"method": "delete",
|
||||
"actionClassName": "Espo\\Tools\\Stream\\Api\\DeleteMyReactions"
|
||||
},
|
||||
{
|
||||
"route": "/Note/:id/reactors/:type",
|
||||
"method": "get",
|
||||
"actionClassName": "Espo\\Tools\\Stream\\Api\\GetNoteReactors"
|
||||
},
|
||||
{
|
||||
"route": "/EmailTemplate/:id/prepare",
|
||||
"method": "post",
|
||||
|
||||
62
application/Espo/Tools/Stream/Api/DeleteMyReactions.php
Normal file
62
application/Espo/Tools/Stream/Api/DeleteMyReactions.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Tools\Stream\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\Note;
|
||||
use Espo\Tools\Stream\MyReactionsService;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class DeleteMyReactions implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private EntityProvider $entityProvider,
|
||||
private MyReactionsService $myReactionsService,
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$id = $request->getRouteParam('id') ?? throw new BadRequest();
|
||||
$type = $request->getRouteParam('type') ?? throw new BadRequest();
|
||||
|
||||
$note = $this->entityProvider->getByClass(Note::class, $id);
|
||||
|
||||
$this->myReactionsService->unReact($note, $type);
|
||||
|
||||
return ResponseComposer::json(true);
|
||||
}
|
||||
}
|
||||
106
application/Espo/Tools/Stream/Api/GetNoteReactors.php
Normal file
106
application/Espo/Tools/Stream/Api/GetNoteReactors.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Tools\Stream\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\Core\Record\SearchParamsFetcher;
|
||||
use Espo\Core\Select\SelectBuilderFactory;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\UserReaction;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\Part\Condition;
|
||||
use Espo\ORM\Query\Part\Expression;
|
||||
use Espo\ORM\Query\SelectBuilder;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class GetNoteReactors implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private EntityProvider $entityProvider,
|
||||
private SearchParamsFetcher $searchParamsFetcher,
|
||||
private SelectBuilderFactory $selectBuilderFactory,
|
||||
private EntityManager $entityManager,
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$id = $request->getRouteParam('id') ?? throw new BadRequest();
|
||||
$type = $request->getRouteParam('type') ?? throw new BadRequest();
|
||||
|
||||
$note = $this->entityProvider->getByClass(Note::class, $id);
|
||||
$searchParams = $this->searchParamsFetcher->fetch($request);
|
||||
|
||||
$query = $this->selectBuilderFactory
|
||||
->create()
|
||||
->from(User::ENTITY_TYPE)
|
||||
->withSearchParams($searchParams)
|
||||
->withStrictAccessControl()
|
||||
->withDefaultOrder()
|
||||
->buildQueryBuilder()
|
||||
->select([
|
||||
'id',
|
||||
'name',
|
||||
'userName',
|
||||
])
|
||||
->where(
|
||||
Condition::in(
|
||||
Expression::column('id'),
|
||||
SelectBuilder::create()
|
||||
->from(UserReaction::ENTITY_TYPE)
|
||||
->select('userId')
|
||||
->where([
|
||||
'type' => $type,
|
||||
'parentId' => $note->getId(),
|
||||
'parentType' => $note->getEntityType(),
|
||||
])
|
||||
->build()
|
||||
)
|
||||
)
|
||||
->build();
|
||||
|
||||
$repository = $this->entityManager->getRDBRepositoryByClass(User::class);
|
||||
|
||||
$users = $repository->clone($query)->find();
|
||||
$count = $repository->clone($query)->count();
|
||||
|
||||
return ResponseComposer::json([
|
||||
'list' => $users->getValueMapList(),
|
||||
'total' => $count,
|
||||
]);
|
||||
}
|
||||
}
|
||||
62
application/Espo/Tools/Stream/Api/PostMyReactions.php
Normal file
62
application/Espo/Tools/Stream/Api/PostMyReactions.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Tools\Stream\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\Note;
|
||||
use Espo\Tools\Stream\MyReactionsService;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class PostMyReactions implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private EntityProvider $entityProvider,
|
||||
private MyReactionsService $myReactionsService,
|
||||
) {}
|
||||
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$id = $request->getRouteParam('id') ?? throw new BadRequest();
|
||||
$type = $request->getRouteParam('type') ?? throw new BadRequest();
|
||||
|
||||
$note = $this->entityProvider->getByClass(Note::class, $id);
|
||||
|
||||
$this->myReactionsService->react($note, $type);
|
||||
|
||||
return ResponseComposer::json(true);
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,8 @@ class GlobalRecordService
|
||||
private EntityManager $entityManager,
|
||||
private QueryHelper $queryHelper,
|
||||
private NoteAccessControl $noteAccessControl,
|
||||
private NoteHelper $noteHelper
|
||||
private NoteHelper $noteHelper,
|
||||
private MassNotePreparator $massNotePreparator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -139,6 +140,8 @@ class GlobalRecordService
|
||||
$this->noteHelper->prepare($note);
|
||||
}
|
||||
|
||||
$this->massNotePreparator->prepare($collection);
|
||||
|
||||
return RecordCollection::createNoCount($collection, $maxSize);
|
||||
}
|
||||
|
||||
|
||||
165
application/Espo/Tools/Stream/MassNotePreparator.php
Normal file
165
application/Espo/Tools/Stream/MassNotePreparator.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Tools\Stream;
|
||||
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\UserReaction;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\Part\Expression;
|
||||
use Espo\ORM\Query\Part\Selection;
|
||||
use Espo\ORM\Query\SelectBuilder;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class MassNotePreparator
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private User $user,
|
||||
private Config $config,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param iterable<Note> $notes
|
||||
*/
|
||||
public function prepare(iterable $notes): void
|
||||
{
|
||||
if ($this->noAvailableReactions()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ids = $this->getPostIds($notes);
|
||||
|
||||
$this->prepareMyReactions($ids, $notes);
|
||||
$this->prepareReactionCounts($ids, $notes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<Note> $notes
|
||||
* @return string[]
|
||||
*/
|
||||
private function getPostIds(iterable $notes): array
|
||||
{
|
||||
$ids = [];
|
||||
|
||||
foreach ($notes as $note) {
|
||||
if ($note->getType() !== Note::TYPE_POST) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ids[] = $note->getId();
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $ids
|
||||
* @param iterable<Note> $notes
|
||||
*/
|
||||
private function prepareMyReactions(array $ids, iterable $notes): void
|
||||
{
|
||||
$myUserReactionCollection = $this->entityManager
|
||||
->getRDBRepositoryByClass(UserReaction::class)
|
||||
->where([
|
||||
'userId' => $this->user->getId(),
|
||||
'parentType' => Note::ENTITY_TYPE,
|
||||
'parentId' => $ids,
|
||||
])
|
||||
->find();
|
||||
|
||||
/** @var UserReaction[] $myUserReactions */
|
||||
$myUserReactions = iterator_to_array($myUserReactionCollection);
|
||||
|
||||
foreach ($notes as $note) {
|
||||
$noteMyReactions = [];
|
||||
|
||||
foreach ($myUserReactions as $reaction) {
|
||||
if ($reaction->getParent()->getId() !== $note->getId()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$noteMyReactions[] = $reaction->getType();
|
||||
}
|
||||
|
||||
$note->set('myReactions', $noteMyReactions);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $ids
|
||||
* @param iterable<Note> $notes
|
||||
*/
|
||||
private function prepareReactionCounts(array $ids, iterable $notes): void
|
||||
{
|
||||
$query = SelectBuilder::create()
|
||||
->from(UserReaction::ENTITY_TYPE)
|
||||
->select([
|
||||
Selection::create(Expression::count(Expression::column('id')), 'count'),
|
||||
'parentId',
|
||||
'type',
|
||||
])
|
||||
->where([
|
||||
'parentType' => Note::ENTITY_TYPE,
|
||||
'parentId' => $ids,
|
||||
])
|
||||
->group('parentId')
|
||||
->group('type')
|
||||
->build();
|
||||
|
||||
/** @var array<int, array{count: int, type: string, parentId: string}> $rows */
|
||||
$rows = $this->entityManager
|
||||
->getQueryExecutor()
|
||||
->execute($query)
|
||||
->fetchAll();
|
||||
|
||||
foreach ($notes as $note) {
|
||||
$counts = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if ($row['parentId'] !== $note->getId()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$counts[$row['type']] = $row['count'];
|
||||
}
|
||||
|
||||
$note->set('reactionCounts', $counts);
|
||||
}
|
||||
}
|
||||
|
||||
private function noAvailableReactions(): bool
|
||||
{
|
||||
return $this->config->get('availableReactions', []) === [];
|
||||
}
|
||||
}
|
||||
155
application/Espo/Tools/Stream/MyReactionsService.php
Normal file
155
application/Espo/Tools/Stream/MyReactionsService.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Tools\Stream;
|
||||
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\WebSocket\Submission as WebSocketSubmission;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\UserReaction;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\DeleteBuilder;
|
||||
use Espo\Tools\UserReaction\NotificationService;
|
||||
|
||||
class MyReactionsService
|
||||
{
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private EntityManager $entityManager,
|
||||
private User $user,
|
||||
private WebSocketSubmission $webSocketSubmission,
|
||||
private NotificationService $notificationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
public function react(Note $note, string $type): void
|
||||
{
|
||||
if (!$this->isReactionAllowed($type)) {
|
||||
throw new Forbidden("Not allowed reaction '$type'.");
|
||||
}
|
||||
|
||||
if ($note->getType() !== Note::TYPE_POST) {
|
||||
throw new Forbidden("Cannot react on non-post note.");
|
||||
}
|
||||
|
||||
$this->entityManager->getTransactionManager()->run(function () use ($type, $note) {
|
||||
$repository = $this->entityManager->getRDBRepositoryByClass(UserReaction::class);
|
||||
|
||||
$found = $repository
|
||||
->forUpdate()
|
||||
->where([
|
||||
'userId' => $this->user->getId(),
|
||||
'parentType' => Note::ENTITY_TYPE,
|
||||
'parentId' => $note->getId(),
|
||||
'type' => $type,
|
||||
])
|
||||
->findOne();
|
||||
|
||||
if ($found) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->deleteAll($note);
|
||||
$this->notificationService->removeNoteUnread($note, $this->user);
|
||||
|
||||
$reaction = $repository->getNew();
|
||||
|
||||
$reaction
|
||||
->setParent($note)
|
||||
->setUser($this->user)
|
||||
->setType($type);
|
||||
|
||||
$this->entityManager->saveEntity($reaction);
|
||||
});
|
||||
|
||||
$this->webSocketSubmit($note);
|
||||
$this->notificationService->notifyNote($note, $type);
|
||||
}
|
||||
|
||||
public function unReact(Note $note, string $type): void
|
||||
{
|
||||
$repository = $this->entityManager->getRDBRepositoryByClass(UserReaction::class);
|
||||
|
||||
$reaction = $repository
|
||||
->where([
|
||||
'userId' => $this->user->getId(),
|
||||
'parentType' => $note->getEntityType(),
|
||||
'parentId' => $note->getId(),
|
||||
'type' => $type,
|
||||
])
|
||||
->findOne();
|
||||
|
||||
if (!$reaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->notificationService->removeNoteUnread($note, $this->user, $type);
|
||||
|
||||
$this->entityManager->removeEntity($reaction);
|
||||
|
||||
$this->webSocketSubmit($note);
|
||||
}
|
||||
|
||||
private function isReactionAllowed(string $type): bool
|
||||
{
|
||||
/** @var string[] $allowedReactions */
|
||||
$allowedReactions = $this->config->get('availableReactions') ?? [];
|
||||
|
||||
return in_array($type, $allowedReactions);
|
||||
}
|
||||
|
||||
private function deleteAll(Note $note): void
|
||||
{
|
||||
$deleteQuery = DeleteBuilder::create()
|
||||
->from(UserReaction::ENTITY_TYPE)
|
||||
->where([
|
||||
'userId' => $this->user->getId(),
|
||||
'parentType' => Note::ENTITY_TYPE,
|
||||
'parentId' => $note->getId(),
|
||||
])
|
||||
->build();
|
||||
|
||||
$this->entityManager->getQueryExecutor()->execute($deleteQuery);
|
||||
}
|
||||
|
||||
private function webSocketSubmit(Note $note): void
|
||||
{
|
||||
if (!$this->config->get('useWebSocket')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$topic = "streamUpdate.{$note->getParentType()}.{$note->getParentId()}";
|
||||
|
||||
$this->webSocketSubmission->submit($topic, null, (object) ['noteId' => $note->getId()]);
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,8 @@ class RecordService
|
||||
private Helper $helper,
|
||||
private QueryHelper $queryHelper,
|
||||
private Metadata $metadata,
|
||||
private NoteHelper $noteHelper
|
||||
private NoteHelper $noteHelper,
|
||||
private MassNotePreparator $massNotePreparator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -158,6 +159,8 @@ class RecordService
|
||||
$this->prepareNote($item, $scope, $id);
|
||||
}
|
||||
|
||||
$this->massNotePreparator->prepare($collection);
|
||||
|
||||
return $collection;
|
||||
}
|
||||
|
||||
@@ -216,6 +219,8 @@ class RecordService
|
||||
$this->prepareNote($e, $scope, $id);
|
||||
}
|
||||
|
||||
$this->massNotePreparator->prepare($collection);
|
||||
|
||||
$count = $this->entityManager
|
||||
->getRDBRepositoryByClass(Note::class)
|
||||
->clone($countBuilder->build())
|
||||
|
||||
@@ -68,7 +68,8 @@ class UserRecordService
|
||||
private NoteAccessControl $noteAccessControl,
|
||||
private Helper $helper,
|
||||
private QueryHelper $queryHelper,
|
||||
private NoteHelper $noteHelper
|
||||
private NoteHelper $noteHelper,
|
||||
private MassNotePreparator $massNotePreparator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -614,6 +615,8 @@ class UserRecordService
|
||||
$this->noteHelper->prepare($e);
|
||||
}
|
||||
|
||||
$this->massNotePreparator->prepare($collection);
|
||||
|
||||
return RecordCollection::createNoCount($collection, $maxSize);
|
||||
}
|
||||
}
|
||||
|
||||
118
application/Espo/Tools/UserReaction/NotificationService.php
Normal file
118
application/Espo/Tools/UserReaction/NotificationService.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Tools\UserReaction;
|
||||
|
||||
use Espo\Core\Field\LinkParent;
|
||||
use Espo\Core\ORM\Entity;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\Entities\Notification;
|
||||
use Espo\Entities\Preferences;
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Tools\Stream\Service;
|
||||
|
||||
class NotificationService
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private User $user,
|
||||
private Service $streamService,
|
||||
) {}
|
||||
|
||||
public function notifyNote(Note $note, string $type): void
|
||||
{
|
||||
$recipientId = $note->getCreatedById();
|
||||
|
||||
if (!$recipientId || $recipientId === $this->user->getId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parent = $note->getParent();
|
||||
|
||||
if ($parent && !$this->streamService->checkIsFollowed($parent, $note->getCreatedById())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->isEnabledForUser($recipientId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = $this->entityManager->getRDBRepositoryByClass(Notification::class)->getNew();
|
||||
|
||||
$data = [
|
||||
'type' => $type,
|
||||
'userId' => $this->user->getId(),
|
||||
];
|
||||
|
||||
$notification
|
||||
->setType(Notification::TYPE_USER_REACTION)
|
||||
->setUserId($recipientId)
|
||||
->setRelated(LinkParent::createFromEntity($note));
|
||||
|
||||
if ($parent instanceof Entity) {
|
||||
$notification->setRelatedParent($parent);
|
||||
$data['entityName'] = $parent->get('name');
|
||||
}
|
||||
|
||||
$notification->setData($data);
|
||||
|
||||
$this->entityManager->saveEntity($notification);
|
||||
}
|
||||
|
||||
private function isEnabledForUser(string $recipientId): bool
|
||||
{
|
||||
$recipientPreferences = $this->entityManager->getRepositoryByClass(Preferences::class)->getById($recipientId);
|
||||
|
||||
return $recipientPreferences && $recipientPreferences->get('reactionNotifications');
|
||||
}
|
||||
|
||||
public function removeNoteUnread(Note $note, User $user, ?string $type = null): void
|
||||
{
|
||||
/** @var Notification[] $notifications */
|
||||
$notifications = $this->entityManager
|
||||
->getRDBRepositoryByClass(Notification::class)
|
||||
->where([
|
||||
'read' => false,
|
||||
'createdById' => $user->getId(),
|
||||
'type' => Notification::TYPE_USER_REACTION,
|
||||
'relatedId' => $note->getId(),
|
||||
'relatedType' => $note->getEntityType(),
|
||||
])
|
||||
->find();
|
||||
|
||||
foreach ($notifications as $notification) {
|
||||
if ($type && $notification->getData()?->type !== $type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->entityManager->removeEntity($notification);
|
||||
}
|
||||
}
|
||||
}
|
||||
144
application/Espo/Tools/UserReaction/ReactionStreamService.php
Normal file
144
application/Espo/Tools/UserReaction/ReactionStreamService.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
namespace Espo\Tools\UserReaction;
|
||||
|
||||
use Espo\Core\Exceptions\BadRequest;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Field\DateTime;
|
||||
use Espo\Core\Select\SearchParams;
|
||||
use Espo\Core\Select\Where\Item;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\UserReaction;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\ORM\Query\SelectBuilder;
|
||||
use Espo\Tools\Stream\UserRecordService;
|
||||
use stdClass;
|
||||
|
||||
class ReactionStreamService
|
||||
{
|
||||
private const MAX_NOTE_COUNT = 100;
|
||||
private const MAX_PERIOD = '2 hours';
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private UserRecordService $userRecordService,
|
||||
private User $user,
|
||||
private Config $config,
|
||||
) {}
|
||||
|
||||
|
||||
/**
|
||||
* Get reaction updates.
|
||||
*
|
||||
* @param string[] $noteIds
|
||||
* @return stdClass[]
|
||||
* @throws Forbidden
|
||||
* @throws BadRequest
|
||||
* @throws NotFound
|
||||
* @internal
|
||||
*/
|
||||
public function getReactionUpdates(DateTime $after, array $noteIds, ?string $userId): array
|
||||
{
|
||||
if (count($noteIds) > $this->getMaxCount()) {
|
||||
throw new Forbidden("Too many note IDs.");
|
||||
}
|
||||
|
||||
$userId ??= $this->user->getId();
|
||||
|
||||
$after = $this->getAfter($after);
|
||||
|
||||
$updatedIds = [];
|
||||
|
||||
$query = SelectBuilder::create()
|
||||
->from(UserReaction::ENTITY_TYPE)
|
||||
->select('parentId')
|
||||
->where([
|
||||
'parentId' => $noteIds,
|
||||
'parentType' => Note::ENTITY_TYPE,
|
||||
'createdAt>=' => $after->toString(),
|
||||
])
|
||||
->group('parentId')
|
||||
->build();
|
||||
|
||||
/** @var array{parentId: string}[] $rows */
|
||||
$rows = $this->entityManager->getQueryExecutor()->execute($query)->fetchAll();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$updatedIds[] = $row['parentId'];
|
||||
}
|
||||
|
||||
if (!$updatedIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$searchParams = SearchParams::create()
|
||||
->withSelect(['id', 'reactionCounts', 'myReactions'])
|
||||
->withWhereAdded(
|
||||
Item::createBuilder()
|
||||
->setType(Item\Type::IN)
|
||||
->setAttribute('id')
|
||||
->setValue($updatedIds)
|
||||
->build()
|
||||
);
|
||||
|
||||
$updatedNotes = $this->userRecordService->find($userId, $searchParams);
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($updatedNotes->getCollection() as $note) {
|
||||
$result[] = (object) [
|
||||
'id' => $note->getId(),
|
||||
'myReactions' => $note->get('myReactions'),
|
||||
'reactionCounts' => $note->get('reactionCounts')
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function getAfter(DateTime $after): DateTime
|
||||
{
|
||||
$afterMax = DateTime::createNow()->modify('-' . self::MAX_PERIOD);
|
||||
|
||||
if ($afterMax->isGreaterThan($after)) {
|
||||
$after = $afterMax;
|
||||
}
|
||||
|
||||
return $after;
|
||||
}
|
||||
|
||||
private function getMaxCount(): int
|
||||
{
|
||||
return $this->config->get('streamReactionsCheckMaxSize') ?? self::MAX_NOTE_COUNT;
|
||||
}
|
||||
}
|
||||
@@ -8,17 +8,23 @@
|
||||
<ul class="dropdown-menu pull-right list-row-dropdown-menu" data-id="{{model.id}}">
|
||||
{{#each actionList}}
|
||||
{{#if this}}
|
||||
<li>
|
||||
<a
|
||||
{{#if link}}href="{{link}}"{{else}}role="button"{{/if}}
|
||||
tabindex="0"
|
||||
class="action"
|
||||
{{#if action}}data-action="{{action}}"{{/if}}
|
||||
{{#each data}}
|
||||
data-{{hyphen @key}}="{{./this}}"
|
||||
{{/each}}
|
||||
>{{#if html}}{{{html}}}{{else}}{{#if text}}{{text}}{{else}}{{translate label scope=../scope}}{{/if}}{{/if}}
|
||||
</a>
|
||||
<li
|
||||
{{#if viewKey}} data-view-key="{{viewKey}}" {{/if}}
|
||||
>
|
||||
{{#if viewKey}}
|
||||
{{{lookup ../this viewKey}}}
|
||||
{{else}}
|
||||
<a
|
||||
{{#if link}} href="{{link}}" {{else}} role="button" {{/if}}
|
||||
tabindex="0"
|
||||
class="action"
|
||||
{{#if action}}data-action="{{action}}"{{/if}}
|
||||
{{#each data}}
|
||||
data-{{hyphen @key}}="{{./this}}"
|
||||
{{/each}}
|
||||
>{{#if html}}{{{html}}}{{else}}{{#if text}}{{text}}{{else}}{{translate label scope=../scope}}{{/if}}{{/if}}
|
||||
</a>
|
||||
{{/if}}
|
||||
</li>
|
||||
{{else}}
|
||||
{{#unless @first}}
|
||||
|
||||
@@ -36,4 +36,5 @@
|
||||
{{#if isPinned}}
|
||||
<span class="fas fa-map-pin fa-sm pin-icon" title="{{translate 'Pinned' scope='Note'}}"></span>
|
||||
{{/if}}
|
||||
<div class="reactions-container">{{{reactions}}}</div>
|
||||
</div>
|
||||
|
||||
@@ -34,11 +34,22 @@ class NoteCollection extends Collection {
|
||||
|
||||
paginationByNumber = false
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {string|null}
|
||||
*/
|
||||
reactionsCheckDate = null
|
||||
|
||||
/**
|
||||
* @type {Record[]}
|
||||
*/
|
||||
pinnedList
|
||||
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
reactionsCheckMaxSize = 0;
|
||||
|
||||
/** @inheritDoc */
|
||||
prepareAttributes(response, params) {
|
||||
const total = this.total;
|
||||
@@ -54,6 +65,24 @@ class NoteCollection extends Collection {
|
||||
this.pinnedList = Espo.Utils.cloneDeep(response.pinnedList);
|
||||
}
|
||||
|
||||
this.reactionsCheckDate = response.reactionsCheckDate;
|
||||
|
||||
/** @type {Record[]} */
|
||||
const updatedReactions = response.updatedReactions;
|
||||
|
||||
if (updatedReactions) {
|
||||
updatedReactions.forEach(item => {
|
||||
const model = this.get(item.id);
|
||||
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.set(item, {keepRowActions: true});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -76,6 +105,16 @@ class NoteCollection extends Collection {
|
||||
options.remove = false;
|
||||
options.at = 0;
|
||||
options.maxSize = null;
|
||||
|
||||
if (this.reactionsCheckMaxSize) {
|
||||
options.data.reactionsAfter = this.reactionsCheckDate || options.data.after;
|
||||
|
||||
options.data.reactionsCheckNoteIds = this.models
|
||||
.filter(model => model.attributes.type === 'Post')
|
||||
.map(model => model.id)
|
||||
.slice(0, this.reactionsCheckMaxSize)
|
||||
.join(',');
|
||||
}
|
||||
}
|
||||
|
||||
return this.fetch(options);
|
||||
|
||||
55
client/src/handlers/note/record-detail-setup.js
Normal file
55
client/src/handlers/note/record-detail-setup.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
export default class {
|
||||
|
||||
/**
|
||||
* @param {import('views/record/detail').default} view
|
||||
*/
|
||||
constructor(view) {
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
process() {
|
||||
this.controlFields();
|
||||
|
||||
this.view.listenTo(this.view.model, 'sync', () => this.controlFields());
|
||||
}
|
||||
|
||||
controlFields() {
|
||||
const attachmentsIds = this.view.model.attributes.attachmentsIds;
|
||||
|
||||
if (!attachmentsIds || !attachmentsIds.length) {
|
||||
this.view.hideField('attachments');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.view.showField('attachments');
|
||||
}
|
||||
}
|
||||
83
client/src/helpers/misc/reactions.js
Normal file
83
client/src/helpers/misc/reactions.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
class ReactionsHelper {
|
||||
|
||||
/**
|
||||
* @param {import('models/settings').default} config
|
||||
* @param {import('metadata').default} metadata
|
||||
*/
|
||||
constructor(config, metadata) {
|
||||
/** @private */
|
||||
this.config = config;
|
||||
/** @private */
|
||||
this.metadata = metadata;
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {{
|
||||
* type: string,
|
||||
* iconClass: string,
|
||||
* }[]}
|
||||
*/
|
||||
this.list = metadata.get('app.reactions.list') || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {{
|
||||
* type: string,
|
||||
* iconClass: string,
|
||||
* }[]}
|
||||
*/
|
||||
getDefinitionList() {
|
||||
return this.list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string[]}
|
||||
*/
|
||||
getAvailableReactions() {
|
||||
return this.config.get('availableReactions') || []
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|null} type
|
||||
* @return {string|null}
|
||||
*/
|
||||
getIconClass(type) {
|
||||
const item = this.list.find(it => it.type === type);
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.iconClass;
|
||||
}
|
||||
}
|
||||
|
||||
export default ReactionsHelper;
|
||||
@@ -902,6 +902,8 @@ Espo.Ui = {
|
||||
* @property {boolean} [noHideOnOutsideClick=false] Don't hide on clicking outside.
|
||||
* @property {function(): void} [onShow] On-show callback.
|
||||
* @property {function(): void} [onHide] On-hide callback.
|
||||
* @property {string|function(): string} [title] A title text.
|
||||
* @property {boolean} [keepElementTitle] Keep an original element's title.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -910,7 +912,12 @@ Espo.Ui = {
|
||||
* @param {Element|JQuery} element An element.
|
||||
* @param {Espo.Ui~PopoverOptions} o Options.
|
||||
* @param {module:view} [view] A view.
|
||||
* @return {{hide: function(), destroy: function(), show: function(), detach: function()}}
|
||||
* @return {{
|
||||
* hide: function(),
|
||||
* destroy: function(),
|
||||
* show: function(): string,
|
||||
* detach: function(),
|
||||
* }}
|
||||
*/
|
||||
popover: function (element, o, view) {
|
||||
const $el = $(element);
|
||||
@@ -935,6 +942,8 @@ Espo.Ui = {
|
||||
html: true,
|
||||
content: content,
|
||||
trigger: o.trigger || 'manual',
|
||||
title: o.title,
|
||||
keepElementTitle: o.keepElementTitle,
|
||||
})
|
||||
.on('shown.bs.popover', () => {
|
||||
isShown = true;
|
||||
@@ -1021,6 +1030,8 @@ Espo.Ui = {
|
||||
const show = () => {
|
||||
// noinspection JSUnresolvedReference
|
||||
$el.popover('show');
|
||||
|
||||
return $el.attr('aria-describedby');
|
||||
};
|
||||
|
||||
if (view) {
|
||||
|
||||
@@ -387,6 +387,10 @@ class ModalView extends View {
|
||||
if (!this.noFullHeight) {
|
||||
this.initBodyScrollListener();
|
||||
}
|
||||
|
||||
if (this.getParentView()) {
|
||||
this.getParentView().trigger('modal-shown');
|
||||
}
|
||||
});
|
||||
|
||||
this.once('remove', () => {
|
||||
|
||||
@@ -41,10 +41,11 @@ class NotificationContainerFieldView extends BaseFieldView {
|
||||
'EntityRemoved',
|
||||
'Message',
|
||||
'System',
|
||||
'UserReaction',
|
||||
]
|
||||
|
||||
setup() {
|
||||
switch (this.model.get('type')) {
|
||||
switch (this.model.attributes.type) {
|
||||
case 'Note':
|
||||
this.processNote(this.model.get('noteData'));
|
||||
|
||||
|
||||
@@ -111,14 +111,22 @@ class BaseNotificationItemView extends View {
|
||||
|
||||
string = string.toLowerCase();
|
||||
|
||||
const language = this.getPreferences().get('language') || this.getConfig().get('language');
|
||||
|
||||
if (['de_DE', 'nl_NL'].includes(language)) {
|
||||
if (this.toUpperCaseFirstLetter()) {
|
||||
string = Espo.Utils.upperCaseFirst(string);
|
||||
}
|
||||
|
||||
return string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @property
|
||||
* @return {boolean}
|
||||
*/
|
||||
toUpperCaseFirstLetter() {
|
||||
const language = this.getPreferences().get('language') || this.getConfig().get('language');
|
||||
|
||||
return ['de_DE', 'nl_NL'].includes(language);
|
||||
}
|
||||
}
|
||||
|
||||
export default BaseNotificationItemView;
|
||||
|
||||
120
client/src/views/notification/items/user-reaction.js
Normal file
120
client/src/views/notification/items/user-reaction.js
Normal file
@@ -0,0 +1,120 @@
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
import BaseNotificationItemView from 'views/notification/items/base';
|
||||
import ReactionsHelper from 'helpers/misc/reactions';
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
export default class UserReactionNotificationItemView extends BaseNotificationItemView {
|
||||
|
||||
// language=Handlebars
|
||||
templateContent = `
|
||||
<div class="stream-head-container">
|
||||
<div class="pull-left">
|
||||
{{{avatar}}}
|
||||
</div>
|
||||
<div class="stream-head-text-container">
|
||||
<span class="{{reactionIconClass}} text-muted"></span>
|
||||
<span class="text-muted message">
|
||||
{{{message}}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stream-date-container">
|
||||
<span class="text-muted small">{{{createdAt}}}</span>
|
||||
</div>
|
||||
`
|
||||
|
||||
messageName = 'userPostReaction'
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {string|null}
|
||||
*/
|
||||
reactionIconClass
|
||||
|
||||
|
||||
data() {
|
||||
return {
|
||||
...super.data(),
|
||||
reactionIconClass: this.reactionIconClass,
|
||||
};
|
||||
}
|
||||
|
||||
setup() {
|
||||
const data = /** @type {Object.<string, *>} */this.model.attributes.data || {};
|
||||
|
||||
const relatedParentId = this.model.attributes.relatedParentId;
|
||||
const relatedParentType = this.model.attributes.relatedParentType;
|
||||
|
||||
this.userId = this.model.attributes.createdById;
|
||||
|
||||
this.messageData['type'] = this.translate(data.type, 'reactions');
|
||||
|
||||
const reactionsHelper = new ReactionsHelper(this.getConfig(), this.getMetadata());
|
||||
this.reactionIconClass = reactionsHelper.getIconClass(data.type);
|
||||
|
||||
const userElement = document.createElement('a');
|
||||
userElement.href = `#User/view/${this.model.attributes.createdById}`;
|
||||
userElement.dataset.id = this.model.attributes.createdById;
|
||||
userElement.dataset.scope = 'User';
|
||||
userElement.textContent = this.model.attributes.createdByName;
|
||||
|
||||
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.messageName = 'userPostInParentReaction';
|
||||
}
|
||||
|
||||
let postLabel = this.getLanguage().translateOption('Post', 'type', 'Note');
|
||||
|
||||
if (!this.toUpperCaseFirstLetter()) {
|
||||
postLabel = Espo.Utils.lowerCaseFirst(postLabel);
|
||||
}
|
||||
|
||||
const postElement = document.createElement('a');
|
||||
postElement.href = `#Note/view/${this.model.attributes.relatedId}`;
|
||||
postElement.dataset.id = this.model.attributes.relatedId;
|
||||
postElement.dataset.scope = 'Note';
|
||||
postElement.textContent = postLabel;
|
||||
|
||||
this.messageData['post'] = postElement;
|
||||
|
||||
this.createMessage();
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ import View from 'view';
|
||||
* groupIndex?: number,
|
||||
* name?: string,
|
||||
* text?: string,
|
||||
* html?: string,
|
||||
* viewKey?: string,
|
||||
* }} module:views/record/row-actions/actions~item
|
||||
*/
|
||||
|
||||
@@ -62,7 +64,13 @@ class DefaultRowActionsView extends View {
|
||||
|
||||
this.setupAdditionalActions();
|
||||
|
||||
this.listenTo(this.model, 'change', () => this.reRender());
|
||||
this.listenTo(this.model, 'change', (m, /** Record */o) => {
|
||||
if (o.keepRowActions) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reRender();
|
||||
});
|
||||
}
|
||||
|
||||
afterRender() {
|
||||
|
||||
129
client/src/views/settings/fields/available-reactions.js
Normal file
129
client/src/views/settings/fields/available-reactions.js
Normal file
@@ -0,0 +1,129 @@
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
import ArrayFieldView from 'views/fields/array';
|
||||
import ReactionsHelper from 'helpers/misc/reactions';
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
export default class extends ArrayFieldView {
|
||||
|
||||
/**
|
||||
* @type {Object.<string, string>}
|
||||
* @private
|
||||
*/
|
||||
iconClassMap
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {ReactionsHelper}
|
||||
*/
|
||||
reactionsHelper
|
||||
|
||||
setup() {
|
||||
this.reactionsHelper = new ReactionsHelper(this.getConfig(), this.getMetadata());
|
||||
|
||||
this.iconClassMap = this.reactionsHelper.getDefinitionList().reduce((o, it) => {
|
||||
return {
|
||||
[it.type]: it.iconClass,
|
||||
...o,
|
||||
};
|
||||
}, {});
|
||||
|
||||
super.setup();
|
||||
}
|
||||
|
||||
setupOptions() {
|
||||
const list = this.reactionsHelper.getDefinitionList();
|
||||
|
||||
this.params.options = list.map(it => it.type);
|
||||
|
||||
this.translatedOptions = list.reduce((o, it) => {
|
||||
return {
|
||||
[it.type]: this.translate(it.type, 'reactions'),
|
||||
...o
|
||||
};
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @return {string}
|
||||
*/
|
||||
getItemHtml(value) {
|
||||
const html = super.getItemHtml(value);
|
||||
|
||||
const item = /** @type {HTMLElement} */
|
||||
new DOMParser().parseFromString(html, 'text/html').body.childNodes[0];
|
||||
|
||||
const icon = this.createIconElement(value);
|
||||
|
||||
item.prepend(icon);
|
||||
|
||||
return item.outerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} value
|
||||
* @return {HTMLSpanElement}
|
||||
*/
|
||||
createIconElement(value) {
|
||||
const icon = document.createElement('span');
|
||||
|
||||
(this.iconClassMap[value] || '')
|
||||
.split(' ')
|
||||
.filter(it => it)
|
||||
.forEach(it => icon.classList.add(it));
|
||||
|
||||
icon.classList.add('text-soft');
|
||||
icon.style.display = 'inline-block';
|
||||
icon.style.width = 'var(--24px)';
|
||||
|
||||
return icon;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
async actionAddItem() {
|
||||
const view = await super.actionAddItem();
|
||||
|
||||
view.whenRendered().then(() => {
|
||||
const anchors = /** @type {HTMLAnchorElement[]} */
|
||||
view.element.querySelectorAll(`a[data-value]`);
|
||||
|
||||
anchors.forEach(a => {
|
||||
const icon = this.createIconElement(a.dataset.value);
|
||||
|
||||
a.prepend(icon);
|
||||
});
|
||||
});
|
||||
|
||||
return view;
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,7 @@ class NoteStreamView extends View {
|
||||
listType: this.listType,
|
||||
isThis: this.isThis,
|
||||
parentModel: this.parentModel,
|
||||
isNotification: this.options.isNotification,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
************************************************************************/
|
||||
|
||||
import NoteStreamView from 'views/stream/note';
|
||||
import NoteReactionsView from 'views/stream/reactions';
|
||||
|
||||
class PostNoteStreamView extends NoteStreamView {
|
||||
|
||||
@@ -49,6 +50,9 @@ class PostNoteStreamView extends NoteStreamView {
|
||||
}
|
||||
|
||||
setup() {
|
||||
this.addActionHandler('react', (e, target) => this.react(target.dataset.type));
|
||||
this.addActionHandler('unReact', (e, target) => this.unReact(target.dataset.type));
|
||||
|
||||
this.createField('post', null, null, 'views/stream/fields/post');
|
||||
|
||||
this.createField('attachments', 'attachmentMultiple', {}, 'views/stream/fields/attachment-multiple', {
|
||||
@@ -57,6 +61,8 @@ class PostNoteStreamView extends NoteStreamView {
|
||||
|
||||
this.isInternal = this.model.get('isInternal');
|
||||
|
||||
this.setupReactions();
|
||||
|
||||
if (!this.model.get('post') && this.model.get('parentId')) {
|
||||
this.messageName = 'attach';
|
||||
|
||||
@@ -213,6 +219,64 @@ class PostNoteStreamView extends NoteStreamView {
|
||||
|
||||
this.createMessage();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} type
|
||||
*/
|
||||
async react(type) {
|
||||
Espo.Ui.notify(' ... ');
|
||||
|
||||
const previousMyReactions = this.model.attributes.myReactions;
|
||||
|
||||
this.model.set({myReactions: [type]}, {userReaction: true});
|
||||
|
||||
try {
|
||||
await Espo.Ajax.postRequest(`Note/${this.model.id}/myReactions/${type}`);
|
||||
} catch (e) {
|
||||
this.model.set({myReactions: previousMyReactions}, {userReaction: true});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Espo.Ui.success(this.translate('Reacted') + ' · ' + this.translate(type, 'reactions'));
|
||||
|
||||
await this.model.fetch({userReaction: true, keepRowActions: true});
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} type
|
||||
*/
|
||||
async unReact(type) {
|
||||
Espo.Ui.notify(' ... ');
|
||||
|
||||
const previousMyReactions = this.model.attributes.myReactions;
|
||||
|
||||
this.model.set({myReactions: []}, {userReaction: true});
|
||||
|
||||
try {
|
||||
await Espo.Ajax.deleteRequest(`Note/${this.model.id}/myReactions/${type}`);
|
||||
} catch (e) {
|
||||
this.model.set({myReactions: previousMyReactions}, {userReaction: true});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Espo.Ui.warning(this.translate('Reaction Removed'));
|
||||
|
||||
await this.model.fetch({userReaction: true, keepRowActions: true});
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
setupReactions() {
|
||||
const view = new NoteReactionsView({model: this.model});
|
||||
this.assignView('reactions', view, '.reactions-container');
|
||||
|
||||
this.listenTo(this.model, 'change:reactionCounts change:myReactions', () => view.reRenderWhenNoPopover());
|
||||
}
|
||||
}
|
||||
|
||||
export default PostNoteStreamView;
|
||||
|
||||
@@ -338,7 +338,7 @@ class PanelStreamView extends RelationshipPanelView {
|
||||
const model = this.collection.get(data.noteId);
|
||||
|
||||
if (model) {
|
||||
model.fetch()
|
||||
model.fetch({keepRowActions: true})
|
||||
.then(() => this.syncPinnedModel(model, true));
|
||||
}
|
||||
|
||||
@@ -463,6 +463,13 @@ class PanelStreamView extends RelationshipPanelView {
|
||||
}, view => {
|
||||
view.render();
|
||||
|
||||
this.listenTo(this.pinnedCollection, 'change',
|
||||
(/** import('model').default */model, /** Record */o) => {
|
||||
if (o.userReaction) {
|
||||
this.syncPinnedModel(model, false);
|
||||
}
|
||||
});
|
||||
|
||||
this.listenTo(view, 'after:save', /** import('model').default */model => {
|
||||
this.syncPinnedModel(model, false);
|
||||
});
|
||||
@@ -493,6 +500,13 @@ class PanelStreamView extends RelationshipPanelView {
|
||||
this.syncPinnedModel(model, true);
|
||||
});
|
||||
|
||||
this.listenTo(this.collection, 'change',
|
||||
(/** import('model').default */model, /** Record */o) => {
|
||||
if (o.userReaction) {
|
||||
this.syncPinnedModel(model, true);
|
||||
}
|
||||
});
|
||||
|
||||
this.listenTo(view, 'quote-reply', /** string */quoted => this.quoteReply(quoted));
|
||||
}
|
||||
});
|
||||
@@ -619,6 +633,8 @@ class PanelStreamView extends RelationshipPanelView {
|
||||
attachmentsNames: model.attributes.attachmentsNames,
|
||||
attachmentsTypes: model.attributes.attachmentsTypes,
|
||||
data: model.attributes.data,
|
||||
reactionCounts: model.attributes.reactionCounts,
|
||||
myReactions: model.attributes.myReactions,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
202
client/src/views/stream/reactions.js
Normal file
202
client/src/views/stream/reactions.js
Normal file
@@ -0,0 +1,202 @@
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
import View from 'view';
|
||||
import Collection from 'collection';
|
||||
import ListRecordView from 'views/record/list';
|
||||
import ReactionsHelper from 'helpers/misc/reactions';
|
||||
|
||||
export default class NoteReactionsView extends View {
|
||||
|
||||
// language=Handlebars
|
||||
templateContent = `
|
||||
{{#each dataList}}
|
||||
<a
|
||||
class="reaction-count small text-soft"
|
||||
role="button"
|
||||
title="{{label}}"
|
||||
data-type="{{type}}"
|
||||
>
|
||||
<span data-role="icon" class="{{iconClass}} {{#if reacted}} text-primary {{/if}}"></span>
|
||||
<span data-role="count">{{count}}</span>
|
||||
</a>
|
||||
{{/each}}
|
||||
`
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {string[]}
|
||||
*/
|
||||
availableReactions
|
||||
|
||||
/**
|
||||
* @type {Object.<string, string>}
|
||||
* @private
|
||||
*/
|
||||
iconClassMap
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {{destroy: function(), show: function()}}
|
||||
*/
|
||||
popover
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* model: import('model').default,
|
||||
* }} options
|
||||
*/
|
||||
constructor(options) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
data() {
|
||||
/** @type {Record.<string, number>} */
|
||||
const counts = this.model.attributes.reactionCounts || {};
|
||||
/** @type {string[]} */
|
||||
const myReactions = this.model.attributes.myReactions || [];
|
||||
|
||||
return {
|
||||
dataList: this.availableReactions
|
||||
.filter(type => counts[type])
|
||||
.map(type => {
|
||||
return {
|
||||
type: type,
|
||||
count: counts[type].toString(),
|
||||
label: this.translate('Reactions') + ' · ' + this.translate(type, 'reactions'),
|
||||
iconClass: this.iconClassMap[type],
|
||||
reacted: myReactions.includes(type),
|
||||
};
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
setup() {
|
||||
const reactionsHelper = new ReactionsHelper(this.getConfig(), this.getMetadata());
|
||||
|
||||
this.availableReactions = reactionsHelper.getAvailableReactions();
|
||||
|
||||
const list = reactionsHelper.getDefinitionList();
|
||||
|
||||
this.iconClassMap = list.reduce((o, it) => {
|
||||
return {[it.type]: it.iconClass, ...o};
|
||||
}, {});
|
||||
|
||||
this.addHandler('click', 'a.reaction-count', (e, target) => this.showUsers(target.dataset.type));
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} type
|
||||
*/
|
||||
async showUsers(type) {
|
||||
const a = this.element.querySelector(`a.reaction-count[data-type="${type}"]`);
|
||||
|
||||
/*if (this.popover) {
|
||||
this.popover.destroy();
|
||||
}*/
|
||||
|
||||
const popover = Espo.Ui.popover(a, {
|
||||
placement: 'bottom',
|
||||
content: `
|
||||
<div class="center-align for-list-view">
|
||||
<span class="fas fa-spinner fa-spin text-soft"></span>
|
||||
</div>
|
||||
`,
|
||||
preventDestroyOnRender: true,
|
||||
noToggleInit: true,
|
||||
keepElementTitle: true,
|
||||
title: this.translate('Reactions') + ' · ' + this.translate(type, 'reactions'),
|
||||
onHide: () => {
|
||||
this.popover = undefined;
|
||||
|
||||
this.trigger('popover-hidden');
|
||||
},
|
||||
}, this);
|
||||
|
||||
this.popover = popover;
|
||||
|
||||
const id = popover.show();
|
||||
|
||||
document.querySelector(`#${id}`).classList.add('popover-list-view');
|
||||
|
||||
const selector = `#${id} .popover-content`;
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
const container = document.querySelector(selector);
|
||||
|
||||
/** @type {import('collection').default} */
|
||||
const users = await this.getCollectionFactory().create('User');
|
||||
|
||||
users.url = `Note/${this.model.id}/reactors/${type}`;
|
||||
users.maxSize = this.getConfig().get('recordsPerPageSmall') || 5;
|
||||
|
||||
await users.fetch();
|
||||
|
||||
if (!document.body.contains(container)) {
|
||||
popover.hide();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const listView = new ListRecordView({
|
||||
collection: users,
|
||||
listLayout: [
|
||||
{
|
||||
name: 'name',
|
||||
view: 'views/user/fields/name',
|
||||
link: true,
|
||||
}
|
||||
],
|
||||
checkboxes: false,
|
||||
displayTotalCount: false,
|
||||
headerDisabled: true,
|
||||
buttonsDisabled: true,
|
||||
rowActionsDisabled: true,
|
||||
});
|
||||
|
||||
await this.assignView('users', listView);
|
||||
|
||||
listView.setSelector(selector);
|
||||
|
||||
await listView.render();
|
||||
|
||||
this.listenToOnce(listView, 'modal-shown', () => popover.destroy());
|
||||
}
|
||||
|
||||
// @todo Prevent popover disappearing.
|
||||
reRenderWhenNoPopover() {
|
||||
if (this.popover) {
|
||||
this.once('popover-hidden', () => this.reRender());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.reRender();
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,16 @@ class ListStreamRecordView extends ListExpandedRecordView {
|
||||
|
||||
massActionsDisabled = true
|
||||
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {boolean}
|
||||
*/
|
||||
isUserStream
|
||||
|
||||
setup() {
|
||||
this.isUserStream = this.options.isUserStream || false;
|
||||
|
||||
this.itemViews = this.getMetadata().get('clientDefs.Note.itemViews') || {};
|
||||
|
||||
super.setup();
|
||||
@@ -47,6 +56,12 @@ class ListStreamRecordView extends ListExpandedRecordView {
|
||||
this.buildRows(() => this.reRender());
|
||||
});
|
||||
|
||||
if (this.isUserStream || this.model.entityType === 'User') {
|
||||
const collection = /** @type {import('collections/note').default} */this.collection;
|
||||
|
||||
collection.reactionsCheckMaxSize = this.getConfig().get('streamReactionsCheckMaxSize') || 0;
|
||||
}
|
||||
|
||||
this.listenTo(this.collection, 'sync', (c, r, options) => {
|
||||
if (!options.fetchNew) {
|
||||
return;
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
************************************************************************/
|
||||
|
||||
import DefaultRowActionsView from 'views/record/row-actions/default';
|
||||
import ReactionsHelper from 'helpers/misc/reactions';
|
||||
import ReactionsRowActionView from 'views/stream/record/row-actions/reactions/reactions';
|
||||
|
||||
class StreamDefaultNoteRowActionsView extends DefaultRowActionsView {
|
||||
|
||||
@@ -34,6 +36,18 @@ class StreamDefaultNoteRowActionsView extends DefaultRowActionsView {
|
||||
|
||||
isDetached = false
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {string[]}
|
||||
*/
|
||||
availableReactions
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {ReactionsHelper}
|
||||
*/
|
||||
reactionHelper
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
@@ -46,6 +60,11 @@ class StreamDefaultNoteRowActionsView extends DefaultRowActionsView {
|
||||
|
||||
this.pinnedMaxCount = this.getConfig().get('notePinnedMaxCount');
|
||||
}
|
||||
|
||||
// @todo Use service.
|
||||
this.reactionHelper = new ReactionsHelper(this.getConfig(), this.getMetadata());
|
||||
|
||||
this.availableReactions = this.reactionHelper.getAvailableReactions();
|
||||
}
|
||||
|
||||
getActionList() {
|
||||
@@ -117,6 +136,96 @@ class StreamDefaultNoteRowActionsView extends DefaultRowActionsView {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.hasReactions()) {
|
||||
this.getReactionItems().forEach(item => list.push(item));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @return {boolean}
|
||||
*/
|
||||
hasReactions() {
|
||||
return this.model.attributes.type === 'Post' &&
|
||||
this.availableReactions.length &&
|
||||
!this.options.isNotification;
|
||||
}
|
||||
|
||||
async prepareRender() {
|
||||
if (!this.hasReactions() || this.availableReactions.length === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reactionsView = new ReactionsRowActionView({
|
||||
reactions: this.availableReactions.map(type => {
|
||||
return {
|
||||
type: type,
|
||||
iconClass: this.reactionHelper.getIconClass(type),
|
||||
label: this.translate(type, 'reactions'),
|
||||
isReacted: this.isUserReacted(type),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
await this.assignView('reactions', reactionsView, '[data-view-key="reactions"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} type
|
||||
* @return {boolean}
|
||||
*/
|
||||
isUserReacted(type) {
|
||||
/** @type {string[]} */
|
||||
const myReactions = this.model.attributes.myReactions || [];
|
||||
|
||||
return myReactions.includes(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @return {module:views/record/row-actions/actions~item[]}
|
||||
*/
|
||||
getReactionItems() {
|
||||
const list = [];
|
||||
|
||||
if (this.availableReactions.length > 1) {
|
||||
return [{
|
||||
viewKey: 'reactions',
|
||||
groupIndex: 11,
|
||||
}];
|
||||
}
|
||||
|
||||
this.availableReactions.forEach(type => {
|
||||
const iconClass = this.reactionHelper.getIconClass(type);
|
||||
|
||||
const label = this.getHelper().escapeString(this.translate(type, 'reactions'));
|
||||
|
||||
let html = iconClass ?
|
||||
`<span class="${iconClass} text-soft item-icon"></span><span class="item-text">${label}</span>` :
|
||||
label;
|
||||
|
||||
const reacted = this.isUserReacted(type);
|
||||
|
||||
if (reacted) {
|
||||
html =
|
||||
`<span class="check-icon fas fa-check pull-right"></span>` +
|
||||
`<div>${html}</div>`;
|
||||
}
|
||||
|
||||
list.push({
|
||||
action: reacted ? 'unReact' : 'react',
|
||||
html: html,
|
||||
data: {
|
||||
id: this.model.id,
|
||||
type: type,
|
||||
},
|
||||
groupIndex: 3,
|
||||
});
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/************************************************************************
|
||||
* This file is part of EspoCRM.
|
||||
*
|
||||
* EspoCRM – Open Source CRM application.
|
||||
* Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
|
||||
* Website: https://www.espocrm.com
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* The interactive user interfaces in modified source and object code versions
|
||||
* of this program must display Appropriate Legal Notices, as required under
|
||||
* Section 5 of the GNU Affero General Public License version 3.
|
||||
*
|
||||
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
|
||||
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
|
||||
************************************************************************/
|
||||
|
||||
import View from 'view';
|
||||
|
||||
export default class ReactionsRowActionView extends View {
|
||||
|
||||
// language=Handlebars
|
||||
templateContent = `
|
||||
<div class="item-icon-grid">
|
||||
{{#each reactions}}
|
||||
<a
|
||||
role="button"
|
||||
{{#if isReacted}}
|
||||
data-action="unReact"
|
||||
{{else}}
|
||||
data-action="react"
|
||||
{{/if}}
|
||||
data-type="{{type}}"
|
||||
title="{{label}}"
|
||||
class=" {{#if isReacted}} text-primary {{else}} text-soft {{/if}}"
|
||||
><span class="{{iconClass}}"></span></a>
|
||||
{{/each}}
|
||||
</div>
|
||||
`
|
||||
/**
|
||||
* @param {{
|
||||
* reactions: {
|
||||
* type: string,
|
||||
* iconClass: string|null,
|
||||
* label: string,
|
||||
* isReacted: boolean,
|
||||
* }[]
|
||||
* }} options
|
||||
*/
|
||||
constructor(options) {
|
||||
super(options);
|
||||
|
||||
this.reactions = options.reactions;
|
||||
}
|
||||
|
||||
data() {
|
||||
return {
|
||||
reactions: this.reactions,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2207,13 +2207,45 @@ td > span.color-icon {
|
||||
}
|
||||
|
||||
.stream-date-container {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
|
||||
.pin-icon {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
position: relative;
|
||||
top: var(--5px);
|
||||
position: absolute;
|
||||
right: var(--14px);
|
||||
top: var(--36px);
|
||||
|
||||
color: var(--text-muted-color);
|
||||
}
|
||||
|
||||
.reactions-container {
|
||||
float: right;
|
||||
display: inline-flex;
|
||||
width: calc(100% - var(--190px));
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--12px);
|
||||
row-gap: var(--4px);
|
||||
padding-top: var(--3px);
|
||||
justify-content: right;
|
||||
|
||||
a {
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
|
||||
span.text-primary {
|
||||
color: var(--brand-primary-10);
|
||||
}
|
||||
}
|
||||
|
||||
text-align: right;
|
||||
min-width: var(--30px);
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stream-head-container {
|
||||
@@ -3846,6 +3878,20 @@ body > .autocomplete-suggestions.text-search-suggestions {
|
||||
.popover-title {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&.popover-list-view {
|
||||
width: var(--220px);
|
||||
}
|
||||
|
||||
&:has(.for-list-view) {
|
||||
width: var(--220px);
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
&:has(> .list > table) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-group-item > .iti {
|
||||
|
||||
@@ -146,6 +146,33 @@ ul.dropdown-menu-with-icons {
|
||||
}
|
||||
}
|
||||
|
||||
ul.dropdown-menu {
|
||||
> li {
|
||||
a {
|
||||
.item-icon + .item-text {
|
||||
padding-left: var(--8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> li {
|
||||
.item-icon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(var(--34px), 1fr));
|
||||
|
||||
> a {
|
||||
padding: var(--4px) var(--4px);
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--dropdown-link-hover-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group.open .dropdown-toggle {
|
||||
&.btn-text {
|
||||
box-shadow: none;
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -16,7 +16,7 @@
|
||||
"autobahn-espo": "github:yurikuzn/autobahn-espo#0.1.0",
|
||||
"autonumeric": "^4.6.0",
|
||||
"backbone": "^1.3.3",
|
||||
"bootstrap": "github:yurikuzn/espo-bootstrap#276c0c575c69a3d18109fbfed22ead867677bd4b",
|
||||
"bootstrap": "github:yurikuzn/espo-bootstrap#0.1.1",
|
||||
"bootstrap-colorpicker": "^2.5.2",
|
||||
"bootstrap-datepicker": "^1.9.0",
|
||||
"bullbone": "github:yurikuzn/bull#1.2.16",
|
||||
@@ -2584,9 +2584,8 @@
|
||||
},
|
||||
"node_modules/bootstrap": {
|
||||
"name": "bootstrap-for-espo",
|
||||
"version": "0.1.0",
|
||||
"resolved": "git+ssh://git@github.com/yurikuzn/espo-bootstrap.git#276c0c575c69a3d18109fbfed22ead867677bd4b",
|
||||
"integrity": "sha512-tIm6wWhRIhxC8foUTO0DbsN3pBwVG+jKFyJNCHbQBfeHMxs7RsLNk1VRl9Layth4cTc0xS6C+HLbumOYpjGFRA==",
|
||||
"version": "0.1.1",
|
||||
"resolved": "git+ssh://git@github.com/yurikuzn/espo-bootstrap.git#99ba8bcd05ef0021c59179b5d0460624aa5f5976",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -9999,9 +9998,8 @@
|
||||
}
|
||||
},
|
||||
"bootstrap": {
|
||||
"version": "git+ssh://git@github.com/yurikuzn/espo-bootstrap.git#276c0c575c69a3d18109fbfed22ead867677bd4b",
|
||||
"integrity": "sha512-tIm6wWhRIhxC8foUTO0DbsN3pBwVG+jKFyJNCHbQBfeHMxs7RsLNk1VRl9Layth4cTc0xS6C+HLbumOYpjGFRA==",
|
||||
"from": "bootstrap@github:yurikuzn/espo-bootstrap#276c0c575c69a3d18109fbfed22ead867677bd4b"
|
||||
"version": "git+ssh://git@github.com/yurikuzn/espo-bootstrap.git#99ba8bcd05ef0021c59179b5d0460624aa5f5976",
|
||||
"from": "bootstrap@github:yurikuzn/espo-bootstrap#0.1.1"
|
||||
},
|
||||
"bootstrap-colorpicker": {
|
||||
"version": "2.5.2",
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"autobahn-espo": "github:yurikuzn/autobahn-espo#0.1.0",
|
||||
"autonumeric": "^4.6.0",
|
||||
"backbone": "^1.3.3",
|
||||
"bootstrap": "github:yurikuzn/espo-bootstrap#276c0c575c69a3d18109fbfed22ead867677bd4b",
|
||||
"bootstrap": "github:yurikuzn/espo-bootstrap#0.1.1",
|
||||
"bootstrap-colorpicker": "^2.5.2",
|
||||
"bootstrap-datepicker": "^1.9.0",
|
||||
"bullbone": "github:yurikuzn/bull#1.2.16",
|
||||
|
||||
31
schema/metadata/app/reactions.json
Normal file
31
schema/metadata/app/reactions.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://www.espocrm.com/schema/metadata/app/reactions.json",
|
||||
"title": "app/language",
|
||||
"description": "Reactions.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"list": {
|
||||
"type": "array",
|
||||
"description": "A list of available reactions.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"iconClass"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"maxLength": 10,
|
||||
"description": "A type."
|
||||
},
|
||||
"iconClass": {
|
||||
"type": "string",
|
||||
"description": "A CSS class for an icon."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user