diff --git a/app/bundles/FormBundle/Assets/js/form.js b/app/bundles/FormBundle/Assets/js/form.js index 4ea7e76ce66..027e1c4b6ea 100644 --- a/app/bundles/FormBundle/Assets/js/form.js +++ b/app/bundles/FormBundle/Assets/js/form.js @@ -107,7 +107,42 @@ Mautic.formBuilderNewComponentInit = function () { mQuery(this).val(''); mQuery(this).trigger('chosen:updated'); }); -} +}; + +Mautic.changeSelectOptions = function(selectEl, options) { + selectEl.empty(); + mQuery.each(options, function(key, field) { + selectEl.append( + mQuery('') + .attr('value', field.value) + .attr('data-list-type', field.isListType ? 1 : 0) + .text(field.label) + ); + }); + selectEl.trigger('chosen:updated'); +}; + +Mautic.fetchFieldsOnObjectChange = function() { + var fieldSelect = mQuery('select#formfield_mappedField'); + fieldSelect.attr('disable', true); + mQuery.ajax({ + url: mauticAjaxUrl + "?action=form:getFieldsForObject", + data: { + mappedObject: mQuery('select#formfield_mappedObject').val(), + mappedField: mQuery('input#formfield_originalMappedField').val(), + formId: mQuery('input#mauticform_sessionId').val() + }, + success: function (response) { + Mautic.changeSelectOptions(fieldSelect, response.fields); + }, + error: function (response, textStatus, errorThrown) { + Mautic.processAjaxError(response, textStatus, errorThrown); + }, + complete: function () { + fieldSelect.removeAttr('disable'); + } + }); +}; Mautic.updateFormFields = function () { Mautic.activateLabelLoadingIndicator('campaignevent_properties_field'); @@ -344,4 +379,4 @@ Mautic.selectFormType = function(formType) { mQuery('.form-type-modal').remove(); mQuery('.form-type-modal-backdrop').remove(); -}; \ No newline at end of file +}; diff --git a/app/bundles/FormBundle/Collection/FieldCollection.php b/app/bundles/FormBundle/Collection/FieldCollection.php new file mode 100644 index 00000000000..b1fc0063d5e --- /dev/null +++ b/app/bundles/FormBundle/Collection/FieldCollection.php @@ -0,0 +1,56 @@ + + */ +final class FieldCollection extends \ArrayIterator +{ + /** + * @return array + */ + public function toChoices(): array + { + $choices = []; + + /** @var FieldCrate $field */ + foreach ($this as $field) { + $choices[$field->getName()] = $field->getKey(); + } + + return $choices; + } + + public function getFieldByKey(string $key): FieldCrate + { + /** @var FieldCrate $field */ + foreach ($this as $field) { + if ($key === $field->getKey()) { + return $field; + } + } + + throw new FieldNotFoundException("Field with key {$key} was not found."); + } + + /** + * @param string[] $keys + */ + public function removeFieldsWithKeys(array $keys, string $keyToKeep = null): FieldCollection + { + return new self( + array_filter( + $this->getArrayCopy(), + function (FieldCrate $field) use ($keys, $keyToKeep) { + return ($keyToKeep && $field->getKey() === $keyToKeep) || !in_array($field->getKey(), $keys, true); + } + ) + ); + } +} diff --git a/app/bundles/FormBundle/Collection/MappedObjectCollection.php b/app/bundles/FormBundle/Collection/MappedObjectCollection.php new file mode 100644 index 00000000000..9efbd604220 --- /dev/null +++ b/app/bundles/FormBundle/Collection/MappedObjectCollection.php @@ -0,0 +1,12 @@ + + */ +final class MappedObjectCollection extends \ArrayIterator +{ +} diff --git a/app/bundles/FormBundle/Collection/ObjectCollection.php b/app/bundles/FormBundle/Collection/ObjectCollection.php new file mode 100644 index 00000000000..345d48ec7d5 --- /dev/null +++ b/app/bundles/FormBundle/Collection/ObjectCollection.php @@ -0,0 +1,28 @@ + + */ +final class ObjectCollection extends \ArrayIterator +{ + /** + * @return array + */ + public function toChoices(): array + { + $choices = []; + + /** @var ObjectCrate $object */ + foreach ($this as $object) { + $choices[$object->getName()] = $object->getKey(); + } + + return $choices; + } +} diff --git a/app/bundles/FormBundle/Collector/AlreadyMappedFieldCollector.php b/app/bundles/FormBundle/Collector/AlreadyMappedFieldCollector.php new file mode 100644 index 00000000000..06828e8fea3 --- /dev/null +++ b/app/bundles/FormBundle/Collector/AlreadyMappedFieldCollector.php @@ -0,0 +1,81 @@ +cacheProvider = $cacheProvider; + } + + public function getFields(string $formId, string $object): array + { + $cacheItem = $this->cacheProvider->getItem($this->buildCacheKey($formId, $object)); + + return json_decode($cacheItem->get() ?? '[]', true); + } + + public function addField(string $formId, string $object, string $fieldKey): void + { + $this->fetchAndSave($formId, $object, function (array $fields) use ($fieldKey) { + if (!in_array($fieldKey, $fields, true)) { + $fields[] = $fieldKey; + } + + return $fields; + }); + } + + public function removeField(string $formId, string $object, string $fieldKey): void + { + $this->fetchAndSave($formId, $object, function (array $fields) use ($fieldKey) { + $cacheKey = array_search($fieldKey, $fields, true); + + if (false !== $cacheKey) { + unset($fields[$cacheKey]); + + // Reset indexes. + $fields = array_values($fields); + } + + return $fields; + }); + } + + public function removeAllForForm(string $formId): void + { + $this->cacheProvider->invalidateTags([$this->buildCacheTag($formId)]); + } + + private function fetchAndSave(string $formId, string $object, callable $callback): void + { + $cacheItem = $this->cacheProvider->getItem($this->buildCacheKey($formId, $object)); + $fields = json_decode($cacheItem->get() ?? '[]', true); + $cacheItem->set(json_encode($callback($fields))); + $cacheItem->expiresAfter(self::EXPIRATION_IN_SECONDS); + $cacheItem->tag($this->buildCacheTag($formId)); + $this->cacheProvider->save($cacheItem); + } + + private function buildCacheKey(string $formId, string $object): string + { + return sprintf('mautic.form.%s.object.%s.fields.mapped', $formId, $object); + } + + private function buildCacheTag(string $formId): string + { + return sprintf('mautic.form.%s.fields.mapped', $formId); + } +} diff --git a/app/bundles/FormBundle/Collector/AlreadyMappedFieldCollectorInterface.php b/app/bundles/FormBundle/Collector/AlreadyMappedFieldCollectorInterface.php new file mode 100644 index 00000000000..4dd5f239fb0 --- /dev/null +++ b/app/bundles/FormBundle/Collector/AlreadyMappedFieldCollectorInterface.php @@ -0,0 +1,24 @@ +dispatcher = $dispatcher; + } + + public function getFields(string $object): FieldCollection + { + if (!isset($this->fieldCollections[$object])) { + $this->collect($object); + } + + return $this->fieldCollections[$object]; + } + + private function collect(string $object): void + { + $event = new FieldCollectEvent($object); + $this->dispatcher->dispatch($event, FormEvents::ON_FIELD_COLLECT); + $this->fieldCollections[$object] = $event->getFields(); + } +} diff --git a/app/bundles/FormBundle/Collector/FieldCollectorInterface.php b/app/bundles/FormBundle/Collector/FieldCollectorInterface.php new file mode 100644 index 00000000000..9f2084f3610 --- /dev/null +++ b/app/bundles/FormBundle/Collector/FieldCollectorInterface.php @@ -0,0 +1,12 @@ +fieldCollector = $fieldCollector; + } + + public function buildCollection(string ...$objects): MappedObjectCollection + { + $mappedObjectCollection = new MappedObjectCollection(); + + foreach ($objects as $object) { + if ($object) { + $mappedObjectCollection->offsetSet($object, $this->fieldCollector->getFields($object)); + } + } + + return $mappedObjectCollection; + } +} diff --git a/app/bundles/FormBundle/Collector/MappedObjectCollectorInterface.php b/app/bundles/FormBundle/Collector/MappedObjectCollectorInterface.php new file mode 100644 index 00000000000..230c86e52bc --- /dev/null +++ b/app/bundles/FormBundle/Collector/MappedObjectCollectorInterface.php @@ -0,0 +1,12 @@ +dispatcher = $dispatcher; + } + + public function getObjects(): ObjectCollection + { + if (null === $this->objects) { + $this->collect(); + } + + return $this->objects; + } + + private function collect(): void + { + $event = new ObjectCollectEvent(); + $this->dispatcher->dispatch($event, FormEvents::ON_OBJECT_COLLECT); + $this->objects = $event->getObjects(); + } +} diff --git a/app/bundles/FormBundle/Collector/ObjectCollectorInterface.php b/app/bundles/FormBundle/Collector/ObjectCollectorInterface.php new file mode 100644 index 00000000000..4c82ebe014f --- /dev/null +++ b/app/bundles/FormBundle/Collector/ObjectCollectorInterface.php @@ -0,0 +1,12 @@ + FieldType::class, 'arguments' => [ 'translator', + 'mautic.form.collector.object', + 'mautic.form.collector.field', + 'mautic.form.collector.already.mapped.field', ], 'methodCalls' => [ 'setFieldModel' => ['mautic.form.model.field'], @@ -190,6 +193,7 @@ 'mautic.tracker.contact', 'mautic.schema.helper.column', 'mautic.schema.helper.table', + 'mautic.form.collector.mapped.object', ], ], 'mautic.form.model.submission' => [ @@ -234,6 +238,22 @@ ], ], 'other' => [ + 'mautic.form.collector.object' => [ + 'class' => \Mautic\FormBundle\Collector\ObjectCollector::class, + 'arguments' => ['event_dispatcher'], + ], + 'mautic.form.collector.field' => [ + 'class' => \Mautic\FormBundle\Collector\FieldCollector::class, + 'arguments' => ['event_dispatcher'], + ], + 'mautic.form.collector.mapped.object' => [ + 'class' => \Mautic\FormBundle\Collector\MappedObjectCollector::class, + 'arguments' => ['mautic.form.collector.field'], + ], + 'mautic.form.collector.already.mapped.field' => [ + 'class' => \Mautic\FormBundle\Collector\AlreadyMappedFieldCollector::class, + 'arguments' => ['mautic.cache.provider'], + ], 'mautic.helper.form.field_helper' => [ 'class' => FormFieldHelper::class, 'arguments' => [ diff --git a/app/bundles/FormBundle/Controller/AjaxController.php b/app/bundles/FormBundle/Controller/AjaxController.php index f66d7a3d91b..477f669d110 100644 --- a/app/bundles/FormBundle/Controller/AjaxController.php +++ b/app/bundles/FormBundle/Controller/AjaxController.php @@ -4,17 +4,35 @@ use Mautic\CoreBundle\Controller\AjaxController as CommonAjaxController; use Mautic\CoreBundle\Helper\InputHelper; +use Mautic\FormBundle\Collector\AlreadyMappedFieldCollectorInterface; +use Mautic\FormBundle\Collector\FieldCollectorInterface; +use Mautic\FormBundle\Crate\FieldCrate; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Event\ControllerEvent; -/** - * Class AjaxController. - */ class AjaxController extends CommonAjaxController { + /** + * @var FieldCollectorInterface + */ + private $fieldCollector; + + /** + * @var AlreadyMappedFieldCollectorInterface + */ + private $mappedFieldCollector; + + public function initialize(ControllerEvent $event) + { + $this->fieldCollector = $this->container->get('mautic.form.collector.field'); + $this->mappedFieldCollector = $this->container->get('mautic.form.collector.already.mapped.field'); + } + /** * @param string $name * - * @return \Symfony\Component\HttpFoundation\JsonResponse + * @return JsonResponse */ protected function reorderFieldsAction(Request $request, $bundle, $name = 'fields') { @@ -39,7 +57,35 @@ protected function reorderFieldsAction(Request $request, $bundle, $name = 'field } /** - * @return \Symfony\Component\HttpFoundation\JsonResponse + * @return JsonResponse + */ + protected function getFieldsForObjectAction(Request $request) + { + $formId = $request->get('formId'); + $mappedObject = $request->get('mappedObject'); + $mappedField = $request->get('mappedField'); + $mappedFields = $this->mappedFieldCollector->getFields($formId, $mappedObject); + $fields = $this->fieldCollector->getFields($mappedObject); + $fields = $fields->removeFieldsWithKeys($mappedFields, $mappedField); + + return $this->sendJsonResponse( + [ + 'fields' => array_map( + function (FieldCrate $field) { + return [ + 'label' => $field->getName(), + 'value' => $field->getKey(), + 'isListType' => $field->isListType(), + ]; + }, + $fields->getArrayCopy() + ), + ] + ); + } + + /** + * @return JsonResponse */ protected function reorderActionsAction(Request $request) { @@ -47,7 +93,7 @@ protected function reorderActionsAction(Request $request) } /** - * @return \Symfony\Component\HttpFoundation\JsonResponse + * @return JsonResponse */ protected function updateFormFieldsAction(Request $request) { @@ -104,7 +150,7 @@ protected function updateFormFieldsAction(Request $request) /** * Ajax submit for forms. * - * @return \Symfony\Component\HttpFoundation\JsonResponse + * @return JsonResponse */ public function submitAction() { diff --git a/app/bundles/FormBundle/Controller/FieldController.php b/app/bundles/FormBundle/Controller/FieldController.php index c5e5a216375..7da74d78a85 100644 --- a/app/bundles/FormBundle/Controller/FieldController.php +++ b/app/bundles/FormBundle/Controller/FieldController.php @@ -3,6 +3,8 @@ namespace Mautic\FormBundle\Controller; use Mautic\CoreBundle\Controller\FormController as CommonFormController; +use Mautic\FormBundle\Collector\AlreadyMappedFieldCollectorInterface; +use Mautic\FormBundle\Collector\MappedObjectCollectorInterface; use Mautic\FormBundle\Entity\Field; use Mautic\FormBundle\Event\FormBuilderEvent; use Mautic\FormBundle\FormEvents; @@ -18,6 +20,16 @@ class FieldController extends CommonFormController private FieldModel $formFieldModel; + /** + * @var MappedObjectCollectorInterface + */ + private $mappedObjectCollector; + + /** + * @var AlreadyMappedFieldCollectorInterface + */ + private $alreadyMappedFieldCollector; + private FormFieldHelper $fieldHelper; public function initialize(ControllerEvent $event) @@ -32,6 +44,9 @@ public function initialize(ControllerEvent $event) $this->formFieldModel = $formFieldModel; $this->fieldHelper = $this->get('mautic.helper.form.field_helper'); + + $this->mappedObjectCollector = $this->get('mautic.form.collector.mapped.object'); + $this->alreadyMappedFieldCollector = $this->get('mautic.form.collector.already.mapped.field'); } /** @@ -125,13 +140,9 @@ public function newAction() $session->set('mautic.form.'.$formId.'.fields.modified', $fields); // Keep track of used lead fields - $usedLeadFields = $this->get('session')->get('mautic.form.'.$formId.'.fields.leadfields', []); - if (!empty($formData['leadField']) && empty($formData['parent'])) { - $usedLeadFields[$keyId] = $formData['leadField']; - } else { - unset($usedLeadFields[$keyId]); + if (!empty($formField['mappedObject']) && !empty($formField['mappedField']) && empty($formData['parent'])) { + $this->alreadyMappedFieldCollector->addField($formId, $formField['mappedObject'], $formField['mappedField']); } - $session->set('mautic.form.'.$formId.'.fields.leadfields', $usedLeadFields); } else { $success = 0; } @@ -175,8 +186,7 @@ public function newAction() 'id' => $keyId, 'formId' => $formId, 'formName' => null === $formEntity ? 'newform' : $formEntity->generateFormName(), - 'contactFields' => $leadFieldModel->getFieldListWithProperties(), - 'companyFields' => $leadFieldModel->getFieldListWithProperties('company'), + 'mappedFields' => $this->mappedObjectCollector->buildCollection((string) $formField['mappedObject']), 'inBuilder' => true, 'fields' => $this->fieldHelper->getChoiceList($customComponents['fields']), 'viewOnlyFields' => $customComponents['viewOnlyFields'], @@ -270,13 +280,9 @@ public function editAction($objectId) $session->set('mautic.form.'.$formId.'.fields.modified', $fields); // Keep track of used lead fields - $usedLeadFields = $this->get('session')->get('mautic.form.'.$formId.'.fields.leadfields', []); - if (!empty($formData['leadField']) && empty($formData['parent'])) { - $usedLeadFields[$objectId] = $formData['leadField']; - } else { - unset($usedLeadFields[$objectId]); + if (!empty($formField['mappedObject']) && !empty($formField['mappedField']) && empty($formData['parent'])) { + $this->alreadyMappedFieldCollector->addField($formId, $formField['mappedObject'], $formField['mappedField']); } - $session->set('mautic.form.'.$formId.'.fields.leadfields', $usedLeadFields); } } } @@ -312,9 +318,9 @@ public function editAction($objectId) $template = (!empty($customParams)) ? $customParams['template'] : 'MauticFormBundle:Field:'.$fieldType.'.html.php'; //prevent undefined errors - $entity = new Field(); - $blank = $entity->convertToArray(); - $formField = array_merge($blank, $formField); + $entity = new Field(); + $blank = $entity->convertToArray(); + $formField = array_merge($blank, $formField); $leadFieldModel = $this->getModel('lead.field'); \assert($leadFieldModel instanceof \Mautic\LeadBundle\Model\FieldModel); @@ -327,8 +333,7 @@ public function editAction($objectId) 'field' => $formField, 'id' => $objectId, 'formId' => $formId, - 'contactFields' => $leadFieldModel->getFieldListWithProperties(), - 'companyFields' => $leadFieldModel->getFieldListWithProperties('company'), + 'mappedFields' => $this->mappedObjectCollector->buildCollection((string) $formField['mappedObject']), 'inBuilder' => true, 'fields' => $this->fieldHelper->getChoiceList($customComponents['fields']), 'formFields' => $fields, @@ -379,13 +384,9 @@ public function deleteAction($objectId) $formField = (array_key_exists($objectId, $fields)) ? $fields[$objectId] : null; if ('POST' === $this->request->getMethod() && null !== $formField) { - $usedLeadFields = $session->get('mautic.form.'.$formId.'.fields.leadfields'); - - // Allow to select the lead field from the delete field again - $unusedLeadField = array_search($formField['leadField'], $usedLeadFields); - if (!empty($formField['leadField']) && empty($formField['parent']) && false !== $unusedLeadField) { - unset($usedLeadFields[$unusedLeadField]); - $session->set('mautic.form.'.$formId.'.fields.leadfields', $usedLeadFields); + if ($formField['mappedObject'] && $formField['mappedField']) { + // Allow to select the lead field from the delete field again + $this->alreadyMappedFieldCollector->removeField($formId, $formField['mappedObject'], $formField['mappedField']); } //add the field to the delete list @@ -407,7 +408,8 @@ public function deleteAction($objectId) } /** - * @param $formId + * @param int $formId + * @param mixed[] $formField * * @return mixed */ @@ -416,7 +418,7 @@ private function getFieldForm($formId, array $formField) //fire the form builder event $formModel = $this->getModel('form.form'); \assert($formModel instanceof FormModel); - $customComponents = $formModel->getCustomComponents(); + $customComponents = $this->formModel->getCustomComponents(); $customParams = (isset($customComponents['fields'][$formField['type']])) ? $customComponents['fields'][$formField['type']] : false; $formFieldModel = $this->getModel('form.field'); diff --git a/app/bundles/FormBundle/Controller/FormController.php b/app/bundles/FormBundle/Controller/FormController.php index d25c4b556b9..078f327bd36 100644 --- a/app/bundles/FormBundle/Controller/FormController.php +++ b/app/bundles/FormBundle/Controller/FormController.php @@ -6,19 +6,37 @@ use Mautic\CoreBundle\Factory\PageHelperFactoryInterface; use Mautic\CoreBundle\Form\Type\DateRangeType; use Mautic\CoreBundle\Model\AuditLogModel; +use Mautic\FormBundle\Collector\AlreadyMappedFieldCollectorInterface; +use Mautic\FormBundle\Collector\MappedObjectCollector; use Mautic\FormBundle\Entity\Field; use Mautic\FormBundle\Entity\Form; use Mautic\FormBundle\Exception\ValidationException; use Mautic\FormBundle\Helper\FormFieldHelper; use Mautic\FormBundle\Model\FormModel; use Mautic\FormBundle\Model\SubmissionModel; -use Mautic\LeadBundle\Model\FieldModel; use Symfony\Component\Form\FormError; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\ControllerEvent; class FormController extends CommonFormController { + /** + * @var AlreadyMappedFieldCollectorInterface + */ + private $alreadyMappedFieldCollector; + + /** + * @var MappedObjectCollector + */ + private $mappedObjectCollector; + + public function initialize(ControllerEvent $event) + { + $this->alreadyMappedFieldCollector = $this->get('mautic.form.collector.already.mapped.field'); + $this->mappedObjectCollector = $this->get('mautic.form.collector.mapped.object'); + } + /** * @param int $page * @@ -427,25 +445,21 @@ public function newAction() /** @var FormFieldHelper $fieldHelper */ $fieldHelper = $this->get('mautic.helper.form.field_helper'); - $leadFieldModel = $this->getModel('lead.field'); - \assert($leadFieldModel instanceof FieldModel); - return $this->delegateView( [ 'viewParameters' => [ 'fields' => $fieldHelper->getChoiceList($customComponents['fields']), + 'formFields' => $modifiedFields, + 'mappedFields' => $this->mappedObjectCollector->buildCollection(...$entity->getMappedFieldObjects()), + 'deletedFields' => $deletedFields, 'viewOnlyFields' => $customComponents['viewOnlyFields'], 'actions' => $customComponents['choices'], 'actionSettings' => $customComponents['actions'], - 'formFields' => $modifiedFields, 'formActions' => $modifiedActions, - 'deletedFields' => $deletedFields, 'deletedActions' => $deletedActions, 'tmpl' => $this->request->isXmlHttpRequest() ? $this->request->get('tmpl', 'index') : 'index', 'activeForm' => $entity, 'form' => $form->createView(), - 'contactFields' => $leadFieldModel->getFieldListWithProperties(), - 'companyFields' => $leadFieldModel->getFieldListWithProperties('company'), 'inBuilder' => true, ], 'contentTemplate' => 'MauticFormBundle:Builder:index.html.php', @@ -480,6 +494,10 @@ public function editAction($objectId, $ignorePost = false, $forceTypeSelection = $formData = $this->request->request->get('mauticform'); $sessionId = isset($formData['sessionId']) ? $formData['sessionId'] : null; $customComponents = $model->getCustomComponents(); + $modifiedFields = []; + $deletedFields = []; + $modifiedActions = []; + $deletedActions = []; if ($objectId instanceof Form) { $entity = $objectId; @@ -702,13 +720,12 @@ public function editAction($objectId, $ignorePost = false, $forceTypeSelection = if ($cleanSlate) { //clean slate $this->clearSessionComponents($objectId); + $this->alreadyMappedFieldCollector->removeAllForForm($objectId); //load existing fields into session - $modifiedFields = []; - $usedLeadFields = []; - $usedCompanyFields = []; - $existingFields = $entity->getFields()->toArray(); - $submitButton = false; + $modifiedFields = []; + $existingFields = $entity->getFields()->toArray(); + $submitButton = false; foreach ($existingFields as $formField) { // Check to see if the field still exists @@ -735,14 +752,15 @@ public function editAction($objectId, $ignorePost = false, $forceTypeSelection = // Set the custom parameters $field['customParameters'] = $customComponents['fields'][$field['type']]; } - $field['formId'] = $objectId; + $field['formId'] = $objectId; $modifiedFields[$id] = $field; - if (!empty($field['leadField']) && empty($field['parent'])) { - $usedLeadFields[$id] = $field['leadField']; + if (!empty($field['mappedObject']) && !empty($field['mappedField']) && empty($field['parent'])) { + $this->alreadyMappedFieldCollector->addField($objectId, $field['mappedObject'], $field['mappedField']); } } + if (!$submitButton) { //means something deleted the submit button from the form //add a submit button $keyId = 'new'.hash('sha1', uniqid(mt_rand())); @@ -758,7 +776,6 @@ public function editAction($objectId, $ignorePost = false, $forceTypeSelection = $modifiedFields[$keyId]['formId'] = $objectId; unset($modifiedFields[$keyId]['form']); } - $session->set('mautic.form.'.$objectId.'.fields.leadfields', $usedLeadFields); if (!empty($reorder)) { uasort( @@ -815,27 +832,23 @@ function ($a, $b) { $deletedActions = []; } - $leadFieldModel = $this->getModel('lead.field'); - \assert($leadFieldModel instanceof FieldModel); - return $this->delegateView( [ 'viewParameters' => [ 'fields' => $availableFields, + 'formFields' => $modifiedFields, + 'deletedFields' => $deletedFields, + 'mappedFields' => $this->mappedObjectCollector->buildCollection(...$entity->getMappedFieldObjects()), + 'formActions' => $modifiedActions, + 'deletedActions' => $deletedActions, 'viewOnlyFields' => $customComponents['viewOnlyFields'], 'actions' => $customComponents['choices'], 'actionSettings' => $customComponents['actions'], - 'formFields' => $modifiedFields, 'fieldSettings' => $customComponents['fields'], - 'formActions' => $modifiedActions, - 'deletedFields' => $deletedFields, - 'deletedActions' => $deletedActions, 'tmpl' => $this->request->isXmlHttpRequest() ? $this->request->get('tmpl', 'index') : 'index', 'activeForm' => $entity, 'form' => $form->createView(), 'forceTypeSelection' => $forceTypeSelection, - 'contactFields' => $leadFieldModel->getFieldListWithProperties('lead'), - 'companyFields' => $leadFieldModel->getFieldListWithProperties('company'), 'inBuilder' => true, ], 'contentTemplate' => 'MauticFormBundle:Builder:index.html.php', @@ -1137,10 +1150,10 @@ public function clearSessionComponents($sessionId) $session = $this->get('session'); $session->remove('mautic.form.'.$sessionId.'.fields.modified'); $session->remove('mautic.form.'.$sessionId.'.fields.deleted'); - $session->remove('mautic.form.'.$sessionId.'.fields.leadfields'); - $session->remove('mautic.form.'.$sessionId.'.actions.modified'); $session->remove('mautic.form.'.$sessionId.'.actions.deleted'); + + $this->alreadyMappedFieldCollector->removeAllForForm((string) $sessionId); } public function batchRebuildHtmlAction() diff --git a/app/bundles/FormBundle/Crate/FieldCrate.php b/app/bundles/FormBundle/Crate/FieldCrate.php new file mode 100644 index 00000000000..e4260c09773 --- /dev/null +++ b/app/bundles/FormBundle/Crate/FieldCrate.php @@ -0,0 +1,62 @@ +key = $key; + $this->name = $name; + $this->type = $type; + $this->properties = $properties; + } + + public function getKey(): string + { + return $this->key; + } + + public function getName(): string + { + return $this->name; + } + + public function getType(): string + { + return $this->type; + } + + /** + * @return mixed[] + */ + public function getProperties(): array + { + return $this->properties; + } + + public function isListType(): bool + { + $isListType = in_array($this->getType(), FormFieldHelper::getListTypes()); + $hasList = !empty($this->getProperties()['list']); + $hasOptionList = !empty($this->getProperties()['optionlist']); + + return $isListType || $hasList || $hasOptionList; + } +} diff --git a/app/bundles/FormBundle/Crate/ObjectCrate.php b/app/bundles/FormBundle/Crate/ObjectCrate.php new file mode 100644 index 00000000000..96cb907e0f2 --- /dev/null +++ b/app/bundles/FormBundle/Crate/ObjectCrate.php @@ -0,0 +1,27 @@ +key = $key; + $this->name = $name; + } + + public function getKey(): string + { + return $this->key; + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/app/bundles/FormBundle/Entity/Field.php b/app/bundles/FormBundle/Entity/Field.php index 6c3128d9096..9f357f6f9aa 100644 --- a/app/bundles/FormBundle/Entity/Field.php +++ b/app/bundles/FormBundle/Entity/Field.php @@ -149,6 +149,16 @@ class Field */ private $parent; + /** + * @var string + */ + private $mappedObject; + + /** + * @var string + */ + private $mappedField; + /** * Reset properties on clone. */ @@ -163,62 +173,23 @@ public static function loadMetadata(ORM\ClassMetadata $metadata) $builder = new ClassMetadataBuilder($metadata); $builder->setTable('form_fields') - ->setCustomRepositoryClass('Mautic\FormBundle\Entity\FieldRepository') + ->setCustomRepositoryClass(FieldRepository::class) ->addIndex(['type'], 'form_field_type_search'); $builder->addId(); - - $builder->addField('label', 'text'); - - $builder->createField('showLabel', 'boolean') - ->columnName('show_label') - ->nullable() - ->build(); - - $builder->addField('alias', 'string'); - - $builder->addField('type', 'string'); - - $builder->createField('isCustom', 'boolean') - ->columnName('is_custom') - ->build(); - - $builder->createField('customParameters', 'array') - ->columnName('custom_parameters') - ->nullable() - ->build(); - - $builder->createField('defaultValue', 'text') - ->columnName('default_value') - ->nullable() - ->build(); - - $builder->createField('isRequired', 'boolean') - ->columnName('is_required') - ->build(); - - $builder->createField('validationMessage', 'text') - ->columnName('validation_message') - ->nullable() - ->build(); - - $builder->createField('helpMessage', 'text') - ->columnName('help_message') - ->nullable() - ->build(); - - $builder->createField('order', 'integer') - ->columnName('field_order') - ->nullable() - ->build(); - - $builder->createField('properties', 'array') - ->nullable() - ->build(); - - $builder->createField('validation', 'json_array') - ->nullable() - ->build(); + $builder->addField('label', Types::TEXT); + $builder->addNullableField('showLabel', Types::BOOLEAN, 'show_label'); + $builder->addField('alias', Types::STRING); + $builder->addField('type', Types::STRING); + $builder->addNamedField('isCustom', Types::BOOLEAN, 'is_custom'); + $builder->addNullableField('customParameters', Types::ARRAY, 'custom_parameters'); + $builder->addNullableField('defaultValue', Types::TEXT, 'default_value'); + $builder->addNamedField('isRequired', Types::BOOLEAN, 'is_required'); + $builder->addNullableField('validationMessage', Types::TEXT, 'validation_message'); + $builder->addNullableField('helpMessage', Types::TEXT, 'help_message'); + $builder->addNullableField('order', Types::INTEGER, 'field_order'); + $builder->addNullableField('properties', Types::ARRAY); + $builder->addNullableField('validation', Types::JSON); $builder->addNullableField('parent', 'string', 'parent_id'); $builder->addNullableField('conditions', 'json_array'); @@ -228,23 +199,17 @@ public static function loadMetadata(ORM\ClassMetadata $metadata) ->addJoinColumn('form_id', 'id', false, false, 'CASCADE') ->build(); - $builder->addNullableField('labelAttributes', 'string', 'label_attr'); - - $builder->addNullableField('inputAttributes', 'string', 'input_attr'); - - $builder->addNullableField('containerAttributes', 'string', 'container_attr'); - - $builder->addNullableField('leadField', 'string', 'lead_field'); - - $builder->addNullableField('saveResult', 'boolean', 'save_result'); - - $builder->addNullableField('isAutoFill', 'boolean', 'is_auto_fill'); - - $builder->addNullableField('showWhenValueExists', 'boolean', 'show_when_value_exists'); - - $builder->addNullableField('showAfterXSubmissions', 'integer', 'show_after_x_submissions'); - + $builder->addNullableField('labelAttributes', Types::STRING, 'label_attr'); + $builder->addNullableField('inputAttributes', Types::STRING, 'input_attr'); + $builder->addNullableField('containerAttributes', Types::STRING, 'container_attr'); + $builder->addNullableField('leadField', Types::STRING, 'lead_field'); + $builder->addNullableField('saveResult', Types::BOOLEAN, 'save_result'); + $builder->addNullableField('isAutoFill', Types::BOOLEAN, 'is_auto_fill'); + $builder->addNullableField('showWhenValueExists', Types::BOOLEAN, 'show_when_value_exists'); + $builder->addNullableField('showAfterXSubmissions', Types::INTEGER, 'show_after_x_submissions'); $builder->addNullableField('alwaysDisplay', Types::BOOLEAN, 'always_display'); + $builder->addNullableField('mappedObject', Types::STRING, 'mapped_object'); + $builder->addNullableField('mappedField', Types::STRING, 'mapped_field'); } /** @@ -274,17 +239,19 @@ public static function loadApiMetadata(ApiMetadataDriver $metadata) 'labelAttributes', 'inputAttributes', 'containerAttributes', - 'leadField', + 'leadField', // @deprecated, to be removed in Mautic 4. Use mappedObject and mappedField instead. 'saveResult', 'isAutoFill', + 'mappedObject', + 'mappedField', ] ) ->build(); } /** - * @param $prop - * @param $val + * @param string $prop + * @param mixed $val */ private function isChanged($prop, $val) { @@ -547,8 +514,6 @@ public function getValidationMessage() } /** - * Set form. - * * @return Field */ public function setForm(Form $form) @@ -569,8 +534,6 @@ public function getForm() } /** - * Set labelAttributes. - * * @param string $labelAttributes * * @return Field @@ -584,8 +547,6 @@ public function setLabelAttributes($labelAttributes) } /** - * Get labelAttributes. - * * @return string */ public function getLabelAttributes() @@ -594,8 +555,6 @@ public function getLabelAttributes() } /** - * Set inputAttributes. - * * @param string $inputAttributes * * @return Field @@ -609,8 +568,6 @@ public function setInputAttributes($inputAttributes) } /** - * Get inputAttributes. - * * @return string */ public function getInputAttributes() @@ -647,8 +604,6 @@ public function convertToArray() } /** - * Set showLabel. - * * @param bool $showLabel * * @return Field @@ -662,8 +617,6 @@ public function setShowLabel($showLabel) } /** - * Get showLabel. - * * @return bool */ public function getShowLabel() @@ -682,8 +635,6 @@ public function showLabel() } /** - * Set helpMessage. - * * @param string $helpMessage * * @return Field @@ -697,8 +648,6 @@ public function setHelpMessage($helpMessage) } /** - * Get helpMessage. - * * @return string */ public function getHelpMessage() @@ -707,8 +656,6 @@ public function getHelpMessage() } /** - * Set isCustom. - * * @param bool $isCustom * * @return Field @@ -721,8 +668,6 @@ public function setIsCustom($isCustom) } /** - * Get isCustom. - * * @return bool */ public function getIsCustom() @@ -741,8 +686,6 @@ public function isCustom() } /** - * Set customParameters. - * * @param array $customParameters * * @return Field @@ -755,8 +698,6 @@ public function setCustomParameters($customParameters) } /** - * Get customParameters. - * * @return array */ public function getCustomParameters() @@ -781,6 +722,8 @@ public function setSessionId($sessionId) } /** + * @deprecated, to be removed in Mautic 4. Use mappedObject and mappedField instead. + * * @return mixed */ public function getLeadField() @@ -789,6 +732,8 @@ public function getLeadField() } /** + * @deprecated, to be removed in Mautic 4. Use mappedObject and mappedField instead. + * * @param mixed $leadField */ public function setLeadField($leadField) @@ -892,7 +837,12 @@ public function showForContact($submissions = null, Lead $lead = null, Form $for } // Hide the field if the value is already known from the lead profile - if (null !== $lead && $this->leadField && !empty($lead->getFieldValue($this->leadField)) && !$this->isAutoFill) { + if (null !== $lead + && $this->mappedField + && 'contact' === $this->mappedObject + && !empty($lead->getFieldValue($this->mappedField)) + && !$this->isAutoFill + ) { return false; } } @@ -1036,4 +986,24 @@ private function findParentFieldInForm(): ?Field return null; } + + public function getMappedObject(): ?string + { + return $this->mappedObject; + } + + public function setMappedObject(?string $mappedObject): void + { + $this->mappedObject = $mappedObject; + } + + public function getMappedField(): ?string + { + return $this->mappedField; + } + + public function setMappedField(?string $mappedField): void + { + $this->mappedField = $mappedField; + } } diff --git a/app/bundles/FormBundle/Entity/Form.php b/app/bundles/FormBundle/Entity/Form.php index 939a9b40a78..0c521796218 100644 --- a/app/bundles/FormBundle/Entity/Form.php +++ b/app/bundles/FormBundle/Entity/Form.php @@ -13,9 +13,6 @@ use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Mapping\ClassMetadata; -/** - * Class Form. - */ class Form extends FormEntity { /** @@ -140,9 +137,6 @@ public function __clone() parent::__clone(); } - /** - * Construct. - */ public function __construct() { $this->fields = new ArrayCollection(); @@ -335,8 +329,6 @@ protected function isChanged($prop, $val) } /** - * Get id. - * * @return int */ public function getId() @@ -345,8 +337,6 @@ public function getId() } /** - * Set name. - * * @param string $name * * @return Form @@ -360,8 +350,6 @@ public function setName($name) } /** - * Get name. - * * @return string */ public function getName() @@ -370,8 +358,6 @@ public function getName() } /** - * Set description. - * * @param string $description * * @return Form @@ -385,8 +371,6 @@ public function setDescription($description) } /** - * Get description. - * * @return string */ public function getDescription($truncate = false, $length = 45) @@ -401,8 +385,6 @@ public function getDescription($truncate = false, $length = 45) } /** - * Set cachedHtml. - * * @param string $cachedHtml * * @return Form @@ -415,8 +397,6 @@ public function setCachedHtml($cachedHtml) } /** - * Get cachedHtml. - * * @return string */ public function getCachedHtml() @@ -425,8 +405,6 @@ public function getCachedHtml() } /** - * Get render style. - * * @return string */ public function getRenderStyle() @@ -435,8 +413,6 @@ public function getRenderStyle() } /** - * Set postAction. - * * @param string $postAction * * @return Form @@ -450,8 +426,6 @@ public function setPostAction($postAction) } /** - * Get postAction. - * * @return string */ public function getPostAction() @@ -460,8 +434,6 @@ public function getPostAction() } /** - * Set postActionProperty. - * * @param string $postActionProperty * * @return Form @@ -475,8 +447,6 @@ public function setPostActionProperty($postActionProperty) } /** - * Get postActionProperty. - * * @return string */ public function getPostActionProperty() @@ -484,17 +454,12 @@ public function getPostActionProperty() return $this->postActionProperty; } - /** - * Get result count. - */ public function getResultCount() { return count($this->submissions); } /** - * Set publishUp. - * * @param \DateTime $publishUp * * @return Form @@ -508,8 +473,6 @@ public function setPublishUp($publishUp) } /** - * Get publishUp. - * * @return \DateTime */ public function getPublishUp() @@ -518,8 +481,6 @@ public function getPublishUp() } /** - * Set publishDown. - * * @param \DateTime $publishDown * * @return Form @@ -533,8 +494,6 @@ public function setPublishDown($publishDown) } /** - * Get publishDown. - * * @return \DateTime */ public function getPublishDown() @@ -543,9 +502,7 @@ public function getPublishDown() } /** - * Add a field. - * - * @param $key + * @param int|string $key * * @return Form */ @@ -560,9 +517,7 @@ public function addField($key, Field $field) } /** - * Remove a field. - * - * @param $key + * @param int|string $key */ public function removeField($key, Field $field) { @@ -573,8 +528,6 @@ public function removeField($key, Field $field) } /** - * Get fields. - * * @return \Doctrine\Common\Collections\Collection|Field[] */ public function getFields() @@ -583,8 +536,6 @@ public function getFields() } /** - * Get array of field aliases. - * * @return array */ public function getFieldAliases() @@ -602,8 +553,26 @@ public function getFieldAliases() } /** - * Set alias. + * Loops trough the form fields and returns a simple array of mapped object keys if any. * + * @return string[] + */ + public function getMappedFieldObjects(): array + { + return array_values( + array_filter( + array_unique( + $this->getFields()->map( + function (Field $field) { + return $field->getMappedObject(); + } + )->toArray() + ) + ) + ); + } + + /** * @param string $alias * * @return Form @@ -617,8 +586,6 @@ public function setAlias($alias) } /** - * Get alias. - * * @return string */ public function getAlias() @@ -627,8 +594,6 @@ public function getAlias() } /** - * Add submissions. - * * @return Form */ public function addSubmission(Submission $submissions) @@ -638,17 +603,12 @@ public function addSubmission(Submission $submissions) return $this; } - /** - * Remove submissions. - */ public function removeSubmission(Submission $submissions) { $this->submissions->removeElement($submissions); } /** - * Get submissions. - * * @return \Doctrine\Common\Collections\Collection|Submission[] */ public function getSubmissions() @@ -657,9 +617,7 @@ public function getSubmissions() } /** - * Add actions. - * - * @param $key + * @param int|string $key * * @return Form */ @@ -673,9 +631,6 @@ public function addAction($key, Action $action) return $this; } - /** - * Remove action. - */ public function removeAction(Action $action) { $this->actions->removeElement($action); @@ -690,8 +645,6 @@ public function clearActions() } /** - * Get actions. - * * @return \Doctrine\Common\Collections\Collection|Action[] */ public function getActions() diff --git a/app/bundles/FormBundle/Event/FieldCollectEvent.php b/app/bundles/FormBundle/Event/FieldCollectEvent.php new file mode 100644 index 00000000000..afb5f0fa6ac --- /dev/null +++ b/app/bundles/FormBundle/Event/FieldCollectEvent.php @@ -0,0 +1,36 @@ +object = $object; + $this->fields = new FieldCollection(); + } + + public function getObject(): string + { + return $this->object; + } + + public function appendField(FieldCrate $field): void + { + $this->fields->append($field); + } + + public function getFields(): FieldCollection + { + return $this->fields; + } +} diff --git a/app/bundles/FormBundle/Event/ObjectCollectEvent.php b/app/bundles/FormBundle/Event/ObjectCollectEvent.php new file mode 100644 index 00000000000..fd340e72a05 --- /dev/null +++ b/app/bundles/FormBundle/Event/ObjectCollectEvent.php @@ -0,0 +1,29 @@ +objects = new ObjectCollection(); + } + + public function appendObject(ObjectCrate $object): void + { + $this->objects->append($object); + } + + public function getObjects(): ObjectCollection + { + return $this->objects; + } +} diff --git a/app/bundles/FormBundle/Event/Service/FieldValueTransformer.php b/app/bundles/FormBundle/Event/Service/FieldValueTransformer.php index 278ce322013..ee92a98e154 100644 --- a/app/bundles/FormBundle/Event/Service/FieldValueTransformer.php +++ b/app/bundles/FormBundle/Event/Service/FieldValueTransformer.php @@ -29,9 +29,6 @@ class FieldValueTransformer */ private $isTransformed = false; - /** - * FieldValueTransformer constructor. - */ public function __construct(RouterInterface $router) { $this->router = $router; @@ -39,9 +36,10 @@ public function __construct(RouterInterface $router) public function transformValuesAfterSubmit(SubmissionEvent $submissionEvent) { - if ($this->isIsTransformed()) { + if (true === $this->isTransformed) { return; } + $fields = $submissionEvent->getForm()->getFields(); $contactFieldMatches = $submissionEvent->getContactFieldMatches(); $tokens = $submissionEvent->getTokens(); @@ -65,8 +63,8 @@ public function transformValuesAfterSubmit(SubmissionEvent $submissionEvent) $this->tokensToUpdate[$tokenAlias] = $tokens[$tokenAlias] = $newValue; } - $contactFieldAlias = $field->getLeadField(); - if (!empty($contactFieldMatches[$contactFieldAlias])) { + $contactFieldAlias = $field->getMappedField(); + if ('contact' === $field->getMappedObject() && !empty($contactFieldMatches[$contactFieldAlias])) { $this->contactFieldsToUpdate[$contactFieldAlias] = $contactFieldMatches[$contactFieldAlias] = $newValue; } @@ -96,6 +94,8 @@ public function getTokensToUpdate() } /** + * @deprecated will be removed in Mautic 4. This should have been a private method. Not actually needed. + * * @return bool */ public function isIsTransformed() diff --git a/app/bundles/FormBundle/Exception/FieldNotFoundException.php b/app/bundles/FormBundle/Exception/FieldNotFoundException.php new file mode 100644 index 00000000000..e3232785d26 --- /dev/null +++ b/app/bundles/FormBundle/Exception/FieldNotFoundException.php @@ -0,0 +1,9 @@ +translator = $translator; - } + private $objectCollector; + + /** + * @var FieldCollectorInterface + */ + private $fieldCollector; /** - * {@inheritdoc} + * @var AlreadyMappedFieldCollectorInterface */ + private $mappedFieldCollector; + + public function __construct( + TranslatorInterface $translator, + ObjectCollectorInterface $objectCollector, + FieldCollectorInterface $fieldCollector, + AlreadyMappedFieldCollectorInterface $mappedFieldCollector + ) { + $this->translator = $translator; + $this->objectCollector = $objectCollector; + $this->fieldCollector = $fieldCollector; + $this->mappedFieldCollector = $mappedFieldCollector; + } + public function buildForm(FormBuilderInterface $builder, array $options) { // Populate settings @@ -57,7 +73,7 @@ public function buildForm(FormBuilderInterface $builder, array $options) $addLabelAttributes = $addInputAttributes = $addContainerAttributes = - $addLeadFieldList = + $addMappedFieldList = $addSaveResult = $addBehaviorFields = $addIsRequired = true; @@ -86,7 +102,7 @@ public function buildForm(FormBuilderInterface $builder, array $options) 'inputAttributesText', 'addContainerAttributes', 'containerAttributesText', - 'addLeadFieldList', + 'addMappedFieldList', 'addSaveResult', 'addBehaviorFields', 'addIsRequired', @@ -101,7 +117,7 @@ public function buildForm(FormBuilderInterface $builder, array $options) $type = $options['data']['type']; switch ($type) { case 'freetext': - $addHelpMessage = $addDefaultValue = $addIsRequired = $addLeadFieldList = $addSaveResult = $addBehaviorFields = false; + $addHelpMessage = $addDefaultValue = $addIsRequired = $addMappedFieldList = $addSaveResult = $addBehaviorFields = false; $labelText = 'mautic.form.field.form.header'; $showLabelText = 'mautic.form.field.form.showheader'; $inputAttributesText = 'mautic.form.field.form.freetext_attributes'; @@ -111,7 +127,7 @@ public function buildForm(FormBuilderInterface $builder, array $options) $cleanMasks['properties'] = 'html'; break; case 'freehtml': - $addHelpMessage = $addDefaultValue = $addIsRequired = $addLeadFieldList = $addSaveResult = $addBehaviorFields = false; + $addHelpMessage = $addDefaultValue = $addIsRequired = $addMappedFieldList = $addSaveResult = $addBehaviorFields = false; $labelText = 'mautic.form.field.form.header'; $showLabelText = 'mautic.form.field.form.showheader'; $inputAttributesText = 'mautic.form.field.form.freehtml_attributes'; @@ -120,16 +136,16 @@ public function buildForm(FormBuilderInterface $builder, array $options) $cleanMasks['properties'] = 'html'; break; case 'button': - $addHelpMessage = $addShowLabel = $addDefaultValue = $addLabelAttributes = $addIsRequired = $addLeadFieldList = $addSaveResult = $addBehaviorFields = false; + $addHelpMessage = $addShowLabel = $addDefaultValue = $addLabelAttributes = $addIsRequired = $addMappedFieldList = $addSaveResult = $addBehaviorFields = false; break; case 'hidden': $addHelpMessage = $addShowLabel = $addLabelAttributes = $addIsRequired = false; break; case 'captcha': - $addShowLabel = $addIsRequired = $addDefaultValue = $addLeadFieldList = $addSaveResult = $addBehaviorFields = false; + $addShowLabel = $addIsRequired = $addDefaultValue = $addMappedFieldList = $addSaveResult = $addBehaviorFields = false; break; case 'pagebreak': - $addShowLabel = $allowCustomAlias = $addHelpMessage = $addIsRequired = $addDefaultValue = $addLeadFieldList = $addSaveResult = $addBehaviorFields = false; + $addShowLabel = $allowCustomAlias = $addHelpMessage = $addIsRequired = $addDefaultValue = $addMappedFieldList = $addSaveResult = $addBehaviorFields = false; break; case 'select': $cleanMasks['properties']['list']['list']['label'] = 'strict_html'; @@ -394,51 +410,64 @@ public function buildForm(FormBuilderInterface $builder, array $options) ); } - if ($addLeadFieldList) { - if (!isset($options['data']['leadField'])) { - switch ($type) { - case 'email': - $data = 'email'; - break; - case 'country': - $data = 'country'; - break; - case 'tel': - $data = 'phone'; - break; - default: - $data = ''; - break; - } - } elseif (isset($options['data']['leadField'])) { - $data = $options['data']['leadField']; - } else { - $data = ''; - } + if ($addMappedFieldList) { + $mappedObject = $options['data']['mappedObject'] ?? 'contact'; + $mappedField = $options['data']['mappedField'] ?? null; + $builder->add( + 'mappedObject', + ChoiceType::class, + [ + 'choices' => $this->objectCollector->getObjects()->toChoices(), + 'label' => 'mautic.form.field.form.mapped.object', + 'label_attr' => ['class' => 'control-label'], + 'attr' => [ + 'class' => 'form-control', + 'tooltip' => 'mautic.form.field.help.mapped.object', + 'onchange' => 'Mautic.fetchFieldsOnObjectChange();', + ], + 'required' => false, + 'data' => $mappedObject, + ] + ); + + $fields = $this->fieldCollector->getFields($mappedObject); + $mappedFields = $this->mappedFieldCollector->getFields((string) $options['data']['formId'], $mappedObject); + $fields = $fields->removeFieldsWithKeys($mappedFields, (string) $mappedField); $builder->add( - 'leadField', + 'mappedField', ChoiceType::class, [ - 'choices' => $options['leadFields'], - 'choice_attr' => function ($val, $key, $index) use ($options) { - $objects = ['lead', 'company']; - foreach ($objects as $object) { - if (!empty($options['leadFieldProperties'][$object][$val]) && (in_array($options['leadFieldProperties'][$object][$val]['type'], FormFieldHelper::getListTypes()) || !empty($options['leadFieldProperties'][$object][$val]['properties']['list']) || !empty($options['leadFieldProperties'][$object][$val]['properties']['optionlist']))) { + 'choices' => $fields->toChoices(), + 'choice_attr' => function ($val) use ($fields) { + try { + $field = $fields->getFieldByKey($val); + if ($field->isListType()) { return ['data-list-type' => 1]; } + } catch (FieldNotFoundException $e) { } return []; }, - 'label' => 'mautic.form.field.form.lead_field', + 'label' => 'mautic.form.field.form.mapped.field', 'label_attr' => ['class' => 'control-label'], 'attr' => [ 'class' => 'form-control', - 'tooltip' => 'mautic.form.field.help.lead_field', + 'tooltip' => 'mautic.form.field.help.mapped.field', ], 'required' => false, - 'data' => $data, + 'data' => $mappedField ?? $this->getDefaultMappedField((string) $type), + ] + ); + + $builder->add( + 'originalMappedField', + HiddenType::class, + [ + 'label' => false, + 'required' => false, + 'data' => $mappedField, ] ); } @@ -586,9 +615,6 @@ public function buildForm(FormBuilderInterface $builder, array $options) } } - /** - * {@inheritdoc} - */ public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults( @@ -597,16 +623,31 @@ public function configureOptions(OptionsResolver $resolver) ] ); - $resolver->setDefined(['customParameters', 'leadFieldProperties']); - - $resolver->setRequired(['leadFields']); + $resolver->setDefined(['customParameters']); } - /** - * {@inheritdoc} - */ public function getBlockPrefix() { return 'formfield'; } + + private function getDefaultMappedField(string $type): string + { + switch ($type) { + case 'email': + $default = 'email'; + break; + case 'country': + $default = 'country'; + break; + case 'tel': + $default = 'phone'; + break; + default: + $default = ''; + break; + } + + return $default; + } } diff --git a/app/bundles/FormBundle/Form/Type/SortableListTrait.php b/app/bundles/FormBundle/Form/Type/SortableListTrait.php index 79e8a51ecef..3622a185b9e 100644 --- a/app/bundles/FormBundle/Form/Type/SortableListTrait.php +++ b/app/bundles/FormBundle/Form/Type/SortableListTrait.php @@ -20,13 +20,13 @@ public function addSortableList(FormBuilderInterface $builder, $options, $listNa $listOptions = [ 'with_labels' => true, 'attr' => [ - 'data-show-on' => '{"'.$formName.'_properties_syncList_1": "", "'.$formName.'_leadField:data-list-type": "empty"}', + 'data-show-on' => '{"'.$formName.'_properties_syncList_1": "", "'.$formName.'_mappedField:data-list-type": "empty"}', ], 'option_required' => false, 'constraint_callback' => new Callback( function ($validateMe, ExecutionContextInterface $context) use ($listName) { $data = $context->getRoot()->getData(); - if ((empty($data['properties']['syncList']) || empty($data['leadField'])) && !count($data['properties'][$listName]['list'])) { + if ((empty($data['properties']['syncList']) || empty($data['mappedField'])) && !count($data['properties'][$listName]['list'])) { $context->buildViolation('mautic.form.lists.count')->addViolation(); } } @@ -43,7 +43,7 @@ function ($validateMe, ExecutionContextInterface $context) use ($listName) { YesNoButtonGroupType::class, [ 'attr' => [ - 'data-show-on' => '{"'.$formName.'_leadField:data-list-type": "1"}', + 'data-show-on' => '{"'.$formName.'_mappedField:data-list-type": "1"}', ], 'label' => 'mautic.form.field.form.property_list_sync_choices', 'data' => !isset($options['data']['syncList']) ? false : (bool) $options['data']['syncList'], @@ -53,7 +53,7 @@ function ($validateMe, ExecutionContextInterface $context) use ($listName) { $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) { $formData = $event->getForm()->getParent()->getData(); $form = $event->getForm(); - if (empty($formData['leadField'])) { + if (empty($formData['mappedField'])) { // Disable sync list if a contact field is not mapped $data = $event->getData(); $data['syncList'] = '0'; diff --git a/app/bundles/FormBundle/FormEvents.php b/app/bundles/FormBundle/FormEvents.php index 98b4ff556a7..a08e2a8ae10 100644 --- a/app/bundles/FormBundle/FormEvents.php +++ b/app/bundles/FormBundle/FormEvents.php @@ -148,4 +148,24 @@ final class FormEvents * @var string */ public const ON_DETERMINE_SUBMISSION_RATE_WINNER = 'mautic.form.on_submission_rate_winner'; + + /** + * The mautic.form.on_object_collect event is fired when there is a call for all available objects that can provide fields for mapping. + * + * The event listener receives a + * Mautic\CoreBundles\Event\ObjectCollectEvent + * + * @var string + */ + public const ON_OBJECT_COLLECT = 'mautic.form.on_object_collect'; + + /** + * The mautic.form.on_field_collect event is fired when there is a call for all available fields for specific object that can be provided for mapping. + * + * The event listener receives a + * Mautic\CoreBundles\Event\FieldCollectEvent + * + * @var string + */ + public const ON_FIELD_COLLECT = 'mautic.form.on_field_collect'; } diff --git a/app/bundles/FormBundle/Helper/PropertiesAccessor.php b/app/bundles/FormBundle/Helper/PropertiesAccessor.php index 7dcd0f425fd..3affddd0d88 100644 --- a/app/bundles/FormBundle/Helper/PropertiesAccessor.php +++ b/app/bundles/FormBundle/Helper/PropertiesAccessor.php @@ -25,8 +25,9 @@ public function __construct(FormModel $formModel) */ public function getProperties(array $field) { - if ('country' === $field['type'] || (!empty($field['leadField']) && !empty($field['properties']['syncList']))) { - return $this->formModel->getContactFieldPropertiesList($field['leadField']); + $hasContactFieldMapped = !empty($field['mappedField']) && !empty($field['mappedObject']) && 'contact' === $field['mappedObject']; + if ('country' === $field['type'] || ($hasContactFieldMapped && !empty($field['properties']['syncList']))) { + return $this->formModel->getContactFieldPropertiesList((string) $field['mappedField']); } elseif (!empty($field['properties'])) { return $this->getOptionsListFromProperties($field['properties']); } @@ -48,7 +49,7 @@ public function getChoices($options) } if (!is_array($options)) { - $options = explode('|', $options); + $options = explode('|', (string) $options); } foreach ($options as $option) { diff --git a/app/bundles/FormBundle/Model/FieldModel.php b/app/bundles/FormBundle/Model/FieldModel.php index 24d3d4636ac..71cc302f1e1 100644 --- a/app/bundles/FormBundle/Model/FieldModel.php +++ b/app/bundles/FormBundle/Model/FieldModel.php @@ -47,27 +47,6 @@ public function setSession(Session $session) */ public function createForm($entity, $formFactory, $action = null, $options = []) { - list($fields, $choices) = $this->getObjectFields('lead'); - list($companyFields, $companyChoices) = $this->getObjectFields('company'); - - // Only show the lead fields not already used - $usedLeadFields = $this->session->get('mautic.form.'.$entity['formId'].'.fields.leadfields', []); - $testLeadFields = array_flip($usedLeadFields); - $currentLeadField = (isset($entity['leadField'])) ? $entity['leadField'] : null; - if (!empty($currentLeadField) && isset($testLeadFields[$currentLeadField])) { - unset($testLeadFields[$currentLeadField]); - } - - foreach ($choices as &$group) { - $group = array_diff_key($group, $testLeadFields); - } - - $options['leadFields']['lead'] = $choices; - $options['leadFieldProperties']['lead'] = $fields; - - $options['leadFields']['company'] = $companyChoices; - $options['leadFieldProperties']['company'] = $companyFields; - if ($action) { $options['action'] = $action; } @@ -75,6 +54,9 @@ public function createForm($entity, $formFactory, $action = null, $options = []) return $formFactory->create(FieldType::class, $entity, $options); } + /** + * @deprecated to be removed in Mautic 4. This method is not used anymore. + */ public function getObjectFields($object = 'lead') { $fields = $this->leadFieldModel->getFieldListWithProperties($object); @@ -94,8 +76,6 @@ public function getObjectFields($object = 'lead') } /** - * {@inheritdoc} - * * @return \Mautic\FormBundle\Entity\FieldRepository */ public function getRepository() @@ -103,17 +83,11 @@ public function getRepository() return $this->em->getRepository('MauticFormBundle:Field'); } - /** - * {@inheritdoc} - */ public function getPermissionBase() { return 'form:forms'; } - /** - * {@inheritdoc} - */ public function getEntity($id = null) { if (null === $id) { diff --git a/app/bundles/FormBundle/Model/FormModel.php b/app/bundles/FormBundle/Model/FormModel.php index fe087a9ae1f..e2886221e13 100644 --- a/app/bundles/FormBundle/Model/FormModel.php +++ b/app/bundles/FormBundle/Model/FormModel.php @@ -9,9 +9,11 @@ use Mautic\CoreBundle\Helper\TemplatingHelper; use Mautic\CoreBundle\Helper\ThemeHelperInterface; use Mautic\CoreBundle\Model\FormModel as CommonFormModel; +use Mautic\FormBundle\Collector\MappedObjectCollectorInterface; use Mautic\FormBundle\Entity\Action; use Mautic\FormBundle\Entity\Field; use Mautic\FormBundle\Entity\Form; +use Mautic\FormBundle\Entity\FormRepository; use Mautic\FormBundle\Event\FormBuilderEvent; use Mautic\FormBundle\Event\FormEvent; use Mautic\FormBundle\Form\Type\FormType; @@ -87,8 +89,10 @@ class FormModel extends CommonFormModel private $tableSchemaHelper; /** - * FormModel constructor. + * @var MappedObjectCollectorInterface */ + private $mappedObjectCollector; + public function __construct( RequestStack $requestStack, TemplatingHelper $templatingHelper, @@ -100,25 +104,27 @@ public function __construct( FormUploader $formUploader, ContactTracker $contactTracker, ColumnSchemaHelper $columnSchemaHelper, - TableSchemaHelper $tableSchemaHelper + TableSchemaHelper $tableSchemaHelper, + MappedObjectCollectorInterface $mappedObjectCollector ) { - $this->requestStack = $requestStack; - $this->templatingHelper = $templatingHelper; - $this->themeHelper = $themeHelper; - $this->formActionModel = $formActionModel; - $this->formFieldModel = $formFieldModel; - $this->fieldHelper = $fieldHelper; - $this->leadFieldModel = $leadFieldModel; - $this->formUploader = $formUploader; - $this->contactTracker = $contactTracker; - $this->columnSchemaHelper = $columnSchemaHelper; - $this->tableSchemaHelper = $tableSchemaHelper; + $this->requestStack = $requestStack; + $this->templatingHelper = $templatingHelper; + $this->themeHelper = $themeHelper; + $this->formActionModel = $formActionModel; + $this->formFieldModel = $formFieldModel; + $this->fieldHelper = $fieldHelper; + $this->leadFieldModel = $leadFieldModel; + $this->formUploader = $formUploader; + $this->contactTracker = $contactTracker; + $this->columnSchemaHelper = $columnSchemaHelper; + $this->tableSchemaHelper = $tableSchemaHelper; + $this->mappedObjectCollector = $mappedObjectCollector; } /** * {@inheritdoc} * - * @return \Mautic\FormBundle\Entity\FormRepository + * @return FormRepository */ public function getRepository() { @@ -172,7 +178,7 @@ public function getEntity($id = null) if ($entity && $entity->getFields()) { foreach ($entity->getFields() as $field) { - $this->addLeadFieldOptions($field); + $this->addMappedFieldOptions($field); } } @@ -404,6 +410,8 @@ public function saveEntity($entity, $unlock = true) $entity->setAlias($alias); } + $this->backfillReplacedPropertiesForBc($entity); + //save the form so that the ID is available for the form html parent::saveEntity($entity, $unlock); @@ -527,8 +535,7 @@ public function generateHtml(Form $entity, $persist = true) 'fieldSettings' => $this->getCustomComponents()['fields'], 'viewOnlyFields' => $this->getCustomComponents()['viewOnlyFields'], 'fields' => $fields, - 'contactFields' => $this->leadFieldModel->getFieldListWithProperties(), - 'companyFields' => $this->leadFieldModel->getFieldListWithProperties('company'), + 'mappedFields' => $this->mappedObjectCollector->buildCollection(...$entity->getMappedFieldObjects()), 'form' => $entity, 'theme' => $theme, 'submissions' => $submissions, @@ -557,7 +564,6 @@ public function getPages(array $fields): array $pages = ['open' => [], 'close' => []]; $openFieldId = - $closeFieldId = $previousId = $lastPage = false; $pageCount = 1; @@ -817,7 +823,7 @@ public function populateValuesWithGetParameters(Form $form, &$formHtml) } /** - * @param $formHtml + * @param string $formHtml */ public function populateValuesWithLead(Form $form, &$formHtml) { @@ -831,7 +837,7 @@ public function populateValuesWithLead(Form $form, &$formHtml) $isAutoFill = $field->getIsAutoFill(); // we want work just with matched autofill fields - if (isset($leadField) && $isAutoFill) { + if ($field->getMappedField() && 'contact' === $field->getMappedObject() && $field->getIsAutoFill()) { $autoFillFields[$key] = $field; } } @@ -847,7 +853,7 @@ public function populateValuesWithLead(Form $form, &$formHtml) } foreach ($autoFillFields as $field) { - $value = $lead->getFieldValue($field->getLeadField()); + $value = $lead->getFieldValue($field->getMappedField()); // just skip string empty field if ('' !== $value) { $this->fieldHelper->populateField($field, $value, $formName, $formHtml); @@ -1077,16 +1083,16 @@ private function generateJsScript($html) /** * Finds out whether the. */ - private function addLeadFieldOptions(Field $formField) + private function addMappedFieldOptions(Field $formField): void { - $formFieldProps = $formField->getProperties(); - $contactFieldAlias = $formField->getLeadField(); + $formFieldProps = $formField->getProperties(); + $mappedFieldAlias = $formField->getMappedField(); - if (empty($formFieldProps['syncList']) || empty($contactFieldAlias)) { + if (empty($formFieldProps['syncList']) || empty($mappedFieldAlias) || 'contact' !== $formField->getMappedObject()) { return; } - $list = $this->getContactFieldPropertiesList($contactFieldAlias); + $list = $this->getContactFieldPropertiesList($mappedFieldAlias); if (!empty($list)) { $formFieldProps['list'] = ['list' => $list]; @@ -1102,7 +1108,7 @@ private function addLeadFieldOptions(Field $formField) */ public function getContactFieldPropertiesList(string $contactFieldAlias): ?array { - $contactField = $this->leadFieldModel->getEntityByAlias($contactFieldAlias); + $contactField = $this->leadFieldModel->getEntityByAlias($contactFieldAlias); // @todo this must use all objects as well. Not just contact. if (empty($contactField) || !in_array($contactField->getType(), ContactFieldHelper::getListTypes())) { return null; @@ -1153,4 +1159,19 @@ public function findFormFieldByAlias(Form $form, $fieldAlias) return null; } + + private function backfillReplacedPropertiesForBc(Form $entity): void + { + /** @var Field $field */ + foreach ($entity->getFields() as $field) { + if (!$field->getLeadField() && $field->getMappedField()) { + $field->setLeadField($field->getMappedField()); + } elseif ($field->getLeadField() && !$field->getMappedField()) { + $field->setMappedField($field->getLeadField()); + $field->setMappedObject( + 'company' === substr($field->getLeadField(), 0, 7) && 'company' !== $field->getLeadField() ? 'company' : 'contact' + ); + } + } + } } diff --git a/app/bundles/FormBundle/Model/SubmissionModel.php b/app/bundles/FormBundle/Model/SubmissionModel.php index 5cb8b79d842..0a79f319f78 100644 --- a/app/bundles/FormBundle/Model/SubmissionModel.php +++ b/app/bundles/FormBundle/Model/SubmissionModel.php @@ -340,11 +340,11 @@ public function saveSubmission($post, $server, Form $form, Request $request, $re $validationErrors[$alias] = $isValid; } - $leadField = $f->getLeadField(); - if (!empty($leadField)) { + $mappedField = $f->getMappedField(); + if (!empty($mappedField) && in_array($f->getMappedObject(), ['company', 'contact'])) { $leadValue = $value; - $leadFieldMatches[$leadField] = $leadValue; + $leadFieldMatches[$mappedField] = $leadValue; } $tokens["{formfield={$alias}}"] = $this->normalizeValue($value, $f); diff --git a/app/bundles/FormBundle/Tests/Collection/FieldCollectionTest.php b/app/bundles/FormBundle/Tests/Collection/FieldCollectionTest.php new file mode 100644 index 00000000000..98ea1094324 --- /dev/null +++ b/app/bundles/FormBundle/Tests/Collection/FieldCollectionTest.php @@ -0,0 +1,77 @@ +assertSame( + [ + 'email' => '6', + 'first_name' => '7', + ], + $collection->toChoices() + ); + } + + public function testToChoicesWithoutObjects(): void + { + $collection = new FieldCollection(); + + $this->assertSame([], $collection->toChoices()); + } + + public function testGetFieldByKey(): void + { + $field6 = new FieldCrate('6', 'email', 'email', []); + $field7 = new FieldCrate('7', 'first_name', 'text', []); + $collection = new FieldCollection([$field6, $field7]); + + $this->assertSame($field6, $collection->getFieldByKey('6')); + $this->assertSame($field7, $collection->getFieldByKey('7')); + + $this->expectException(FieldNotFoundException::class); + $collection->getFieldByKey('8'); + } + + public function testRemoveFieldsWithKeysWithNoKeyToKeep(): void + { + $field6 = new FieldCrate('6', 'email', 'email', []); + $field7 = new FieldCrate('7', 'first_name', 'text', []); + $field8 = new FieldCrate('8', 'last_name', 'text', []); + $originalCollection = new FieldCollection([$field6, $field7, $field8]); + $resultCollection = $originalCollection->removeFieldsWithKeys(['6', '8']); + + // It should return a clone of the original collection. Not mutation. + $this->assertNotSame($originalCollection, $resultCollection); + $this->assertCount(1, $resultCollection); + $this->assertSame($field7, $resultCollection->getFieldByKey('7')); + } + + public function testRemoveFieldsWithKeysWithKeyToKeep(): void + { + $field6 = new FieldCrate('6', 'email', 'email', []); + $field7 = new FieldCrate('7', 'first_name', 'text', []); + $field8 = new FieldCrate('8', 'last_name', 'text', []); + $originalCollection = new FieldCollection([$field6, $field7, $field8]); + $resultCollection = $originalCollection->removeFieldsWithKeys(['6', '8'], '8'); + + $this->assertCount(2, $resultCollection); + $this->assertSame($field7, $resultCollection->getFieldByKey('7')); + $this->assertSame($field8, $resultCollection->getFieldByKey('8')); + } +} diff --git a/app/bundles/FormBundle/Tests/Collection/ObjectCollectionTest.php b/app/bundles/FormBundle/Tests/Collection/ObjectCollectionTest.php new file mode 100644 index 00000000000..1e75d959bc0 --- /dev/null +++ b/app/bundles/FormBundle/Tests/Collection/ObjectCollectionTest.php @@ -0,0 +1,36 @@ +assertSame( + [ + 'Contact' => 'contact', + 'Company' => 'company', + ], + $collection->toChoices() + ); + } + + public function testToChoicesWithoutObjects(): void + { + $collection = new ObjectCollection(); + + $this->assertSame([], $collection->toChoices()); + } +} diff --git a/app/bundles/FormBundle/Tests/Collector/AlreadyMappedFieldCollectorTest.php b/app/bundles/FormBundle/Tests/Collector/AlreadyMappedFieldCollectorTest.php new file mode 100644 index 00000000000..11183b7455a --- /dev/null +++ b/app/bundles/FormBundle/Tests/Collector/AlreadyMappedFieldCollectorTest.php @@ -0,0 +1,96 @@ +cacheProvider = $this->createMock(CacheProviderInterface::class); + $this->collector = new AlreadyMappedFieldCollector($this->cacheProvider); + } + + public function testWorkflow(): void + { + $createCacheItem = \Closure::bind( + function () { + $item = new CacheItem(); + $item->isHit = false; + $item->isTaggable = true; + + return $item; + }, + $this, + CacheItem::class + ); + $cacheItem = $createCacheItem(); + + $formId = '3'; + $object = 'contact'; + + $this->cacheProvider->method('getItem') + ->with('mautic.form.3.object.contact.fields.mapped') + ->willReturn($cacheItem); + + $this->cacheProvider->expects($this->exactly(4)) + ->method('save') + ->with($cacheItem); + + // Ensure we get an empty array at the beginning. + $this->assertNull($cacheItem->get()); + $this->assertSame([], $this->collector->getFields($formId, $object)); + + // Add a mapped field. + $this->collector->addField('3', 'contact', '44'); + $this->assertSame(['44'], $this->collector->getFields($formId, $object)); + + // The field with key 44 should be added to the cache item. + $this->assertSame('["44"]', $cacheItem->get()); + + // Add another mapped field. + $this->collector->addField('3', 'contact', '55'); + + // The field with key 55 should be added to the cache item. + $this->assertSame('["44","55"]', $cacheItem->get()); + $this->assertSame(['44', '55'], $this->collector->getFields($formId, $object)); + + // Remove an exsting field. + $this->collector->removeField('3', 'contact', '44'); + + // The field with key 44 should be removed from the cache item. + $this->assertSame('["55"]', $cacheItem->get()); + $this->assertSame(['55'], $this->collector->getFields($formId, $object)); + + // Remove a not exsting field. + $this->collector->removeField('3', 'contact', '44'); + + // Still the same result after removing a field that did not exist. + $this->assertSame('["55"]', $cacheItem->get()); + $this->assertSame(['55'], $this->collector->getFields($formId, $object)); + + $this->cacheProvider->expects($this->once()) + ->method('invalidateTags') + ->with(['mautic.form.3.fields.mapped']); + + $this->collector->removeAllForForm($formId); + } +} diff --git a/app/bundles/FormBundle/Tests/Collector/FieldCollectorTest.php b/app/bundles/FormBundle/Tests/Collector/FieldCollectorTest.php new file mode 100644 index 00000000000..dd2ec8d77c4 --- /dev/null +++ b/app/bundles/FormBundle/Tests/Collector/FieldCollectorTest.php @@ -0,0 +1,40 @@ +dispatchMethodCallCounter; + + \assert($event instanceof FieldCollectEvent); + Assert::assertSame('contact', $event->getObject()); + + return new FieldCollection(); + } + }; + + $fieldCollector = new FieldCollector($dispatcher); + $fieldCollection = $fieldCollector->getFields('contact'); + + // Calling for the second time to ensure it's cached and the dispatcher is called only once. + $fieldCollection = $fieldCollector->getFields('contact'); + + Assert::assertInstanceOf(FieldCollection::class, $fieldCollection); + Assert::assertEquals(1, $dispatcher->dispatchMethodCallCounter); + } +} diff --git a/app/bundles/FormBundle/Tests/Collector/MappedObjectCollectorTest.php b/app/bundles/FormBundle/Tests/Collector/MappedObjectCollectorTest.php new file mode 100644 index 00000000000..4d56488bc10 --- /dev/null +++ b/app/bundles/FormBundle/Tests/Collector/MappedObjectCollectorTest.php @@ -0,0 +1,76 @@ +getFieldsMethodCallCounter; + + return new FieldCollection(); + } + }; + + $mappedObjectCollector = new MappedObjectCollector($fieldCollector); + $objectCollection = $mappedObjectCollector->buildCollection(''); + Assert::assertInstanceOf(MappedObjectCollection::class, $objectCollection); + Assert::assertCount(0, $objectCollection); + Assert::assertEquals(0, $fieldCollector->getFieldsMethodCallCounter); + } + + public function testBuildCollectionForOneObject(): void + { + $fieldCollector = new class() implements FieldCollectorInterface { + public int $getFieldsMethodCallCounter = 0; + + public function getFields(string $object): FieldCollection + { + Assert::assertSame($object, 'contact'); + ++$this->getFieldsMethodCallCounter; + + return new FieldCollection(); + } + }; + + $mappedObjectCollector = new MappedObjectCollector($fieldCollector); + $objectCollection = $mappedObjectCollector->buildCollection('contact'); + Assert::assertInstanceOf(MappedObjectCollection::class, $objectCollection); + Assert::assertCount(1, $objectCollection); + Assert::assertEquals(1, $fieldCollector->getFieldsMethodCallCounter); + } + + public function testBuildCollectionForMultipleObjects(): void + { + $fieldCollector = new class() implements FieldCollectorInterface { + public int $getFieldsMethodCallCounter = 0; + + public function getFields(string $object): FieldCollection + { + Assert::assertContains($object, ['company', 'contact']); + ++$this->getFieldsMethodCallCounter; + + return new FieldCollection(); + } + }; + + $mappedObjectCollector = new MappedObjectCollector($fieldCollector); + $objectCollection = $mappedObjectCollector->buildCollection('contact', 'company'); + Assert::assertInstanceOf(MappedObjectCollection::class, $objectCollection); + Assert::assertCount(2, $objectCollection); + Assert::assertEquals(2, $fieldCollector->getFieldsMethodCallCounter); + } +} diff --git a/app/bundles/FormBundle/Tests/Collector/ObjectCollectorTest.php b/app/bundles/FormBundle/Tests/Collector/ObjectCollectorTest.php new file mode 100644 index 00000000000..f855cff8ab3 --- /dev/null +++ b/app/bundles/FormBundle/Tests/Collector/ObjectCollectorTest.php @@ -0,0 +1,39 @@ +dispatchMethodCallCounter; + + Assert::assertInstanceOf(ObjectCollectEvent::class, $event); + + return new ObjectCollection(); + } + }; + + $objectCollector = new ObjectCollector($dispatcher); + $objectCollection = $objectCollector->getObjects(); + + // Calling for the second time to ensure it's cached and the dispatcher is called only once. + $objectCollection = $objectCollector->getObjects(); + + Assert::assertInstanceOf(ObjectCollection::class, $objectCollection); + Assert::assertEquals(1, $dispatcher->dispatchMethodCallCounter); + } +} diff --git a/app/bundles/FormBundle/Tests/Controller/AjaxControllerFunctionalTest.php b/app/bundles/FormBundle/Tests/Controller/AjaxControllerFunctionalTest.php new file mode 100644 index 00000000000..041d6d15ab1 --- /dev/null +++ b/app/bundles/FormBundle/Tests/Controller/AjaxControllerFunctionalTest.php @@ -0,0 +1,45 @@ +client->request( + Request::METHOD_GET, + '/s/ajax?action=form:getFieldsForObject&mappedObject=company&mappedField=&formId=10', + [], + [], + $this->createAjaxHeaders() + ); + $clientResponse = $this->client->getResponse(); + $payload = json_decode($clientResponse->getContent(), true); + Assert::assertSame(Response::HTTP_OK, $clientResponse->getStatusCode()); + + // Assert some random fields exist. + Assert::assertSame( + [ + 'label' => 'Company Email', + 'value' => 'companyemail', + 'isListType' => false, + ], + $payload['fields'][4] + ); + Assert::assertSame( + [ + 'label' => 'Industry', + 'value' => 'companyindustry', + 'isListType' => true, + ], + $payload['fields'][9] + ); + } +} diff --git a/app/bundles/FormBundle/Tests/Controller/Api/FormApiControllerFunctionalTest.php b/app/bundles/FormBundle/Tests/Controller/Api/FormApiControllerFunctionalTest.php index 8a6706f8763..ee5f2461ea5 100644 --- a/app/bundles/FormBundle/Tests/Controller/Api/FormApiControllerFunctionalTest.php +++ b/app/bundles/FormBundle/Tests/Controller/Api/FormApiControllerFunctionalTest.php @@ -1,15 +1,91 @@ 'API form', + 'description' => 'Form created via API test', + 'formType' => 'standalone', + 'isPublished' => true, + 'fields' => [ + [ + 'label' => 'Email', + 'type' => 'text', + 'alias' => 'email', + 'mappedObject' => 'contact', + 'mappedField' => 'email', + 'showLabel' => true, + 'isRequired' => true, + ], + [ + 'label' => 'Number', + 'type' => 'number', + 'alias' => 'number', + 'leadField' => 'points', // @deprecated Setting leadField, no mappedField or mappedObject (BC). + ], + [ + 'label' => 'Company', + 'type' => 'text', + 'alias' => 'company', + 'leadField' => 'company', // @deprecated Setting leadField, no mappedField or mappedObject (BC). + ], + [ + 'label' => 'Company Phone', + 'type' => 'tel', + 'alias' => 'phone', + 'leadField' => 'companyphone', // @deprecated Setting leadField, no mappedField or mappedObject (BC). + ], + [ + 'label' => 'Country', + 'type' => 'country', + 'alias' => 'country', + 'mappedObject' => 'contact', + 'mappedField' => 'country', + ], + [ + 'label' => 'Multiselect', + 'type' => 'select', + 'alias' => 'multiselect', + 'properties' => [ + 'syncList' => 0, + 'multiple' => 1, + 'list' => [ + 'list' => [ + [ + 'label' => 'One', + 'value' => 'one', + ], + [ + 'label' => 'Two', + 'value' => 'two', + ], + ], + ], + ], + ], + [ + 'label' => 'Submit', + 'type' => 'button', + ], + ], + 'actions' => [ + ], + ]; + + public function testFormWorkflow(): void { $payload = [ 'name' => 'Form API test', @@ -69,38 +145,208 @@ public function testFormWorkflow() $this->assertEquals($payload['fields'][$i]['type'], $response['form']['fields'][$i]['type']); $this->assertEquals($payload['fields'][$i]['leadField'], $response['form']['fields'][$i]['leadField']); } + } - // Get: - $this->client->request('GET', "/api/forms/{$formId}"); + public function testSingleFormWorkflow(): void + { + $payload = self::TEST_PAYLOAD; + $fieldCount = count($payload['fields']); + $this->client->request(Request::METHOD_POST, '/api/forms/new', $payload); $clientResponse = $this->client->getResponse(); $response = json_decode($clientResponse->getContent(), true); - $this->assertSame(Response::HTTP_OK, $clientResponse->getStatusCode()); - $this->assertSame($formId, $response['form']['id'], 'ID of the created form does not match with the fetched one.'); - $this->assertEquals('Form API renamed', $response['form']['name']); + $formId = $response['form']['id']; + + $this->assertSame(Response::HTTP_CREATED, $clientResponse->getStatusCode(), $clientResponse->getContent()); + $this->assertGreaterThan(0, $formId); + $this->assertEquals($payload['name'], $response['form']['name']); + $this->assertEquals($payload['description'], $response['form']['description']); $this->assertEquals($payload['formType'], $response['form']['formType']); - $this->assertEquals($payload['isPublished'], $response['form']['isPublished']); + $this->assertNotEmpty($response['form']['cachedHtml']); + $this->assertCount($fieldCount, $response['form']['fields']); + $this->assertEquals($payload['fields'][0]['label'], $response['form']['fields'][0]['label']); + $this->assertEquals($payload['fields'][0]['type'], $response['form']['fields'][0]['type']); + $this->assertEquals($payload['fields'][0]['mappedObject'], $response['form']['fields'][0]['mappedObject']); + $this->assertEquals($payload['fields'][0]['mappedField'], $response['form']['fields'][0]['mappedField']); + $this->assertEquals( + $payload['fields'][0]['mappedField'], + $response['form']['fields'][0]['leadField'] + ); // @deprecated leadField was replaced by mappedField. Check for BC. + $this->assertEquals($payload['fields'][0]['showLabel'], $response['form']['fields'][0]['showLabel']); + $this->assertEquals($payload['fields'][0]['isRequired'], $response['form']['fields'][0]['isRequired']); + $this->assertEquals($payload['fields'][1]['label'], $response['form']['fields'][1]['label']); + $this->assertEquals($payload['fields'][1]['type'], $response['form']['fields'][1]['type']); + $this->assertEquals('contact', $response['form']['fields'][1]['mappedObject']); + $this->assertEquals('points', $response['form']['fields'][1]['mappedField']); + $this->assertEquals( + $payload['fields'][1]['leadField'], + $response['form']['fields'][1]['leadField'] + ); // @deprecated leadField was replaced by mappedField. Check for BC. + $this->assertTrue($response['form']['fields'][1]['showLabel']); + $this->assertFalse($response['form']['fields'][1]['isRequired']); + $this->assertEquals($payload['fields'][2]['label'], $response['form']['fields'][2]['label']); + $this->assertEquals($payload['fields'][2]['type'], $response['form']['fields'][2]['type']); + $this->assertEquals('contact', $response['form']['fields'][2]['mappedObject']); + $this->assertEquals('company', $response['form']['fields'][2]['mappedField']); + $this->assertEquals( + $payload['fields'][2]['leadField'], + $response['form']['fields'][2]['leadField'] + ); // @deprecated leadField was replaced by mappedField. Check for BC. + $this->assertEquals($payload['fields'][3]['label'], $response['form']['fields'][3]['label']); + $this->assertEquals($payload['fields'][3]['type'], $response['form']['fields'][3]['type']); + $this->assertEquals('company', $response['form']['fields'][3]['mappedObject']); + $this->assertEquals('companyphone', $response['form']['fields'][3]['mappedField']); + $this->assertEquals( + $payload['fields'][3]['leadField'], + $response['form']['fields'][3]['leadField'] + ); // @deprecated leadField was replaced by mappedField. Check for BC. + + // Edit PATCH: + $patchPayload = [ + 'name' => 'API form renamed', + 'fields' => [ + [ + 'label' => 'State', + 'type' => 'select', + 'alias' => 'state', + 'mappedObject' => 'contact', + 'mappedField' => 'state', + 'parent' => $response['form']['fields'][4]['id'], + 'conditions' => [ + 'expr' => 'in', + 'any' => 1, + 'values' => [], + ], + 'properties' => [ + 'syncList' => 1, + 'multiple' => 0, + ], + ], + ], + ]; + $this->client->request(Request::METHOD_PATCH, "/api/forms/{$formId}/edit", $patchPayload); + $clientResponse = $this->client->getResponse(); + $response = json_decode($clientResponse->getContent(), true); + $fieldCount = $fieldCount + 1; + + $this->assertSame(Response::HTTP_OK, $clientResponse->getStatusCode(), $clientResponse->getContent()); + $this->assertSame($formId, $response['form']['id']); + $this->assertEquals('API form renamed', $response['form']['name']); $this->assertEquals($payload['description'], $response['form']['description']); - $this->assertIsArray($response['form']['fields']); - $this->assertCount(count($payload['fields']), $response['form']['fields']); - for ($i = 0; $i < count($payload['fields']); ++$i) { - $this->assertEquals($payload['fields'][$i]['label'], $response['form']['fields'][$i]['label']); - $this->assertEquals($payload['fields'][$i]['alias'], $response['form']['fields'][$i]['alias']); - $this->assertEquals($payload['fields'][$i]['type'], $response['form']['fields'][$i]['type']); - $this->assertEquals($payload['fields'][$i]['leadField'], $response['form']['fields'][$i]['leadField']); - } + $this->assertCount($fieldCount, $response['form']['fields']); + $this->assertEquals($payload['formType'], $response['form']['formType']); + $this->assertNotEmpty($response['form']['cachedHtml']); + + // Edit PUT: + $payload['description'] .= ' renamed'; + $payload['fields'] = []; // Set fields to an empty array as it would duplicate all fields. + $payload['postAction'] = 'return'; // Must be present for PUT as all empty values are being cleared. + $this->client->request(Request::METHOD_PUT, "/api/forms/{$formId}/edit", $payload); + $clientResponse = $this->client->getResponse(); + $response = json_decode($clientResponse->getContent(), true); + $this->assertSame(Response::HTTP_OK, $clientResponse->getStatusCode(), $clientResponse->getContent()); + $this->assertSame($formId, $response['form']['id']); + $this->assertEquals($payload['name'], $response['form']['name']); + $this->assertEquals('Form created via API test renamed', $response['form']['description']); + $this->assertCount($fieldCount, $response['form']['fields']); + $this->assertEquals($payload['formType'], $response['form']['formType']); + $this->assertNotEmpty($response['form']['cachedHtml']); + + // Get: + $this->client->request(Request::METHOD_GET, "/api/forms/{$formId}"); + $clientResponse = $this->client->getResponse(); + $response = json_decode($clientResponse->getContent(), true); + + $this->assertSame(Response::HTTP_OK, $clientResponse->getStatusCode(), $clientResponse->getContent()); + $this->assertSame($formId, $response['form']['id']); + $this->assertEquals($payload['name'], $response['form']['name']); + $this->assertEquals($payload['description'], $response['form']['description']); + $this->assertCount($fieldCount, $response['form']['fields']); + $this->assertEquals($payload['formType'], $response['form']['formType']); + $this->assertNotEmpty($response['form']['cachedHtml']); + + // Submit the form: + $crawler = $this->client->request(Request::METHOD_GET, "/form/{$formId}"); + $formCrawler = $crawler->filter('form[id=mauticform_apiform]'); + $this->assertSame(1, $formCrawler->count()); + $form = $formCrawler->form(); + $form->setValues([ + 'mauticform[email]' => 'john@doe.test', + 'mauticform[number]' => '123', + 'mauticform[company]' => 'Doe Corp', + 'mauticform[phone]' => '+420444555666', + 'mauticform[country]' => 'Czech Republic', + 'mauticform[state]' => 'Plzeňský kraj', + 'mauticform[multiselect]' => ['two'], + ]); + $this->client->submit($form); + + // Ensure the submission was created properly. + $submissions = $this->em->getRepository(Submission::class)->findAll(); + + Assert::assertCount(1, $submissions); + + /** @var Submission $submission */ + $submission = $submissions[0]; + Assert::assertSame([ + 'email' => 'john@doe.test', + 'number' => 123.0, + 'company' => 'Doe Corp', + 'phone' => '+420444555666', + 'country' => 'Czech Republic', + 'multiselect' => 'two', + 'state' => 'Plzeňský kraj', + ], $submission->getResults()); + + // A contact should be created by the submission. + $contact = $submission->getLead(); + + Assert::assertSame('john@doe.test', $contact->getEmail()); + Assert::assertSame('Czech Republic', $contact->getCountry()); + Assert::assertSame('Plzeňský kraj', $contact->getState()); + Assert::assertSame(123, $contact->getPoints()); + Assert::assertSame('Doe Corp', $contact->getCompany()); + + $companies = $this->em->getRepository(Company::class)->findAll(); + + Assert::assertCount(1, $companies); + + // A company should be created by the submission. + /** @var Company $company */ + $company = $companies[0]; + Assert::assertSame('Doe Corp', $company->getName()); + Assert::assertSame('+420444555666', $company->getPhone()); + + // The previous request changes user to anonymous. We have to configure API again. + $this->setUpSymfony( + [ + 'api_enabled' => true, + 'api_enable_basic_auth' => true, + 'create_custom_field_in_background' => false, + 'mailer_from_name' => 'Mautic', + 'db_driver' => 'pdo_mysql', + ] + ); // Delete: - $this->client->request('DELETE', "/api/forms/{$formId}/delete"); + $this->client->request(Request::METHOD_DELETE, "/api/forms/{$formId}/delete"); $clientResponse = $this->client->getResponse(); - $this->assertSame(Response::HTTP_OK, $clientResponse->getStatusCode()); + $response = json_decode($clientResponse->getContent(), true); + + $this->assertSame(Response::HTTP_OK, $clientResponse->getStatusCode(), $clientResponse->getContent()); + $this->assertNull($response['form']['id']); + $this->assertEquals($payload['name'], $response['form']['name']); + $this->assertEquals($payload['description'], $response['form']['description']); + $this->assertCount($fieldCount, $response['form']['fields']); + $this->assertEquals($payload['formType'], $response['form']['formType']); + $this->assertNotEmpty($response['form']['cachedHtml']); - // Get (ensure it's deleted): - $this->client->request('GET', "/api/forms/{$formId}"); + // Get (ensure that the form is gone): + $this->client->request(Request::METHOD_GET, "/api/forms/{$formId}"); $clientResponse = $this->client->getResponse(); $response = json_decode($clientResponse->getContent(), true); - $this->assertSame(404, $clientResponse->getStatusCode()); - $this->assertSame(404, $response['errors'][0]['code']); + $this->assertSame(Response::HTTP_NOT_FOUND, $clientResponse->getStatusCode(), $clientResponse->getContent()); + $this->assertSame(Response::HTTP_NOT_FOUND, $response['errors'][0]['code']); } public function testFormWithChangeTagsAction() diff --git a/app/bundles/FormBundle/Tests/Controller/FieldControllerFunctionalTest.php b/app/bundles/FormBundle/Tests/Controller/FieldControllerFunctionalTest.php index ae398ab38c9..d134a87d1a0 100644 --- a/app/bundles/FormBundle/Tests/Controller/FieldControllerFunctionalTest.php +++ b/app/bundles/FormBundle/Tests/Controller/FieldControllerFunctionalTest.php @@ -14,6 +14,21 @@ final class FieldControllerFunctionalTest extends MauticMysqlTestCase { protected $useCleanupRollback = false; + public function testNewEmailFieldFormIsPreMapped(): void + { + $this->client->request( + Request::METHOD_GET, + '/s/forms/field/new?type=email&tmpl=field&formId=temporary_form_hash&inBuilder=1', + [], + [], + $this->createAjaxHeaders() + ); + $clientResponse = $this->client->getResponse(); + $payload = json_decode($clientResponse->getContent(), true); + Assert::assertSame(Response::HTTP_OK, $clientResponse->getStatusCode()); + Assert::assertStringContainsString('