diff --git a/Civi/RemoteTools/ActionHandler/AbstractProfileEntityActionsHandler.php b/Civi/RemoteTools/ActionHandler/AbstractProfileEntityActionsHandler.php index 685b462..be5db1d 100644 --- a/Civi/RemoteTools/ActionHandler/AbstractProfileEntityActionsHandler.php +++ b/Civi/RemoteTools/ActionHandler/AbstractProfileEntityActionsHandler.php @@ -38,6 +38,7 @@ use Civi\RemoteTools\EntityProfile\Helper\ProfileEntityLoaderInterface; use Civi\RemoteTools\EntityProfile\RemoteEntityProfileInterface; use Civi\RemoteTools\Form\FormSpec\FormSpec; +use Civi\RemoteTools\Form\FormSpec\Util\ReadOnlyFieldsRemover; use Civi\RemoteTools\Form\Validation\ValidationResult; use CRM_Remotetools_ExtensionUtil as E; @@ -219,7 +220,7 @@ public function submitCreateForm(RemoteSubmitCreateFormAction $action $createdValues = $this->api4->createEntity( $this->profile->getEntityName(), $formSpec->getDataTransformer()->toEntityValues( - $action->getData(), + ReadOnlyFieldsRemover::removeReadOnlyFields($formSpec, $action->getData()), NULL, $action->getResolvedContactId() ), @@ -259,7 +260,7 @@ public function submitUpdateForm(RemoteSubmitUpdateFormAction $action } $newEntityValues = $formSpec->getDataTransformer()->toEntityValues( - $action->getData(), + ReadOnlyFieldsRemover::removeReadOnlyFields($formSpec, $action->getData()), $entityValues, $action->getResolvedContactId() ); diff --git a/Civi/RemoteTools/Api4/Action/RemoteSubmitCreateFormAction.php b/Civi/RemoteTools/Api4/Action/RemoteSubmitCreateFormAction.php index 06d5419..b4b2ea8 100644 --- a/Civi/RemoteTools/Api4/Action/RemoteSubmitCreateFormAction.php +++ b/Civi/RemoteTools/Api4/Action/RemoteSubmitCreateFormAction.php @@ -25,7 +25,7 @@ /** * @api */ -final class RemoteSubmitCreateFormAction extends AbstractProfileAwareRemoteAction { +class RemoteSubmitCreateFormAction extends AbstractProfileAwareRemoteAction { use ArgumentsParameterOptionalTrait; diff --git a/Civi/RemoteTools/Api4/Action/RemoteSubmitUpdateFormAction.php b/Civi/RemoteTools/Api4/Action/RemoteSubmitUpdateFormAction.php index df5cf16..a07694a 100644 --- a/Civi/RemoteTools/Api4/Action/RemoteSubmitUpdateFormAction.php +++ b/Civi/RemoteTools/Api4/Action/RemoteSubmitUpdateFormAction.php @@ -25,7 +25,7 @@ /** * @api */ -final class RemoteSubmitUpdateFormAction extends AbstractProfileAwareRemoteAction { +class RemoteSubmitUpdateFormAction extends AbstractProfileAwareRemoteAction { use DataParameterTrait; diff --git a/Civi/RemoteTools/Api4/Action/RemoteValidateUpdateFormAction.php b/Civi/RemoteTools/Api4/Action/RemoteValidateUpdateFormAction.php index d930a96..a011618 100644 --- a/Civi/RemoteTools/Api4/Action/RemoteValidateUpdateFormAction.php +++ b/Civi/RemoteTools/Api4/Action/RemoteValidateUpdateFormAction.php @@ -25,7 +25,7 @@ /** * @api */ -final class RemoteValidateUpdateFormAction extends AbstractProfileAwareRemoteAction { +class RemoteValidateUpdateFormAction extends AbstractProfileAwareRemoteAction { use DataParameterTrait; diff --git a/Civi/RemoteTools/Form/FormSpec/AbstractFormField.php b/Civi/RemoteTools/Form/FormSpec/AbstractFormField.php index 7798e83..1405b15 100644 --- a/Civi/RemoteTools/Form/FormSpec/AbstractFormField.php +++ b/Civi/RemoteTools/Form/FormSpec/AbstractFormField.php @@ -14,6 +14,8 @@ abstract class AbstractFormField extends AbstractFormInput { private bool $required = FALSE; + private bool $readOnly = FALSE; + private ?bool $nullable = NULL; private bool $hasDefaultValue = FALSE; @@ -41,6 +43,19 @@ public function setRequired(bool $required): self { return $this; } + public function isReadOnly(): bool { + return $this->readOnly; + } + + /** + * @return $this + */ + public function setReadOnly(bool $readOnly): self { + $this->readOnly = $readOnly; + + return $this; + } + public function isNullable(): bool { return NULL === $this->nullable ? !$this->isRequired() : $this->nullable; } diff --git a/Civi/RemoteTools/Form/FormSpec/Util/ReadOnlyFieldsRemover.php b/Civi/RemoteTools/Form/FormSpec/Util/ReadOnlyFieldsRemover.php new file mode 100644 index 0000000..c05347f --- /dev/null +++ b/Civi/RemoteTools/Form/FormSpec/Util/ReadOnlyFieldsRemover.php @@ -0,0 +1,25 @@ + $values + * + * @phpstan-return array + */ + public static function removeReadOnlyFields(FormSpec $formSpec, array $values): array { + foreach ($formSpec->getFields() as $field) { + if ($field->isReadOnly()) { + unset($values[$field->getName()]); + } + } + + return $values; + } + +} diff --git a/Civi/RemoteTools/JsonSchema/FormSpec/Factory/BooleanFieldFactory.php b/Civi/RemoteTools/JsonSchema/FormSpec/Factory/BooleanFieldFactory.php index c9d9d85..4e323f3 100644 --- a/Civi/RemoteTools/JsonSchema/FormSpec/Factory/BooleanFieldFactory.php +++ b/Civi/RemoteTools/JsonSchema/FormSpec/Factory/BooleanFieldFactory.php @@ -30,6 +30,9 @@ public function createSchema(AbstractFormField $field): JsonSchema { if ($field->hasDefaultValue()) { $keywords['default'] = $field->getDefaultValue(); } + if ($field->isReadOnly()) { + $keywords['readOnly'] = TRUE; + } return new JsonSchemaBoolean($keywords, $field->isNullable()); } diff --git a/Civi/RemoteTools/JsonSchema/FormSpec/Factory/IntegerFieldFactory.php b/Civi/RemoteTools/JsonSchema/FormSpec/Factory/IntegerFieldFactory.php index cf4c1d2..a2d7586 100644 --- a/Civi/RemoteTools/JsonSchema/FormSpec/Factory/IntegerFieldFactory.php +++ b/Civi/RemoteTools/JsonSchema/FormSpec/Factory/IntegerFieldFactory.php @@ -31,6 +31,9 @@ public function createSchema(AbstractFormField $field): JsonSchema { if ($field->hasDefaultValue()) { $keywords['default'] = $field->getDefaultValue(); } + if ($field->isReadOnly()) { + $keywords['readOnly'] = TRUE; + } if ($field instanceof AbstractNumberField) { if (NULL !== $field->getMaximum()) { $keywords['maximum'] = $field->getMaximum(); diff --git a/Civi/RemoteTools/JsonSchema/FormSpec/Factory/NumberFieldFactory.php b/Civi/RemoteTools/JsonSchema/FormSpec/Factory/NumberFieldFactory.php index 93d525b..b31e3b9 100644 --- a/Civi/RemoteTools/JsonSchema/FormSpec/Factory/NumberFieldFactory.php +++ b/Civi/RemoteTools/JsonSchema/FormSpec/Factory/NumberFieldFactory.php @@ -36,6 +36,9 @@ public function createSchema(AbstractFormField $field): JsonSchema { if ($field->hasDefaultValue()) { $keywords['default'] = $field->getDefaultValue(); } + if ($field->isReadOnly()) { + $keywords['readOnly'] = TRUE; + } if ($field instanceof AbstractNumberField) { if (NULL !== $field->getMaximum()) { $keywords['maximum'] = $field->getMaximum(); diff --git a/Civi/RemoteTools/JsonSchema/FormSpec/Factory/OptionFieldFactory.php b/Civi/RemoteTools/JsonSchema/FormSpec/Factory/OptionFieldFactory.php index 2af1648..7d7bb93 100644 --- a/Civi/RemoteTools/JsonSchema/FormSpec/Factory/OptionFieldFactory.php +++ b/Civi/RemoteTools/JsonSchema/FormSpec/Factory/OptionFieldFactory.php @@ -49,6 +49,9 @@ public function createSchema(AbstractFormField $field): JsonSchema { if ($field->hasDefaultValue()) { $keywords['default'] = $field->getDefaultValue(); } + if ($field->isReadOnly()) { + $keywords['readOnly'] = TRUE; + } return new JsonSchema($keywords); } diff --git a/Civi/RemoteTools/JsonSchema/FormSpec/Factory/StringFieldFactory.php b/Civi/RemoteTools/JsonSchema/FormSpec/Factory/StringFieldFactory.php index 36e0cf4..21115a8 100644 --- a/Civi/RemoteTools/JsonSchema/FormSpec/Factory/StringFieldFactory.php +++ b/Civi/RemoteTools/JsonSchema/FormSpec/Factory/StringFieldFactory.php @@ -35,6 +35,9 @@ public function createSchema(AbstractFormField $field): JsonSchema { if ($field->hasDefaultValue()) { $keywords['default'] = $field->getDefaultValue(); } + if ($field->isReadOnly()) { + $keywords['readOnly'] = TRUE; + } if ($field instanceof AbstractTextField) { if (NULL !== $field->getMaxLength()) { $keywords['maxLength'] = $field->getMaxLength(); diff --git a/tests/phpunit/Civi/RemoteTools/ActionHandler/AbstractProfileEntityActionsHandlerTest.php b/tests/phpunit/Civi/RemoteTools/ActionHandler/AbstractProfileEntityActionsHandlerTest.php index 0a443bb..a71a5ae 100644 --- a/tests/phpunit/Civi/RemoteTools/ActionHandler/AbstractProfileEntityActionsHandlerTest.php +++ b/tests/phpunit/Civi/RemoteTools/ActionHandler/AbstractProfileEntityActionsHandlerTest.php @@ -10,20 +10,29 @@ use Civi\RemoteTools\Api4\Action\RemoteGetCreateFormAction; use Civi\RemoteTools\Api4\Action\RemoteGetFieldsAction; use Civi\RemoteTools\Api4\Action\RemoteGetUpdateFormAction; +use Civi\RemoteTools\Api4\Action\RemoteSubmitCreateFormAction; +use Civi\RemoteTools\Api4\Action\RemoteSubmitUpdateFormAction; use Civi\RemoteTools\Api4\Action\RemoteValidateCreateFormAction; +use Civi\RemoteTools\Api4\Action\RemoteValidateUpdateFormAction; use Civi\RemoteTools\Api4\Api4Interface; use Civi\RemoteTools\EntityProfile\Authorization\GrantResult; use Civi\RemoteTools\EntityProfile\EntityProfilePermissionDecorator; use Civi\RemoteTools\EntityProfile\Helper\ProfileEntityDeleterInterface; use Civi\RemoteTools\EntityProfile\Helper\ProfileEntityLoaderInterface; use Civi\RemoteTools\EntityProfile\RemoteEntityProfileInterface; +use Civi\RemoteTools\Form\FormSpec\DataTransformerInterface; +use Civi\RemoteTools\Form\FormSpec\Field\TextField; use Civi\RemoteTools\Form\FormSpec\FormSpec; use Civi\RemoteTools\Form\FormSpec\ValidatorInterface; use Civi\RemoteTools\Form\Validation\ValidationError; use Civi\RemoteTools\Form\Validation\ValidationResult; use Civi\RemoteTools\PHPUnit\Traits\CreateMockTrait; +use PHPUnit\Framework\MockObject\Invocation; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub\ReturnValueMap; +use PHPUnit\Framework\MockObject\Stub\Stub; use PHPUnit\Framework\TestCase; +use function PHPUnit\Framework\atLeastOnce; /** * @covers \Civi\RemoteTools\ActionHandler\AbstractProfileEntityActionsHandler @@ -48,6 +57,8 @@ final class AbstractProfileEntityActionsHandlerTest extends TestCase { */ private MockObject $entityLoaderMock; + private bool $fieldLoadOptionsForFormSpec = FALSE; + /** * @var \Civi\RemoteTools\ActionHandler\AbstractProfileEntityActionsHandler&\PHPUnit\Framework\MockObject\MockObject */ @@ -58,6 +69,8 @@ final class AbstractProfileEntityActionsHandlerTest extends TestCase { */ private MockObject $profileMock; + private ?Stub $api4ExecuteStub = NULL; + protected function setUp(): void { parent::setUp(); $this->api4Mock = $this->createMock(Api4Interface::class); @@ -73,6 +86,8 @@ protected function setUp(): void { $this->profileMock->method('isCheckApiPermissions') ->with(self::RESOLVED_CONTACT_ID) ->willReturn(FALSE); + $this->profileMock->method('getFieldLoadOptionsForFormSpec') + ->willReturnCallback(fn() => $this->fieldLoadOptionsForFormSpec); } public function testDelete(): void { @@ -131,10 +146,8 @@ public function testGetCreateForm(): void { $arguments = ['key' => 'value']; $actionMock->setArguments($arguments); - $this->profileMock->method('isCreateGranted') - ->with($arguments, self::RESOLVED_CONTACT_ID) - ->willReturn(GrantResult::newPermitted()); - $this->profileMock->method('getFieldLoadOptionsForFormSpec')->willReturn(TRUE); + $this->mockIsCreateGranted($arguments, GrantResult::newPermitted()); + $this->fieldLoadOptionsForFormSpec = TRUE; $entityFields = [ 'foo' => ['name' => 'foo'], @@ -160,15 +173,12 @@ public function testGetCreateForm(): void { static::assertSame(['form' => 'Test'], $this->handler->getCreateForm($actionMock)); } - public function testGetCreateFormDeiniedWithForm(): void { + public function testGetCreateFormDeniedWithForm(): void { $actionMock = $this->createActionMock(RemoteGetCreateFormAction::class); $arguments = ['key' => 'value']; $actionMock->setArguments($arguments); - $this->profileMock->method('isCreateGranted') - ->with($arguments, self::RESOLVED_CONTACT_ID) - ->willReturn(GrantResult::newDeniedWithForm()); - $this->profileMock->method('getFieldLoadOptionsForFormSpec')->willReturn(FALSE); + $this->mockIsCreateGranted($arguments, GrantResult::newDeniedWithForm()); $entityFields = [ 'foo' => ['name' => 'foo'], @@ -199,9 +209,7 @@ public function testGetCreateFormDenied(): void { $arguments = ['key' => 'value']; $actionMock->setArguments($arguments); - $this->profileMock->method('isCreateGranted') - ->with($arguments, self::RESOLVED_CONTACT_ID) - ->willReturn(GrantResult::newDenied('Denied')); + $this->mockIsCreateGranted($arguments, GrantResult::newDenied('Denied')); $this->expectException(UnauthorizedException::class); $this->expectExceptionMessage('Denied'); @@ -216,10 +224,8 @@ public function testValidateCreateForm(): void { $actionMock->setArguments($arguments); $actionMock->setData($formData); - $this->profileMock->method('isCreateGranted') - ->with($arguments, self::RESOLVED_CONTACT_ID) - ->willReturn(GrantResult::newPermitted()); - $this->profileMock->method('getFieldLoadOptionsForFormSpec')->willReturn(TRUE); + $this->mockIsCreateGranted($arguments, GrantResult::newPermitted()); + $this->fieldLoadOptionsForFormSpec = TRUE; $entityFields = [ 'foo' => ['name' => 'foo'], @@ -256,6 +262,59 @@ public function validate(array $formData, ?array $currentEntityValues, ?int $con ], $this->handler->validateCreateForm($actionMock)); } + public function testSubmitCreateForm(): void { + $actionMock = $this->createActionMock(RemoteSubmitCreateFormAction::class); + $arguments = ['key' => 'value']; + $actionMock->setArguments(['key' => 'value']); + $formData = ['foo' => 'bar', 'bar' => 'baz']; + $actionMock->setData($formData); + + $entityFields = [ + 'foo' => ['name' => 'foo'], + 'bar' => ['name' => 'bar'], + ]; + + $this->mockIsCreateGranted($arguments, GrantResult::newPermitted()); + + $this->mockApi4Execute('Entity', 'getFields', [ + 'loadOptions' => FALSE, + 'values' => [], + 'checkPermissions' => FALSE, + ], new Result(array_values($entityFields))); + + $formSpec = new FormSpec('Title'); + $formSpec->addElement(new TextField('foo', 'Foo')); + $formSpec->addElement((new TextField('bar', 'Bar'))->setReadOnly(TRUE)); + + $this->profileMock->method('getCreateFormSpec') + ->with($arguments, $entityFields, self::RESOLVED_CONTACT_ID) + ->willReturn($formSpec); + + $validatorMock = $this->createMock(ValidatorInterface::class); + $formSpec->appendValidator($validatorMock); + $validatorMock->expects(static::once())->method('validate') + ->with($formData, NULL, self::RESOLVED_CONTACT_ID) + ->willReturn(ValidationResult::new()); + + // Read only field "bar" should be dropped. + $dataTransformerMock = $this->createMock(DataTransformerInterface::class); + $dataTransformerMock->expects(static::once())->method('toEntityValues') + ->with(['foo' => 'bar'], NULL, self::RESOLVED_CONTACT_ID) + ->willReturn(['foo' => 'bar2']); + $formSpec->setDataTransformer($dataTransformerMock); + + $createdValues = ['id' => 12, 'foo' => 'bar2']; + $this->api4Mock->expects(static::once())->method('createEntity') + ->with('Entity', ['foo' => 'bar2'], ['checkPermissions' => FALSE]) + ->willReturn(new Result([$createdValues])); + + $this->profileMock->method('getSaveSuccessMessage') + ->with($createdValues, NULL, $formData, self::RESOLVED_CONTACT_ID) + ->willReturn('Ok'); + + static::assertSame(['message' => 'Ok'], $this->handler->submitCreateForm($actionMock)); + } + public function testGetUpdateForm(): void { $actionMock = $this->createActionMock(RemoteGetUpdateFormAction::class); $actionMock->setId(12); @@ -266,38 +325,22 @@ public function testGetUpdateForm(): void { 'bar' => ['name' => 'bar'], ]; - $this->profileMock->method('getSelectFieldNames') - ->with(['*'], 'update', [], self::RESOLVED_CONTACT_ID) - ->willReturn(['foo']); + $this->mockGetSelectFieldNames(['foo']); $this->profileMock->method('isUpdateGranted') ->with($entityValues, self::RESOLVED_CONTACT_ID) ->willReturn(GrantResult::newPermitted()); - $this->profileMock->method('getFieldLoadOptionsForFormSpec')->willReturn(FALSE); - - $valueMap = [ - [ - 'Entity', - 'get', - [ - 'select' => ['foo'], - 'where' => [['id', '=', 12]], - 'checkPermissions' => FALSE, - ], - new Result([$entityValues]), - ], - [ - 'Entity', - 'getFields', - [ - 'loadOptions' => FALSE, - 'values' => ['id' => 12], - 'checkPermissions' => FALSE, - ], - new Result(array_values($entityFields)), - ], - ]; - $this->api4Mock->method('execute') - ->willReturnMap($valueMap); + + $this->mockApi4Execute('Entity', 'get', [ + 'select' => ['foo'], + 'where' => [['id', '=', 12]], + 'checkPermissions' => FALSE, + ], new Result([$entityValues])); + + $this->mockApi4Execute('Entity', 'getFields', [ + 'loadOptions' => FALSE, + 'values' => ['id' => 12], + 'checkPermissions' => FALSE, + ], new Result(array_values($entityFields))); $formSpec = new FormSpec('Title'); $this->profileMock->method('getUpdateFormSpec') @@ -321,38 +364,21 @@ public function testGetUpdateFormDeniedWithForm(): void { 'bar' => ['name' => 'bar'], ]; - $this->profileMock->method('getSelectFieldNames') - ->with(['*'], 'update', [], self::RESOLVED_CONTACT_ID) - ->willReturn(['foo']); - $this->profileMock->method('isUpdateGranted') - ->with($entityValues, self::RESOLVED_CONTACT_ID) - ->willReturn(GrantResult::newDeniedWithForm()); - $this->profileMock->method('getFieldLoadOptionsForFormSpec')->willReturn(TRUE); - - $valueMap = [ - [ - 'Entity', - 'get', - [ - 'select' => ['foo'], - 'where' => [['id', '=', 12]], - 'checkPermissions' => FALSE, - ], - new Result([$entityValues]), - ], - [ - 'Entity', - 'getFields', - [ - 'loadOptions' => TRUE, - 'values' => ['id' => 12], - 'checkPermissions' => FALSE, - ], - new Result(array_values($entityFields)), - ], - ]; - $this->api4Mock->method('execute') - ->willReturnMap($valueMap); + $this->mockGetSelectFieldNames(['foo']); + $this->mockIsUpdateGranted($entityValues, GrantResult::newDeniedWithForm()); + $this->fieldLoadOptionsForFormSpec = TRUE; + + $this->mockApi4Execute('Entity', 'get', [ + 'select' => ['foo'], + 'where' => [['id', '=', 12]], + 'checkPermissions' => FALSE, + ], new Result([$entityValues])); + + $this->mockApi4Execute('Entity', 'getFields', [ + 'loadOptions' => TRUE, + 'values' => ['id' => 12], + 'checkPermissions' => FALSE, + ], new Result(array_values($entityFields))); $formSpec = new FormSpec('Title'); $this->profileMock->method('getUpdateFormSpec') @@ -372,12 +398,8 @@ public function testGetUpdateFormDenied(): void { $entityValues = ['foo' => 'f']; - $this->profileMock->method('getSelectFieldNames') - ->with(['*'], 'update', [], self::RESOLVED_CONTACT_ID) - ->willReturn(['foo']); - $this->profileMock->method('isUpdateGranted') - ->with($entityValues, self::RESOLVED_CONTACT_ID) - ->willReturn(GrantResult::newDenied('Denied')); + $this->mockGetSelectFieldNames(['foo']); + $this->mockIsUpdateGranted($entityValues, GrantResult::newDenied('Denied')); $this->api4Mock->method('execute') ->with('Entity', 'get', [ @@ -394,6 +416,117 @@ public function testGetUpdateFormDenied(): void { $this->handler->getUpdateForm($actionMock); } + public function testValidateUpdateForm(): void { + $actionMock = $this->createActionMock(RemoteValidateUpdateFormAction::class); + $actionMock->setId(12); + $formData = ['foo' => 'bar']; + $actionMock->setData($formData); + + $entityValues = ['foo' => 'f', 'bar' => 'b']; + $entityFields = [ + 'foo' => ['name' => 'foo'], + 'bar' => ['name' => 'bar'], + ]; + + $this->mockGetSelectFieldNames(['foo', 'bar']); + $this->mockIsUpdateGranted($entityValues, GrantResult::newPermitted()); + + $this->mockApi4Execute('Entity', 'get', [ + 'select' => ['foo', 'bar'], + 'where' => [['id', '=', 12]], + 'checkPermissions' => FALSE, + ], new Result([$entityValues])); + + $this->mockApi4Execute('Entity', 'getFields', [ + 'loadOptions' => FALSE, + 'values' => ['id' => 12], + 'checkPermissions' => FALSE, + ], new Result(array_values($entityFields))); + + $formSpec = new FormSpec('Title'); + $formSpec->addElement(new TextField('foo', 'Foo')); + + $this->profileMock->method('getUpdateFormSpec') + ->with($entityValues, $entityFields, self::RESOLVED_CONTACT_ID) + ->willReturn($formSpec); + + $validatorMock = $this->createMock(ValidatorInterface::class); + $formSpec->appendValidator($validatorMock); + $validatorMock->expects(static::once())->method('validate') + ->with($formData, $entityValues, self::RESOLVED_CONTACT_ID) + ->willReturn(ValidationResult::new( + ValidationError::new('field', 'invalid1'), + ValidationError::new('field', 'invalid2'), + )); + + static::assertSame([ + 'valid' => FALSE, + 'errors' => [ + 'field' => ['invalid1', 'invalid2'], + ], + ], $this->handler->validateUpdateForm($actionMock)); + } + + public function testSubmitUpdateForm(): void { + $actionMock = $this->createActionMock(RemoteSubmitUpdateFormAction::class); + $actionMock->setId(12); + $formData = ['foo' => 'bar', 'bar' => 'baz']; + $actionMock->setData($formData); + + $entityValues = ['foo' => 'f', 'bar' => 'b']; + $entityFields = [ + 'foo' => ['name' => 'foo'], + 'bar' => ['name' => 'bar'], + ]; + + $this->mockGetSelectFieldNames(['foo', 'bar']); + $this->mockIsUpdateGranted($entityValues, GrantResult::newPermitted()); + + $this->mockApi4Execute('Entity', 'get', [ + 'select' => ['foo', 'bar'], + 'where' => [['id', '=', 12]], + 'checkPermissions' => FALSE, + ], new Result([$entityValues])); + + $this->mockApi4Execute('Entity', 'getFields', [ + 'loadOptions' => FALSE, + 'values' => ['id' => 12], + 'checkPermissions' => FALSE, + ], new Result(array_values($entityFields))); + + $formSpec = new FormSpec('Title'); + $formSpec->addElement(new TextField('foo', 'Foo')); + $formSpec->addElement((new TextField('bar', 'Bar'))->setReadOnly(TRUE)); + + $this->profileMock->method('getUpdateFormSpec') + ->with($entityValues, $entityFields, self::RESOLVED_CONTACT_ID) + ->willReturn($formSpec); + + $validatorMock = $this->createMock(ValidatorInterface::class); + $formSpec->appendValidator($validatorMock); + $validatorMock->expects(static::once())->method('validate') + ->with($formData, $entityValues, self::RESOLVED_CONTACT_ID) + ->willReturn(ValidationResult::new()); + + // Read only field "bar" should be dropped. + $dataTransformerMock = $this->createMock(DataTransformerInterface::class); + $dataTransformerMock->expects(static::once())->method('toEntityValues') + ->with(['foo' => 'bar'], $entityValues, self::RESOLVED_CONTACT_ID) + ->willReturn(['foo' => 'bar2']); + $formSpec->setDataTransformer($dataTransformerMock); + + $this->api4Mock->expects(static::once())->method('updateEntity') + ->with('Entity', 12, ['foo' => 'bar2']) + ->willReturn(new Result([['foo' => 'bar2']])); + + $newEntityValues = ['foo' => 'bar2'] + $entityValues; + $this->profileMock->method('getSaveSuccessMessage') + ->with($newEntityValues, $entityValues, $formData, self::RESOLVED_CONTACT_ID) + ->willReturn('Ok'); + + static::assertSame(['message' => 'Ok'], $this->handler->submitUpdateForm($actionMock)); + } + /** * Intersection types are not supported by phpstan in template. * @@ -411,4 +544,80 @@ private function createActionMock(string $className): MockObject { return $actionMock; } + /** + * @phpstan-param list $fieldNames + */ + private function mockGetSelectFieldNames(array $fieldNames): void { + $this->profileMock->expects(static::atLeast(1))->method('getSelectFieldNames') + ->with(['*'], 'update', [], self::RESOLVED_CONTACT_ID) + ->willReturn($fieldNames); + } + + /** + * @phpstan-param array $arguments + */ + private function mockIsCreateGranted(array $arguments, GrantResult $grantResult): void { + $this->profileMock->expects(static::atLeast(1))->method('isCreateGranted') + ->with($arguments, self::RESOLVED_CONTACT_ID) + ->willReturn($grantResult); + } + + /** + * @phpstan-param array $entityValues + */ + private function mockIsUpdateGranted(array $entityValues, GrantResult $grantResult): void { + $this->profileMock->expects(static::atLeast(1))->method('isUpdateGranted') + ->with($entityValues, self::RESOLVED_CONTACT_ID) + ->willReturn($grantResult); + } + + /** + * @phpstan-param array $params + */ + private function mockApi4Execute(string $entityName, string $actionName, array $params, Result $result): void { + if (NULL === $this->api4ExecuteStub) { + // Lazy ReturnValueMap. + $this->api4ExecuteStub = new class () implements Stub { + + /** + * @phpstan-var list> + */ + public array $valueMap = []; + + /** + * @inheritDoc + */ + public function toString(): string { + return $this->getReturnValueMap()->toString(); + } + + /** + * @inheritDoc + * + * @return mixed + */ + public function invoke(Invocation $invocation) { + return $this->getReturnValueMap()->invoke($invocation); + } + + private function getReturnValueMap(): ReturnValueMap { + return new ReturnValueMap($this->valueMap); + } + + }; + + $this->api4Mock->expects(static::atLeast(1)) + ->method('execute') + ->will($this->api4ExecuteStub); + } + + // @phpstan-ignore-next-line + $this->api4ExecuteStub->valueMap[] = [ + $entityName, + $actionName, + $params, + $result, + ]; + } + }