diff --git a/application/Espo/Core/FieldProcessing/Stars/StarLoader.php b/application/Espo/Core/FieldProcessing/Stars/StarLoader.php new file mode 100644 index 0000000000..5c90d3a32c --- /dev/null +++ b/application/Espo/Core/FieldProcessing/Stars/StarLoader.php @@ -0,0 +1,57 @@ +. + * + * 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\FieldProcessing\Stars; + +use Espo\Core\FieldProcessing\Loader; +use Espo\Core\FieldProcessing\Loader\Params; +use Espo\ORM\Entity; +use Espo\Tools\Stars\StarService; + +/** + * @implements Loader + */ +class StarLoader implements Loader +{ + public function __construct( + private StarService $service, + ) {} + + public function process(Entity $entity, Params $params): void + { + if ( + !$entity->hasAttribute('isStarred') || + !$this->service->isEnabled($entity->getEntityType()) + ) { + return; + } + + $entity->set('isStarred', $this->service->checkIsStarred($entity)); + } +} diff --git a/application/Espo/Core/Select/Primary/Filters/Starred.php b/application/Espo/Core/Select/Primary/Filters/Starred.php new file mode 100644 index 0000000000..9bb14e833b --- /dev/null +++ b/application/Espo/Core/Select/Primary/Filters/Starred.php @@ -0,0 +1,59 @@ +. + * + * 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\Select\Primary\Filters; + +use Espo\Core\Select\Primary\Filter; +use Espo\Entities\StarSubscription; +use Espo\Entities\User; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +/** + * @noinspection PhpUnused + */ +class Starred implements Filter +{ + public function __construct(private string $entityType, private User $user) + {} + + public function apply(QueryBuilder $queryBuilder): void + { + $alias = 'starredPrimaryFilter'; + + $queryBuilder->join( + StarSubscription::ENTITY_TYPE, + $alias, + [ + "$alias.entityType" => $this->entityType, + "$alias.entityId=:" => 'id', + "$alias.userId" => $this->user->getId(), + ] + ); + } +} diff --git a/application/Espo/Core/Utils/Database/Orm/Converter.php b/application/Espo/Core/Utils/Database/Orm/Converter.php index ad3bcd6ba0..170e60487c 100644 --- a/application/Espo/Core/Utils/Database/Orm/Converter.php +++ b/application/Espo/Core/Utils/Database/Orm/Converter.php @@ -519,6 +519,18 @@ class Converter } } + // @todo Refactor. + if ($scopeDefs['stars'] ?? false) { + if (!isset($entityMetadata['fields']['isStarred'])) { + $ormMetadata[$entityType]['attributes']['isStarred'] = [ + 'type' => Entity::BOOL, + 'notStorable' => true, + 'notExportable' => true, + 'readOnly' => true, + ]; + } + } + // @todo Refactor. if ($this->metadata->get(['entityDefs', $entityType, 'optimisticConcurrencyControl'])) { $ormMetadata[$entityType]['attributes']['versionNumber'] = [ diff --git a/application/Espo/Entities/StarSubscription.php b/application/Espo/Entities/StarSubscription.php new file mode 100644 index 0000000000..fd94aca7a8 --- /dev/null +++ b/application/Espo/Entities/StarSubscription.php @@ -0,0 +1,37 @@ +. + * + * 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\ORM\Entity; + +class StarSubscription extends Entity +{ + public const ENTITY_TYPE = 'StarSubscription'; +} diff --git a/application/Espo/Resources/i18n/en_US/EntityManager.json b/application/Espo/Resources/i18n/en_US/EntityManager.json index c6bf6638be..4e886a00b8 100644 --- a/application/Espo/Resources/i18n/en_US/EntityManager.json +++ b/application/Espo/Resources/i18n/en_US/EntityManager.json @@ -43,6 +43,7 @@ "optimisticConcurrencyControl": "Optimistic concurrency control", "updateDuplicateCheck": "Duplicate check on update", "duplicateCheckFieldList": "Duplicate check fields", + "stars": "Stars", "layout": "Layout", "selectFilter": "Select Filter", "author": "Author", @@ -94,6 +95,7 @@ "duplicateCheckFieldList": "Which fields to check when performing checking for duplicates.", "updateDuplicateCheck": "Perform checking for duplicates when updating a record.", "optimisticConcurrencyControl": "Prevents writing conflicts.", + "stars": "The ability to star records. Stars can be used by users to bookmark records.", "statusField": "Updates of this field are logged in stream.", "textFilterFields": "Fields used by text search.", "stream": "Whether entity has a Stream.", diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index 7850cc4123..f08ca30701 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -193,6 +193,9 @@ "Country": "Country", "City": "City", "PostalCode": "Postal Code", + "Star": "Star", + "Unstar": "Unstar", + "Starred": "Starred", "Followed": "Followed", "Follow": "Follow", "Followers": "Followers", @@ -407,7 +410,8 @@ }, "presetFilters": { "followed": "Followed", - "all": "All" + "all": "All", + "starred": "Starred" }, "massActions": { "delete": "Delete", diff --git a/application/Espo/Resources/metadata/app/entityManagerParams.json b/application/Espo/Resources/metadata/app/entityManagerParams.json index 1dee6bad91..3ae86b6041 100644 --- a/application/Espo/Resources/metadata/app/entityManagerParams.json +++ b/application/Espo/Resources/metadata/app/entityManagerParams.json @@ -1,5 +1,12 @@ { "Global": { + "stars": { + "location": "scopes", + "fieldDefs": { + "type": "bool", + "tooltip": true + } + }, "optimisticConcurrencyControl": { "location": "entityDefs", "fieldDefs": { diff --git a/application/Espo/Resources/metadata/app/fieldProcessing.json b/application/Espo/Resources/metadata/app/fieldProcessing.json index c1dac143ed..d7df8d4204 100644 --- a/application/Espo/Resources/metadata/app/fieldProcessing.json +++ b/application/Espo/Resources/metadata/app/fieldProcessing.json @@ -6,7 +6,8 @@ "Espo\\Core\\FieldProcessing\\LinkParent\\Loader", "Espo\\Core\\FieldProcessing\\EmailAddress\\Loader", "Espo\\Core\\FieldProcessing\\PhoneNumber\\Loader", - "Espo\\Core\\FieldProcessing\\Stream\\FollowersLoader" + "Espo\\Core\\FieldProcessing\\Stream\\FollowersLoader", + "Espo\\Core\\FieldProcessing\\Stars\\StarLoader" ], "listLoaderClassNameList": [ "Espo\\Core\\FieldProcessing\\LinkParent\\Loader", diff --git a/application/Espo/Resources/metadata/entityDefs/StarSubscription.json b/application/Espo/Resources/metadata/entityDefs/StarSubscription.json new file mode 100644 index 0000000000..287f3e08aa --- /dev/null +++ b/application/Espo/Resources/metadata/entityDefs/StarSubscription.json @@ -0,0 +1,23 @@ +{ + "fields": { + "id": { + "type": "id", + "dbType": "bigint", + "autoincrement": true + }, + "entity": { + "type": "linkParent" + }, + "user": { + "type": "link" + }, + "createdAt": { + "type": "datetime" + } + }, + "indexes": { + "userEntity": { + "columns": ["userId", "entityId", "entityType"] + } + } +} diff --git a/application/Espo/Resources/routes.json b/application/Espo/Resources/routes.json index 8fc87b425e..10259af012 100644 --- a/application/Espo/Resources/routes.json +++ b/application/Espo/Resources/routes.json @@ -564,6 +564,16 @@ "action": "unfollow" } }, + { + "route": "/:entityType/:id/starSubscription", + "method": "put", + "actionClassName": "Espo\\Tools\\Stars\\Api\\PutStar" + }, + { + "route": "/:entityType/:id/starSubscription", + "method": "delete", + "actionClassName": "Espo\\Tools\\Stars\\Api\\DeleteUnstar" + }, { "route": "/:controller/:id/:link", "method": "get", diff --git a/application/Espo/Tools/EntityManager/NameUtil.php b/application/Espo/Tools/EntityManager/NameUtil.php index 814b606e7a..bfedb8473e 100644 --- a/application/Espo/Tools/EntityManager/NameUtil.php +++ b/application/Espo/Tools/EntityManager/NameUtil.php @@ -66,6 +66,7 @@ class NameUtil 'stream', 'subscription', 'followers', + 'starSubscription', 'action', 'null', 'false', diff --git a/application/Espo/Tools/FieldManager/FieldManager.php b/application/Espo/Tools/FieldManager/FieldManager.php index 2f994b2d3f..11d3f8ae86 100644 --- a/application/Espo/Tools/FieldManager/FieldManager.php +++ b/application/Espo/Tools/FieldManager/FieldManager.php @@ -57,6 +57,7 @@ class FieldManager 'deleted', 'skipDuplicateCheck', 'isFollowed', + 'isStarred', 'versionNumber', 'null', 'false', diff --git a/application/Espo/Tools/Stars/Api/DeleteUnstar.php b/application/Espo/Tools/Stars/Api/DeleteUnstar.php new file mode 100644 index 0000000000..f6112f28bf --- /dev/null +++ b/application/Espo/Tools/Stars/Api/DeleteUnstar.php @@ -0,0 +1,93 @@ +. + * + * 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\Stars\Api; + +use Espo\Core\Acl; +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\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\Tools\Stars\StarService; + +/** + * @noinspection PhpUnused + */ +class DeleteUnstar implements Action +{ + public function __construct( + private EntityManager $entityManager, + private Acl $acl, + private User $user, + private StarService $service, + ) {} + + public function process(Request $request): Response + { + $id = $request->getRouteParam('id'); + $entityType = $request->getRouteParam('entityType'); + + if (!is_string($id) || !is_string($entityType)) { + throw new BadRequest(); + } + + $entity = $this->getEntity($entityType, $id); + + $this->service->unstar($entity, $this->user); + + return ResponseComposer::json(true); + } + + /** + * @throws Forbidden + * @throws NotFound + */ + private function getEntity(string $entityType, string $id): Entity + { + $entity = $this->entityManager + ->getRDBRepository($entityType) + ->getById($id); + + if (!$entity) { + throw new NotFound(); + } + + if (!$this->acl->checkEntityRead($entity)) { + throw new Forbidden(); + } + + return $entity; + } +} diff --git a/application/Espo/Tools/Stars/Api/PutStar.php b/application/Espo/Tools/Stars/Api/PutStar.php new file mode 100644 index 0000000000..28b8f75a35 --- /dev/null +++ b/application/Espo/Tools/Stars/Api/PutStar.php @@ -0,0 +1,93 @@ +. + * + * 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\Stars\Api; + +use Espo\Core\Acl; +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\Exceptions\Forbidden; +use Espo\Core\Exceptions\NotFound; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use Espo\Tools\Stars\StarService; + +/** + * @noinspection PhpUnused + */ +class PutStar implements Action +{ + public function __construct( + private EntityManager $entityManager, + private Acl $acl, + private User $user, + private StarService $service, + ) {} + + public function process(Request $request): Response + { + $id = $request->getRouteParam('id'); + $entityType = $request->getRouteParam('entityType'); + + if (!is_string($id) || !is_string($entityType)) { + throw new BadRequest(); + } + + $entity = $this->getEntity($entityType, $id); + + $this->service->star($entity, $this->user); + + return ResponseComposer::json(true); + } + + /** + * @throws Forbidden + * @throws NotFound + */ + private function getEntity(string $entityType, string $id): Entity + { + $entity = $this->entityManager + ->getRDBRepository($entityType) + ->getById($id); + + if (!$entity) { + throw new NotFound(); + } + + if (!$this->acl->checkEntityRead($entity)) { + throw new Forbidden(); + } + + return $entity; + } +} diff --git a/application/Espo/Tools/Stars/StarService.php b/application/Espo/Tools/Stars/StarService.php new file mode 100644 index 0000000000..9cc5b8ea19 --- /dev/null +++ b/application/Espo/Tools/Stars/StarService.php @@ -0,0 +1,103 @@ +. + * + * 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\Stars; + +use Espo\Core\Utils\DateTime; +use Espo\Core\Utils\Metadata; +use Espo\Entities\StarSubscription; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +class StarService +{ + public function __construct( + private EntityManager $entityManager, + private User $user, + private Metadata $metadata, + ) {} + + public function checkIsStarred(Entity $entity, ?string $userId = null): bool + { + $userId ??= $this->user->getId(); + + return (bool) $this->entityManager + ->getRDBRepository(StarSubscription::ENTITY_TYPE) + ->select(['id']) + ->where([ + 'userId' => $userId, + 'entityType' => $entity->getEntityType(), + 'entityId' => $entity->getId(), + ]) + ->findOne(); + } + + public function isEnabled(string $entityType): bool + { + return (bool) $this->metadata->get("scopes.$entityType.stars"); + } + + public function star(Entity $entity, User $user): bool + { + if ($this->checkIsStarred($entity, $user->getId())) { + return true; + } + + $this->entityManager->createEntity(StarSubscription::ENTITY_TYPE, [ + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + 'userId' => $user->getId(), + 'createdAt' => DateTime::getSystemNowString(), + ]); + + return true; + } + + public function unstar(Entity $entity, User $user): bool + { + if (!$this->isEnabled($entity->getEntityType())) { + return false; + } + + $delete = $this->entityManager->getQueryBuilder() + ->delete() + ->from(StarSubscription::ENTITY_TYPE) + ->where([ + 'userId' => $user->getId(), + 'entityId' => $entity->getId(), + 'entityType' => $entity->getEntityType(), + ]) + ->build(); + + $this->entityManager->getQueryExecutor()->execute($delete); + + return true; + } +} diff --git a/client/src/views/detail.js b/client/src/views/detail.js index aaa2b8582d..ffbc0d86a8 100644 --- a/client/src/views/detail.js +++ b/client/src/views/detail.js @@ -109,6 +109,7 @@ class DetailView extends MainView { this.setupRecord(); this.setupPageTitle(); this.initFollowButtons(); + this.initStarButtons(); this.initRedirect(); } @@ -221,6 +222,86 @@ class DetailView extends MainView { .get('clientDefs.' + this.scope + '.recordViews.detail') || this.recordView; } + /** @private */ + initStarButtons() { + if (!this.getMetadata().get(`scopes.${this.scope}.stars`)) { + return; + } + + this.addStarButtons(); + this.listenTo(this.model, 'change:isStarred', () => this.controlStarButtons()); + } + + /** @private */ + addStarButtons() { + const isStarred = this.model.get('isStarred'); + + this.addMenuItem('buttons', { + name: 'unstar', + iconHtml: '', + text: this.translate('Starred'), + hidden: !isStarred, + //title: this.translate('Unstar'), + onClick: () => this.actionUnstar(), + }, true); + + this.addMenuItem('buttons', { + name: 'star', + iconHtml: '', + text: this.translate('Star'), + //title: this.translate('Star'), + hidden: isStarred || !this.model.has('isStarred'), + onClick: () => this.actionStar(), + }, true); + } + + /** @private */ + controlStarButtons() { + const isStarred = this.model.get('isStarred'); + + if (isStarred) { + this.hideHeaderActionItem('star'); + this.showHeaderActionItem('unstar'); + + return; + } + + this.hideHeaderActionItem('unstar'); + this.showHeaderActionItem('star'); + } + + /** + * Action 'star'. + */ + actionStar() { + this.disableMenuItem('star'); + + Espo.Ajax + .putRequest(`${this.entityType}/${this.model.id}/starSubscription`) + .then(() => { + this.hideHeaderActionItem('star'); + + this.model.set('isStarred', true, {sync: true}); + }) + .finally(() => this.enableMenuItem('star')); + } + + /** + * Action 'unstar'. + */ + actionUnstar() { + this.disableMenuItem('unstar'); + + Espo.Ajax + .deleteRequest(`${this.entityType}/${this.model.id}/starSubscription`) + .then(() => { + this.hideHeaderActionItem('unstar'); + + this.model.set('isStarred', false, {sync: true}); + }) + .finally(() => this.enableMenuItem('unstar')); + } + /** @private */ initFollowButtons() { if (!this.getMetadata().get(['scopes', this.scope, 'stream'])) { diff --git a/client/src/views/record/search.js b/client/src/views/record/search.js index 29775406f4..fab49bcec0 100644 --- a/client/src/views/record/search.js +++ b/client/src/views/record/search.js @@ -253,7 +253,7 @@ class SearchView extends View { const filterList = this.options.filterList || this.getMetadata().get(['clientDefs', this.scope, 'filterList']) || []; - this.presetFilterList = Espo.Utils.clone(filterList).filter(item => { + this.presetFilterList = filterList.filter(item => { if (typeof item === 'string') { return true; } @@ -281,6 +281,12 @@ class SearchView extends View { return true; }); + if (this.getMetadata().get(`scopes.${this.scope}.stars`)) { + this.presetFilterList.unshift({ + name: 'starred', + }); + } + ((this.getPreferences().get('presetFilters') || {})[this.scope] || []) .forEach(item => { this.presetFilterList.push(item);