pinned notes
This commit is contained in:
@@ -70,6 +70,7 @@ class BeforeCreate implements SaveHook
|
||||
|
||||
$targetType = $entity->getTargetType();
|
||||
|
||||
$entity->clear('isPinned');
|
||||
$entity->clear('isGlobal');
|
||||
|
||||
switch ($targetType) {
|
||||
|
||||
@@ -32,7 +32,6 @@ namespace Espo\Classes\RecordHooks\Note;
|
||||
use Espo\Core\Exceptions\ForbiddenSilent;
|
||||
use Espo\Core\Record\Hook\SaveHook;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\Entities\User;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\Tools\Stream\NoteUtil;
|
||||
|
||||
@@ -43,18 +42,27 @@ use Espo\Tools\Stream\NoteUtil;
|
||||
class BeforeUpdate implements SaveHook
|
||||
{
|
||||
public function __construct(
|
||||
private User $user,
|
||||
private NoteUtil $noteUtil
|
||||
private NoteUtil $noteUtil,
|
||||
) {}
|
||||
|
||||
public function process(Entity $entity): void
|
||||
{
|
||||
if (!$this->isEditableType($entity)) {
|
||||
throw new ForbiddenSilent("Note is not editable.");
|
||||
}
|
||||
|
||||
if ($entity->isPost()) {
|
||||
$this->noteUtil->handlePostText($entity);
|
||||
}
|
||||
|
||||
if (!$entity->isPost() && !$this->user->isAdmin()) {
|
||||
throw new ForbiddenSilent("Only 'Post' type allowed.");
|
||||
if (!$entity->isPost()) {
|
||||
$entity->clear('post');
|
||||
$entity->clear('attachmentsIds');
|
||||
}
|
||||
}
|
||||
|
||||
private function isEditableType(Note $entity): bool
|
||||
{
|
||||
return $entity->getType() == Note::TYPE_POST;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,19 +68,28 @@ class Stream
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
if ($id === null && $scope !== UserEntity::ENTITY_TYPE) {
|
||||
throw new BadRequest("No ID.");
|
||||
}
|
||||
|
||||
$searchParams = $this->fetchSearchParams($request);
|
||||
|
||||
$result = $scope === UserEntity::ENTITY_TYPE ?
|
||||
$this->userRecordService->find($id, $searchParams) :
|
||||
$this->service->find($scope, $id ?? '', $searchParams);
|
||||
if ($scope === UserEntity::ENTITY_TYPE) {
|
||||
$collection = $this->userRecordService->find($id, $searchParams);
|
||||
|
||||
return (object) [
|
||||
'total' => $collection->getTotal(),
|
||||
'list' => $collection->getValueMapList(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($id === null) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
$collection = $this->service->find($scope, $id, $searchParams);
|
||||
$pinnedCollection = $this->service->getPinned($scope, $id);
|
||||
|
||||
return (object) [
|
||||
'total' => $result->getTotal(),
|
||||
'list' => $result->getValueMapList(),
|
||||
'total' => $collection->getTotal(),
|
||||
'list' => $collection->getValueMapList(),
|
||||
'pinnedList' => $pinnedCollection->getValueMapList(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
|
||||
namespace Espo\Entities;
|
||||
|
||||
use Espo\Core\Field\LinkMultiple;
|
||||
use Espo\Core\Field\LinkParent;
|
||||
use Espo\Core\ORM\Entity;
|
||||
|
||||
@@ -327,4 +326,16 @@ class Note extends Entity
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPinned(): bool
|
||||
{
|
||||
return (bool) $this->get('isPinned');
|
||||
}
|
||||
|
||||
public function setIsPinned(bool $isPinned): self
|
||||
{
|
||||
$this->set('isPinned', $isPinned);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,11 +29,17 @@
|
||||
|
||||
namespace Espo\Hooks\Note;
|
||||
|
||||
use Espo\Core\Hook\Hook\AfterSave;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\ORM\Entity;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Core\WebSocket\Submission as WebSocketSubmission;
|
||||
use Espo\ORM\Repository\Option\SaveOptions;
|
||||
|
||||
class WebSocketSubmit
|
||||
/**
|
||||
* @implements AfterSave<Note>
|
||||
*/
|
||||
class WebSocketSubmit implements AfterSave
|
||||
{
|
||||
public static int $order = 20;
|
||||
|
||||
@@ -42,32 +48,29 @@ class WebSocketSubmit
|
||||
private Config $config
|
||||
) {}
|
||||
|
||||
public function afterSave(Entity $entity): void
|
||||
public function afterSave(Entity $entity, SaveOptions $options): void
|
||||
{
|
||||
if (!$this->config->get('useWebSocket')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parentId = $entity->get('parentId');
|
||||
$parentType = $entity->get('parentType');
|
||||
$parentId = $entity->getParentId();
|
||||
$parentType = $entity->getParentType();
|
||||
|
||||
if (!$parentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$parentType) {
|
||||
if (!$parentId || !$parentType) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = (object) [
|
||||
'createdById' => $entity->get('createdById'),
|
||||
'createdById' => $entity->getCreatedById(),
|
||||
];
|
||||
|
||||
if (!$entity->isNew()) {
|
||||
$data->noteId = $entity->getId();
|
||||
$data->pin = $entity->isAttributeChanged('isPinned');
|
||||
}
|
||||
|
||||
$topic = "streamUpdate.{$parentType}.{$parentId}";
|
||||
$topic = "streamUpdate.$parentType.$parentId";
|
||||
|
||||
$this->webSocketSubmission->submit($topic, null, $data);
|
||||
}
|
||||
|
||||
@@ -255,6 +255,7 @@ return [
|
||||
'recordListMaxSizeLimit' => 200,
|
||||
'noteDeleteThresholdPeriod' => '1 month',
|
||||
'noteEditThresholdPeriod' => '7 days',
|
||||
'notePinnedMaxCount' => 5,
|
||||
'emailForceUseExternalClient' => false,
|
||||
'useWebSocket' => false,
|
||||
'webSocketMessager' => 'ZeroMQ',
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"type": "Type",
|
||||
"isGlobal": "Is Global",
|
||||
"isInternal": "Is Internal (for internal users)",
|
||||
"isPinned": "Is Pinned",
|
||||
"related": "Related",
|
||||
"createdByGender": "Created By Gender",
|
||||
"data": "Data",
|
||||
@@ -43,10 +44,14 @@
|
||||
},
|
||||
"labels": {
|
||||
"View Posts": "View Posts",
|
||||
"View Activity": "View Activity"
|
||||
"View Activity": "View Activity",
|
||||
"Pin": "Pin",
|
||||
"Unpin": "Unpin",
|
||||
"Pinned": "Pinned"
|
||||
},
|
||||
"messages": {
|
||||
"writeMessage": "Write your message here"
|
||||
"writeMessage": "Write your message here",
|
||||
"pinnedMaxCountExceeded": "Cannot pin more notes. Max allowed number is {count}."
|
||||
},
|
||||
"links": {
|
||||
"portals": "Portals",
|
||||
|
||||
@@ -49,12 +49,14 @@
|
||||
"portals"
|
||||
],
|
||||
"maxLength": 7,
|
||||
"customizationDisabled": true
|
||||
"customizationDisabled": true,
|
||||
"readOnlyAfterCreate": true
|
||||
},
|
||||
"parent": {
|
||||
"type": "linkParent",
|
||||
"customizationDisabled": true,
|
||||
"view": "views/note/fields/parent"
|
||||
"view": "views/note/fields/parent",
|
||||
"readOnlyAfterCreate": true
|
||||
},
|
||||
"related": {
|
||||
"type": "linkParent",
|
||||
@@ -81,21 +83,25 @@
|
||||
"teams": {
|
||||
"type": "linkMultiple",
|
||||
"noLoad": true,
|
||||
"customizationDisabled": true
|
||||
"customizationDisabled": true,
|
||||
"readOnlyAfterCreate": true
|
||||
},
|
||||
"portals": {
|
||||
"type": "linkMultiple",
|
||||
"noLoad": true,
|
||||
"customizationDisabled": true
|
||||
"customizationDisabled": true,
|
||||
"readOnlyAfterCreate": true
|
||||
},
|
||||
"users": {
|
||||
"type": "linkMultiple",
|
||||
"noLoad": true,
|
||||
"customizationDisabled": true
|
||||
"customizationDisabled": true,
|
||||
"readOnlyAfterCreate": true
|
||||
},
|
||||
"isGlobal": {
|
||||
"type": "bool",
|
||||
"customizationDisabled": true
|
||||
"customizationDisabled": true,
|
||||
"readOnlyAfterCreate": true
|
||||
},
|
||||
"createdByGender": {
|
||||
"type": "foreign",
|
||||
@@ -107,11 +113,18 @@
|
||||
"type": "jsonArray",
|
||||
"notStorable": true,
|
||||
"utility": true,
|
||||
"customizationDisabled": true
|
||||
"customizationDisabled": true,
|
||||
"readOnly": true
|
||||
},
|
||||
"isInternal": {
|
||||
"type": "bool",
|
||||
"customizationDisabled": true
|
||||
"customizationDisabled": true,
|
||||
"readOnlyAfterCreate": true
|
||||
},
|
||||
"isPinned": {
|
||||
"type": "bool",
|
||||
"customizationDisabled": true,
|
||||
"readOnly": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "datetime",
|
||||
|
||||
@@ -564,6 +564,16 @@
|
||||
"action": "unfollow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"route": "/:Note/:id/pin",
|
||||
"method": "post",
|
||||
"actionClassName": "Espo\\Tools\\Stream\\Api\\PostNotePin"
|
||||
},
|
||||
{
|
||||
"route": "/:Note/:id/pin",
|
||||
"method": "delete",
|
||||
"actionClassName": "Espo\\Tools\\Stream\\Api\\DeleteNotePin"
|
||||
},
|
||||
{
|
||||
"route": "/:entityType/:id/starSubscription",
|
||||
"method": "put",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<?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\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\Note;
|
||||
use Espo\ORM\EntityManager;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class DeleteNotePin implements Action
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private Acl $acl
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$id = $request->getRouteParam('id');
|
||||
|
||||
if (!$id) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
$note = $this->getNote($id);
|
||||
|
||||
$this->checkParent($note);
|
||||
|
||||
if ($note->isPinned()) {
|
||||
$note->setIsPinned(false);
|
||||
$this->entityManager->saveEntity($note);
|
||||
}
|
||||
|
||||
return ResponseComposer::json(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
*/
|
||||
private function getNote(string $id): Note
|
||||
{
|
||||
$note = $this->entityManager->getRDBRepositoryByClass(Note::class)->getById($id);
|
||||
|
||||
if (!$note) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityRead($note)) {
|
||||
throw new Forbidden("No read access.");
|
||||
}
|
||||
|
||||
return $note;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function checkParent(Note $note): void
|
||||
{
|
||||
if (!$note->getParentType() || !$note->getParentId()) {
|
||||
throw new Forbidden("No parent.");
|
||||
}
|
||||
|
||||
$parent = $this->entityManager->getEntityById($note->getParentType(), $note->getParentId());
|
||||
|
||||
if (!$parent) {
|
||||
throw new Forbidden("Parent not found.");
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityEdit($parent)) {
|
||||
throw new Forbidden("No parent edit access.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?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\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\Error\Body;
|
||||
use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Utils\Config;
|
||||
use Espo\Entities\Note;
|
||||
use Espo\ORM\EntityManager;
|
||||
|
||||
/**
|
||||
* @noinspection PhpUnused
|
||||
*/
|
||||
class PostNotePin implements Action
|
||||
{
|
||||
private const PINNED_MAX_COUNT = 5;
|
||||
|
||||
public function __construct(
|
||||
private Config $config,
|
||||
private EntityManager $entityManager,
|
||||
private Acl $acl
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function process(Request $request): Response
|
||||
{
|
||||
$id = $request->getRouteParam('id');
|
||||
|
||||
if (!$id) {
|
||||
throw new BadRequest();
|
||||
}
|
||||
|
||||
$note = $this->getNote($id);
|
||||
|
||||
$this->checkCanBePinned($note);
|
||||
$this->checkParent($note);
|
||||
$this->checkPinnedCount($note);
|
||||
|
||||
return ResponseComposer::json(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
*/
|
||||
private function getNote(string $id): Note
|
||||
{
|
||||
$note = $this->entityManager->getRDBRepositoryByClass(Note::class)->getById($id);
|
||||
|
||||
if (!$note) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityRead($note)) {
|
||||
throw new Forbidden("No read access.");
|
||||
}
|
||||
|
||||
$note->setIsPinned(true);
|
||||
$this->entityManager->saveEntity($note);
|
||||
|
||||
return $note;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function checkPinnedCount(Note $entity): void
|
||||
{
|
||||
$maxCount = $this->config->get('notePinnedMaxCount') ?? self::PINNED_MAX_COUNT;
|
||||
|
||||
$count = $this->entityManager
|
||||
->getRDBRepositoryByClass(Note::class)
|
||||
->where([
|
||||
'parentId' => $entity->getParentId(),
|
||||
'parentType' => $entity->getParentType(),
|
||||
'isPinned' => true,
|
||||
])
|
||||
->count();
|
||||
|
||||
if ($count < $maxCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw Forbidden::createWithBody(
|
||||
'Pinned notes max count exceeded.',
|
||||
Body::create()->withMessageTranslation('pinnedMaxCountExceeded', 'Note', ['count' => (string) $count])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function checkParent(Note $note): void
|
||||
{
|
||||
if (!$note->getParentType() || !$note->getParentId()) {
|
||||
throw new Forbidden("No parent.");
|
||||
}
|
||||
|
||||
$parent = $this->entityManager->getEntityById($note->getParentType(), $note->getParentId());
|
||||
|
||||
if (!$parent) {
|
||||
throw new Forbidden("Parent not found.");
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntityEdit($parent)) {
|
||||
throw new Forbidden("No parent edit access.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
*/
|
||||
private function checkCanBePinned(Note $note): void
|
||||
{
|
||||
if (!$this->isEditableType($note)) {
|
||||
throw new Forbidden("Cannot pin note.");
|
||||
}
|
||||
}
|
||||
|
||||
private function isEditableType(Note $entity): bool
|
||||
{
|
||||
return in_array($entity->getType(), [
|
||||
Note::TYPE_POST,
|
||||
Note::TYPE_EMAIL_RECEIVED,
|
||||
Note::TYPE_EMAIL_SENT,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ use Espo\Core\Exceptions\Forbidden;
|
||||
use Espo\Core\Exceptions\NotFound;
|
||||
use Espo\Core\Select\SearchParams;
|
||||
use Espo\Core\Utils\Metadata;
|
||||
use Espo\ORM\Collection;
|
||||
use Espo\ORM\EntityManager;
|
||||
use Espo\Entities\User;
|
||||
use Espo\Entities\Note;
|
||||
@@ -48,6 +49,8 @@ use Espo\Tools\Stream\RecordService\QueryHelper;
|
||||
|
||||
class RecordService
|
||||
{
|
||||
private const PINNED_MAX_SIZE = 100;
|
||||
|
||||
public function __construct(
|
||||
private EntityManager $entityManager,
|
||||
private User $user,
|
||||
@@ -69,19 +72,7 @@ class RecordService
|
||||
*/
|
||||
public function find(string $scope, string $id, SearchParams $searchParams): RecordCollection
|
||||
{
|
||||
if ($scope === User::ENTITY_TYPE) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getEntityById($scope, $id);
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound();
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntity($entity, Table::ACTION_STREAM)) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
$this->checkAccess($scope, $id);
|
||||
|
||||
return $this->findInternal($scope, $id, $searchParams);
|
||||
}
|
||||
@@ -123,6 +114,53 @@ class RecordService
|
||||
return $this->findInternal($scope, $id, $searchParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pinned notes.
|
||||
*
|
||||
* @return Collection<Note>
|
||||
* @throws Forbidden
|
||||
* @throws BadRequest
|
||||
* @throws NotFound
|
||||
*/
|
||||
public function getPinned(string $scope, string $id): Collection
|
||||
{
|
||||
$this->checkAccess($scope, $id);
|
||||
|
||||
$builder = $this->queryHelper->buildBaseQueryBuilder(SearchParams::create());
|
||||
|
||||
$where = [
|
||||
'parentType' => $scope,
|
||||
'parentId' => $id,
|
||||
'isPinned' => true,
|
||||
];
|
||||
|
||||
if ($this->user->isPortal()) {
|
||||
$where[] = ['isInternal' => true];
|
||||
}
|
||||
|
||||
$this->applyPortalAccess($builder, $where);
|
||||
$this->applyAccess($builder, $id, $scope, $where);
|
||||
$this->applyIgnore($where);
|
||||
$this->applyStatusIgnore($scope, $where);
|
||||
|
||||
$builder->where($where);
|
||||
|
||||
$builder
|
||||
->limit(0, self::PINNED_MAX_SIZE)
|
||||
->order('number', 'DESC');
|
||||
|
||||
$collection = $this->entityManager
|
||||
->getRDBRepositoryByClass(Note::class)
|
||||
->clone($builder->build())
|
||||
->find();
|
||||
|
||||
foreach ($collection as $item) {
|
||||
$this->prepareNote($item, $scope, $id);
|
||||
}
|
||||
|
||||
return $collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a record stream records.
|
||||
*
|
||||
@@ -402,4 +440,25 @@ class RecordService
|
||||
$this->noteHelper->prepare($note);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Forbidden
|
||||
* @throws NotFound
|
||||
*/
|
||||
private function checkAccess(string $scope, string $id): void
|
||||
{
|
||||
if ($scope === User::ENTITY_TYPE) {
|
||||
throw new Forbidden();
|
||||
}
|
||||
|
||||
$entity = $this->entityManager->getEntityById($scope, $id);
|
||||
|
||||
if (!$entity) {
|
||||
throw new NotFound("Record not found.");
|
||||
}
|
||||
|
||||
if (!$this->acl->checkEntity($entity, Table::ACTION_STREAM)) {
|
||||
throw new Forbidden("No stream access.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-container">
|
||||
{{{list}}}
|
||||
</div>
|
||||
{{#if hasPinned}}
|
||||
<div class="list-container" data-role="pinned">{{{pinnedList}}}</div>
|
||||
{{/if}}
|
||||
<div class="list-container" data-role="stream">{{{list}}}</div>
|
||||
|
||||
@@ -214,7 +214,7 @@ class Collection {
|
||||
/**
|
||||
* Add models or a model.
|
||||
*
|
||||
* @param {Model[]|Model} models Models ar a model.
|
||||
* @param {Model[]|Model|Record} models Models ar a model.
|
||||
* @param {{
|
||||
* merge?: boolean,
|
||||
* at?: number,
|
||||
@@ -749,7 +749,7 @@ class Collection {
|
||||
* Prepare attributes.
|
||||
*
|
||||
* @protected
|
||||
* @param {*} response A response from the backend.
|
||||
* @param {Object.<string, *>|Record[]} response A response from the backend.
|
||||
* @param {Object.<string, *>} options Options.
|
||||
* @returns {Object.<string, *>[]}
|
||||
*/
|
||||
|
||||
@@ -34,6 +34,11 @@ class NoteCollection extends Collection {
|
||||
|
||||
paginationByNumber = false
|
||||
|
||||
/**
|
||||
* @type {Record[]}
|
||||
*/
|
||||
pinnedList
|
||||
|
||||
/** @inheritDoc */
|
||||
prepareAttributes(response, params) {
|
||||
const total = this.total;
|
||||
@@ -41,11 +46,12 @@ class NoteCollection extends Collection {
|
||||
const list = super.prepareAttributes(response, params);
|
||||
|
||||
if (params.data && params.data.after) {
|
||||
if (total >= 0 && response.total >= 0) {
|
||||
this.total = total + response.total;
|
||||
} else {
|
||||
this.total = total;
|
||||
}
|
||||
this.total = total >= 0 && response.total >= 0 ?
|
||||
total + response.total : total;
|
||||
}
|
||||
|
||||
if (response.pinnedList) {
|
||||
this.pinnedList = Espo.Utils.cloneDeep(response.pinnedList);
|
||||
}
|
||||
|
||||
return list;
|
||||
|
||||
@@ -2722,6 +2722,8 @@ class DetailRecordView extends BaseRecordView {
|
||||
|
||||
if (editAccess === null) {
|
||||
this.listenToOnce(this.model, 'sync', () => {
|
||||
this.model.trigger('acl-edit-ready');
|
||||
|
||||
this.manageAccessEdit(true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3260,6 +3260,7 @@ class ListRecordView extends View {
|
||||
.then(() => {
|
||||
Espo.Ui.success(this.translate('Removed'));
|
||||
|
||||
this.trigger('after:delete', model);
|
||||
this.removeRecordFromList(id);
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -119,6 +119,8 @@ class NoteStreamView extends View {
|
||||
isEditable: this.isEditable,
|
||||
isRemovable: this.isRemovable,
|
||||
listType: this.listType,
|
||||
isThis: this.isThis,
|
||||
parentModel: this.parentModel,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ class PanelStreamView extends RelationshipPanelView {
|
||||
/** @private */
|
||||
_justPosted = false
|
||||
|
||||
/** @type {import('collections/note').default} */
|
||||
pinnedCollection
|
||||
|
||||
additionalEvents = {
|
||||
/** @this PanelStreamView */
|
||||
'focus textarea[data-name="post"]': function () {
|
||||
@@ -102,6 +105,7 @@ class PanelStreamView extends RelationshipPanelView {
|
||||
data.postDisabled = this.postDisabled;
|
||||
data.placeholderText = this.placeholderText;
|
||||
data.allowInternalNotes = this.allowInternalNotes;
|
||||
data.hasPinned = this.hasPinned;
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -204,6 +208,8 @@ class PanelStreamView extends RelationshipPanelView {
|
||||
this.allowInternalNotes = this.getMetadata().get(['clientDefs', this.entityType, 'allowInternalNotes']);
|
||||
}
|
||||
|
||||
this.hasPinned = this.model.entityType !== 'User';
|
||||
|
||||
this.isInternalNoteMode = false;
|
||||
|
||||
this.storageTextKey = 'stream-post-' + this.model.entityType + '-' + this.model.id;
|
||||
@@ -267,7 +273,10 @@ class PanelStreamView extends RelationshipPanelView {
|
||||
this.initPostEvents(view);
|
||||
});
|
||||
|
||||
this.wait(this.createCollection());
|
||||
this.wait(
|
||||
this.createCollection()
|
||||
.then(() => this.setupPinned())
|
||||
);
|
||||
|
||||
this.listenTo(this.seed, 'change:attachmentsIds', () => {
|
||||
this.controlPostButtonAvailability();
|
||||
@@ -319,7 +328,9 @@ class PanelStreamView extends RelationshipPanelView {
|
||||
model.fetch();
|
||||
}
|
||||
|
||||
return;
|
||||
if (!data.pin) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.collection.fetchNew();
|
||||
@@ -428,12 +439,65 @@ class PanelStreamView extends RelationshipPanelView {
|
||||
}
|
||||
|
||||
const onSync = () => {
|
||||
if (this.hasPinned) {
|
||||
this.pinnedCollection.add(this.collection.pinnedList);
|
||||
|
||||
this.createView('pinnedList', 'views/stream/record/list', {
|
||||
selector: '> .list-container[data-role="pinned"]',
|
||||
collection: this.pinnedCollection,
|
||||
model: this.model,
|
||||
noDataDisabled: true,
|
||||
}, view => {
|
||||
view.render();
|
||||
|
||||
this.listenTo(view, 'after:save', /** import('model').default */model => {
|
||||
const cModel = this.collection.get(model.id);
|
||||
|
||||
if (!cModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
cModel.setMultiple({
|
||||
post: model.attributes.post,
|
||||
attachmentsIds: model.attributes.attachmentsIds,
|
||||
attachmentsNames: model.attributes.attachmentsNames,
|
||||
});
|
||||
});
|
||||
|
||||
this.listenTo(view, 'after:delete', /** import('model').default */model => {
|
||||
this.collection.remove(model.id);
|
||||
this.collection.trigger('update-sync');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.createView('list', 'views/stream/record/list', {
|
||||
selector: '> .list-container',
|
||||
selector: '> .list-container[data-role="stream"]',
|
||||
collection: this.collection,
|
||||
model: this.model,
|
||||
}, view => {
|
||||
view.render();
|
||||
|
||||
if (this.pinnedCollection) {
|
||||
this.listenTo(view, 'after:delete', /** import('model').default */model => {
|
||||
this.pinnedCollection.remove(model.id);
|
||||
this.pinnedCollection.trigger('update-sync');
|
||||
});
|
||||
|
||||
this.listenTo(view, 'after:save', /** import('model').default */model => {
|
||||
const cModel = this.pinnedCollection.get(model.id);
|
||||
|
||||
if (!cModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
cModel.setMultiple({
|
||||
post: model.attributes.post,
|
||||
attachmentsIds: model.attributes.attachmentsIds,
|
||||
attachmentsNames: model.attributes.attachmentsNames,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.stopListening(this.model, 'all');
|
||||
@@ -798,6 +862,53 @@ class PanelStreamView extends RelationshipPanelView {
|
||||
enablePostButton() {
|
||||
this.$postButton.removeClass('disabled').removeAttr('disabled');
|
||||
}
|
||||
|
||||
setupPinned() {
|
||||
if (!this.hasPinned) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promise = this.getCollectionFactory().create('Note')
|
||||
.then(/** import('collections/note').default */collection => {
|
||||
this.pinnedCollection = collection;
|
||||
|
||||
this.listenTo(this.collection, 'sync', () => {
|
||||
if (!this.collection.pinnedList) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pinnedCollection.reset();
|
||||
this.pinnedCollection.add(this.collection.pinnedList);
|
||||
this.pinnedCollection.trigger('sync', this.pinnedCollection, {}, {});
|
||||
});
|
||||
|
||||
this.listenTo(this.pinnedCollection, 'pin unpin', () => {
|
||||
this.collection.fetchNew();
|
||||
});
|
||||
|
||||
this.listenTo(this.pinnedCollection, 'pin', id => {
|
||||
const model = this.collection.get(id);
|
||||
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.set('isPinned', true);
|
||||
});
|
||||
|
||||
this.listenTo(this.pinnedCollection, 'unpin', id => {
|
||||
const model = this.collection.get(id);
|
||||
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.set('isPinned', false);
|
||||
});
|
||||
});
|
||||
|
||||
this.wait(promise);
|
||||
}
|
||||
}
|
||||
|
||||
export default PanelStreamView;
|
||||
|
||||
@@ -49,6 +49,10 @@ class ListStreamRecordView extends ListExpandedRecordView {
|
||||
|
||||
this.isRenderingNew = false;
|
||||
|
||||
this.listenTo(this.collection, 'update-sync', () => {
|
||||
this.buildRows(() => this.reRender());
|
||||
});
|
||||
|
||||
this.listenTo(this.collection, 'sync', (c, r, options) => {
|
||||
if (!options.fetchNew) {
|
||||
return;
|
||||
@@ -195,6 +199,60 @@ class ListStreamRecordView extends ListExpandedRecordView {
|
||||
showNewRecords() {
|
||||
return this.collection.fetchNew();
|
||||
}
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
/**
|
||||
* @private
|
||||
* @param {{id: string}} data
|
||||
*/
|
||||
actionPin(data) {
|
||||
const collection = /** @type {import('collections/note').default} */this.collection;
|
||||
|
||||
Espo.Ui.notify(' ... ');
|
||||
|
||||
Espo.Ajax.postRequest(`Note/${data.id}/pin`).then(() => {
|
||||
Espo.Ui.notify(false);
|
||||
|
||||
const model = collection.get(data.id);
|
||||
|
||||
if (model) {
|
||||
model.set('isPinned', true);
|
||||
}
|
||||
|
||||
if (collection.pinnedList) {
|
||||
collection.fetchNew();
|
||||
}
|
||||
|
||||
collection.trigger('pin', model.id);
|
||||
});
|
||||
}
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
/**
|
||||
* @private
|
||||
* @param {{id: string}} data
|
||||
*/
|
||||
actionUnpin(data) {
|
||||
const collection = /** @type {import('collections/note').default} */this.collection;
|
||||
|
||||
Espo.Ui.notify(' ... ');
|
||||
|
||||
Espo.Ajax.deleteRequest(`Note/${data.id}/pin`).then(() => {
|
||||
Espo.Ui.notify(false);
|
||||
|
||||
const model = collection.get(data.id);
|
||||
|
||||
if (model) {
|
||||
model.set('isPinned', false);
|
||||
}
|
||||
|
||||
if (collection.pinnedList) {
|
||||
collection.fetchNew();
|
||||
}
|
||||
|
||||
collection.trigger('unpin', model.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default ListStreamRecordView;
|
||||
|
||||
@@ -30,6 +30,18 @@ import DefaultRowActionsView from 'views/record/row-actions/default';
|
||||
|
||||
class StreamDefaultNoteRowActionsView extends DefaultRowActionsView {
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
/** @type import('model').default */
|
||||
this.parentModel = this.options.parentModel;
|
||||
|
||||
if (this.options.isThis && this.parentModel) {
|
||||
this.listenTo(this.model, 'change:isPinned', () => this.reRender());
|
||||
this.listenToOnce(this.parentModel, 'acl-edit-ready', () => this.reRender());
|
||||
}
|
||||
}
|
||||
|
||||
getActionList() {
|
||||
const list = [];
|
||||
|
||||
@@ -40,6 +52,7 @@ class StreamDefaultNoteRowActionsView extends DefaultRowActionsView {
|
||||
data: {
|
||||
id: this.model.id,
|
||||
},
|
||||
groupIndex: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,9 +63,35 @@ class StreamDefaultNoteRowActionsView extends DefaultRowActionsView {
|
||||
data: {
|
||||
id: this.model.id,
|
||||
},
|
||||
groupIndex: 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
this.options.isThis &&
|
||||
['Post', 'EmailReceived', 'EmailSent'].includes(this.model.get('type')) &&
|
||||
this.parentModel &&
|
||||
this.getAcl().checkModel(this.parentModel, 'edit')
|
||||
) {
|
||||
!this.model.get('isPinned') ?
|
||||
list.push({
|
||||
action: 'pin',
|
||||
label: 'Pin',
|
||||
data: {
|
||||
id: this.model.id,
|
||||
},
|
||||
groupIndex: 1,
|
||||
}) :
|
||||
list.push({
|
||||
action: 'unpin',
|
||||
label: 'Unpin',
|
||||
data: {
|
||||
id: this.model.id,
|
||||
},
|
||||
groupIndex: 1,
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
@panel-danger-bg-value: @state-danger-bg-value;
|
||||
@panel-warning-bg-value: @state-warning-bg-value;
|
||||
|
||||
@warning-bg-value: #2f2d28;
|
||||
|
||||
@navbar-inverse-color-value: @gray-light-value;
|
||||
@navbar-inverse-bg-value: #161515;
|
||||
@navbar-inverse-link-color-value: @text-color-value;
|
||||
|
||||
@@ -1219,22 +1219,6 @@ section {
|
||||
}
|
||||
}
|
||||
|
||||
.panel > .panel-body > .list-container > .list > .list-group {
|
||||
> .list-group-item {
|
||||
border-width: 0;
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
> li.list-group-item:first-child {
|
||||
border-top-width: 1px;
|
||||
border-top-color: transparent;
|
||||
}
|
||||
|
||||
> li.list-group-item:last-child {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.panel.dashlet > .panel-body > .list-container > .list > .list-group {
|
||||
> li.list-group-item:last-child {
|
||||
border-bottom-width: 1px;
|
||||
@@ -3697,6 +3681,21 @@ body > .autocomplete-suggestions.text-search-suggestions {
|
||||
}
|
||||
}
|
||||
|
||||
.panel > .panel-body .list-container[data-role="pinned"] > .list {
|
||||
.list-group {
|
||||
> .list-group-item {
|
||||
&:not(.active) {
|
||||
background-color: var(--warning-bg);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-top-width: 1px;
|
||||
border-top-color: var(--default-border-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@import "misc/kanban.less";
|
||||
@import "misc/wysiwyg.less";
|
||||
|
||||
|
||||
@@ -695,3 +695,29 @@ body {
|
||||
.panel-group-accordion > .panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel > .panel-body > .list-container > {
|
||||
> .list > .list-group {
|
||||
> .list-group-item {
|
||||
border-width: 0;
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
> li.list-group-item:first-child {
|
||||
border-top-width: 1px;
|
||||
border-top-color: transparent;
|
||||
}
|
||||
|
||||
> li.list-group-item:last-child {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:has(+ .list-container) {
|
||||
> .list > .list-group {
|
||||
> li.list-group-item:last-child {
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,8 @@
|
||||
--default-heading-bg-color: @default-heading-bg-color-value;
|
||||
--default-border-color: @default-border-color-value;
|
||||
|
||||
--warning-bg: @warning-bg-value;
|
||||
|
||||
--panel-border-radius: @panel-border-radius-value;
|
||||
--panel-border-width: @panel-border-width-value;
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
@state-default-text-value: @text-gray-color-value;
|
||||
@state-default-bg-value: @gray-lighter-value;
|
||||
|
||||
@warning-bg-value: #fffdf2;
|
||||
|
||||
@default-box-shadow-value: 2px 2px 4px rgba(0, 0, 0, 0.09);
|
||||
|
||||
@top-bar-box-shadow-value: 2px 2px 2px rgba(0, 0, 0, 0.09);
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
@state-default-text-value: #c4c4c4;
|
||||
@state-default-bg-value: #4d4c59;
|
||||
|
||||
@warning-bg-value: #81714a1a;
|
||||
|
||||
@link-color-value: #99bce9;
|
||||
|
||||
@table-bg-value: @panel-bg-value;
|
||||
|
||||
Reference in New Issue
Block a user