diff --git a/application/Espo/Classes/RecordHooks/Note/BeforeCreate.php b/application/Espo/Classes/RecordHooks/Note/BeforeCreate.php index 94a1b52c14..dbe76b5f6c 100644 --- a/application/Espo/Classes/RecordHooks/Note/BeforeCreate.php +++ b/application/Espo/Classes/RecordHooks/Note/BeforeCreate.php @@ -70,6 +70,7 @@ class BeforeCreate implements SaveHook $targetType = $entity->getTargetType(); + $entity->clear('isPinned'); $entity->clear('isGlobal'); switch ($targetType) { diff --git a/application/Espo/Classes/RecordHooks/Note/BeforeUpdate.php b/application/Espo/Classes/RecordHooks/Note/BeforeUpdate.php index b847a74f3e..ece5b13670 100644 --- a/application/Espo/Classes/RecordHooks/Note/BeforeUpdate.php +++ b/application/Espo/Classes/RecordHooks/Note/BeforeUpdate.php @@ -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; + } } diff --git a/application/Espo/Controllers/Stream.php b/application/Espo/Controllers/Stream.php index 3f7c04f79b..08d17a3128 100644 --- a/application/Espo/Controllers/Stream.php +++ b/application/Espo/Controllers/Stream.php @@ -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(), ]; } diff --git a/application/Espo/Entities/Note.php b/application/Espo/Entities/Note.php index 111a627fa2..38d694177b 100644 --- a/application/Espo/Entities/Note.php +++ b/application/Espo/Entities/Note.php @@ -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; + } } diff --git a/application/Espo/Hooks/Note/WebSocketSubmit.php b/application/Espo/Hooks/Note/WebSocketSubmit.php index 43fce0bc23..ad2f96064f 100644 --- a/application/Espo/Hooks/Note/WebSocketSubmit.php +++ b/application/Espo/Hooks/Note/WebSocketSubmit.php @@ -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 + */ +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); } diff --git a/application/Espo/Resources/defaults/config.php b/application/Espo/Resources/defaults/config.php index 4a44ce044d..ba41e93030 100644 --- a/application/Espo/Resources/defaults/config.php +++ b/application/Espo/Resources/defaults/config.php @@ -255,6 +255,7 @@ return [ 'recordListMaxSizeLimit' => 200, 'noteDeleteThresholdPeriod' => '1 month', 'noteEditThresholdPeriod' => '7 days', + 'notePinnedMaxCount' => 5, 'emailForceUseExternalClient' => false, 'useWebSocket' => false, 'webSocketMessager' => 'ZeroMQ', diff --git a/application/Espo/Resources/i18n/en_US/Note.json b/application/Espo/Resources/i18n/en_US/Note.json index d5f9aaccac..15122de754 100644 --- a/application/Espo/Resources/i18n/en_US/Note.json +++ b/application/Espo/Resources/i18n/en_US/Note.json @@ -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", diff --git a/application/Espo/Resources/metadata/entityDefs/Note.json b/application/Espo/Resources/metadata/entityDefs/Note.json index 2b9109991e..a0f249811e 100644 --- a/application/Espo/Resources/metadata/entityDefs/Note.json +++ b/application/Espo/Resources/metadata/entityDefs/Note.json @@ -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", diff --git a/application/Espo/Resources/routes.json b/application/Espo/Resources/routes.json index 10259af012..0202572b3c 100644 --- a/application/Espo/Resources/routes.json +++ b/application/Espo/Resources/routes.json @@ -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", diff --git a/application/Espo/Tools/Stream/Api/DeleteNotePin.php b/application/Espo/Tools/Stream/Api/DeleteNotePin.php new file mode 100644 index 0000000000..25f7ff8ff8 --- /dev/null +++ b/application/Espo/Tools/Stream/Api/DeleteNotePin.php @@ -0,0 +1,114 @@ +. + * + * 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."); + } + } +} diff --git a/application/Espo/Tools/Stream/Api/PostNotePin.php b/application/Espo/Tools/Stream/Api/PostNotePin.php new file mode 100644 index 0000000000..0a370370b0 --- /dev/null +++ b/application/Espo/Tools/Stream/Api/PostNotePin.php @@ -0,0 +1,164 @@ +. + * + * 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, + ]); + } +} diff --git a/application/Espo/Tools/Stream/RecordService.php b/application/Espo/Tools/Stream/RecordService.php index 3d02a9a9f1..a0a96bf51a 100644 --- a/application/Espo/Tools/Stream/RecordService.php +++ b/application/Espo/Tools/Stream/RecordService.php @@ -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 + * @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."); + } + } } diff --git a/client/res/templates/stream/panel.tpl b/client/res/templates/stream/panel.tpl index e2c413767a..6202ebd750 100644 --- a/client/res/templates/stream/panel.tpl +++ b/client/res/templates/stream/panel.tpl @@ -31,6 +31,7 @@ -
- {{{list}}} -
+{{#if hasPinned}} +
{{{pinnedList}}}
+{{/if}} +
{{{list}}}
diff --git a/client/src/collection.js b/client/src/collection.js index fd92c9d0a9..1f81cbb56f 100644 --- a/client/src/collection.js +++ b/client/src/collection.js @@ -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.|Record[]} response A response from the backend. * @param {Object.} options Options. * @returns {Object.[]} */ diff --git a/client/src/collections/note.js b/client/src/collections/note.js index 8ac20cd267..4c4a471839 100644 --- a/client/src/collections/note.js +++ b/client/src/collections/note.js @@ -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; diff --git a/client/src/views/record/detail.js b/client/src/views/record/detail.js index e7b1fbdca1..95b76c009c 100644 --- a/client/src/views/record/detail.js +++ b/client/src/views/record/detail.js @@ -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); }); } diff --git a/client/src/views/record/list.js b/client/src/views/record/list.js index f903e1b581..84600cbc63 100644 --- a/client/src/views/record/list.js +++ b/client/src/views/record/list.js @@ -3260,6 +3260,7 @@ class ListRecordView extends View { .then(() => { Espo.Ui.success(this.translate('Removed')); + this.trigger('after:delete', model); this.removeRecordFromList(id); }) .catch(() => { diff --git a/client/src/views/stream/note.js b/client/src/views/stream/note.js index 85d424c860..9a917769f2 100644 --- a/client/src/views/stream/note.js +++ b/client/src/views/stream/note.js @@ -119,6 +119,8 @@ class NoteStreamView extends View { isEditable: this.isEditable, isRemovable: this.isRemovable, listType: this.listType, + isThis: this.isThis, + parentModel: this.parentModel, }); } } diff --git a/client/src/views/stream/panel.js b/client/src/views/stream/panel.js index 4e15f0cb12..c49f1ab6d7 100644 --- a/client/src/views/stream/panel.js +++ b/client/src/views/stream/panel.js @@ -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; diff --git a/client/src/views/stream/record/list.js b/client/src/views/stream/record/list.js index 0d671cd0ef..920038b9b6 100644 --- a/client/src/views/stream/record/list.js +++ b/client/src/views/stream/record/list.js @@ -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; diff --git a/client/src/views/stream/record/row-actions/default.js b/client/src/views/stream/record/row-actions/default.js index 34ea12880f..c0abd7bd0b 100644 --- a/client/src/views/stream/record/row-actions/default.js +++ b/client/src/views/stream/record/row-actions/default.js @@ -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; } } diff --git a/frontend/less/dark/variables.less b/frontend/less/dark/variables.less index 986beaa573..df169be921 100644 --- a/frontend/less/dark/variables.less +++ b/frontend/less/dark/variables.less @@ -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; diff --git a/frontend/less/espo/custom.less b/frontend/less/espo/custom.less index a8352687a8..4d2c17fa34 100644 --- a/frontend/less/espo/custom.less +++ b/frontend/less/espo/custom.less @@ -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"; diff --git a/frontend/less/espo/elements/panel.less b/frontend/less/espo/elements/panel.less index 25e79aff66..0842d8a4d7 100644 --- a/frontend/less/espo/elements/panel.less +++ b/frontend/less/espo/elements/panel.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; + } + } + } +} diff --git a/frontend/less/espo/root-variables.less b/frontend/less/espo/root-variables.less index a637500a5d..82f67c5141 100644 --- a/frontend/less/espo/root-variables.less +++ b/frontend/less/espo/root-variables.less @@ -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; diff --git a/frontend/less/espo/value-variables.less b/frontend/less/espo/value-variables.less index 0a98442743..86c9938d78 100644 --- a/frontend/less/espo/value-variables.less +++ b/frontend/less/espo/value-variables.less @@ -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); diff --git a/frontend/less/glass/variables.less b/frontend/less/glass/variables.less index 66fde8210b..585be64441 100644 --- a/frontend/less/glass/variables.less +++ b/frontend/less/glass/variables.less @@ -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;