pinned notes

This commit is contained in:
Yuri Kuznetsov
2024-05-07 15:46:51 +03:00
parent 692e0b43e3
commit 3fc4b487b3
27 changed files with 729 additions and 78 deletions
@@ -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;
}
}
+18 -9
View File
@@ -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(),
];
}
+12 -1
View File
@@ -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;
}
}
+14 -11
View File
@@ -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",
+10
View File
@@ -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,
]);
}
}
+72 -13
View File
@@ -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.");
}
}
}
+4 -3
View File
@@ -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>
+2 -2
View File
@@ -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, *>[]}
*/
+11 -5
View File
@@ -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;
+2
View File
@@ -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);
});
}
+1
View File
@@ -3260,6 +3260,7 @@ class ListRecordView extends View {
.then(() => {
Espo.Ui.success(this.translate('Removed'));
this.trigger('after:delete', model);
this.removeRecordFromList(id);
})
.catch(() => {
+2
View File
@@ -119,6 +119,8 @@ class NoteStreamView extends View {
isEditable: this.isEditable,
isRemovable: this.isRemovable,
listType: this.listType,
isThis: this.isThis,
parentModel: this.parentModel,
});
}
}
+114 -3
View File
@@ -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;
+58
View File
@@ -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;
}
}
+2
View File
@@ -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;
+15 -16
View File
@@ -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";
+26
View File
@@ -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;
}
}
}
}
+2
View File
@@ -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;
+2
View File
@@ -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);
+2
View File
@@ -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;