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 @@ -