diff --git a/application/Espo/Entities/Note.php b/application/Espo/Entities/Note.php index 8f6309ab21..9463cfb512 100644 --- a/application/Espo/Entities/Note.php +++ b/application/Espo/Entities/Note.php @@ -272,7 +272,10 @@ class Note extends Entity return $this; } - public function setData(stdClass $data): self + /** + * @param stdClass|array $data + */ + public function setData(stdClass|array $data): self { $this->set('data', $data); diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index a9814af00a..d712c6cdfc 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -565,6 +565,14 @@ "assignThisSelf": "{user} self-assigned this {entityType}", "assignSelf": "{user} self-assigned {entityType} {entity}", + "assignMultiAdd": "{user} assigned {entity} to {assignee}", + "assignMultiRemove": "{user} unassigned {entity} from {removedAssignee}", + "assignMultiAddRemove": "{user} assigned {entity} to {assignee} and unassigned from {removedAssignee}", + + "assignMultiAddThis": "{user} assigned this {entityType} to {assignee}", + "assignMultiRemoveThis": "{user} unassigned this {entityType} from {removedAssignee}", + "assignMultiAddRemoveThis": "{user} assigned this {entityType} to {assignee} and unassigned from {removedAssignee}", + "postThis": "{user} posted", "attachThis": "{user} attached", "statusThis": "{user} updated {field}", diff --git a/application/Espo/Tools/Stream/HookProcessor.php b/application/Espo/Tools/Stream/HookProcessor.php index a465360cc6..03ae284fb8 100644 --- a/application/Espo/Tools/Stream/HookProcessor.php +++ b/application/Espo/Tools/Stream/HookProcessor.php @@ -62,6 +62,8 @@ class HookProcessor /** @var ?array */ private $statusFields = null; + private const FIELD_ASSIGNED_USERS = 'assignedUsers'; + public function __construct( private Metadata $metadata, private EntityManager $entityManager, @@ -425,6 +427,11 @@ class HookProcessor if ($entity->isAttributeChanged('assignedUserId')) { $this->afterSaveStreamNotNewAssignedUserIdChanged($entity, $options); + } else if ( + $entity->hasLinkMultipleField(self::FIELD_ASSIGNED_USERS) && + $entity->isAttributeChanged(self::FIELD_ASSIGNED_USERS . 'Ids') + ) { + $this->afterSaveStreamNotNewAssignedUsersIdsChanged($entity, $options); } $this->service->handleAudited($entity, $options); @@ -483,6 +490,27 @@ class HookProcessor } } + /** + * @param array $options + */ + private function afterSaveStreamNotNewAssignedUsersIdsChanged(CoreEntity $entity, array $options): void + { + $userIds = $entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS); + + /** @var string[] $prevUserIds */ + $prevUserIds = $entity->getFetched(self::FIELD_ASSIGNED_USERS . 'Ids') ?? []; + + foreach (array_diff($userIds, $prevUserIds) as $userId) { + $this->service->followEntity($entity, $userId); + } + + $this->service->noteAssign($entity, $options); + + if (in_array($this->user->getId(), $userIds)) { + $entity->set('isFollowed', true); + } + } + private function afterSaveStreamNotNew2(CoreEntity $entity): void { // Not recommended to use. diff --git a/application/Espo/Tools/Stream/Service.php b/application/Espo/Tools/Stream/Service.php index 3a7ce7a6c0..cd82f0b508 100644 --- a/application/Espo/Tools/Stream/Service.php +++ b/application/Espo/Tools/Stream/Service.php @@ -29,6 +29,7 @@ namespace Espo\Tools\Stream; +use Espo\Core\Field\LinkMultiple; use Espo\Core\Field\LinkParent; use Espo\Core\ORM\Repository\Option\SaveOption; use Espo\Core\ORM\Type\FieldType; @@ -87,6 +88,8 @@ class Service 'Closed Lost', ]; + private const FIELD_ASSIGNED_USERS = 'assignedUsers'; + /** * @var array< * string, @@ -581,6 +584,20 @@ class Service $data['assignedUserId'] = $entity->get('assignedUserId'); $data['assignedUserName'] = $entity->get('assignedUserName'); + } else if ( + $entity instanceof CoreEntity && + $entity->hasLinkMultipleField(self::FIELD_ASSIGNED_USERS) && + $entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS) !== [] + ) { + /** @var LinkMultiple $users */ + $users = $entity->getValueObject(self::FIELD_ASSIGNED_USERS); + + $data['assignedUsers'] = array_map(function ($it) { + return [ + 'id' => $it->getId(), + 'name' => $it->getName(), + ]; + }, $users->getList()); } $field = $this->getStatusField($entityType); @@ -754,19 +771,7 @@ class Service $note->setParent(LinkParent::createFromEntity($entity)); $this->setSuperParent($entity, $note, true); - - if ($entity->get('assignedUserId')) { - $this->loadAssignedUserName($entity); - - $note->set('data', [ - 'assignedUserId' => $entity->get('assignedUserId'), - 'assignedUserName' => $entity->get('assignedUserName'), - ]); - } else { - $note->set('data', [ - 'assignedUserId' => null - ]); - } + $this->setAssignData($entity, $note); $noteOptions = []; @@ -1185,4 +1190,58 @@ class Service /** @var Note */ return $this->entityManager->getNewEntity(Note::ENTITY_TYPE); } + + private function setAssignData(Entity $entity, Note $note): void + { + if ( + $entity instanceof CoreEntity && + $entity->hasLinkMultipleField(self::FIELD_ASSIGNED_USERS) + ) { + $data = []; + + $newIds = $entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS); + /** @var array $newNames */ + $newNames = get_object_vars($entity->get(self::FIELD_ASSIGNED_USERS . 'Names') ?? (object) []); + + /** @var string[] $prevIds */ + $prevIds = $entity->getFetched(self::FIELD_ASSIGNED_USERS . 'Ids') ?? []; + /** @var array $prevNames */ + $prevNames = get_object_vars($entity->getFetched(self::FIELD_ASSIGNED_USERS . 'Names') ?? (object) []); + + $addedIds = array_values(array_diff($newIds, $prevIds)); + $removedIds = array_values(array_diff($prevIds, $newIds)); + $names = array_merge($prevNames, $newNames); + + $data['addedAssignedUsers'] = array_map(function ($id) use ($names) { + return [ + 'id' => $id, + 'name' => $names[$id] ?? null, + ]; + }, $addedIds); + + $data['removedAssignedUsers'] = array_map(function ($id) use ($names) { + return [ + 'id' => $id, + 'name' => $names[$id] ?? null, + ]; + }, $removedIds); + + $note->setData($data); + + return; + } + + if ($entity->get('assignedUserId')) { + $this->loadAssignedUserName($entity); + + $note->setData([ + 'assignedUserId' => $entity->get('assignedUserId'), + 'assignedUserName' => $entity->get('assignedUserName'), + ]); + + return; + } + + $note->setData(['assignedUserId' => null]); + } } diff --git a/client/src/views/stream/notes/assign.js b/client/src/views/stream/notes/assign.js index e69593499a..43c22048b1 100644 --- a/client/src/views/stream/notes/assign.js +++ b/client/src/views/stream/notes/assign.js @@ -28,6 +28,8 @@ import NoteStreamView from 'views/stream/note'; +/** @module views/stream/notes/assign */ + class AssignNoteStreamView extends NoteStreamView { template = 'stream/notes/assign' @@ -48,12 +50,34 @@ class AssignNoteStreamView extends NoteStreamView { }; } + /** + * @typedef {{ + * assignedUserId?: string, + * assignedUserName?: string, + * addedAssignedUsers?: {id: string, name: string|null}[], + * removedAssignedUsers?: {id: string, name: string|null}[], + * }} module:views/stream/notes/assign~data + */ + setup() { - const data = this.model.get('data'); + this.setupData(); + + this.createMessage(); + } + + setupData() { + /** @type {module:views/stream/notes/assign~data} */ + const data = this.model.get('data') || {}; this.assignedUserId = data.assignedUserId || null; this.assignedUserName = data.assignedUserName || data.assignedUserId || null; + if (data.addedAssignedUsers) { + this.setupDataMulti(data); + + return; + } + this.messageData['assignee'] = $('') .addClass('nowrap name-avatar') @@ -78,17 +102,78 @@ class AssignNoteStreamView extends NoteStreamView { } else { this.messageName += 'Void'; } - } else { - if (this.assignedUserId) { - if (this.assignedUserId === this.model.get('createdById')) { - this.messageName += 'Self'; - } - } else { - this.messageName += 'Void'; - } + + return; } - this.createMessage(); + if (this.assignedUserId) { + if (this.assignedUserId === this.model.get('createdById')) { + this.messageName += 'Self'; + } + + return; + } + + this.messageName += 'Void'; + } + + /** + * @private + * @param {module:views/stream/notes/assign~data} data + */ + setupDataMulti(data) { + this.messageName = 'assignMultiAdd'; + + const added = data.addedAssignedUsers; + const removed = data.removedAssignedUsers; + + if (!added || !removed) { + return; + } + + if (added.length && removed.length) { + this.messageName = 'assignMultiAddRemove'; + } else if (removed.length) { + this.messageName = 'assignMultiRemove'; + } + + if (added.length) { + this.messageData['assignee'] = this.createUsersElement(added); + } + + if (removed.length) { + this.messageData['removedAssignee'] = this.createUsersElement(removed); + } + } + + /** + * @private + * @param {{id: string, name: ?string}[]} users + * @return {HTMLElement} + */ + createUsersElement(users) { + const wrapper = document.createElement('span'); + + users.forEach((it, i) => { + const a = document.createElement('a'); + a.href = `#User/view/${it.id}`; + a.text = it.name || it.id; + a.dataset.id = it.id; + a.dataset.scope = 'User'; + + const span = document.createElement('span'); + span.className = 'nowrap name-avatar'; + span.innerHTML = this.getHelper().getAvatarHtml(it.id, 'small', 16, 'avatar-link'); + span.appendChild(a); + + wrapper.appendChild(span); + + if (i < users.length - 1) { + wrapper.appendChild(document.createTextNode(', ')); + } + }); + + return wrapper; } } diff --git a/client/src/views/stream/notes/create.js b/client/src/views/stream/notes/create.js index 5c7f516e6a..c94c3806c7 100644 --- a/client/src/views/stream/notes/create.js +++ b/client/src/views/stream/notes/create.js @@ -28,6 +28,8 @@ import NoteStreamView from 'views/stream/note'; +/** @module views/stream/notes/create */ + class CreateNoteStreamView extends NoteStreamView { template = 'stream/notes/create' @@ -52,17 +54,56 @@ class CreateNoteStreamView extends NoteStreamView { this.createMessage(); } + /** + * @typedef {{ + * assignedUserId?: string, + * assignedUserName?: string, + * assignedUsers?: {id: string, name: string|null}[], + * statusField?: string, + * statusValue?: string|null, + * statusStyle?: string|null, + * }} module:views/stream/notes/create~data + */ + setupData() { - const data = /** @type {Object.} */this.model.get('data'); + /** @type {module:views/stream/notes/create~data} */ + const data = this.model.get('data') || {}; + + this.setupUsersData(); + + if (data.statusField) { + const statusField = this.statusField = data.statusField; + const statusValue = data.statusValue; + + this.statusStyle = data.statusStyle || 'default'; + this.statusText = this.getLanguage() + .translateOption(statusValue, statusField, this.model.attributes.parentType); + } + } + + setupUsersData() { + /** @type {module:views/stream/notes/create~data} */ + const data = this.model.get('data') || {}; this.assignedUserId = data.assignedUserId || null; this.assignedUserName = data.assignedUserName || data.assignedUserId || null; + if (data.assignedUsers) { + if (data.assignedUsers.length === 1) { + this.assignedUserId = data.assignedUsers[0].id; + this.assignedUserName = data.assignedUsers[0].name; + } else if (data.assignedUsers.length > 1) { + this.setupUsersDataMulti(); + + return; + } + } + this.messageData['assignee'] = $('') .addClass('nowrap name-avatar') .append( - this.getHelper().getAvatarHtml(data.assignedUserId, 'small', 16, 'avatar-link'), + this.getHelper().getAvatarHtml(this.assignedUserId, 'small', 16, 'avatar-link'), $('') .attr('href', `#User/view/${this.assignedUserId}`) .text(this.assignedUserName) @@ -84,27 +125,56 @@ class CreateNoteStreamView extends NoteStreamView { if (this.isThis) { this.messageName += 'This'; - if (this.assignedUserId === this.model.get('createdById')) { + if (this.assignedUserId === this.model.attributes.createdById) { this.messageName += 'Self'; } } else { - if (this.assignedUserId === this.model.get('createdById')) { + if (this.assignedUserId === this.model.attributes.createdById) { this.messageName += 'Self'; - } - else if (isYou) { + } else if (isYou) { this.messageName += 'You'; } } } + } - if (data.statusField) { - const statusField = this.statusField = data.statusField; - const statusValue = data.statusValue; + setupUsersDataMulti() { + this.messageName = 'createAssigned'; - this.statusStyle = data.statusStyle || 'default'; - this.statusText = this.getLanguage() - .translateOption(statusValue, statusField, this.model.get('parentType')); - } + /** @type {module:views/stream/notes/create~data} */ + const data = this.model.get('data') || {}; + + this.messageData['assignee'] = this.createUsersElement(data.assignedUsers); + } + + /** + * @private + * @param {{id: string, name: ?string}[]} users + * @return {HTMLElement} + */ + createUsersElement(users) { + const wrapper = document.createElement('span'); + + users.forEach((it, i) => { + const a = document.createElement('a'); + a.href = `#User/view/${it.id}`; + a.text = it.name || it.id; + a.dataset.id = it.id; + a.dataset.scope = 'User'; + + const span = document.createElement('span'); + span.className = 'nowrap name-avatar'; + span.innerHTML = this.getHelper().getAvatarHtml(it.id, 'small', 16, 'avatar-link'); + span.appendChild(a); + + wrapper.appendChild(span); + + if (i < users.length - 1) { + wrapper.appendChild(document.createTextNode(', ')); + } + }); + + return wrapper; } }