reactions

This commit is contained in:
Yuri Kuznetsov
2024-11-06 14:14:12 +02:00
parent 4d868ce59f
commit 0f127091c2
61 changed files with 2366 additions and 41 deletions

19
.idea/jsonSchemas.xml generated
View File

@@ -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>

View File

@@ -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"

View File

@@ -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]);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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>
*/

View File

@@ -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;
}

View 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;
}
}

View File

@@ -308,5 +308,7 @@ return [
'authIpAddressWhitelist' => [],
'authIpAddressCheckExcludedUsersIds' => [],
'authIpAddressCheckExcludedUsersNames' => (object) [],
'availableReactions' => ['Like'],
'streamReactionsCheckMaxSize' => 50,
'isInstalled' => false,
];

View File

@@ -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"
}
}

View File

@@ -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",

View File

@@ -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": {

View File

@@ -0,0 +1 @@
[]

View File

@@ -139,6 +139,10 @@
[
{"name": "assignmentNotificationsIgnoreEntityTypeList"},
{"name": "assignmentEmailNotificationsIgnoreEntityTypeList"}
],
[
{"name": "reactionNotifications"},
false
]
]
}

View File

@@ -27,7 +27,11 @@
"label": "Notifications",
"name": "notifications",
"rows": [
[{"name": "receiveStreamEmailNotifications"}, false]
[{"name": "receiveStreamEmailNotifications"}, false],
[
{"name": "reactionNotifications"},
false
]
]
}
]

View File

@@ -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"}]
]
}
]

View File

@@ -153,6 +153,9 @@
},
"authIpAddressCheckExcludedUsers": {
"level": "superAdmin"
},
"availableReactions": {
"level": "global"
}
}
}

View 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"
}
]
}

View File

@@ -11,5 +11,8 @@
},
"itemViews": {
"Post": "views/stream/notes/post"
},
"viewSetupHandlers": {
"record/detail": ["handlers/note/record-detail-setup"]
}
}

View File

@@ -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,

View File

@@ -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"

View File

@@ -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",

View File

@@ -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"
]
}
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,3 @@
{
"entity": true
}

View File

@@ -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",

View 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);
}
}

View 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,
]);
}
}

View 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);
}
}

View File

@@ -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);
}

View 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', []) === [];
}
}

View 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()]);
}
}

View File

@@ -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())

View File

@@ -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);
}
}

View 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);
}
}
}

View 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;
}
}

View File

@@ -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}}

View File

@@ -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>

View File

@@ -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);

View 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');
}
}

View 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;

View File

@@ -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) {

View File

@@ -387,6 +387,10 @@ class ModalView extends View {
if (!this.noFullHeight) {
this.initBodyScrollListener();
}
if (this.getParentView()) {
this.getParentView().trigger('modal-shown');
}
});
this.once('remove', () => {

View File

@@ -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'));

View File

@@ -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;

View 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();
}
}

View File

@@ -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() {

View 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;
}
}

View File

@@ -123,6 +123,7 @@ class NoteStreamView extends View {
listType: this.listType,
isThis: this.isThis,
parentModel: this.parentModel,
isNotification: this.options.isNotification,
});
}
}

View File

@@ -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;

View File

@@ -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,
});
}

View 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();
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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,
};
}
}

View File

@@ -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 {

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View 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."
}
}
}
}
}
}