diff --git a/application/Espo/Controllers/KanbanOrder.php b/application/Espo/Controllers/KanbanOrder.php
new file mode 100644
index 0000000000..3d31f8af3c
--- /dev/null
+++ b/application/Espo/Controllers/KanbanOrder.php
@@ -0,0 +1,73 @@
+serviceFactory = $serviceFactory;
+ }
+
+ public function postActionStore(Request $request)
+ {
+ $data = $request->getParsedBody();
+
+ $entityType = $data->entityType;
+ $group = $data->group;
+ $ids = $data->ids;
+
+ if (empty($entityType) || !is_string($entityType)) {
+ throw new BadRequest();
+ }
+
+ if (empty($group) || !is_string($group)) {
+ throw new BadRequest();
+ }
+
+ if (!is_array($ids)) {
+ throw new BadRequest();
+ }
+
+ $this->serviceFactory
+ ->create('KanbanOrder')
+ ->order($entityType, $group, $ids);
+
+ return true;
+ }
+}
diff --git a/application/Espo/Controllers/PopupNotification.php b/application/Espo/Controllers/PopupNotification.php
new file mode 100644
index 0000000000..cb67f80762
--- /dev/null
+++ b/application/Espo/Controllers/PopupNotification.php
@@ -0,0 +1,51 @@
+serviceFactory = $serviceFactory;
+ }
+
+ public function getActionGrouped()
+ {
+ return $this->serviceFactory
+ ->create('PopupNotification')
+ ->getGroupedList();
+ }
+}
diff --git a/application/Espo/Modules/Crm/Resources/metadata/app/popupNotifications.json b/application/Espo/Modules/Crm/Resources/metadata/app/popupNotifications.json
index 90eff94815..32b96ce4c1 100644
--- a/application/Espo/Modules/Crm/Resources/metadata/app/popupNotifications.json
+++ b/application/Espo/Modules/Crm/Resources/metadata/app/popupNotifications.json
@@ -1,6 +1,9 @@
{
"event": {
"url": "Activities/action/popupNotifications",
+ "grouped": true,
+ "serviceName": "Activities",
+ "methodName": "getPopupNotifications",
"interval": 15,
"useWebSocket": true,
"portalDisabled": true,
diff --git a/application/Espo/Resources/metadata/entityDefs/KanbanOrder.json b/application/Espo/Resources/metadata/entityDefs/KanbanOrder.json
new file mode 100644
index 0000000000..090daba0c4
--- /dev/null
+++ b/application/Espo/Resources/metadata/entityDefs/KanbanOrder.json
@@ -0,0 +1,35 @@
+{
+ "fields": {
+ "order": {
+ "type": "int",
+ "dbType": "smallint"
+ },
+ "entity": {
+ "type": "linkParent"
+ },
+ "group": {
+ "type": "varchar",
+ "maxLength": 100
+ },
+ "userId": {
+ "type": "varchar",
+ "maxLength": 11
+ }
+ },
+ "links": {
+ "entity": {
+ "type": "belongsToParent"
+ }
+ },
+ "indexes": {
+ "entityUserId": {
+ "columns": ["entityType", "entityId", "userId"]
+ },
+ "entityType": {
+ "columns": ["entityType"]
+ },
+ "entityTypeUserId": {
+ "columns": ["entityType", "userId"]
+ }
+ }
+}
diff --git a/application/Espo/Services/KanbanOrder.php b/application/Espo/Services/KanbanOrder.php
new file mode 100644
index 0000000000..41a53f9b4b
--- /dev/null
+++ b/application/Espo/Services/KanbanOrder.php
@@ -0,0 +1,83 @@
+orderer = $orderer;
+ $this->acl = $acl;
+ $this->user = $user;
+ $this->config = $config;
+ }
+
+ public function order(string $entityType, string $group, array $ids)
+ {
+ if (!$this->acl->check($entityType, 'read')) {
+ throw new Forbidden();
+ }
+
+ if ($this->user->isPortal()) {
+ throw new Forbidden();
+ }
+
+ $processor = $this->orderer
+ ->createProcessor()
+ ->setEntityType($entityType)
+ ->setGroup($group)
+ ->setUserId($this->user->id);
+
+ $maxOrderNumber = $this->config->get('kanbanMaxOrderNumber') ?? null;
+
+ if ($maxOrderNumber) {
+ $processor->setMaxNumber($maxOrderNumber);
+ }
+
+ $processor->order($ids);
+ }
+}
diff --git a/application/Espo/Services/PopupNotification.php b/application/Espo/Services/PopupNotification.php
new file mode 100644
index 0000000000..7cc45ff74c
--- /dev/null
+++ b/application/Espo/Services/PopupNotification.php
@@ -0,0 +1,101 @@
+metadata = $metadata;
+ $this->serviceFactory = $serviceFactory;
+ $this->user = $user;
+ }
+
+ public function getGroupedList() : StdClass
+ {
+ $data = $this->metadata->get(['app', 'popupNotifications']) ?? [];
+
+ $data = array_filter($data, function ($item) {
+ if (!($item['grouped'] ?? false)) {
+ return false;
+ }
+
+ if (!($item['serviceName'] ?? null)) {
+ return false;
+ }
+
+ if (!($item['methodName'] ?? null)) {
+ return false;
+ }
+
+ $portalDisabled = $item['portalDisabled'] ?? false;
+
+ if ($portalDisabled && $this->user->isPortal()) {
+ return false;
+ }
+
+ return true;
+ });
+
+ $result = (object) [];
+
+ foreach ($data as $type => $item) {
+ $serviceName = $item['serviceName'];
+ $methodName = $item['methodName'];
+
+ try {
+ $service = $this->serviceFactory->create($serviceName);
+
+ $result->$type = $service->$methodName($this->user->id);
+ }
+ catch (Throwable $e) {
+ $this->log->error("Popup notifications: " . $e->getMessage());
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/application/Espo/Services/Record.php b/application/Espo/Services/Record.php
index 9bd5d4af08..b3a9d1e24e 100644
--- a/application/Espo/Services/Record.php
+++ b/application/Espo/Services/Record.php
@@ -1356,17 +1356,30 @@ class Record implements Crud,
$disableCount = true;
}
+ $orderDisabled = $this->metadata->get(['scopes', $this->entityType, 'kanbanOrderDisabled']) ?? false;
+
$this->handleListParams($params);
$kanban = $this->injectableFactory->create(KanbanTool::class);
- return $kanban
+ $kanban
->setEntityType($this->entityType)
->setRecordService($this)
->setSearchParams($params)
->setCountDisabled($disableCount)
+ ->setOrderDisabled($orderDisabled)
->setMaxSelectTextAttributeLength($this->getMaxSelectTextAttributeLength())
- ->getResult();
+ ->setUserId($this->getUser()->id);
+
+ $maxOrderNumber = $this->getConfig()->get('kanbanMaxOrderNumber') ?? null;
+
+
+
+ if ($maxOrderNumber) {
+ $kanban->setMaxOrderNumber($maxOrderNumber);
+ }
+
+ return $kanban->getResult();
}
protected function getEntityEvenDeleted(string $id) : ?Entity
diff --git a/application/Espo/Tools/Kanban/Kanban.php b/application/Espo/Tools/Kanban/Kanban.php
index 2918246690..bcb02dcd6c 100644
--- a/application/Espo/Tools/Kanban/Kanban.php
+++ b/application/Espo/Tools/Kanban/Kanban.php
@@ -34,11 +34,11 @@ use Espo\Core\{
Utils\Metadata,
Select\SelectManagerFactory,
ORM\EntityManager,
- //Record\Collection as RecordCollection,
};
use Espo\{
Services\Record as RecordService,
+ ORM\QueryParams\Select as SelectQuery,
};
class Kanban
@@ -47,10 +47,18 @@ class Kanban
protected $countDisabled = false;
+ protected $orderDisabled = false;
+
protected $searchParams = [];
protected $maxSelectTextAttributeLength = null;
+ protected $userId = null;
+
+ protected $maxOrderNumber = 50;
+
+ const MAX_GROUP_LENGTH = 100;
+
protected $metadata;
protected $selectManagerFactory;
protected $entityManager;
@@ -93,6 +101,27 @@ class Kanban
return $this;
}
+ public function setOrderDisabled(bool $orderDisabled) : self
+ {
+ $this->orderDisabled = $orderDisabled;
+
+ return $this;
+ }
+
+ public function setUserId(string $userId) : self
+ {
+ $this->userId = $userId;
+
+ return $this;
+ }
+
+ public function setMaxOrderNumber(int $maxOrderNumber) : self
+ {
+ $this->maxOrderNumber = $maxOrderNumber;
+
+ return $this;
+ }
+
public function setMaxSelectTextAttributeLength(?int $maxSelectTextAttributeLength) : self
{
$this->maxSelectTextAttributeLength = $maxSelectTextAttributeLength;
@@ -159,14 +188,47 @@ class Kanban
$selectParamsSub = $selectParams;
$selectParamsSub['whereClause'][] = [
- $statusField => $status
+ $statusField => $status,
];
$o = (object) [
'name' => $status,
];
- $collectionSub = $repository->find($selectParamsSub);
+ $query = SelectQuery::fromRaw($selectParamsSub);
+
+ $newOrder = $selectParamsSub['orderBy'] ?? [];
+
+ array_unshift($newOrder, [
+ 'COALESCE:(kanbanOrder.order, ' . strval($this->maxOrderNumber + 1) . ')',
+ 'ASC',
+ ]);
+
+ if ($this->userId && !$this->orderDisabled) {
+ $group = mb_substr($status, 0, self::MAX_GROUP_LENGTH);
+
+ $builder = $this->entityManager
+ ->getQueryBuilder()
+ ->select()
+ ->clone($query)
+ ->order($newOrder)
+ ->leftJoin(
+ 'KanbanOrder',
+ 'kanbanOrder',
+ [
+ 'kanbanOrder.entityType' => $this->entityType,
+ 'kanbanOrder.entityId:' => 'id',
+ 'kanbanOrder.group' => $group,
+ 'kanbanOrder.userId' => $this->userId,
+ ]
+ );
+
+ $query = $builder->build();
+ }
+
+ $collectionSub = $repository
+ ->clone($query)
+ ->find();
if (!$this->countDisabled) {
$totalSub = $repository->count($selectParamsSub);
diff --git a/application/Espo/Tools/Kanban/Orderer.php b/application/Espo/Tools/Kanban/Orderer.php
new file mode 100644
index 0000000000..9adc68032b
--- /dev/null
+++ b/application/Espo/Tools/Kanban/Orderer.php
@@ -0,0 +1,76 @@
+entityManager = $entityManager;
+ $this->metadata = $metadata;
+ }
+
+ public function setEntityType(string $entityType) : OrdererProcessor
+ {
+ return $this->createProcessor()->setEntityType($entityType);
+ }
+
+ public function setGroup(string $group) : OrdererProcessor
+ {
+ return $this->createProcessor()->setGroup($group);
+ }
+
+ public function setUserId(string $userId) : OrdererProcessor
+ {
+ return $this->createProcessor()->setUserId($userId);
+ }
+
+ public function setMaxNumber(int $maxNumber) : OrdererProcessor
+ {
+ return $this->createProcessor()->setMaxNumber($maxNumber);
+ }
+
+ public function createProcessor() : OrdererProcessor
+ {
+ return new OrdererProcessor(
+ $this->entityManager,
+ $this->metadata
+ );
+ }
+}
diff --git a/application/Espo/Tools/Kanban/OrdererProcessor.php b/application/Espo/Tools/Kanban/OrdererProcessor.php
new file mode 100644
index 0000000000..5084170139
--- /dev/null
+++ b/application/Espo/Tools/Kanban/OrdererProcessor.php
@@ -0,0 +1,232 @@
+entityManager = $entityManager;
+ $this->metadata = $metadata;
+ }
+
+ public function setEntityType(string $entityType) : self
+ {
+ $this->entityType = $entityType;
+
+ return $this;
+ }
+
+ public function setGroup(string $group) : self
+ {
+ $group = mb_substr($group, 0, self::MAX_GROUP_LENGTH);
+
+ $this->group = $group;
+
+ return $this;
+ }
+
+ public function setUserId(string $userId) : self
+ {
+ $this->userId = $userId;
+
+ return $this;
+ }
+
+ public function setMaxNumber(int $maxNumber) : self
+ {
+ $this->maxNumber = $maxNumber;
+
+ return $this;
+ }
+
+ public function order(array $ids)
+ {
+ $this->validate();
+
+ $count = count($ids);
+
+ if (!$count) {
+ return;
+ }
+
+ $this->entityManager
+ ->getTransactionManager()
+ ->start();
+
+ $deleteQuery = $this->entityManager
+ ->getQueryBuilder()
+ ->delete()
+ ->from('KanbanOrder')
+ ->where([
+ 'entityType' => $this->entityType,
+ 'userId' => $this->userId,
+ 'entityId' => $ids,
+ ])
+ ->build();
+
+ $this->entityManager->getQueryExecutor()->execute($deleteQuery);
+
+ $minOrder = null;
+
+ $first = $this->entityManager
+ ->getRepository('KanbanOrder')
+ ->select(['id', 'order'])
+ ->where([
+ 'entityType' => $this->entityType,
+ 'userId' => $this->userId,
+ 'group' => $this->group,
+ ])
+ ->order('order')
+ ->findOne();
+
+ if ($first) {
+ $minOrder = $first->get('order');
+ }
+
+ if ($minOrder !== null) {
+ $offset = $count - $minOrder;
+
+ $updateQuery = $this->entityManager
+ ->getQueryBuilder()
+ ->update()
+ ->in('KanbanOrder')
+ ->where([
+ 'entityType' => $this->entityType,
+ 'group' => $this->group,
+ 'userId' => $this->userId,
+ ])
+ ->set([
+ 'order:' => 'ADD:(order, ' . strval($offset) . ')'
+ ])
+ ->build();
+
+ $this->entityManager->getQueryExecutor()->execute($updateQuery);
+ }
+
+ $collection = $this->entityManager
+ ->getCollectionFactory()
+ ->create('KanbanOrder');
+
+ foreach ($ids as $i => $id) {
+ $item = $this->entityManager->getEntity('KanbanOrder');
+
+ $item->set([
+ 'id' => Util::generateId(),
+ 'entityId' => $id,
+ 'entityType' => $this->entityType,
+ 'group' => $this->group,
+ 'userId' => $this->userId,
+ 'order' => $i,
+ ]);
+
+ $collection[] = $item;
+ }
+
+ $this->entityManager->getMapper()->massInsert($collection);
+
+ $deleteQuery = $this->entityManager
+ ->getQueryBuilder()
+ ->delete()
+ ->from('KanbanOrder')
+ ->where([
+ 'entityType' => $this->entityType,
+ 'group' => $this->group,
+ 'userId' => $this->userId,
+ 'order>' => $this->maxNumber,
+ ])
+ ->build();
+
+ $this->entityManager->getQueryExecutor()->execute($deleteQuery);
+
+ $this->entityManager
+ ->getTransactionManager()
+ ->commit();
+ }
+
+ private function validate()
+ {
+ if (! $this->entityType) {
+ throw new LogicException("No entity type.");
+ }
+
+ if (! $this->group) {
+ throw new LogicException("No group.");
+ }
+
+ if (! $this->userId) {
+ throw new LogicException("No user ID.");
+ }
+
+ if (! $this->metadata->get(['scopes', $this->entityType, 'object'])) {
+ throw new LogicException("Not allowed entity type.");
+ }
+
+ $orderDisabled = $this->metadata->get(['scopes', $this->entityType, 'kanbanOrderDisabled']);
+
+ if ($orderDisabled) {
+ throw new LogicException("Order is disabled.");
+ }
+
+ $statusField = $this->metadata->get(['scopes', $this->entityType, 'statusField']);
+
+ if (! $statusField) {
+ throw new LogicException("Not status field.");
+ }
+
+ $statusList = $this->metadata->get(['entityDefs', $this->entityType, 'fields', $statusField, 'options']) ?? [];
+
+ if (! in_array($this->group, $statusList)) {
+ throw new LogicException("Group is not available in status list.");
+ }
+ }
+}
diff --git a/client/res/templates/modals/kanban-move-over.tpl b/client/res/templates/modals/kanban-move-over.tpl
index 3037a21069..333143d775 100644
--- a/client/res/templates/modals/kanban-move-over.tpl
+++ b/client/res/templates/modals/kanban-move-over.tpl
@@ -1,4 +1,4 @@
-
+
{{#each optionDataList}}
-
{{label}}
diff --git a/client/src/views/modals/kanban-move-over.js b/client/src/views/modals/kanban-move-over.js
index 834d13df4c..4456ab3940 100644
--- a/client/src/views/modals/kanban-move-over.js
+++ b/client/src/views/modals/kanban-move-over.js
@@ -68,20 +68,33 @@ define('views/modals/kanban-move-over', 'views/modal', function (Dep) {
this.optionDataList = [];
- (this.getMetadata().get(['entityDefs', this.scope, 'fields', this.statusField, 'options']) || []).forEach(function (item) {
+ (this.getMetadata().get(
+ ['entityDefs', this.scope, 'fields', this.statusField, 'options']) || []
+ ).forEach(function (item) {
this.optionDataList.push({
value: item,
- label: this.getLanguage().translateOption(item, this.statusField, this.scope)
+ label: this.getLanguage().translateOption(item, this.statusField, this.scope),
});
}, this);
},
moveTo: function (status) {
var attributes = {};
+
attributes[this.statusField] = status;
- this.model.save(attributes, {patch: true}).then(function () {
- Espo.Ui.success(this.translate('Done'));
- }.bind(this));
+
+ this.model
+ .save(
+ attributes,
+ {
+ patch: true,
+ isMoveTo: true,
+ }
+ )
+ .then(function () {
+ Espo.Ui.success(this.translate('Done'));
+ }.bind(this));
+
this.close();
},
diff --git a/client/src/views/notification/badge.js b/client/src/views/notification/badge.js
index cb9407785c..a6d830ca25 100644
--- a/client/src/views/notification/badge.js
+++ b/client/src/views/notification/badge.js
@@ -57,6 +57,7 @@ define('views/notification/badge', 'view', function (Dep) {
if (this.timeout) {
clearTimeout(this.timeout);
}
+
for (var name in this.popoupTimeouts) {
clearTimeout(this.popoupTimeouts[name]);
}
@@ -76,8 +77,10 @@ define('views/notification/badge', 'view', function (Dep) {
window.addEventListener('storage', function (e) {
if (e.key == 'messageClosePopupNotificationId') {
var id = localStorage.getItem('messageClosePopupNotificationId');
+
if (id) {
var key = 'popup-' + id;
+
if (this.hasView(key)) {
this.markPopupRemoved(id);
this.clearView(key);
@@ -102,18 +105,24 @@ define('views/notification/badge', 'view', function (Dep) {
this.runCheckUpdates(true);
this.$popupContainer = $('#popup-notifications-container');
+
if (!$(this.$popupContainer).length) {
this.$popupContainer = $('
').attr('id', 'popup-notifications-container').addClass('hidden').appendTo('body');
}
var popupNotificationsData = this.popupNotificationsData = this.getMetadata().get('app.popupNotifications') || {};
+
for (var name in popupNotificationsData) {
this.checkPopupNotifications(name);
}
+
+ this.checkGroupedPopupNotifications();
},
playSound: function () {
- if (this.notificationSoundsDisabled) return;
+ if (this.notificationSoundsDisabled) {
+ return;
+ }
var html = '' +
'