Compare commits

...

4 Commits

Author SHA1 Message Date
Eric Espie
4ada74e63f Debug data within the form 2025-10-22 17:37:38 +02:00
Eric Espie
0ccb452ab7 merge 2025-10-22 16:55:09 +02:00
Benjamin Dalsass
0dae7346d1 N°8772 - Form dependencies manager implementation
- turbo implementation
2025-10-20 15:16:44 +02:00
Benjamin Dalsass
cdfded766f N°8772 - Form dependencies manager implementation 2025-10-17 09:03:45 +02:00
15 changed files with 1090 additions and 2 deletions

View File

@@ -6,4 +6,44 @@
.ibo-prop-header {
@extend %ibo-font-size-150;
padding-bottom: 14px;
}
}
.help-text{
padding: 1px 5px;
background-color: #d7e3f8;
border: 1px solid #c6e7f5;
border-radius: 5px;
margin: 5px 0;
font-size: 0.9em;
}
.form-error ul{
padding: 1px 5px;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 5px;
margin: 5px 0;
font-size: 0.9em;
}
.subform{
background-color: #efefef;
border-radius: 5px;
padding: 10px;
}
.form-buttons{
margin: 20px 0;
}
#form select{
padding: 0;
overflow-y: auto;
}
#form select option{
height: 30px;
display: flex;
align-items: center;
}

View File

@@ -473,6 +473,16 @@ return array(
'Combodo\\iTop\\Form\\Validator\\MultipleChoicesValidator' => $baseDir . '/sources/Form/Validator/MultipleChoicesValidator.php',
'Combodo\\iTop\\Form\\Validator\\NotEmptyExtKeyValidator' => $baseDir . '/sources/Form/Validator/NotEmptyExtKeyValidator.php',
'Combodo\\iTop\\Form\\Validator\\SelectObjectValidator' => $baseDir . '/sources/Form/Validator/SelectObjectValidator.php',
'Combodo\\iTop\\Forms\\Dependency\\DependencyDescription' => $baseDir . '/sources/Forms/Dependency/DependencyDescription.php',
'Combodo\\iTop\\Forms\\Dependency\\DependencyHandler' => $baseDir . '/sources/Forms/Dependency/DependencyHandler.php',
'Combodo\\iTop\\Forms\\FormBuilder\\FormBuilder' => $baseDir . '/sources/Forms/FormBuilder/FormBuilder.php',
'Combodo\\iTop\\Forms\\FormBuilder\\FormTypeExtension' => $baseDir . '/sources/Forms/FormBuilder/FormTypeExtension.php',
'Combodo\\iTop\\Forms\\FormBuilder\\ResolvedFormType' => $baseDir . '/sources/Forms/FormBuilder/ResolvedFormType.php',
'Combodo\\iTop\\Forms\\FormBuilder\\ResolvedFormTypeFactory' => $baseDir . '/sources/Forms/FormBuilder/ResolvedFormTypeFactory.php',
'Combodo\\iTop\\Forms\\FormType\\AttributeChoiceType' => $baseDir . '/sources/Forms/FormType/AttributeChoiceType.php',
'Combodo\\iTop\\Forms\\FormType\\AttributeValueChoiceType' => $baseDir . '/sources/Forms/FormType/AttributeValueChoiceType.php',
'Combodo\\iTop\\Forms\\FormType\\OqlType' => $baseDir . '/sources/Forms/FormType/OqlType.php',
'Combodo\\iTop\\Forms\\Forms' => $baseDir . '/sources/Forms/Forms.php',
'Combodo\\iTop\\Forms\\Twig\\Extension\\FormCompatibilityExtension' => $baseDir . '/sources/Forms/Twig/Extension/FormCompatibilityExtension.php',
'Combodo\\iTop\\PhpParser\\Evaluation\\PhpExpressionEvaluator' => $baseDir . '/sources/PhpParser/Evaluation/PhpExpressionEvaluator.php',
'Combodo\\iTop\\Renderer\\BlockRenderer' => $baseDir . '/sources/Renderer/BlockRenderer.php',

View File

@@ -854,6 +854,16 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f
'Combodo\\iTop\\Form\\Validator\\MultipleChoicesValidator' => __DIR__ . '/../..' . '/sources/Form/Validator/MultipleChoicesValidator.php',
'Combodo\\iTop\\Form\\Validator\\NotEmptyExtKeyValidator' => __DIR__ . '/../..' . '/sources/Form/Validator/NotEmptyExtKeyValidator.php',
'Combodo\\iTop\\Form\\Validator\\SelectObjectValidator' => __DIR__ . '/../..' . '/sources/Form/Validator/SelectObjectValidator.php',
'Combodo\\iTop\\Forms\\Dependency\\DependencyDescription' => __DIR__ . '/../..' . '/sources/Forms/Dependency/DependencyDescription.php',
'Combodo\\iTop\\Forms\\Dependency\\DependencyHandler' => __DIR__ . '/../..' . '/sources/Forms/Dependency/DependencyHandler.php',
'Combodo\\iTop\\Forms\\FormBuilder\\FormBuilder' => __DIR__ . '/../..' . '/sources/Forms/FormBuilder/FormBuilder.php',
'Combodo\\iTop\\Forms\\FormBuilder\\FormTypeExtension' => __DIR__ . '/../..' . '/sources/Forms/FormBuilder/FormTypeExtension.php',
'Combodo\\iTop\\Forms\\FormBuilder\\ResolvedFormType' => __DIR__ . '/../..' . '/sources/Forms/FormBuilder/ResolvedFormType.php',
'Combodo\\iTop\\Forms\\FormBuilder\\ResolvedFormTypeFactory' => __DIR__ . '/../..' . '/sources/Forms/FormBuilder/ResolvedFormTypeFactory.php',
'Combodo\\iTop\\Forms\\FormType\\AttributeChoiceType' => __DIR__ . '/../..' . '/sources/Forms/FormType/AttributeChoiceType.php',
'Combodo\\iTop\\Forms\\FormType\\AttributeValueChoiceType' => __DIR__ . '/../..' . '/sources/Forms/FormType/AttributeValueChoiceType.php',
'Combodo\\iTop\\Forms\\FormType\\OqlType' => __DIR__ . '/../..' . '/sources/Forms/FormType/OqlType.php',
'Combodo\\iTop\\Forms\\Forms' => __DIR__ . '/../..' . '/sources/Forms/Forms.php',
'Combodo\\iTop\\Forms\\Twig\\Extension\\FormCompatibilityExtension' => __DIR__ . '/../..' . '/sources/Forms/Twig/Extension/FormCompatibilityExtension.php',
'Combodo\\iTop\\PhpParser\\Evaluation\\PhpExpressionEvaluator' => __DIR__ . '/../..' . '/sources/PhpParser/Evaluation/PhpExpressionEvaluator.php',
'Combodo\\iTop\\Renderer\\BlockRenderer' => __DIR__ . '/../..' . '/sources/Renderer/BlockRenderer.php',

View File

@@ -26,6 +26,7 @@ use Combodo\iTop\Application\WebPage\ErrorPage;
use Combodo\iTop\Application\WebPage\iTopWebPage;
use Combodo\iTop\Application\WebPage\WebPage;
use Combodo\iTop\Controller\AbstractController;
use Combodo\iTop\Forms\Forms;
use Combodo\iTop\Service\InterfaceDiscovery\InterfaceDiscovery;
use Dict;
use Exception;
@@ -45,7 +46,6 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormFactoryBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormRenderer;
use Symfony\Component\Form\Forms;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Csrf\CsrfTokenManager;
use Twig\Error\SyntaxError;
@@ -496,6 +496,14 @@ abstract class Controller extends AbstractController
$sTemplateName = $this->m_sOperation;
}
$aParams = array_merge($this->GetDefaultParameters(), $aParams);
foreach (InterfaceDiscovery::GetInstance()->FindItopClasses(iProfilerExtension::class) as $sExtension) {
/** @var \Combodo\iTop\Application\TwigBase\Controller\iProfilerExtension $oExtensionInstance */
$oExtensionInstance = $sExtension::GetInstance();
if ($oExtensionInstance->IsEnabled()) {
$aParams = array_merge($aParams, $oExtensionInstance->GetDebugParams($aParams));
}
}
$this->CreatePage($sPageType);
$sHTMLContent = $this->RenderTemplate($aParams, $sTemplateName, 'html', $sErrorMsg);
if ($sHTMLContent !== false) {

View File

@@ -0,0 +1,107 @@
<?php
namespace Combodo\iTop\Forms\Dependency;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
class DependencyDescription
{
private array $aPostSetData = [];
private array $aPostSubmitData = [];
private bool $isAdded = false;
public function __construct(public readonly array $aDependencies, public readonly string|FormBuilderInterface $child, public readonly ?string $type = null, public readonly array $options = [])
{
}
public function IsAdded(): bool
{
return $this->isAdded;
}
public function SetAdded(bool $bAdded): void
{
$this->isAdded = $bAdded;
}
public function IsDataReady(string $sEventType): bool
{
$aData = ($sEventType === FormEvents::POST_SET_DATA) ? $this->aPostSetData : $this->aPostSubmitData;
foreach (array_keys($this->aDependencies) as $sInput){
if(!array_key_exists($sInput, $aData) || $aData[$sInput] == null){
return false;
}
}
return true;
}
public function IsPostSetDataReady(): bool
{
foreach ($this->aDependencies as $sData => $sValue) {
if (!array_key_exists($sData, $this->aPostSetData)) {
return false;
}
}
return true;
}
public function IsPostSubmitDataReady(): bool
{
foreach ($this->aDependencies as $sData => $sValue) {
if (!array_key_exists($sData, $this->aPostSubmitData)) {
return false;
}
}
return true;
}
public function SetData(string $sEventType, string $sData, mixed $oValue): void
{
if($oValue === null) return;
if($sEventType === FormEvents::POST_SET_DATA){
$this->aPostSetData[$sData] = $oValue;
}
else{
$this->aPostSubmitData[$sData] = $oValue;
}
}
public function GetData(string $sEventType): array
{
if($sEventType === FormEvents::POST_SET_DATA){
return $this->aPostSetData;
}
else{
return $this->aPostSubmitData;
}
}
public function SetPostSetData(string $sInput, mixed $oData): void
{
$this->aPostSetData[$sInput] = $oData;
}
public function SetPostSubmitData(string $sInput, mixed $oData): void
{
$this->aPostSubmitData[$sInput] = $oData;
}
public function IsReady(string $sEventType): bool
{
$aData = ($sEventType === FormEvents::POST_SET_DATA) ? $this->aPostSetData : $this->aPostSubmitData;
foreach (array_keys($this->aDependencies) as $sInput){
if(!array_key_exists($sInput, $aData)){
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,173 @@
<?php
namespace Combodo\iTop\Forms\Dependency;
use Exception;
use Symfony\Component\Form\Event\PostSetDataEvent;
use Symfony\Component\Form\Event\PostSubmitEvent;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
class DependencyHandler
{
/** @var array dépendencies descriptions stored on builder add */
private array $aDependenciesDescription = [];
/** @var array dependencies map computed on form pre set data */
private array $aDependenciesMap = [];
/**
* Constructor.
*
* @param FormBuilderInterface $builder
*/
public function __construct(public FormBuilderInterface $builder)
{
// Initialize the dependencies listeners once the form is built
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
$oForm = $event->getForm();
$this->InitializeDependenciesMap($oForm);
});
}
/**
*Initialize the dependencies map and register listeners on the dependencies inputs.
*
* @param FormInterface $oForm
*
* @return void
*/
private function InitializeDependenciesMap(FormInterface $oForm): void
{
/** iterate throw dependencies descriptions... @var DependencyDescription $oDependencyDescription */
foreach ($this->aDependenciesDescription as $oDependencyDescription) {
/** iterate throw dependencies items... */
foreach ($oDependencyDescription->aDependencies as $sInput => $aData) {
// get the dependency field name
$sDependency = $aData['source'];
// get the field input name
$sOutput = array_key_exists('output', $aData) ? $aData['output'] : null;
// add the dependency to the map
if(!array_key_exists($sDependency, $this->aDependenciesMap)){
$this->aDependenciesMap[$sDependency] = [];
}
$this->aDependenciesMap[$sDependency][] = ['description' => $oDependencyDescription, 'input' => $sInput, 'output' => $sOutput];
}
}
/** iterate throw dependencies... */
foreach (array_keys($this->aDependenciesMap) as $sDependency){
// Listen the dependency
$this->builder->get($sDependency)->addEventListener(FormEvents::POST_SET_DATA, $this->GetEventListeningCallback());
$this->builder->get($sDependency)->addEventListener(FormEvents::POST_SUBMIT, $this->GetEventListeningCallback());
}
}
/**
* Add a dependency description.
*
* @param DependencyDescription $oDependencyDescription
*
* @return void
*/
public function AddDependencyDescription(DependencyDescription $oDependencyDescription): void
{
$this->aDependenciesDescription[] = $oDependencyDescription;
}
/**
* The event handling callback.
*
* @return callable
*/
private function GetEventListeningCallback(): callable
{
return function (FormEvent $event){
// Get the event type
$sEventType = $this->GetEventType($event);
// Get the form
$oForm = $event->getForm();
/** Iterate throw dependencies map... */
foreach ($this->aDependenciesMap[$event->getForm()->getName()] as $aData){
// Get the map data
$oDependencyDescription = $aData['description'];
$sInput = $aData['input'];
$sOutput = $aData['output'];
// Compute output value
$oValue = $event->getData();
if(array_key_exists('outputs', $event->getForm()->getConfig()->getOptions())){
$aOutputs = $event->getForm()->getConfig()->getOptions()['outputs'];
if(array_key_exists($sOutput, $aOutputs)){
$oValue = $aOutputs[$sOutput]($oValue);
}
}
// Store the input value
$oDependencyDescription->SetData($sEventType, $sInput, $oValue);
// When dependencies met, add the dependent field if not already done
if(!$oDependencyDescription->IsAdded() && $oDependencyDescription->IsDataReady($sEventType)) {
// Get the dependent field options
$aOptions = $oDependencyDescription->options;
// Add the listener callback to the dependent field if it is also a dependency for another field
if(is_string($oDependencyDescription->child) && array_key_exists($oDependencyDescription->child, $this->aDependenciesMap)) {
$aOptions = array_merge($aOptions, [
'listener_callback' => $this->GetEventListeningCallback(),
]);
}
// Add the dependent field to the form
$oForm->getParent()->add($oDependencyDescription->child, $oDependencyDescription->type, array_merge($aOptions, $oDependencyDescription->type::GetOptionsFromInputs($oDependencyDescription->GetData($sEventType))));
// Mark the dependency as added
$oDependencyDescription->SetAdded(true);
}
}
};
}
/**
* Get the event type.
*
* @param FormEvent $event
*
* @return string
* @throws Exception
*/
private function GetEventType(FormEvent $event): string
{
if($event instanceof PostSetDataEvent) {
return FormEvents::POST_SET_DATA;
}
else if($event instanceof PostSubmitEvent) {
return FormEvents::POST_SUBMIT;
}
throw new Exception(sprintf("Unknown event type %s", get_class($event)));
}
}

View File

@@ -0,0 +1,429 @@
<?php
namespace Combodo\iTop\Forms\FormBuilder;
use Combodo\iTop\Forms\Dependency\DependencyDescription;
use Combodo\iTop\Forms\Dependency\DependencyHandler;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormConfigInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\RequestHandlerInterface;
use Symfony\Component\Form\ResolvedFormTypeInterface;
use Symfony\Component\PropertyAccess\PropertyPathInterface;
use Traversable;
class FormBuilder implements FormBuilderInterface, \IteratorAggregate
{
/** @var DependencyHandler|null dependencies handler */
private DependencyHandler|null $dependencyHandler = null;
/**
* Constructor.
*
* @param FormBuilderInterface $builder
*/
public function __construct(private FormBuilderInterface $builder)
{
}
/**
* Add a dependency description to the form builder.
* The associate form will be created as a hidden field and added later when all its dependencies were met.
*
* @param DependencyDescription $oDependencyDescription the dependency description
*
* @return void
*/
private function AddDependency(DependencyDescription $oDependencyDescription): void
{
if($this->dependencyHandler === null){
\IssueLog::Error('create dependency handler ' . $this->builder->getName());
$this->dependencyHandler = new DependencyHandler($this->builder);
}
$this->dependencyHandler->AddDependencyDescription($oDependencyDescription);
}
public function add(string|FormBuilderInterface $child, ?string $type = null, array $options = []): static
{
if(!empty($options['bindings'])) {
$this->builder->add($child, HiddenType::class);
$this->AddDependency(new DependencyDescription($options['bindings'], $child, $type, $options));
}
else{
$this->builder->add($child, $type, $options);
}
return $this;
}
public function addExpression(string $name, string $expression): static
{
$options['bindings'] = [$expression];
return $this->add($name, null, $options);
}
// pure decoration of FormBuilderInterface
public function getIterator(): Traversable
{
return $this->builder->getIterator();
}
public function count(): int
{
return $this->builder->count();
}
public function create(string $name, ?string $type = null, array $options = []): FormBuilderInterface
{
return $this->builder->create($name, $type, $options);
}
public function get(string $name): FormBuilderInterface
{
return $this->builder->get($name);
}
public function remove(string $name): static
{
$this->builder->remove($name);
return $this;
}
public function has(string $name): bool
{
return $this->builder->has($name);
}
public function all(): array
{
return $this->builder->all();
}
public function getForm(): FormInterface
{
return $this->builder->getForm();
}
public function addEventListener(string $eventName, callable $listener, int $priority = 0): static
{
$this->builder->addEventListener($eventName, $listener, $priority);
return $this;
}
public function addEventSubscriber(EventSubscriberInterface $subscriber): static
{
$this->builder->addEventSubscriber($subscriber);
return $this;
}
public function addViewTransformer(DataTransformerInterface $viewTransformer, bool $forcePrepend = false): static
{
$this->builder->addViewTransformer($viewTransformer, $forcePrepend);
return $this;
}
public function resetViewTransformers(): static
{
$this->builder->resetViewTransformers();
return $this;
}
public function addModelTransformer(DataTransformerInterface $modelTransformer, bool $forceAppend = false): static
{
$this->builder->addModelTransformer($modelTransformer, $forceAppend);
return $this;
}
public function resetModelTransformers(): static
{
$this->builder->resetModelTransformers();
return $this;
}
public function setAttribute(string $name, mixed $value): static
{
$this->builder->setAttribute($name, $value);
return $this;
}
public function setAttributes(array $attributes): static
{
$this->builder->setAttributes($attributes);
return $this;
}
public function setDataMapper(?DataMapperInterface $dataMapper): static
{
$this->builder->setDataMapper($dataMapper);
return $this;
}
public function setDisabled(bool $disabled): static
{
$this->builder->setDisabled($disabled);
return $this;
}
public function setEmptyData(mixed $emptyData): static
{
$this->builder->setEmptyData($emptyData);
return $this;
}
public function setErrorBubbling(bool $errorBubbling): static
{
$this->builder->setErrorBubbling($errorBubbling);
return $this;
}
public function setRequired(bool $required): static
{
$this->builder->setRequired($required);
return $this;
}
public function setPropertyPath(PropertyPathInterface|string|null $propertyPath): static
{
$this->builder->setPropertyPath($propertyPath);
return $this;
}
public function setMapped(bool $mapped): static
{
$this->builder->setMapped($mapped);
return $this;
}
public function setByReference(bool $byReference): static
{
$this->builder->setByReference($byReference);
return $this;
}
public function setInheritData(bool $inheritData): static
{
$this->builder->setInheritData($inheritData);
return $this;
}
public function setCompound(bool $compound): static
{
$this->builder->setCompound($compound);
return $this;
}
public function setType(ResolvedFormTypeInterface $type): static
{
$this->builder->setType($type);
return $this;
}
public function setData(mixed $data): static
{
$this->builder->setData($data);
return $this;
}
public function setDataLocked(bool $locked): static
{
$this->builder->setDataLocked($locked);
return $this;
}
public function setFormFactory(FormFactoryInterface $formFactory)
{
$this->builder->setFormFactory($formFactory);
}
public function setAction(string $action): static
{
$this->builder->setAction($action);
return $this;
}
public function setMethod(string $method): static
{
$this->builder->setMethod($method);
return $this;
}
public function setRequestHandler(RequestHandlerInterface $requestHandler): static
{
$this->builder->setRequestHandler($requestHandler);
return $this;
}
public function setAutoInitialize(bool $initialize): static
{
$this->builder->setAutoInitialize($initialize);
return $this;
}
public function getFormConfig(): FormConfigInterface
{
return $this->builder->getFormConfig();
}
public function setIsEmptyCallback(?callable $isEmptyCallback): static
{
$this->builder->setIsEmptyCallback($isEmptyCallback);
return $this;
}
public function getEventDispatcher(): EventDispatcherInterface
{
return $this->builder->getEventDispatcher();
}
public function getName(): string
{
return $this->builder->getName();
}
public function getPropertyPath(): ?PropertyPathInterface
{
return $this->builder->getPropertyPath();
}
public function getMapped(): bool
{
return $this->builder->getMapped();
}
public function getByReference(): bool
{
return $this->builder->getByReference();
}
public function getInheritData(): bool
{
return $this->builder->getInheritData();
}
public function getCompound(): bool
{
return $this->builder->getCompound();
}
public function getType(): ResolvedFormTypeInterface
{
return $this->builder->getType();
}
public function getViewTransformers(): array
{
return $this->builder->getViewTransformers();
}
public function getModelTransformers(): array
{
return $this->builder->getModelTransformers();
}
public function getDataMapper(): ?DataMapperInterface
{
return $this->builder->getDataMapper();
}
public function getRequired(): bool
{
return $this->builder->getRequired();
}
public function getDisabled(): bool
{
return $this->builder->getDisabled();
}
public function getErrorBubbling(): bool
{
return $this->builder->getErrorBubbling();
}
public function getEmptyData(): mixed
{
return $this->builder->getEmptyData();
}
public function getAttributes(): array
{
return $this->builder->getAttributes();
}
public function hasAttribute(string $name): bool
{
return $this->builder->hasAttribute($name);
}
public function getAttribute(string $name, mixed $default = null): mixed
{
return $this->builder->getAttribute($name, $default);
}
public function getData(): mixed
{
return $this->builder->getData();
}
public function getDataClass(): ?string
{
return $this->builder->getDataClass();
}
public function getDataLocked(): bool
{
return $this->builder->getDataLocked();
}
public function getFormFactory(): FormFactoryInterface
{
return $this->builder->getFormFactory();
}
public function getAction(): string
{
return $this->builder->getAction();
}
public function getMethod(): string
{
return $this->builder->getMethod();
}
public function getRequestHandler(): RequestHandlerInterface
{
return $this->builder->getRequestHandler();
}
public function getAutoInitialize(): bool
{
return $this->builder->getAutoInitialize();
}
public function getOptions(): array
{
return $this->builder->getOptions();
}
public function hasOption(string $name): bool
{
return $this->builder->hasOption($name);
}
public function getOption(string $name, mixed $default = null): mixed
{
return $this->builder->getOption($name, $default);
}
public function getIsEmptyCallback(): ?callable
{
return $this->builder->getIsEmptyCallback();
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Combodo\iTop\Forms\FormBuilder;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
class FormTypeExtension extends AbstractTypeExtension
{
public static function getExtendedTypes(): iterable
{
return [
FormType::class
];
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefined([
'inputs',
'outputs',
'bindings',
'listener_callback'
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
if(array_key_exists('listener_callback', $options)) {
$builder->addEventListener(FormEvents::POST_SET_DATA, $options['listener_callback']);
$builder->addEventListener(FormEvents::POST_SUBMIT, $options['listener_callback']);
}
}
}

View File

@@ -0,0 +1,21 @@
<?php
/*
* @copyright Copyright (C) 2010-2025 Combodo SARL
* @license http://opensource.org/licenses/AGPL-3.0
*/
namespace Combodo\iTop\Forms\FormBuilder;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\ResolvedFormType as SymfonyResolvedFormType;
use Symfony\Component\Form\ResolvedFormTypeInterface;
class ResolvedFormType extends SymfonyResolvedFormType implements ResolvedFormTypeInterface
{
protected function newBuilder(string $name, ?string $dataClass, FormFactoryInterface $factory, array $options): FormBuilderInterface
{
$builder = parent::newBuilder($name, $dataClass, $factory, $options);
return new FormBuilder($builder);
}
}

View File

@@ -0,0 +1,22 @@
<?php
/*
* @copyright Copyright (C) 2010-2025 Combodo SARL
* @license http://opensource.org/licenses/AGPL-3.0
*/
namespace Combodo\iTop\Forms\FormBuilder;
use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\Form\ResolvedFormTypeFactoryInterface;
use Symfony\Component\Form\ResolvedFormTypeInterface;
/**
* Plumbing for iTop custom form builder.
*/
class ResolvedFormTypeFactory implements ResolvedFormTypeFactoryInterface
{
public function createResolvedType(FormTypeInterface $type, array $typeExtensions, ?ResolvedFormTypeInterface $parent = null): ResolvedFormTypeInterface
{
return new ResolvedFormType($type, $typeExtensions, $parent);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Combodo\iTop\Forms\FormType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PreSubmitEvent;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AttributeChoiceType extends AbstractType
{
public function getParent(): string
{
return ChoiceType::class;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('inputs', [
'object_class' => 'string'
]);
$resolver->setDefault('outputs', [
'attribute' => function($oData) {
return $oData;
}
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
// on pre submit
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event) use ($options){
// reset value if not in available choices
if(!empty($event->getData()) && !$this->CheckValue($event->getData(), $options)){
$event->getForm()->addError(new FormError("The value has been reset because it is not part of the available choices anymore."));
$event->setData(null);
}
}, 1);
}
private function CheckValue($oValue, $options): bool
{
if(!in_array($oValue, $options['choices'])){
return false;
}
return true;
}
public static function GetOptionsFromInputs(array $inputs): array
{
$aAttributeCodes = \MetaModel::GetAttributesList($inputs['object_class']);
return [
'choices' => array_combine($aAttributeCodes, $aAttributeCodes)
];
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Combodo\iTop\Forms\FormType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PreSubmitEvent;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AttributeValueChoiceType extends AbstractType
{
public function getParent(): string
{
return ChoiceType::class;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('required', false);
$resolver->setDefault('multiple', true);
$resolver->setDefault('attr', array(
'size' => 10,
'style' => 'height: auto;'
));
$resolver->setDefault('inputs', array(
'object_class' => 'string',
'attribute' => 'string'
));
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
// on pre submit
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event) use ($options){
// reset value if not in available choices
if(!empty($event->getData()) && !$this->CheckValue($event->getData(), $options)){
$event->getForm()->addError(new FormError("The value has been reset because it is not part of the available choices anymore."));
$event->setData(null);
}
}, 1);
}
private function CheckValue($oValue, $options): bool
{
if(!is_array($oValue)){
return false;
}
foreach ($oValue as $v){
if(!in_array($v, $options['choices'])){
return false;
}
}
return true;
}
public static function GetOptionsFromInputs(array $inputs): array
{
$aValues = [];
if(!empty($inputs['attribute'])){
$oAttDef = \MetaModel::GetAttributeDef($inputs['object_class'], $inputs['attribute']);
$aValues = $oAttDef->GetAllowedValues();
$aValues = $aValues !== null ? array_combine($aValues, $aValues) : [];
}
return [
'choices' => $aValues
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Combodo\iTop\Forms\FormType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\OptionsResolver\OptionsResolver;
class OqlType extends AbstractType
{
public function getParent(): string
{
return TextType::class;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('outputs', array(
'selected_class' => function($oData) {
if($oData === null)
return null;
// extract selected class
preg_match('/SELECT\s+(\w+)/', $oData, $aMatches);
return $aMatches[1] ?? null;
}
));
}
}

46
sources/Forms/Forms.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
/*
* @copyright Copyright (C) 2010-2025 Combodo SARL
* @license http://opensource.org/licenses/AGPL-3.0
*/
namespace Combodo\iTop\Forms;
use Combodo\iTop\Forms\FormBuilder\FormTypeExtension;
use Combodo\iTop\Forms\FormBuilder\ResolvedFormTypeFactory;
use Symfony\Component\Form\Extension\HttpFoundation\HttpFoundationExtension;
use Symfony\Component\Form\FormFactoryBuilder;
use Symfony\Component\Form\FormFactoryBuilderInterface;
use Symfony\Component\Form\FormFactoryInterface;
/**
* Plumbing for iTop custom form builder.
*/
final class Forms
{
/**
* Creates a form factory with the iTop configuration.
*/
public static function createFormFactory(): FormFactoryInterface
{
return self::createFormFactoryBuilder()->getFormFactory();
}
/**
* Creates a form factory builder with the iTop configuration.
*/
public static function createFormFactoryBuilder(): FormFactoryBuilderInterface
{
return (new FormFactoryBuilder())
->addExtension(new HttpFoundationExtension())
->addTypeExtension(new FormTypeExtension())
->setResolvedTypeFactory(new ResolvedFormTypeFactory());
}
/**
* This class cannot be instantiated.
*/
private function __construct()
{
}
}

View File

@@ -6,6 +6,7 @@
{% if type == 'text' %}{% set ibo_class='ibo-input-string' %}{% else %}{% set ibo_class='ibo-input-' ~ type %}{% endif %}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' ibo-input ' ~ ibo_class)|trim}) %}
{{- parent() -}}
onChange="this.form.requestSubmit();"
{%- endblock widget_attributes -%}
{%- block form_label -%}
@@ -21,3 +22,9 @@
{% set row_attr = row_attr|merge({class: (row_attr.class|default('') ~ ' ibo-field ibo-content-block ibo-block ibo-field-small')|trim}) %}
{{- parent() -}}
{%- endblock form_row -%}
{%- block form_errors -%}
<div class="form-error">
{{- parent() -}}
</div>
{%- endblock form_errors -%}