diff --git a/application/Espo/Classes/MassAction/User/MassUpdate.php b/application/Espo/Classes/MassAction/User/MassUpdate.php index aecb8c7b29..2982056016 100644 --- a/application/Espo/Classes/MassAction/User/MassUpdate.php +++ b/application/Espo/Classes/MassAction/User/MassUpdate.php @@ -29,40 +29,54 @@ namespace Espo\Classes\MassAction\User; -use Espo\Core\{ - MassAction\Actions\MassUpdate as MassUpdateOriginal, - MassAction\QueryBuilder, - MassAction\Params, - MassAction\Result, - MassAction\Data, - MassAction\MassAction, - Utils\File\Manager as FileManager, - DataManager, - Acl, - ORM\EntityManager, - Exceptions\Forbidden, -}; +use Espo\Core\MassAction\Actions\MassUpdate as MassUpdateOriginal; +use Espo\Core\MassAction\QueryBuilder; +use Espo\Core\MassAction\Params; +use Espo\Core\MassAction\Result; +use Espo\Core\MassAction\Data; +use Espo\Core\MassAction\MassAction; +use Espo\Core\Utils\File\Manager as FileManager; +use Espo\Core\DataManager; +use Espo\Core\Acl; +use Espo\Core\Acl\Table; -use Espo\{ - Entities\User, - ORM\Entity, -}; +use Espo\Core\Exceptions\Forbidden; + +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +use Espo\Tools\MassUpdate\Data as MassUpdateData; class MassUpdate implements MassAction { - private $massUpdateOriginal; + private MassUpdateOriginal $massUpdateOriginal; - private $queryBuilder; + private QueryBuilder $queryBuilder; - private $entityManager; + private EntityManager $entityManager; - private $acl; + private Acl $acl; - private $user; + private User $user; - private $fileManager; + private FileManager $fileManager; - private $dataManager; + private DataManager $dataManager; + + private const PERMISSION = 'massUpdatePermission'; + + private const SYSTEM_USER_ID = 'system'; + + /** @var string[] */ + private array $notAllowedAttributeList = [ + 'type', + 'password', + 'emailAddress', + 'isAdmin', + 'isSuperAdmin', + 'isPortalUser', + ]; public function __construct( MassUpdateOriginal $massUpdateOriginal, @@ -86,48 +100,54 @@ class MassUpdate implements MassAction { $entityType = $params->getEntityType(); - if (!$this->acl->check($entityType, 'edit')) { + if (!$this->user->isAdmin()) { + throw new Forbidden("Only admin can mass-update users."); + } + + if (!$this->acl->check($entityType, Table::ACTION_EDIT)) { throw new Forbidden("No edit access for '{$entityType}'."); } - if ($this->acl->get('massUpdatePermission') !== 'yes') { + if ($this->acl->get(self::PERMISSION) !== Table::LEVEL_YES) { throw new Forbidden("No mass-update permission."); } - if ( - $data->has('type') || - $data->has('password') || - $data->has('emailAddress') || - $data->has('isAdmin') || - $data->has('isSuperAdmin') || - $data->has('isPortalUser') - ) { - throw new Forbidden("Not allowed fields."); - } + $massUpdateData = MassUpdateData::fromMassActionData($data); + + $this->checkAccess($massUpdateData); $query = $this->queryBuilder->build($params); $collection = $this->entityManager - ->getRDBRepository('User') + ->getRDBRepository(User::ENTITY_TYPE) ->clone($query) ->sth() ->select(['id']) ->find(); foreach ($collection as $entity) { - $this->checkEntity($entity, $data); + $this->checkEntity($entity, $massUpdateData); } $result = $this->massUpdateOriginal->process($params, $data); - $this->afterProcess($result, $data); + $this->afterProcess($result, $massUpdateData); return $result; } - protected function checkEntity(Entity $entity, Data $data): void + private function checkAccess(MassUpdateData $data): void { - if ($entity->getId() === 'system') { + foreach ($this->notAllowedAttributeList as $attribute) { + if ($data->has($attribute)) { + throw new Forbidden("Attribute '{$attribute}' not allowed for mass-update."); + } + } + } + + private function checkEntity(Entity $entity, MassUpdateData $data): void + { + if ($entity->getId() === self::SYSTEM_USER_ID) { throw new Forbidden("Can't update 'system' user."); } @@ -138,9 +158,9 @@ class MassUpdate implements MassAction } } - protected function afterProcess(Result $result, Data $dataWrapped): void + private function afterProcess(Result $result, MassUpdateData $dataWrapped): void { - $data = $dataWrapped->getRaw(); + $data = $dataWrapped->getValues(); if ( property_exists($data, 'rolesIds') || @@ -168,12 +188,12 @@ class MassUpdate implements MassAction } } - protected function clearRoleCache(string $id): void + private function clearRoleCache(string $id): void { $this->fileManager->removeFile('data/cache/application/acl/' . $id . '.php'); } - protected function clearPortalRolesCache(): void + private function clearPortalRolesCache(): void { $this->fileManager->removeInDir('data/cache/application/aclPortal'); } diff --git a/application/Espo/Core/MassAction/Actions/MassUpdate.php b/application/Espo/Core/MassAction/Actions/MassUpdate.php index 629d91b00e..2477bc9b2c 100644 --- a/application/Espo/Core/MassAction/Actions/MassUpdate.php +++ b/application/Espo/Core/MassAction/Actions/MassUpdate.php @@ -29,278 +29,27 @@ namespace Espo\Core\MassAction\Actions; -use Espo\Repositories\Attachment as AttachmentRepository; +use Espo\Tools\MassUpdate\Processor; +use Espo\Tools\MassUpdate\Data as MassUpdateData; -use Espo\Entities\User; - -use Espo\Core\{ - MassAction\QueryBuilder, - MassAction\Params, - MassAction\Result, - MassAction\Data, - MassAction\MassAction, - Acl, - Record\ServiceFactory as RecordServiceFactory, - ORM\EntityManager, - Utils\FieldUtil, - Utils\ObjectUtil, - Exceptions\Forbidden, -}; - -use Exception; -use stdClass; +use Espo\Core\MassAction\Params; +use Espo\Core\MassAction\Result; +use Espo\Core\MassAction\Data; +use Espo\Core\MassAction\MassAction; class MassUpdate implements MassAction { - /** - * @var QueryBuilder - */ - protected $queryBuilder; + private Processor $processor; - /** - * @var Acl - */ - protected $acl; - - /** - * @var RecordServiceFactory - */ - protected $recordServiceFactory; - - /** - * @var EntityManager - */ - protected $entityManager; - - /** - * @var FieldUtil - */ - protected $fieldUtil; - - /** - * @var User - */ - private $user; - - public function __construct( - QueryBuilder $queryBuilder, - Acl $acl, - RecordServiceFactory $recordServiceFactory, - EntityManager $entityManager, - FieldUtil $fieldUtil, - User $user - ) { - $this->queryBuilder = $queryBuilder; - $this->acl = $acl; - $this->recordServiceFactory = $recordServiceFactory; - $this->entityManager = $entityManager; - $this->fieldUtil = $fieldUtil; - $this->user = $user; + public function __construct(Processor $processor) + { + $this->processor = $processor; } public function process(Params $params, Data $data): Result { - $entityType = $params->getEntityType(); + $massUpdateData = MassUpdateData::fromMassActionData($data); - if (!$this->acl->check($entityType, 'edit')) { - throw new Forbidden("No edit access for '{$entityType}'."); - } - - if ($this->acl->get('massUpdatePermission') !== 'yes') { - throw new Forbidden("No mass-update permission."); - } - - $valueMap = $data->getRaw(); - - $service = $this->recordServiceFactory->create($entityType); - - $repository = $this->entityManager->getRDBRepository($entityType); - - $service->filterUpdateInput($valueMap); - - $fieldToCopyList = $this->detectFieldToCopyList($entityType, $valueMap); - - $query = $this->queryBuilder->build($params); - - $collection = $repository - ->clone($query) - ->sth() - ->find(); - - $ids = []; - - $count = 0; - - foreach ($collection as $i => $entity) { - if (!$this->acl->check($entity, 'edit')) { - continue; - } - - $itemValueMap = $this->prepareItemValueMap($entityType, $valueMap, $i, $fieldToCopyList); - - $entity->set($itemValueMap); - - try { - $service->processValidation($entity, $itemValueMap); - } - catch (Exception $e) { - continue; - } - - if (!$service->checkAssignment($entity)) { - continue; - } - - $repository->save($entity, [ - 'massUpdate' => true, - 'skipStreamNotesAcl' => true, - 'modifiedById' => $this->user->getId(), - ]); - - /** @var string */ - $id = $entity->getId(); - - $ids[] = $id; - - $count++; - - $service->processActionHistoryRecord('update', $entity); - } - - $result = [ - 'count' => $count, - 'ids' => $ids, - ]; - - return Result::fromArray($result); - } - - /** - * @param string[] $fieldToCopyList - */ - protected function prepareItemValueMap( - string $entityType, - stdClass $valueMap, - int $i, - array $fieldToCopyList - ): stdClass { - - $clonedValueMap = ObjectUtil::clone($valueMap); - - if (!count($fieldToCopyList)) { - return $clonedValueMap; - } - - if ($i === 0) { - return $clonedValueMap; - } - - foreach ($fieldToCopyList as $field) { - $type = $this->fieldUtil->getEntityTypeFieldParam($entityType, $field, 'type'); - - if ($type === 'file' || $type === 'image') { - $this->copyFileField($field, $clonedValueMap); - - continue; - } - - if ($type === 'attachmentMultiple') { - $this->copyAttachmentMultipleField($field, $clonedValueMap); - - continue; - } - } - - return $clonedValueMap; - } - - protected function copyFileField(string $field, stdClass $valueMap): void - { - $idAttribute = $field . 'Id'; - - $id = $valueMap->$idAttribute ?? null; - - if (!$id) { - return; - } - - $attachment = $this->entityManager->getEntity('Attachment', $id); - - if (!$attachment) { - $valueMap->$idAttribute = null; - - return; - } - - /** @var AttachmentRepository $attachmentRepository */ - $attachmentRepository = $this->entityManager->getRepository('Attachment'); - - $copiedAttachment = $attachmentRepository->getCopiedAttachment($attachment); - - $valueMap->$idAttribute = $copiedAttachment->getId(); - } - - protected function copyAttachmentMultipleField(string $field, stdClass $valueMap): void - { - $idsAttribute = $field . 'Ids'; - - $ids = $valueMap->$idsAttribute ?? []; - - if (!count($ids)) { - return; - } - - /** @var AttachmentRepository $attachmentRepository */ - $attachmentRepository = $this->entityManager->getRepository('Attachment'); - - $copiedIds = []; - - foreach ($ids as $id) { - $attachment = $this->entityManager->getEntity('Attachment', $id); - - if (!$attachment) { - continue; - } - - $copiedAttachment = $attachmentRepository->getCopiedAttachment($attachment); - - $copiedIds[] = $copiedAttachment->getId(); - } - - $valueMap->$idsAttribute = $copiedIds; - } - - /** - * @return string[] - */ - protected function detectFieldToCopyList(string $entityType, stdClass $valueMap): array - { - $resultFieldList = []; - - $fieldList = array_merge( - $this->fieldUtil->getFieldByTypeList($entityType, 'file'), - $this->fieldUtil->getFieldByTypeList($entityType, 'image'), - $this->fieldUtil->getFieldByTypeList($entityType, 'attachmentMultiple') - ); - - foreach ($fieldList as $field) { - $actualAttributeList = $this->fieldUtil->getActualAttributeList($entityType, $field); - - $met = false; - - foreach ($actualAttributeList as $attribute) { - $value = $valueMap->$attribute ?? null; - - if ($value) { - $met = true; - } - } - - if ($met) { - $resultFieldList[] = $field; - } - } - - return $resultFieldList; + return $this->processor->process($params, $massUpdateData); } } diff --git a/application/Espo/Core/MassAction/Result.php b/application/Espo/Core/MassAction/Result.php index 8b1b91ebe3..782b8ed5c6 100644 --- a/application/Espo/Core/MassAction/Result.php +++ b/application/Espo/Core/MassAction/Result.php @@ -90,6 +90,7 @@ class Result } /** + * @deprecated * @param array{ * count?: ?int, * ids?: ?string[], diff --git a/application/Espo/Modules/Crm/Classes/MassAction/Opportunity/MassUpdate.php b/application/Espo/Modules/Crm/Classes/MassAction/Opportunity/MassUpdate.php index 4ecbdec29c..5f19e361b7 100644 --- a/application/Espo/Modules/Crm/Classes/MassAction/Opportunity/MassUpdate.php +++ b/application/Espo/Modules/Crm/Classes/MassAction/Opportunity/MassUpdate.php @@ -35,38 +35,39 @@ use Espo\Core\MassAction\Result; use Espo\Core\MassAction\Data; use Espo\Core\MassAction\MassAction; +use Espo\Tools\MassUpdate\Data as MassUpdateData; + use Espo\Core\Utils\Metadata; class MassUpdate implements MassAction { - private $massUpdateOriginal; + private MassUpdateOriginal $massUpdateOriginal; - private $metadata; + private Metadata $metadata; - public function __construct( - MassUpdateOriginal $massUpdateOriginal, - Metadata $metadata - ) { + public function __construct(MassUpdateOriginal $massUpdateOriginal, Metadata $metadata) + { $this->massUpdateOriginal = $massUpdateOriginal; $this->metadata = $metadata; } public function process(Params $params, Data $data): Result { + $massUpdateData = MassUpdateData::fromMassActionData($data); + $probability = null; - if ( - $data->get('stage') && - !$data->has('probability') - ) { + $stage = $massUpdateData->getValue('stage'); + + if ($stage && !$massUpdateData->has('probability')) { $probability = $this->metadata - ->get(['entityDefs', 'Opportunity', 'fields', 'stage', 'probabilityMap', $data->get('stage')]); + ->get(['entityDefs', 'Opportunity', 'fields', 'stage', 'probabilityMap', $stage]); } if ($probability !== null) { - $data = $data->with('probability', $probability); + $massUpdateData = $massUpdateData->with('probability', $probability); } - return $this->massUpdateOriginal->process($params, $data); + return $this->massUpdateOriginal->process($params, $massUpdateData->toMassActionData()); } } diff --git a/application/Espo/Resources/metadata/fields/array.json b/application/Espo/Resources/metadata/fields/array.json index 0db35608de..5532b3865f 100644 --- a/application/Espo/Resources/metadata/fields/array.json +++ b/application/Espo/Resources/metadata/fields/array.json @@ -60,5 +60,6 @@ }, "translatedOptions": true, "dynamicLogicOptions": true, - "personalData": true + "personalData": true, + "massUpdateActionList": ["update", "add", "remove"] } diff --git a/application/Espo/Resources/metadata/fields/attachmentMultiple.json b/application/Espo/Resources/metadata/fields/attachmentMultiple.json index b377d2f1fc..84efac71a6 100644 --- a/application/Espo/Resources/metadata/fields/attachmentMultiple.json +++ b/application/Espo/Resources/metadata/fields/attachmentMultiple.json @@ -54,5 +54,6 @@ "notSortable": true, "filter": true, "personalData": true, - "duplicatorClassName": "Espo\\Classes\\FieldDuplicators\\AttachmentMultiple" + "duplicatorClassName": "Espo\\Classes\\FieldDuplicators\\AttachmentMultiple", + "massUpdateActionList": ["update", "add"] } diff --git a/application/Espo/Resources/metadata/fields/linkMultiple.json b/application/Espo/Resources/metadata/fields/linkMultiple.json index 206d41b923..69d0e77c9e 100644 --- a/application/Espo/Resources/metadata/fields/linkMultiple.json +++ b/application/Espo/Resources/metadata/fields/linkMultiple.json @@ -35,5 +35,6 @@ "filter": true, "valueFactoryClassName": "Espo\\Core\\Field\\LinkMultiple\\LinkMultipleFactory", "attributeExtractorClassName": "Espo\\Core\\Field\\LinkMultiple\\LinkMultipleAttributeExtractor", - "duplicatorClassName": "Espo\\Classes\\FieldDuplicators\\LinkMultiple" + "duplicatorClassName": "Espo\\Classes\\FieldDuplicators\\LinkMultiple", + "massUpdateActionList": ["update", "add", "remove"] } diff --git a/application/Espo/Resources/metadata/fields/multiEnum.json b/application/Espo/Resources/metadata/fields/multiEnum.json index 1475f1ba25..e6b26685fa 100644 --- a/application/Espo/Resources/metadata/fields/multiEnum.json +++ b/application/Espo/Resources/metadata/fields/multiEnum.json @@ -67,5 +67,6 @@ }, "translatedOptions": true, "dynamicLogicOptions": true, - "personalData": true + "personalData": true, + "massUpdateActionList": ["update", "add", "remove"] } diff --git a/application/Espo/Tools/MassUpdate/Action.php b/application/Espo/Tools/MassUpdate/Action.php new file mode 100644 index 0000000000..8667282d72 --- /dev/null +++ b/application/Espo/Tools/MassUpdate/Action.php @@ -0,0 +1,39 @@ + + */ + private array $actions; + + /** + * @param array $actions + */ + private function __construct(stdClass $values, array $actions) + { + $this->values = $values; + $this->actions = $actions; + } + + public function has(string $attribute): bool + { + return property_exists($this->values, $attribute); + } + + /** + * @return string[] + */ + public function getAttributeList(): array + { + return array_keys(get_object_vars($this->values)); + } + + /** + * @return mixed + */ + public function getValue(string $attribute) + { + return $this->getValues()->$attribute ?? null; + } + + public function getValues(): stdClass + { + return ObjectUtil::clone($this->values); + } + + /** + * @return Action::*|null + */ + public function getAction(string $attribute): ?string + { + if (!$this->has($attribute)) { + return null; + } + + return $this->actions[$attribute] ?? Action::UPDATE; + } + + public static function create(): self + { + return new self((object) [], []); + } + + public static function fromMassActionData(ActionData $data): self + { + $values = $data->get('values'); + $rawActions = $data->get('actions'); + + // Backward compatibility. + if (!$data->has('values')) { + return new self($data->getRaw(), []); + } + + if (!$values instanceof stdClass) { + throw new RuntimeException("No `values` in mass-action data."); + } + + if ($rawActions !== null && !$rawActions instanceof stdClass) { + throw new RuntimeException("Bad `actions` in mass-action data."); + } + + if ($rawActions === null) { + $rawActions = (object) []; + } + + return new self($values, get_object_vars($rawActions)); + } + + /** + * @param mixed $value + * @param Action::*|null $action If NULL, the current action will be used. If no current, then 'update'. + */ + public function with(string $attribute, $value, ?string $action = null): self + { + if ($action === null) { + $action = $this->getAction($attribute) ?? Action::UPDATE; + } + + $values = $this->getValues(); + $actions = $this->actions; + + $values->$attribute = $value; + $actions[$attribute] = $action; + + return new self($values, $actions); + } + + public function without(string $attribute): self + { + $values = $this->getValues(); + $actions = $this->actions; + + unset($values->$attribute); + unset($actions[$attribute]); + + return new self($values, $actions); + } + + public function toMassActionData(): ActionData + { + return ActionData::fromRaw((object) [ + 'values' => $this->getValues(), + 'actions' => (object) $this->actions, + ]); + } + + public function __clone() + { + $this->values = ObjectUtil::clone($this->values); + } +} diff --git a/application/Espo/Tools/MassUpdate/MassUpdate.php b/application/Espo/Tools/MassUpdate/MassUpdate.php new file mode 100644 index 0000000000..3b771680d2 --- /dev/null +++ b/application/Espo/Tools/MassUpdate/MassUpdate.php @@ -0,0 +1,80 @@ +massActionFactory = $massActionFactory; + $this->entityManager = $entityManager; + } + + /** + * @param ?User $user Under what user to perform mass-update. If not specified, the system user will be used. + * Access control is applied for the user. + */ + public function process(Params $params, Data $data, ?User $user = null): Result + { + $entityType = $params->getEntityType(); + + if (!$user) { + $user = $this->entityManager->getEntityById(User::ENTITY_TYPE, self::DEFAULT_USER_ID); + } + + if (!$user) { + throw new RuntimeException("No user."); + } + + $action = $this->massActionFactory->createForUser(self::ACTION, $entityType, $user); + + return $action->process($params, $data->toMassActionData()); + } +} diff --git a/application/Espo/Tools/MassUpdate/Processor.php b/application/Espo/Tools/MassUpdate/Processor.php new file mode 100644 index 0000000000..1d0759ca37 --- /dev/null +++ b/application/Espo/Tools/MassUpdate/Processor.php @@ -0,0 +1,332 @@ +valueMapPreparator = $valueMapPreparator; + $this->queryBuilder = $queryBuilder; + $this->acl = $acl; + $this->serviceFactory = $serviceFactory; + $this->entityManager = $entityManager; + $this->fieldUtil = $fieldUtil; + $this->user = $user; + } + + public function process(Params $params, Data $data): Result + { + $entityType = $params->getEntityType(); + + if (!$this->acl->check($entityType, Table::ACTION_EDIT)) { + throw new Forbidden("No edit access for '{$entityType}'."); + } + + if ($this->acl->get(self::PERMISSION) !== Table::LEVEL_YES) { + throw new Forbidden("No mass-update permission."); + } + + $service = $this->serviceFactory->create($entityType); + + $filteredData = $this->filterData($data, $service); + + if ($filteredData->getAttributeList() === []) { + return new Result(0, []); + } + + $copyFieldList = $this->detectFieldToCopyList($entityType, $filteredData); + + $query = $this->queryBuilder->build($params); + + $collection = $this->entityManager + ->getRDBRepository($entityType) + ->clone($query) + ->sth() + ->find(); + + $ids = []; + $count = 0; + + foreach ($collection as $i => $entity) { + $itemResult = $this->processEntity($entity, $filteredData, $i, $copyFieldList, $service); + + if (!$itemResult) { + continue; + } + + $ids[] = $entity->getId(); + $count++; + } + + return new Result($count, $ids); + } + + /** + * @param Service $service + */ + private function filterData(Data $data, Service $service): Data + { + $filteredData = $data; + + $values = $data->getValues(); + + $service->filterUpdateInput($values); + + foreach ($data->getAttributeList() as $attribute) { + if (!property_exists($values, $attribute)) { + $filteredData = $filteredData->without($attribute); + + continue; + } + + $action = $filteredData->getAction($attribute) ?? Action::UPDATE; + $value = $values->$attribute; + + $filteredData = $filteredData->with($attribute, $value, $action); + } + + return $filteredData; + } + + /** + * @param string[] $fieldToCopyList + * @param Service $service + */ + private function processEntity(Entity $entity, Data $data, int $i, array $fieldToCopyList, Service $service): bool + { + if (!$this->acl->check($entity, Table::ACTION_EDIT)) { + return false; + } + + $values = $this->prepareItemValueMap($entity, $data, $i, $fieldToCopyList); + + $entity->set($values); + + try { + $service->processValidation($entity, $values); + } + catch (Exception $e) { + return false; + } + + if (!$service->checkAssignment($entity)) { + return false; + } + + $this->entityManager->saveEntity($entity, [ + 'massUpdate' => true, + 'skipStreamNotesAcl' => true, + 'modifiedById' => $this->user->getId(), + ]); + + $service->processActionHistoryRecord('update', $entity); + + return true; + } + + /** + * @param string[] $copyFieldList + */ + private function prepareItemValueMap(Entity $entity, Data $data, int $i, array $copyFieldList): stdClass + { + $dataModified = $this->copy($entity->getEntityType(), $data, $i, $copyFieldList); + + return $this->valueMapPreparator->prepare($entity, $dataModified); + } + + /** + * @param string[] $copyFieldList + */ + private function copy(string $entityType, Data $data, int $i, array $copyFieldList): Data + { + if (!count($copyFieldList)) { + return $data; + } + + if ($i === 0) { + return $data; + } + + foreach ($copyFieldList as $field) { + $type = $this->fieldUtil->getEntityTypeFieldParam($entityType, $field, 'type'); + + if ($type === 'file' || $type === 'image') { + $data = $this->copyFileField($field, $data); + + continue; + } + + if ($type === 'attachmentMultiple') { + $data = $this->copyAttachmentMultipleField($field, $data); + + continue; + } + } + + return $data; + } + + private function copyFileField(string $field, Data $data): Data + { + $attribute = $field . 'Id'; + + $id = $data->getValue($attribute); + + if (!$id) { + return $data; + } + + $attachment = $this->entityManager->getEntityById(Attachment::ENTITY_TYPE, $id); + + if (!$attachment) { + return $data->with($attribute, null); + } + + /** @var AttachmentRepository $attachmentRepository */ + $attachmentRepository = $this->entityManager->getRepository(Attachment::ENTITY_TYPE); + + $copiedAttachment = $attachmentRepository->getCopiedAttachment($attachment); + + return $data->with($attribute, $copiedAttachment->getId()); + } + + private function copyAttachmentMultipleField(string $field, Data $data): Data + { + $attribute = $field . 'Ids'; + + $ids = $data->getValue($attribute) ?? []; + + if (!is_array($ids)) { + throw new RuntimeException("Bad link-multiple-ids value."); + } + + if (!count($ids)) { + return $data; + } + + /** @var AttachmentRepository $attachmentRepository */ + $attachmentRepository = $this->entityManager->getRepository(Attachment::ENTITY_TYPE); + + $copiedIds = []; + + foreach ($ids as $id) { + $attachment = $this->entityManager->getEntityById(Attachment::ENTITY_TYPE, $id); + + if (!$attachment) { + continue; + } + + $copiedIds[] = $attachmentRepository + ->getCopiedAttachment($attachment) + ->getId(); + } + + return $data->with($attribute, $copiedIds); + } + + /** + * @return string[] + */ + private function detectFieldToCopyList(string $entityType, Data $data): array + { + $resultFieldList = []; + + $fieldList = array_merge( + $this->fieldUtil->getFieldByTypeList($entityType, 'file'), + $this->fieldUtil->getFieldByTypeList($entityType, 'image'), + $this->fieldUtil->getFieldByTypeList($entityType, 'attachmentMultiple') + ); + + foreach ($fieldList as $field) { + $actualAttributeList = $this->fieldUtil->getActualAttributeList($entityType, $field); + + $met = false; + + foreach ($actualAttributeList as $attribute) { + if ($data->getValue($attribute)) { + $met = true; + } + } + + if ($met) { + $resultFieldList[] = $field; + } + } + + return $resultFieldList; + } +} diff --git a/application/Espo/Tools/MassUpdate/ValueMapPreparator.php b/application/Espo/Tools/MassUpdate/ValueMapPreparator.php new file mode 100644 index 0000000000..923ef31df5 --- /dev/null +++ b/application/Espo/Tools/MassUpdate/ValueMapPreparator.php @@ -0,0 +1,254 @@ +ormDefs = $ormDefs; + } + + public function prepare(Entity $entity, Data $data): stdClass + { + $map = (object) []; + + $this->loadAdditionalFields($entity, $data); + + foreach ($data->getAttributeList() as $attribute) { + if ($data->getAction($attribute) === Action::UPDATE) { + $map->$attribute = $data->getValue($attribute); + + continue; + } + + if ($data->getValue($attribute) === null) { + continue; + } + + if (!$entity->has($attribute)) { + continue; + } + + if ($data->getAction($attribute) === Action::ADD) { + $map->$attribute = $this->prepareItemAdd($entity->get($attribute), $data->getValue($attribute)); + + continue; + } + + if ($data->getAction($attribute) === Action::REMOVE) { + $map->$attribute = $this->prepareItemRemove($entity->get($attribute), $data->getValue($attribute)); + + continue; + } + } + + return $map; + } + + private function loadAdditionalFields(Entity $entity, Data $data): void + { + if (!$entity instanceof CoreEntity) { + return; + } + + foreach ($data->getAttributeList() as $attribute) { + if ($entity->has($attribute)) { + continue; + } + + if ( + $entity->getAttributeParam($attribute, 'isLinkMultipleIdList') && + $entity->getAttributeParam($attribute, 'relation') + ) { + $field = $entity->getAttributeParam($attribute, 'relation'); + + $columns = $this->ormDefs + ->getEntity($entity->getEntityType()) + ->getField($field) + ->getParam('columns'); + + $entity->loadLinkMultipleField($field, $columns); + } + } + } + + /** + * @param mixed $set + * @param mixed $ch + * @return mixed + */ + private function prepareItemAdd($set, $ch) + { + if ($set === null && $ch === null) { + return null; + } + + if (is_array($set) || is_array($ch)) { + $set = $set ?? []; + $ch = $ch ?? []; + + if (!is_array($set) || !is_array($ch)) { + return $set; + } + + return $this->prepareItemAddArray($set, $ch); + } + + if ($set instanceof stdClass || $ch instanceof stdClass) { + $set = $set ?? (object) []; + $ch = $ch ?? (object) []; + + if (!$set instanceof stdClass || !$ch instanceof stdClass) { + return $set; + } + + return $this->prepareItemAddObject($set, $ch); + } + + return $set; + } + + /** + * @param mixed[] $set + * @param mixed[] $ch + * @return mixed[] + */ + private function prepareItemAddArray(array $set, array $ch): array + { + if ($ch === []) { + return $set; + } + + $result = $set; + + foreach ($ch as $value) { + if (in_array($value, $result)) { + continue; + } + + $result[] = $value; + } + + return $result; + } + + private function prepareItemAddObject(stdClass $set, stdClass $ch): stdClass + { + $result = ObjectUtil::clone($set); + + foreach (get_object_vars($ch) as $key => $value) { + $result->$key = $value; + } + + return $result; + } + + /** + * @param mixed $set + * @param mixed $ch + * @return mixed + */ + private function prepareItemRemove($set, $ch) + { + if ($set === null && $ch === null) { + return null; + } + + if (is_array($set) || is_array($ch)) { + $set = $set ?? []; + $ch = $ch ?? []; + + if (!is_array($set) || !is_array($ch)) { + return $set; + } + + return $this->prepareItemRemoveArray($set, $ch); + } + + if ($set instanceof stdClass || $ch instanceof stdClass) { + $set = $set ?? (object) []; + $ch = $ch ?? (object) []; + + if (!$set instanceof stdClass || !$ch instanceof stdClass) { + return $set; + } + + return $this->prepareItemRemoveObject($set, $ch); + } + + return $set; + } + + /** + * @param mixed[] $set + * @param mixed[] $ch + * @return mixed[] + */ + private function prepareItemRemoveArray(array $set, array $ch): array + { + if ($ch === []) { + return $set; + } + + $result = $set; + + foreach ($result as $i => $value) { + if (!in_array($value, $ch)) { + continue; + } + + unset($result[$i]); + } + + return array_values($result); + } + + private function prepareItemRemoveObject(stdClass $set, stdClass $ch): stdClass + { + $result = ObjectUtil::clone($set); + + foreach (array_keys(get_object_vars($ch)) as $key) { + unset($result->$key); + } + + return $result; + } +} diff --git a/client/res/templates/modals/mass-update.tpl b/client/res/templates/modals/mass-update.tpl index 3ca4081473..2db965fd01 100644 --- a/client/res/templates/modals/mass-update.tpl +++ b/client/res/templates/modals/mass-update.tpl @@ -29,7 +29,7 @@ {{/unless}}
-
+
diff --git a/client/src/field-manager.js b/client/src/field-manager.js index 8e6166477f..0bf17944ff 100644 --- a/client/src/field-manager.js +++ b/client/src/field-manager.js @@ -162,10 +162,54 @@ return _.union( this.getAttributeList(type, field), - this.metadata.get(['entityDefs', entityType, 'fields', field, 'additionalAttributeList']) || [] + this._getEntityTypeFieldAdditionalAttributeList(entityType, field) ); }, + getEntityTypeFieldActualAttributeList: function (entityType, field) { + let type = this.metadata.get(['entityDefs', entityType, 'fields', field, 'type']); + + if (!type) { + return []; + } + + return _.union( + this.getActualAttributeList(type, field), + this._getEntityTypeFieldAdditionalAttributeList(entityType, field) + ); + }, + + _getEntityTypeFieldAdditionalAttributeList: function (entityType, field) { + let type = this.metadata.get(['entityDefs', entityType, 'fields', field, 'type']); + + if (!type) { + return []; + } + + let partList = this.metadata + .get(['entityDefs', entityType, 'fields', field, 'additionalAttributeList']) || []; + + if (partList.length === 0) { + return []; + } + + let isPrefix = (this.defs[type] || {}).naming === 'prefix'; + + let list = []; + + partList.forEach(item => { + if (isPrefix) { + list.push(item + Espo.Utils.upperCaseFirst(field)); + + return; + } + + list.push(field + Espo.Utils.upperCaseFirst(item)); + }); + + return list; + }, + getAttributeList: function (fieldType, fieldName) { return _.union( this.getActualAttributeList(fieldType, fieldName), diff --git a/client/src/views/modals/mass-update.js b/client/src/views/modals/mass-update.js index 7efac30bd8..7e1da8bfdb 100644 --- a/client/src/views/modals/mass-update.js +++ b/client/src/views/modals/mass-update.js @@ -38,6 +38,12 @@ define('views/modals/mass-update', ['views/modal', 'helpers/mass-action'], funct layoutName: 'massUpdate', + ACTION_UPDATE: 'update', + + ACTION_ADD: 'add', + + ACTION_REMOVE: 'remove', + data: function () { return { scope: this.scope, @@ -81,6 +87,8 @@ define('views/modals/mass-update', ['views/modal', 'helpers/mass-action'], funct this.searchParams = this.options.searchParams; this.byWhere = this.options.byWhere; + this.hasActionMap = {}; + let totalCount = this.options.totalCount; this.helper = new MassActionHelper(this); @@ -120,8 +128,6 @@ define('views/modals/mass-update', ['views/modal', 'helpers/mass-action'], funct }, addField: function (name) { - this.enableButton('update'); - this.$el.find('[data-action="reset"]').removeClass('hidden'); this.$el.find('ul.filter-list li[data-name="'+name+'"]').addClass('hidden'); @@ -130,19 +136,46 @@ define('views/modals/mass-update', ['views/modal', 'helpers/mass-action'], funct this.$el.find('button.select-field').addClass('disabled').attr('disabled', 'disabled'); } - this.notify('Loading...'); + this.addedFieldList.push(name); - var label = this.translate(name, 'fields', this.entityType); + let label = this.getHelper().escapeString( + this.translate(name, 'fields', this.entityType) + ); - var html = '
' + - '' + - '
'; + let $cell = + $('
') + .addClass('cell form-group') + .attr('data-name', name) + .append( + $('