diff --git a/application/Espo/Classes/RecordHooks/Event/BeforeUpdatePreserveDuration.php b/application/Espo/Classes/RecordHooks/Event/BeforeUpdatePreserveDuration.php new file mode 100644 index 0000000000..f30b20fb65 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/Event/BeforeUpdatePreserveDuration.php @@ -0,0 +1,123 @@ + + */ +class BeforeUpdatePreserveDuration implements UpdateHook +{ + private OrmDefs $ormDefs; + + private const DATE_START = 'dateStart'; + + private const DATE_END = 'dateEnd'; + + public function __construct(OrmDefs $ormDefs) + { + $this->ormDefs = $ormDefs; + } + + public function process(Entity $entity, UpdateParams $params): void + { + /** @var CoreEntity $entity */ + + if (!$entity->isAttributeChanged('dateStart')) { + return; + } + + if ($entity->isAttributeWritten('dateEnd')) { + return; + } + + $preserveDurationDisabled = $this->ormDefs + ->getEntity($entity->getEntityType()) + ->getField('dateEnd') + ->getParam('preserveDurationDisabled'); + + if ($preserveDurationDisabled) { + return; + } + + $this->processDateTime($entity); + $this->processDate($entity); + } + + private function processDateTime(Entity $entity): void + { + $dateStartFetchedString = $entity->getFetched('dateStart'); + $dateStartString = $entity->get('dateStart'); + $dateEndString = $entity->get('dateEnd'); + + if (!$dateStartFetchedString || !$dateStartString || !$dateEndString) { + return; + } + + $dateStartFetched = DateTime::fromString($dateStartFetchedString); + $dateStart = DateTime::fromString($dateStartString); + $dateEnd = DateTime::fromString($dateEndString); + + $diff = $dateStartFetched->diff($dateEnd); + + $dateEndModified = $dateStart->add($diff); + + $entity->set('dateEnd', $dateEndModified->getString()); + } + + private function processDate(Entity $entity): void + { + $dateStartFetchedString = $entity->getFetched('dateStartDate'); + $dateStartString = $entity->get('dateStartDate'); + $dateEndString = $entity->get('dateEndDate'); + + if (!$dateStartFetchedString || !$dateStartString || !$dateEndString) { + return; + } + + $dateStartFetched = Date::fromString($dateStartFetchedString); + $dateStart = Date::fromString($dateStartString); + $dateEnd = Date::fromString($dateEndString); + + $diff = $dateStartFetched->diff($dateEnd); + + $dateEndModified = $dateStart->add($diff); + + $entity->set('dateEndDate', $dateEndModified->getString()); + } +} diff --git a/application/Espo/Core/Repositories/Event.php b/application/Espo/Core/Repositories/Event.php index 303ab4e700..9bb023fbbc 100644 --- a/application/Espo/Core/Repositories/Event.php +++ b/application/Espo/Core/Repositories/Event.php @@ -40,7 +40,6 @@ use Espo\Core\{ use DateTime; use DateTimeZone; -use Exception; /** * @extends Database<\Espo\Core\ORM\Entity> @@ -58,11 +57,6 @@ class Event extends Database implements */ protected $reminderSkippingStatusList = ['Held', 'Not Held']; - /** - * @var bool - */ - protected $preserveDuration = true; - /** * @param array $options * @return void @@ -109,31 +103,6 @@ class Event extends Database implements } } - if ( - !$entity->isNew() && - $this->preserveDuration && - $entity->isAttributeChanged('dateStart') && - $entity->get('dateStart') && - !$entity->isAttributeChanged('dateEnd') - ) { - $dateEndPrevious = $entity->getFetched('dateEnd'); - $dateStartPrevious = $entity->getFetched('dateStart'); - - if ($dateStartPrevious && $dateEndPrevious) { - $dtStart = new DateTime($dateStartPrevious); - $dtEnd = new DateTime($dateEndPrevious); - $dt = new DateTime($entity->get('dateStart')); - - $duration = ($dtEnd->getTimestamp() - $dtStart->getTimestamp()); - - $dt->modify('+' . $duration . ' seconds'); - - $dateEnd = $dt->format('Y-m-d H:i:s'); - - $entity->set('dateEnd', $dateEnd); - } - } - parent::beforeSave($entity, $options); } diff --git a/application/Espo/Core/Templates/Metadata/Event/clientDefs.json b/application/Espo/Core/Templates/Metadata/Event/clientDefs.json index 07ee7efeac..3389055b7a 100644 --- a/application/Espo/Core/Templates/Metadata/Event/clientDefs.json +++ b/application/Espo/Core/Templates/Metadata/Event/clientDefs.json @@ -40,6 +40,10 @@ "name":"todays" } ], + "forcePatchAttributeDependencyMap": { + "dateEnd": ["dateStart"], + "dateEndDate": ["dateStartDate"] + }, "dynamicLogic":{ "fields":{ "duration": { diff --git a/application/Espo/Core/Templates/Metadata/Event/recordDefs.json b/application/Espo/Core/Templates/Metadata/Event/recordDefs.json index ec2621635e..f6b00fdc14 100644 --- a/application/Espo/Core/Templates/Metadata/Event/recordDefs.json +++ b/application/Espo/Core/Templates/Metadata/Event/recordDefs.json @@ -4,5 +4,9 @@ ], "saverClassNameList": [ "Espo\\Core\\FieldProcessing\\Reminder\\Saver" + ], + "beforeUpdateHookClassNameList": [ + "__APPEND__", + "Espo\\Classes\\RecordHooks\\Event\\BeforeUpdatePreserveDuration" ] } diff --git a/application/Espo/Modules/Crm/Repositories/Task.php b/application/Espo/Modules/Crm/Repositories/Task.php index 538e648b95..55369511f6 100644 --- a/application/Espo/Modules/Crm/Repositories/Task.php +++ b/application/Espo/Modules/Crm/Repositories/Task.php @@ -37,8 +37,6 @@ class Task extends EventRepository { protected $reminderSkippingStatusList = ['Completed', 'Canceled']; - protected $preserveDuration = false; - protected function beforeSave(Entity $entity, array $options = []) { if ($entity->isAttributeChanged('status')) { diff --git a/application/Espo/Modules/Crm/Resources/metadata/clientDefs/Call.json b/application/Espo/Modules/Crm/Resources/metadata/clientDefs/Call.json index cf5423d33c..a9b3be576b 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/clientDefs/Call.json +++ b/application/Espo/Modules/Crm/Resources/metadata/clientDefs/Call.json @@ -91,6 +91,10 @@ "activitiesCreate": true, "historyCreate": true }, + "forcePatchAttributeDependencyMap": { + "dateEnd": ["dateStart"], + "dateEndDate": ["dateStartDate"] + }, "dynamicLogic":{ "fields":{ "reminders": { diff --git a/application/Espo/Modules/Crm/Resources/metadata/clientDefs/Meeting.json b/application/Espo/Modules/Crm/Resources/metadata/clientDefs/Meeting.json index a533285930..219a94f809 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/clientDefs/Meeting.json +++ b/application/Espo/Modules/Crm/Resources/metadata/clientDefs/Meeting.json @@ -96,6 +96,10 @@ "activitiesCreate": true, "historyCreate": true }, + "forcePatchAttributeDependencyMap": { + "dateEnd": ["dateStart"], + "dateEndDate": ["dateStartDate"] + }, "dynamicLogic":{ "fields":{ "reminders": { diff --git a/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Call.json b/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Call.json index d7311ed9e3..3a47664dfe 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Call.json +++ b/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Call.json @@ -5,5 +5,9 @@ ], "saverClassNameList": [ "Espo\\Core\\FieldProcessing\\Reminder\\Saver" + ], + "beforeUpdateHookClassNameList": [ + "__APPEND__", + "Espo\\Classes\\RecordHooks\\Event\\BeforeUpdatePreserveDuration" ] } diff --git a/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Meeting.json b/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Meeting.json index ed0d9d165e..e4c72c252f 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Meeting.json +++ b/application/Espo/Modules/Crm/Resources/metadata/recordDefs/Meeting.json @@ -5,5 +5,9 @@ "saverClassNameList": [ "Espo\\Core\\FieldProcessing\\Reminder\\Saver", "Espo\\Modules\\Crm\\Classes\\FieldProcessing\\Meeting\\SourceEmailSaver" + ], + "beforeUpdateHookClassNameList": [ + "__APPEND__", + "Espo\\Classes\\RecordHooks\\Event\\BeforeUpdatePreserveDuration" ] } diff --git a/application/Espo/ORM/BaseEntity.php b/application/Espo/ORM/BaseEntity.php index 4a03d1591a..9ffa67fb2b 100644 --- a/application/Espo/ORM/BaseEntity.php +++ b/application/Espo/ORM/BaseEntity.php @@ -100,6 +100,11 @@ class BaseEntity implements Entity private ?ValueAccessor $valueAccessor = null; + /** + * @var array + */ + private array $writtenMap = []; + /** * @param array{ * attributes?: array>, @@ -490,6 +495,8 @@ class BaseEntity implements Entity } $this->setInContainer($attribute, $preparedValue); + + $this->writtenMap[$attribute] = true; } /** @@ -897,6 +904,14 @@ class BaseEntity implements Entity ); } + /** + * Whether an attribute was written (since syncing with DB) regardless being changed. + */ + public function isAttributeWritten(string $name): bool + { + return $this->writtenMap[$name] ?? false; + } + /** * @param mixed $v1 * @param mixed $v2 @@ -1028,6 +1043,8 @@ class BaseEntity implements Entity foreach ($this->fetchedValuesContainer as $attribute => $value) { $this->setFetched($attribute, $value); } + + $this->writtenMap = []; } /** diff --git a/client/src/views/record/base.js b/client/src/views/record/base.js index 10d808e6c1..3da30ec749 100644 --- a/client/src/views/record/base.js +++ b/client/src/views/record/base.js @@ -658,36 +658,58 @@ define( var headers = options.headers || {}; + var model = this.model; + this.lastSaveCancelReason = null; this.beforeBeforeSave(); - var data = this.fetch(); - - var model = this.model; - + let fetchedAttributes = this.fetch(); var initialAttributes = this.attributes; - var beforeSaveAttributes = this.model.getClonedAttributes(); - data = _.extend(Espo.Utils.cloneDeep(beforeSaveAttributes), data); + let attributes = _.extend( + Espo.Utils.cloneDeep(beforeSaveAttributes), + fetchedAttributes + ); - var setAttributes = false; + let setAttributes = {}; if (model.isNew()) { - setAttributes = data; + setAttributes = attributes; } - else { - for (var name in data) { - if (_.isEqual(initialAttributes[name], data[name])) { + + if (!model.isNew()) { + for (let attr in attributes) { + if (_.isEqual(initialAttributes[attr], attributes[attr])) { continue; } - (setAttributes || (setAttributes = {}))[name] = data[name]; + setAttributes[attr] = attributes[attr]; + } + + let forcePatchAttributeDependencyMap = this.forcePatchAttributeDependencyMap || {}; + + for (let attr in forcePatchAttributeDependencyMap) { + if (attr in setAttributes) { + continue; + } + + if (!(attr in fetchedAttributes)) { + continue; + } + + let depAttributeList = forcePatchAttributeDependencyMap[attr]; + + let treatAsChanged = !! depAttributeList.find(attr => attr in setAttributes); + + if (treatAsChanged) { + setAttributes[attr] = attributes[attr]; + } } } - if (!setAttributes) { + if (Object.keys(setAttributes).length === 0) { if (!options.skipNotModifiedWarning) { this.afterNotModified(); } diff --git a/client/src/views/record/detail.js b/client/src/views/record/detail.js index 039574ed1c..5ccb5e1bd8 100644 --- a/client/src/views/record/detail.js +++ b/client/src/views/record/detail.js @@ -1236,6 +1236,9 @@ define('views/record/detail', ['views/record/base', 'view-record-helper'], funct this.getHelper().processSetupHandlers(this, this.setupHandlerType); this.initInlideEditDynamicWithLogicInteroperability(); + + this.forcePatchAttributeDependencyMap = this.getMetadata() + .get(['clientDefs', this.scope, 'forcePatchAttributeDependencyMap']) || {}; }, setupBeforeFinal: function () { diff --git a/tests/unit/Espo/ORM/EntityTest.php b/tests/unit/Espo/ORM/EntityTest.php index ece6141209..3c1cb60bad 100644 --- a/tests/unit/Espo/ORM/EntityTest.php +++ b/tests/unit/Espo/ORM/EntityTest.php @@ -75,7 +75,7 @@ class EntityTest extends \PHPUnit\Framework\TestCase } /** - * @return \Espo\ORM\Entity + * @return \Espo\ORM\BaseEntity */ protected function createEntity(string $entityType, ?string $className = null) { @@ -380,4 +380,23 @@ class EntityTest extends \PHPUnit\Framework\TestCase $entity->getId(); } + + public function testIsAttributeWritten1(): void + { + $entity = $this->createEntity('Test'); + + $entity->set([ + 'int' => '1', + 'object' => (object) [], + ]); + + $entity->setAsFetched(); + + $this->assertEquals(false, $entity->isAttributeWritten('int')); + + $entity->set('int', '1'); + + $this->assertEquals(true, $entity->isAttributeWritten('int')); + $this->assertEquals(false, $entity->isAttributeWritten('object')); + } } diff --git a/upgrades/7.2/data.json b/upgrades/7.2/data.json new file mode 100644 index 0000000000..1449b0403c --- /dev/null +++ b/upgrades/7.2/data.json @@ -0,0 +1,4 @@ +{ + "manifest": { + } +} diff --git a/upgrades/7.2/scripts/AfterUpgrade.php b/upgrades/7.2/scripts/AfterUpgrade.php new file mode 100644 index 0000000000..f7798eb3b4 --- /dev/null +++ b/upgrades/7.2/scripts/AfterUpgrade.php @@ -0,0 +1,76 @@ +updateEventMetadata($container->get('metadata'), $container->get('fileManager')); + } + + private function updateEventMetadata(Metadata $metadata, FileManager $fileManager): void + { + $defs = $metadata->get(['scopes']); + + $toSave = false; + + foreach ($defs as $entityType => $item) { + $isCustom = $item['isCustom'] ?? false; + $type = $item['type'] ?? false; + + if (!$isCustom || $type !== 'Event') { + continue; + } + + $toSave = true; + + $metadata->set('recordDefs', $entityType, [ + 'beforeUpdateHookClassNameList' => [ + "__APPEND__", + "Espo\\Classes\\RecordHooks\\Event\\BeforeUpdatePreserveDuration" + ] + ]); + + $metadata->set('clientDefs', $entityType, [ + 'forcePatchAttributeDependencyMap' => [ + "dateEnd" => ["dateStart"], + "dateEndDate" => ["dateStartDate"] + ] + ]); + } + + if ($toSave) { + $metadata->save(); + } + } +}