<?php
namespace App\Service;
use App\Entity\Contracts\FormInterface;
use App\Entity\Form;
use App\Entity\FormLeadData;
use App\Entity\MasterForm;
use App\Form\CollectionJsonType;
use App\Validator\ExclusiveChoices;
use App\Validator\UniqueLeadDataBy;
use DateTime;
use Exception;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class JsonFormManager
{
/** @var Filesystem $filesystem */
private $filesystem;
/** @var array $formDefinition */
private $formDefinition;
/** @var array|null Definición ya construida; se invalida al cambiar form, query params o fichero */
private $builtFormDefinition;
/** @var ParameterBagInterface $params */
private $params;
/** @var ValidatorInterface $validator */
private $validator;
/** @var Form $form */
private $form;
/** @var array $queryParams */
private $queryParams = [];
public const FOLDER_NAME = 'forms';
public const GROUP_TYPE = 'group';
public const TEXT_TYPE = 'text';
public const TEXTAREA_TYPE = 'textarea';
public const EMAIL_TYPE = 'email';
public const NUMBER_TYPE = 'number';
public const SELECT_TYPE = 'select';
public const CHECKBOX_TYPE = 'checkbox';
public const RADIO_TYPE = 'radio';
public const DATE_TYPE = 'date';
public const DATETIME_TYPE = 'datetime';
public const HIDDEN_TYPE = 'hidden';
public const COLLECTION_TYPE = 'collection';
public const SUBMIT_TYPE = 'submit';
public const SUPPORTED_TYPES = [
self::GROUP_TYPE,
self::TEXT_TYPE,
self::TEXTAREA_TYPE,
self::EMAIL_TYPE,
self::NUMBER_TYPE,
self::SELECT_TYPE,
self::CHECKBOX_TYPE,
self::RADIO_TYPE,
self::DATE_TYPE,
self::DATETIME_TYPE,
self::HIDDEN_TYPE,
self::COLLECTION_TYPE,
self::SUBMIT_TYPE
];
public const SELECTABLE_TYPES = [
self::SELECT_TYPE,
self::CHECKBOX_TYPE,
self::RADIO_TYPE
];
public const ADDABLE_TYPES = [
self::TEXT_TYPE => self::TEXT_TYPE,
self::TEXTAREA_TYPE => self::TEXTAREA_TYPE,
self::EMAIL_TYPE => self::EMAIL_TYPE,
self::NUMBER_TYPE => self::NUMBER_TYPE,
// TODO
// self::SELECT_TYPE => self::SELECT_TYPE,
// self::CHECKBOX_TYPE => self::CHECKBOX_TYPE,
// self::RADIO_TYPE => self::RADIO_TYPE,
self::DATE_TYPE => self::DATE_TYPE,
self::DATETIME_TYPE => self::DATETIME_TYPE,
self::HIDDEN_TYPE => self::HIDDEN_TYPE
];
public const VALUABLE_TYPES = [
self::TEXT_TYPE => self::TEXT_TYPE,
self::TEXTAREA_TYPE => self::TEXTAREA_TYPE,
self::EMAIL_TYPE => self::EMAIL_TYPE,
self::NUMBER_TYPE => self::NUMBER_TYPE,
self::SELECT_TYPE => self::SELECT_TYPE,
self::CHECKBOX_TYPE => self::CHECKBOX_TYPE,
self::RADIO_TYPE => self::RADIO_TYPE,
self::DATE_TYPE => self::DATE_TYPE,
self::DATETIME_TYPE => self::DATETIME_TYPE,
self::HIDDEN_TYPE => self::HIDDEN_TYPE
];
// Delimiters
public const FIELDNAME_DELIMITER = '_';
public const CHECKBOX_VIEW_DELIMITER = ' | ';
public const INCOMPATIBLE_FIELD_DELIMITER = '__';
public const COLLECTION_INDEX_DELIMITER = '#';
/** Tope defensivo de elementos por apartado repetible al expandir columnas y celdas */
public const MAX_COLLECTION_ITEMS = 50;
/**
* @param Filesystem $filesystem
* @param ParameterBagInterface $params
* @param ValidatorInterface $validator
*/
public function __construct(Filesystem $filesystem, ParameterBagInterface $params, ValidatorInterface $validator)
{
$this->filesystem = $filesystem;
$this->params = $params;
$this->validator = $validator;
}
/**
* @return FormInterface|null
*/
public function getForm(): ?FormInterface
{
return $this->form;
}
/**
* @param FormInterface $form
* @return self
*/
public function setForm(FormInterface $form): self
{
$this->form = $form;
$this->builtFormDefinition = null;
return $this;
}
/**
* @param array $queryParams
* @return $this
*/
public function setQueryParams(array $queryParams): self
{
$this->queryParams = $queryParams;
$this->builtFormDefinition = null;
return $this;
}
/**
* @param string $filePath
* @return $this
*/
public function loadFromFilePath(string $filePath): JsonFormManager
{
$this->builtFormDefinition = null;
$filePath = $this->getBasePath() . "/" . $filePath;
if (!$this->filesystem->exists($filePath)) {
throw new FileNotFoundException();
}
$this->formDefinition = json_decode(file_get_contents($filePath), true);
if ($this->form instanceof MasterForm) {
return $this;
}
if ($formFieldAddings = $this->form->getFormFieldAddings()) {
foreach ($formFieldAddings as $adding) {
// TODO group 0 only
$this->formDefinition[0]['fields'][] = [
'type' => $adding->getType(),
'common' => [
'name' => $adding->getName(),
'label' => $adding->getLabel()
],
'attr' => [],
'constraints' => []
];
}
}
$this->addFieldToDefinition();
return $this;
}
public function getFromFormLead(Type $var = null)
{
# code...
}
/**
* @return array|array[]
* @throws Exception
*/
public function getFormDefinition(): array
{
if (!$this->formDefinition) {
throw new Exception('No se puede obtener la definición del formulario sin cargar antes el archvo de definición');
}
if (null !== $this->builtFormDefinition) {
return $this->builtFormDefinition;
}
return $this->builtFormDefinition = array_map(function($value) {
return $this->buildFormItem($value);
}, $this->formDefinition);
}
/**
* @param array $formItem
* @return array
* @throws Exception
*/
private function buildFormItem(array $formItem): array
{
if (!$this->formTypeIsSupported($formItem['type'])) {
throw new Exception(sprintf('El tipo %s no está soportado', $formItem['type']));
}
if ($formItem['type'] == self::GROUP_TYPE) {
$nativeFields = [];
foreach ($formItem['fields'] as $formSubItem) {
$nativeFields[] = $this->buildFormItem($formSubItem);
}
return [
'name' => $formItem['name'],
'class' => FormType::class,
'options' => [
'label' => $formItem['title'],
'inherit_data' => true
],
'fields' => $nativeFields,
'attr' => isset($formItem['attr']) ? $formItem['attr'] : []
];
}
return $this->configureNativeFormItemByType($this->buildNativeFormItem($formItem), $formItem);
}
/**
* @param array $formItem
* @return array
* @throws Exception
*/
private function buildNativeFormItem(array $formItem): array
{
$nativeFormItem = [
'name' => self::buildFieldName($formItem['common']['name'], $formItem['type']),
'options' => [
'label' => $formItem['common']['label'],
'attr' => $formItem['attr'],
'label_html' => true
]
];
if (isset($formItem['common']['help'])) {
$nativeFormItem['options']['help'] = $formItem['common']['help'];
}
if (isset($formItem['attr']['disabled'])) {
$nativeFormItem['options']['disabled'] = $formItem['attr']['disabled'];
}
if (isset($formItem['constraints']['required'])) {
$nativeFormItem['options']['required'] = $formItem['constraints']['required'];
}
// Check if persist field
if (isset($formItem['bind'])) {
$nativeFormItem['options']['attr']['bind'] = $formItem['bind'];
}
// Check if unique
if (isset($formItem['constraints']['unique'])) {
$nativeFormItem['options']['attr']['unique'] = $formItem['constraints']['unique'];
}
if (self::COLLECTION_TYPE === $formItem['type']) {
$nativeFormItem = $this->buildNativeCollection($nativeFormItem, $formItem);
}
// Constraints
$nativeFormItem = $this->buildConstraints($nativeFormItem, $formItem);
// Opciones exclusivas: además del JS, se valida en servidor que no vengan con otras
if (!empty($formItem['attr']['clean-choices-value'])) {
$nativeFormItem['options']['constraints'][] = new ExclusiveChoices(
explode('|', (string) $formItem['attr']['clean-choices-value'])
);
}
// Conditioned fields
return $this->getConditionedFields($nativeFormItem, $formItem);
}
/**
* @param array $nativeFormItem
* @param array $formItem
* @return array
* @throws Exception
*/
private function buildNativeCollection(array $nativeFormItem, array $formItem): array
{
// Min number of elements (for type collections)
if (isset($formItem['constraints']['min_items'])) {
if (!is_numeric($formItem['constraints']['min_items'])) {
throw new Exception($formItem['constraints']['min_items'] . ' debe ser un número entero');
}
$nativeFormItem['options']['attr']['min_items'] = $formItem['constraints']['min_items'];
}
// Max number of elements (for type collections)
if (isset($formItem['constraints']['max_items'])) {
if (!is_numeric($formItem['constraints']['max_items'])) {
throw new Exception($formItem['constraints']['max_items'] . ' debe ser un número entero');
}
$nativeFormItem['options']['attr']['max_items'] = $formItem['constraints']['max_items'];
}
// Add label text (for type collections)
if (isset($formItem['constraints']['add_label'])) {
if (!is_string($formItem['constraints']['add_label'])) {
throw new Exception($formItem['constraints']['add_label'] . ' debe ser texto');
}
$nativeFormItem['options']['attr']['add_label'] = $formItem['constraints']['add_label'];
}
// Remove label text (for type collections)
if (isset($formItem['constraints']['remove_label'])) {
if (!is_string($formItem['constraints']['remove_label'])) {
throw new Exception($formItem['constraints']['remove_label'] . ' debe ser texto');
}
$nativeFormItem['options']['attr']['remove_label'] = $formItem['constraints']['remove_label'];
}
return $nativeFormItem;
}
/**
* @param array $nativeFormItem
* @param array $formItem
* @return array
* @throws Exception
*/
private function configureNativeFormItemByType(array $nativeFormItem, array $formItem): array
{
switch ($formItem['type']) {
case self::TEXT_TYPE:
$nativeFormItem['class'] = TextType::class;
break;
case self::TEXTAREA_TYPE:
$nativeFormItem['class'] = TextareaType::class;
break;
case self::EMAIL_TYPE:
$nativeFormItem['class'] = EmailType::class;
$nativeFormItem['options']['constraints'][] = new Email(['mode' => 'strict']);
break;
case self::NUMBER_TYPE:
$nativeFormItem['options']['html5'] = true;
$nativeFormItem['class'] = NumberType::class;
break;
case self::SELECT_TYPE:
$nativeFormItem['options']['choices'] = $this->buildFieldOptions($formItem['choices']);
// Los catálogos de respuesta tienen que desplegarse en blanco: sin un
// placeholder explícito, un <select> obligatorio renderiza preseleccionada
// su primera opción y el usuario puede enviarla sin haberla elegido.
$nativeFormItem['options']['placeholder'] = $formItem['attr']['placeholder'] ?? '';
if (!empty($formItem['constraints']['required'])) {
// `required` en Symfony es solo presentación, y el formulario se renderiza
// con novalidate (la validación de navegador la lleva el JS), así que la
// obligatoriedad de un select en blanco hay que comprobarla en servidor.
$nativeFormItem['options']['constraints'][] = new NotBlank();
}
if (isset($formItem['attr']['value'])) {
$nativeFormItem['options']['data'] = $formItem['attr']['value'];
}
$nativeFormItem['class'] = ChoiceType::class;
break;
case self::CHECKBOX_TYPE:
$nativeFormItem['options']['expanded'] = true;
$nativeFormItem['options']['multiple'] = true;
$nativeFormItem['options']['choices'] = $this->buildFieldOptions($formItem['choices']);
$data = [];
foreach ($formItem['choices'] as $choice) {
if (isset($choice['checked']) && $choice['checked']) {
$data[$choice['label']] = $choice['value'];
}
}
if (!empty($data)) {
$nativeFormItem['options']['data'] = $data;
}
$nativeFormItem['class'] = ChoiceType::class;
break;
case self::RADIO_TYPE:
$nativeFormItem['options']['expanded'] = true;
$nativeFormItem['options']['multiple'] = false;
$nativeFormItem['options']['choices'] = $this->buildFieldOptions($formItem['choices']);
if (isset($formItem['attr']['value'])) {
$nativeFormItem['options']['data'] = $formItem['attr']['value'];
}
$nativeFormItem['class'] = ChoiceType::class;
break;
case self::DATE_TYPE:
$nativeFormItem['options']['widget'] = 'single_text';
$nativeFormItem['options']['html5'] = true;
$nativeFormItem['class'] = DateType::class;
break;
case self::DATETIME_TYPE:
$nativeFormItem['options']['widget'] = 'single_text';
$nativeFormItem['options']['html5'] = true;
$nativeFormItem['class'] = DateTimeType::class;
break;
case self::HIDDEN_TYPE:
$nativeFormItem['class'] = HiddenType::class;
if (isset($formItem['attr']['value'])) {
$nativeFormItem['options']['data'] = $formItem['attr']['value'];
}
break;
case self::COLLECTION_TYPE:
$items = [];
foreach ($formItem['items'] as $item) {
$items[] = $this->buildFormItem($item);
}
$nativeFormItem['class'] = CollectionType::class;
$nativeFormItem['options']['entry_type'] = CollectionJsonType::class;
$nativeFormItem['options']['entry_options'] = ['collection' => $items];
$nativeFormItem['options']['allow_add'] = true;
$nativeFormItem['options']['allow_delete'] = true;
break;
case self::SUBMIT_TYPE:
$nativeFormItem['class'] = SubmitType::class;
break;
default:
break;
}
return $nativeFormItem;
}
/**
* @param string $type
* @return bool
*/
public static function formTypeIsSupported(string $type): bool
{
return in_array($type, self::SUPPORTED_TYPES);
}
/**
* @param array $choices
* @param bool $fieldIsRequired
* @return array
*/
private function buildFieldOptions(array $choices, bool $fieldIsRequired = false): array
{
$nativeChoices = [];
foreach ($choices as $choice) {
$nativeChoices[$choice['label']] = $choice['value'];
}
return $nativeChoices;
}
/**
* @param string $fieldName
* @param $fieldValue
* @return string
* @throws Exception
*/
public static function formFieldResponseToDb(string $fieldName, $fieldValue): string
{
$type = self::getTypeFromFieldName($fieldName);
if (!self::formTypeIsSupported($type)) {
throw new Exception(sprintf('El tipo %s no está soportado', $type));
}
switch ($type) {
case self::TEXT_TYPE:
case self::TEXTAREA_TYPE:
case self::EMAIL_TYPE:
case self::SELECT_TYPE:
case self::RADIO_TYPE:
return $fieldValue;
case self::NUMBER_TYPE:
return (str_replace(',', '.', $fieldValue));
case self::CHECKBOX_TYPE:
return serialize($fieldValue);
case self::DATE_TYPE:
return $fieldValue->format('Y-m-d');
case self::DATETIME_TYPE:
return $fieldValue->format('Y-m-d H:i:s');
default:
break;
}
return $fieldValue;
}
/**
* @param $field
* @param $type
* @return string
* @throws Exception
*/
public static function formFieldDbItemToPrint($field, $type): string
{
if (!self::formTypeIsSupported($type)) {
throw new Exception(sprintf('El tipo %s no está soportado', $type));
}
switch ($type) {
case self::TEXT_TYPE:
case self::TEXTAREA_TYPE:
case self::EMAIL_TYPE:
case self::SELECT_TYPE:
case self::RADIO_TYPE:
return $field;
case self::NUMBER_TYPE:
return str_replace(".", ",", $field);
case self::CHECKBOX_TYPE:
$unserialize = unserialize($field);
if (is_string($unserialize) || $field === "") {
return $unserialize;
}
return implode(self::CHECKBOX_VIEW_DELIMITER, $unserialize);
case self::DATE_TYPE:
return (new DateTime($field))->format('d-m-Y');
case self::DATETIME_TYPE:
return (new DateTime($field))->format('d-m-Y H:i');
default:
break;
}
return $field;
}
/**
* @param string $fieldName
* @return string
*/
public static function getNameFromFieldName(string $fieldName): string
{
return self::explodeFieldName($fieldName, 0);
}
/**
* @param string $fieldName
* @return string
*/
public static function getTypeFromFieldName(string $fieldName): string
{
return self::explodeFieldName($fieldName, 1);
}
/**
* @return array
* @throws Exception
*/
public function getFormDefinitionFields(): array
{
$result = [];
foreach ($this->getFormDefinition() as $item) {
if (!$this->formItemIsGroup($item)) {
// Is field
$result[] = $item;
continue;
}
foreach ($item['fields'] as $field) {
$result[] = $field;
}
}
return $result;
}
/**
* @param array $formData
* @param array $formDefinitionFields
* @return array
*/
public function getFormDataInFormDefinitionFields(array $formData, array $formDefinitionFields): array
{
foreach ($formData as $key => $value) {
foreach ($formDefinitionFields as $field) {
if ($field['name'] !== $key) {
continue;
}
// TODO: hardcoded
$formData = $this->processPayment($formData, $field, $key, $value);
// Check not binding fields
if (isset($field['options']['attr']['bind']) && !$field['options']['attr']['bind']) {
unset($formData[$key]);
}
}
}
return $formData;
}
/**
* @param string $incompatibleValueString
* @return mixed|string
*/
public function getIncompatibleGroup(string $incompatibleValueString)
{
return $this->getExplodedIncompatibleField($incompatibleValueString)[0];
}
/**
* @param string $incompatibleValueString
* @return false|string[]
*/
public function getExplodedIncompatibleField(string $incompatibleValueString)
{
return explode(self::INCOMPATIBLE_FIELD_DELIMITER, $incompatibleValueString);
}
/**
* @param string $value
* @return string
*/
public static function unFormattedHtml(string $value): string
{
return $value != strip_tags($value) ? strip_tags($value) : $value;
}
/**
* @param array $formItem
* @return bool
*/
private function formItemIsGroup(array $formItem): bool
{
return isset($formItem['fields']);
}
/**
* @param array $nativeFormItem
* @param array $formItem
* @return array
* @throws Exception
*/
private function buildConstraints(array $nativeFormItem, array $formItem): array
{
if (
!isset($formItem['constraints']['validators']) &&
!isset($formItem['constraints']['unique_by']) &&
!isset($formItem['constraints']['incompatible_with'])
) {
return $nativeFormItem;
}
if (isset($formItem['constraints']['unique_by'])) {
$nativeFormItem = $this->buildConstraintUniqueBy($nativeFormItem, $formItem);
}
if (isset($formItem['constraints']['incompatible_with'])) {
$nativeFormItem = $this->buildIncompatibleField($nativeFormItem, $formItem);
}
if (isset($formItem['constraints']['validators'])) {
$nativeFormItem = $this->buildConstraintValidators($nativeFormItem, $formItem);
}
return $nativeFormItem;
}
/**
* @param array $nativeFormItem
* @param array $formItem
* @return array
*/
private function buildConstraintUniqueBy(array $nativeFormItem, array $formItem): array
{
$entity = null;
$campaign = $this->getForm()->getCampaign();
$company = $campaign->getCompany();
switch ($formItem['constraints']['unique_by']) {
case $this->getForm()->getCampaign()->getClass():
$entity = $campaign;
break;
case $this->getForm()->getCampaign()->getCompany()->getClass():
$entity = $company;
}
$nativeFormItem['options']['constraints'][] = new UniqueLeadDataBy(['entity' => $entity]);
return $nativeFormItem;
}
/**
* @param array $nativeFormItem
* @param array $formItem
* @return array
*/
private function buildIncompatibleField(array $nativeFormItem, array $formItem): array
{
$indexes = ['group', 'name', 'type'];
foreach ($formItem['constraints']['incompatible_with'] as $key => $value) {
$value = '';
foreach ($indexes as $index) {
$value .= $formItem['constraints']['incompatible_with'][$key][$index] . self::INCOMPATIBLE_FIELD_DELIMITER;
}
$nativeFormItem['options']['attr']["incompatible-with-$key"] = substr(
$value,
0,
-strlen(self::INCOMPATIBLE_FIELD_DELIMITER)
);
}
return $nativeFormItem;
}
/**
* @param array $nativeFormItem
* @param array $formItem
* @return array
* @throws Exception
*/
private function buildConstraintValidators(array $nativeFormItem, array $formItem): array
{
foreach ($formItem['constraints']['validators'] as $validator => $params) {
$validatorNamespaces = [
'App\Validator',
'\Symfony\Component\Validator\Constraints'
];
$className = null;
foreach ($validatorNamespaces as $validatorNamespace) {
$className = $validatorNamespace . '\\' . $validator;
if ($this->validator->hasMetadataFor($className)) {
break;
}
}
if (!$className) {
throw new Exception();
}
if (!empty($params)) {
$nativeFormItem['options']['constraints'][] = new $className($params);
} else {
$nativeFormItem['options']['constraints'][] = new $className();
}
}
return $nativeFormItem;
}
/**
* @param string $fieldName
* @param int $index
* @return string
*/
private static function explodeFieldName(string $fieldName, int $index): string
{
return explode(self::FIELDNAME_DELIMITER, $fieldName)[$index];
}
/**
* @param string $name
* @param string $type
* @return string
*/
public static function buildFieldName(string $name, string $type): string
{
return $name . self::FIELDNAME_DELIMITER . $type;
}
/**
* Clave de agrupación de FormLeadData: name_type, y name_type#N para el elemento N de un
* apartado repetible.
*
* Los elementos de una colección comparten field y type, así que agrupar solo por
* name_type los interpretaba como versiones sucesivas del mismo campo y se quedaba
* únicamente con el último.
*
* @param string $name
* @param string $type
* @param int|null $collectionIndex
* @return string
*/
public static function buildLeadDataKey(string $name, string $type, ?int $collectionIndex = null): string
{
$fieldName = self::buildFieldName($name, $type);
return null === $collectionIndex
? $fieldName
: $fieldName . self::COLLECTION_INDEX_DELIMITER . $collectionIndex;
}
/**
* name_type de una clave de buildLeadDataKey(), sin el índice de colección.
*
* @param string $key
* @return string
*/
public static function getFieldNameFromLeadDataKey(string $key): string
{
return explode(self::COLLECTION_INDEX_DELIMITER, $key)[0];
}
/**
* Índices presentes por subcampo de colección, a partir del valor vigente de cada clave.
*
* Un elemento vaciado al editar (se reduce el número de elementos) deja como vigente un
* valor '' y no cuenta como presente.
*
* @param iterable<string, FormLeadData> $latestByKey Vigente por buildLeadDataKey()
* @return array<string, int[]> name_type => índices
*/
public static function collectCollectionIndexes(iterable $latestByKey): array
{
$result = [];
foreach ($latestByKey as $data) {
if (null === $data->getCollectionIndex() || '' === (string) $data->getValue()) {
continue;
}
$result[self::buildFieldName($data->getField(), $data->getType())][] = $data->getCollectionIndex();
}
return $result;
}
/**
* Sustituye en el esquema cada apartado repetible por una entrada por elemento y subcampo.
*
* buildFieldsSchema() aporta una sola entrada por subcampo de colección, pero cuántos
* elementos hay lo decide cada registro. El orden resultante es por elemento ("Nombre 1",
* "Edad 1", "Nombre 2"...), igual que se guardan, con el ordinal en la etiqueta como hace
* el alta. Las entradas expandidas se indexan con buildLeadDataKey() y llevan
* 'collectionIndex'; el resto del esquema pasa tal cual.
*
* @param array<string, array> $schema
* @param array<string, int[]> $indexesByField name_type => índices presentes
* @param int $cap
* @return array<string, array>
*/
public static function expandCollectionSchema(array $schema, array $indexesByField, int $cap = self::MAX_COLLECTION_ITEMS): array
{
// Subcampos de cada colección, en el orden del esquema
$collections = [];
foreach ($schema as $fieldName => $meta) {
if (!empty($meta['collection'])) {
$collections[$meta['collection']][$fieldName] = $meta;
}
}
$result = [];
$expanded = [];
foreach ($schema as $fieldName => $meta) {
$collection = $meta['collection'] ?? null;
if (!$collection) {
$result[$fieldName] = $meta;
continue;
}
if (isset($expanded[$collection])) {
continue;
}
$expanded[$collection] = true;
$indexes = [];
foreach (array_keys($collections[$collection]) as $subFieldName) {
foreach ($indexesByField[$subFieldName] ?? [] as $index) {
$indexes[$index] = $index;
}
}
sort($indexes);
foreach (array_slice($indexes, 0, $cap) as $index) {
foreach ($collections[$collection] as $subFieldName => $subMeta) {
$key = self::buildLeadDataKey($subMeta['field'], $subMeta['type'], $index);
$result[$key] = array_merge($subMeta, [
'label' => $subMeta['label'] . ' ' . $index,
'collectionIndex' => $index,
]);
}
}
}
return $result;
}
/**
* @param string $name
* @param string $type
* @param string $label
* @param string $groupLabel
* @return string
*/
public static function buildFieldIndexLabel(string $name, string $type, string $label, string $groupLabel): string
{
return
$name . self::FIELDNAME_DELIMITER .
$type . self::FIELDNAME_DELIMITER .
self::unFormattedHtml($label) . self::FIELDNAME_DELIMITER .
self::unFormattedHtml($groupLabel);
}
/**
* @return array
* @throws Exception
*/
public function buildCsvHeader(): array
{
$result = [];
foreach ($this->buildFieldsSchema() as $index => $item) {
$result[$index]['label'] = $item['label'];
$result[$index]['group_label'] = $item['groupLabel'];
}
return $result;
}
/**
* Campos persistibles de la definición vigente, en el orden en que aparecen en el JSON.
*
* Es la fuente única de verdad para saber qué campos tiene el formulario ahora mismo,
* frente a las etiquetas que quedaron guardadas en FormLeadData al enviarlo.
*
* La clave es buildFieldName() ("empresa_text"), que es la clave natural de FormLeadData
* y la única estable: hay grupos con varios campos que comparten etiqueta (cinco "Otros"
* en PRINCIPIO 6), así que indexar por label los colapsaría en uno.
*
* Los subcampos de un apartado repetible aportan una sola entrada, marcada con el nombre
* de su colección en 'collection'; expandCollectionSchema() la desdobla por elemento.
*
* @return array<string, array{field: string, type: string, label: string, groupLabel: string, groupName: string, collection: string|null}>
* @throws Exception
*/
public function buildFieldsSchema(): array
{
$result = [];
foreach ($this->getFormDefinition() as $item) {
if (!$this->formItemIsGroup($item)) {
// Campo suelto, fuera de grupo
$this->appendFieldToSchema($result, $item, '', '');
continue;
}
$groupLabel = self::unFormattedHtml((string) ($item['options']['label'] ?? ''));
foreach ($item['fields'] as $field) {
$this->appendFieldToSchema($result, $field, $groupLabel, (string) ($item['name'] ?? ''));
}
}
return $result;
}
/**
* @param array $result
* @param array $field
* @param string $groupLabel
* @param string $groupName
* @param string|null $collection Colección a la que pertenece el campo, si es un subcampo
* @return void
*/
private function appendFieldToSchema(
array &$result,
array $field,
string $groupLabel,
string $groupName,
?string $collection = null
): void {
$fieldName = $field['name'];
$type = self::getTypeFromFieldName($fieldName);
// Las colecciones aportan una entrada por cada campo de su item
if (self::COLLECTION_TYPE === $type) {
foreach ($field['options']['entry_options']['collection'] ?? [] as $entry) {
$this->appendFieldToSchema($result, $entry, $groupLabel, $groupName, $fieldName);
}
return;
}
// Fuera lo que no llega a BD: submit, tipos no persistibles y campos con bind = false
if (!isset(self::VALUABLE_TYPES[$type])) {
return;
}
if (isset($field['options']['attr']['bind']) && !$field['options']['attr']['bind']) {
return;
}
$result[$fieldName] = [
'field' => self::getNameFromFieldName($fieldName),
'type' => $type,
'label' => $this->resolveSchemaLabel($field, $groupLabel),
'groupLabel' => $groupLabel,
'groupName' => $groupName,
'collection' => $collection,
];
}
/**
* common.label puede venir vacío o a false en el JSON; se cae al placeholder y, en último
* término, al propio nombre del campo, para que nunca quede una columna sin cabecera.
*
* @param array $field
* @return string
*/
private function resolveSchemaLabel(array $field, string $groupLabel = ''): string
{
// Un campo con "label": false (el título ya lo da su grupo) se etiqueta con el del grupo
$label = ($field['options']['label'] ?? null)
?: ($field['options']['attr']['placeholder'] ?? null)
?: $groupLabel;
return self::unFormattedHtml((string) ($label ?: self::getNameFromFieldName($field['name'])));
}
/**
* @return string
*/
public function getBasePath(): string
{
return $this->params->get('kernel.project_dir') . "/" . self::FOLDER_NAME;
}
/**
* @param array $nativeFormItem
* @param array $formItem
* @return array
*/
private function getConditionedFields(array $nativeFormItem, array $formItem): array
{
if (!in_array($formItem['type'], self::SELECTABLE_TYPES)) {
return $nativeFormItem;
}
if (!isset($formItem['choices'])) {
return $nativeFormItem;
}
$conditionedFields = [];
foreach ($formItem['choices'] as $keyChoice => $choice) {
if (!isset($choice['conditional_on']) || !$choice['conditional_on']) {
continue;
}
$conditionedFields[$keyChoice]['parent_value'] = $choice['value'];
foreach ($choice['conditional_on'] as $key => $item) {
$conditionedFields[$keyChoice]['conditioned_fields'][$key] = $item;
$conditionedFields[$keyChoice]['conditioned_fields'][$key]['field_name'] = self::buildNativeFieldName(
isset($this->findFieldGroup($formItem)['name']) ? $this->findFieldGroup($formItem)['name'] : '',
$item['field']['name'],
$item['field']['type']
);
unset($conditionedFields[$keyChoice]['conditioned_fields'][$key]['field']);
}
}
if (!empty($conditionedFields)) {
$nativeFormItem['options']['attr']['data-conditioned-fields'] = json_encode($conditionedFields, true);
}
return $nativeFormItem;
}
/**
* @param array $formItem
* @return array|mixed|void
*/
private function findFieldGroup(array $formItem)
{
foreach ($this->formDefinition as $group) {
if (!$this->formItemIsGroup($group)) {
continue;
}
foreach ($group['fields'] as $field) {
if ($field['type'] === $formItem['type'] && $field['common']['name'] === $formItem['common']['name']) {
return $group;
}
}
}
return [];
}
/**
* @param string $groupName
* @param string $name
* @param string $type
* @return string
*/
private function buildNativeFieldName(string $groupName, string $name, string $type): string
{
return 'json_' . $groupName . self::FIELDNAME_DELIMITER . self::buildFieldName($name, $type);
}
/**
* @return void
*/
private function addFieldToDefinition()
{
if (!$this->queryParams) {
return;
}
foreach ($this->queryParams as $key => $param) {
foreach ($this->formDefinition as $keyGroup => $group) {
if (!$this->formItemIsGroup($group)) {
continue;
}
foreach ($group['fields'] as $keyField => $field) {
if ($key !== $field['common']['name']) {
continue;
}
$value = null;
$readOnly = false;
switch ($field['type']) {
case self::EMAIL_TYPE:
case self::TEXT_TYPE:
case self::NUMBER_TYPE:
$value = $param;
$readOnly = true;
break;
case self::HIDDEN_TYPE:
$value = $param;
break;
case self::DATE_TYPE:
if (DateTimeService::isDate($param)) {
$value = $param;
$readOnly = true;
}
break;
case self::DATETIME_TYPE:
if (DateTimeService::isDateTime($param)) {
$value = $param;
$readOnly = true;
}
break;
case self::RADIO_TYPE:
case self::SELECT_TYPE:
if (!isset($field['choices'])) {
break;
}
foreach ($field['choices'] as $choice) {
if ($choice['value'] !== $param) {
continue;
}
$value = $param;
$readOnly = true;
break;
}
break;
case self::CHECKBOX_TYPE:
$value = $param;
$readOnly = true;
$this->formDefinition[$keyGroup]['fields'][$keyField]['choices'][0]['label'] = $param;
break;
}
if (!$value) {
continue;
}
$this->formDefinition[$keyGroup]['fields'][$keyField]['attr']['value'] = $value;
if ($readOnly) {
$this->formDefinition[$keyGroup]['fields'][$keyField]['attr']['readonly'] = true;
}
}
}
}
}
/**
* @TODO: hardcoded
* @param array $formData
* @param array $field
* @param string $key
* @param $value
* @return array
*/
private function processPayment(array $formData, array $field, string $key, $value): array
{
$paymentExentionKey = 'aportacion-economica-exenciones_checkbox';
if ($paymentExentionKey === $field['name'] && !$value) {
$formData[$key] = ['Ninguno'];
return $formData;
}
if ($paymentExentionKey === $field['name'] && $value) {
$formData['preferencia-pago-aportacion-economica_radio'] = '-';
$formData['preferencia-domiciliar-pago-aportacion-economica_radio'] = '-';
}
return $formData;
}
}