diff --git a/application/Espo/Hooks/Common/WebSocketSubmit.php b/application/Espo/Hooks/Common/WebSocketSubmit.php new file mode 100644 index 0000000000..81a2430280 --- /dev/null +++ b/application/Espo/Hooks/Common/WebSocketSubmit.php @@ -0,0 +1,70 @@ +addDependency('metadata'); + $this->addDependency('webSocketSubmission'); + } + + protected function getMetadata() + { + return $this->getInjection('metadata'); + } + + protected function getWebSocketSubmission() + { + return $this->getInjection('webSocketSubmission'); + } + + public function afterSave(Entity $entity, array $options = []) + { + if ($options['silent'] ?? false) return; + if ($entity->isNew()) return; + if (!$this->getConfig()->get('useWebSocket')) return; + + $scope = $entity->getEntityType(); + $id = $entity->id; + + if (!$this->getMetadata()->get(['scopes', $scope, 'object'])) return; + + $data = (object) []; + + $topic = "recordUpdate.{$scope}.{$id}"; + $this->getInjection('webSocketSubmission')->submit($topic, null, $data); + } +} diff --git a/application/Espo/Resources/metadata/app/webSocket.json b/application/Espo/Resources/metadata/app/webSocket.json index ef70dc32e9..37c3da4e1e 100644 --- a/application/Espo/Resources/metadata/app/webSocket.json +++ b/application/Espo/Resources/metadata/app/webSocket.json @@ -1,6 +1,10 @@ { "categories": { "newNotification": {}, + "recordUpdate": { + "paramList": ["scope", "id"], + "accessCheckCommand": "AclCheck --userId=:userId --scope=:scope --id=:id --action=read" + }, "streamUpdate": { "paramList": ["scope", "id"], "accessCheckCommand": "AclCheck --userId=:userId --scope=:scope --id=:id --action=stream" diff --git a/client/src/views/fields/base.js b/client/src/views/fields/base.js index 78e7aead98..e5bf19bb5b 100644 --- a/client/src/views/fields/base.js +++ b/client/src/views/fields/base.js @@ -301,6 +301,21 @@ Espo.define('views/fields/base', 'view', function (Dep) { this.setupSearch(); } + this.on('highlight', function () { + var $cell = this.getCellElement(); + $cell.addClass('highlighted'); + $cell.addClass('transition'); + + setTimeout(function () { + $cell.removeClass('highlighted'); + }, this.highlightPeriod || 3000); + + setTimeout(function () { + $cell.removeClass('transition'); + }, (this.highlightPeriod || 3000) + 2000); + + }, this); + this.on('invalid', function () { var $cell = this.getCellElement(); $cell.addClass('has-error'); @@ -356,6 +371,10 @@ Espo.define('views/fields/base', 'view', function (Dep) { if (changed && !options.skipReRender) { this.reRender(); } + + if (changed && options.highlight) { + this.trigger('highlight'); + } } }.bind(this)); @@ -595,6 +614,8 @@ Espo.define('views/fields/base', 'view', function (Dep) { } this.reRender(true); + + this.trigger('after:inline-edit-off'); }, inlineEdit: function () { diff --git a/client/src/views/record/base.js b/client/src/views/record/base.js index f0d1f24734..55195a082a 100644 --- a/client/src/views/record/base.js +++ b/client/src/views/record/base.js @@ -326,6 +326,11 @@ define('views/record/base', ['view', 'view-record-helper', 'dynamic-logic'], fun }, resetModelChanges: function () { + if (this.updatedAttributes) { + this.attributes = this.updatedAttributes; + this.updatedAttributes = null; + } + var attributes = this.model.attributes; for (var attr in attributes) { if (!(attr in this.attributes)) { @@ -333,7 +338,7 @@ define('views/record/base', ['view', 'view-record-helper', 'dynamic-logic'], fun } } - this.model.set(this.attributes); + this.model.set(this.attributes, {skipReRender: true}); }, setModelAttributes: function (setAttributes, options) { diff --git a/client/src/views/record/detail.js b/client/src/views/record/detail.js index b1d629c21f..feb39ccf70 100644 --- a/client/src/views/record/detail.js +++ b/client/src/views/record/detail.js @@ -40,6 +40,8 @@ define('views/record/detail', ['views/record/base', 'view-record-helper'], funct fieldsMode: 'detail', + mode: 'detail', + gridLayout: null, detailLayout: null, @@ -519,6 +521,11 @@ define('views/record/detail', ['views/record/base', 'view-record-helper'], funct this.inlineEditModeIsOn = false; this.setIsNotChanged(); }, this); + this.listenTo(fieldView, 'after:inline-edit-off', function () { + if (this.updatedAttributes) { + this.resetModelChanges(); + } + }, this); } }, @@ -652,6 +659,13 @@ define('views/record/detail', ['views/record/base', 'view-record-helper'], funct }, resetModelChanges: function () { + var skipReRender = true; + if (this.updatedAttributes) { + this.attributes = this.updatedAttributes; + this.updatedAttributes = null; + skipReRender = false; + } + var attributes = this.model.attributes; for (var attr in attributes) { if (!(attr in this.attributes)) { @@ -659,7 +673,7 @@ define('views/record/detail', ['views/record/base', 'view-record-helper'], funct } } - this.model.set(this.attributes, {skipReRender: true}); + this.model.set(this.attributes, {skipReRender: skipReRender}); }, delete: function () { @@ -914,6 +928,20 @@ define('views/record/detail', ['views/record/base', 'view-record-helper'], funct this.$detailButtonContainer = this.$el.find('.detail-button-container'); this.$dropdownItemListButton = this.$detailButtonContainer.find('.dropdown-item-list-button'); }, this); + + if ( + !this.isNew && + this.getConfig().get('useWebSocket') && + this.getMetadata().get(['scopes', this.entityType, 'object']) + ) { + this.subscribeToWebSocket(); + + this.once('remove', function () { + if (this.isSubscribedToWebSocked) { + this.unsubscribeFromWebSocket(); + } + }.bind(this)); + } }, setupBeforeFinal: function () { @@ -1185,10 +1213,15 @@ define('views/record/detail', ['views/record/base', 'view-record-helper'], funct } this.enableButtons(); this.setIsNotChanged(); + + setTimeout(function () { + this.unblockUpdateWebSocket(); + }.bind(this), this.blockUpdateWebSocketPeriod || 500); }, beforeSave: function () { this.notify('Saving...'); + this.blockUpdateWebSocket(); }, beforeBeforeSave: function () { @@ -1718,5 +1751,46 @@ define('views/record/detail', ['views/record/base', 'view-record-helper'], funct this.getRouter().navigate(url, {trigger: true}); }, + subscribeToWebSocket: function () { + var topic = 'recordUpdate.' + this.entityType + '.' + this.model.id; + this.recordUpdateWebSocketTopic = topic; + + this.isSubscribedToWebSocked = true; + + this.getHelper().webSocketManager.subscribe(topic, function (t, data) { + this.handleRecordUpdate(); + }.bind(this)) + }, + + unsubscribeFromWebSocket: function () { + if (!this.isSubscribedToWebSocked) return; + this.getHelper().webSocketManager.unsubscribe(this.recordUpdateWebSocketTopic); + }, + + handleRecordUpdate: function () { + if (this.updateWebSocketIsBlocked) return; + + if (this.inlineEditModeIsOn || this.mode == 'edit') { + var m = this.model.clone(); + m.fetch().then( + function () { + if (this.inlineEditModeIsOn || this.mode == 'edit') { + this.updatedAttributes = Espo.Utils.cloneDeep(m.attributes); + } + }.bind(this) + ); + } else { + this.model.fetch({highlight: true}); + } + }, + + blockUpdateWebSocket: function () { + this.updateWebSocketIsBlocked = true; + }, + + unblockUpdateWebSocket: function () { + this.updateWebSocketIsBlocked = false; + }, + }); }); diff --git a/frontend/less/espo/custom.less b/frontend/less/espo/custom.less index 40f2feeafd..64dca10a0a 100644 --- a/frontend/less/espo/custom.less +++ b/frontend/less/espo/custom.less @@ -770,6 +770,20 @@ table.table > thead th { margin-bottom: 2px; } +.cell.highlighted { + > label { + color: @state-warning-text; + } +} + +.cell.transition { + > label { + -webkit-transition: color .5s ease; + -o-transition: color .5s ease; + transition: color .5s ease; + } +} + .filter .selectize-input { min-height: 30px; }