CForm and Tabular Input

I wondered if CForm would support tabular input. Out of the box it doesn’t, but some very minor changes allow it to do just that. Here’s what I have come up with:

Firstly an extension to CForm.

The changes are:

  • the addition of $_key to allow indexing into $_GET/POST[model]

  • change to the constructor to set the key if the form is for tabular input. Null means a normal form

  • addition of isTabular to determine if the form is for tabular input

  • change to loadData to index into the submitted data for tabular input

  • change to element rendering to produce a valid class




class EForm extends CForm {

  /**

   * @var boolean the key to fetch when the form is used for tabular input

   */

  private $_key;


  /**

   * Constructor.

   * $config, $model and $parent are as for CForm

   * @param mixed key to fetch for the model on form submission when using tabular input

   */

  public function __construct($config,$model,$parent=null,$key=null)

  {

    $this->_key=$key;

    parent::__construct($config,$model,$parent);

  }


  /**

   * The only difference here is to check if the form is used for tabular input.

   * If it is load attributes from the appropriate index from the submitted data

   */

  public function loadData()

  {

    if($this->_model!==null)

    {

      $class=get_class($this->_model);

      if(strcasecmp($this->getRoot()->method,'get') && isset($_POST[$class]))

      {

        if($this->isTabular())

          $this->_model->setAttributes($_POST[$class][$this->_key]);

        else

          $this->_model->setAttributes($_POST[$class]);

      }

      elseif(isset($_GET[$class]))

      {

        if($this->isTabular())

          $this->_model->setAttributes($_GET[$class][$this->_key]);

        else

          $this->_model->setAttributes($_GET[$class]);

      }

    }

    foreach($this->getElements() as $element)

    {

      if($element instanceof self)

        $element->loadData();

     }

  }


  /**

   * Checks if the form is used for tabular input

   * @return true if this form is used for tabular input, false if not

   */

  public function isTabular()

  {

    return isset($this->_key);

  }


  /**

   * Only one line is changed from CForm to render a valid class when

   * using tabular inputs. The line is marked.

   */

  public function renderElement($element)

  {

    if(is_string($element))

    {

      if(($e=$this[$element])===null && ($e=$this->getButtons()->itemAt($element))===null)

        return $element;

      else

        $element=$e;

    }

    if($element->getVisible())

    {

      if($element instanceof CFormInputElement)

      {

        if($element->type==='hidden')

          return "<div style=\"visibility:hidden\">\n".$element->render()."</div>\n";

        else

        {

	  $elementName = $element->name;

          return '<div class="row field_' . strtolower(preg_replace('/(\[\w*\])?\[(\w*)\]/', '_$2', CHtml::resolveName($element->getParent()->getModel(), $elementName))) . "\">\n".$element->render()."</div>\n"; // This line is the change

        }

      }

      elseif($element instanceof CFormButtonElement)

        return $element->render()."\n";

      else

        return $element->render();

    }

    return '';

  }

}



There are some changes required to isAttributeSafe and isAttributeRequired in CModel so that tabular inputs render, so I override them in the model class that uses tabular input.

All that is going on is to strip away the index portion from an attribute name in order to check the attribute, e.g. [index]attribute => attribute




  public function isAttributeRequired($attribute) {

    return parent::isAttributeRequired(preg_replace('/(\[\w+\])?(\w+)/', '$2', $attribute));

  }

  public function isAttributeSafe($attribute) {

    return parent::isAttributeSafe(preg_replace('/(\[\w+\])?(\w+)/', '$2', $attribute));

  }



And that is it. The changes - were they to be implemented in the core - are backward compatible.

Here’s a usage example.

In the application I am developing right now I have products that have a number of parameters; the relationship is many-many. So there is a product table, parameter table and product_parameter table. Which parameters are shown depends on the category of product.

In the product model, to create the form I do:




  public function getForm() {

    $form = new CForm($this->_form, $this);

    $elements = $form->getElements();

    

    $subForm = new CForm(array('elements' => array()), null, $form); // Sub-form to act as a container for the parameter forms.

    $subForm->title = 'Parameters';// Title to make it a fieldset

    $subFormElements = $subForm->getElements();


    // NOTE:: parameters were set earlier as related models to the product

    foreach ($this->parameters as $parameterId => $parameter)

      $subFormElements->add($parameterId, $parameter->getForm($subForm));


    $elements->add('parameters', $subForm);


    return $form;

  }



In the ProductParameter model I have:




  public function getForm($parent) {

    return new EForm(array(

      'elements' => array(

        "[{$this->parameterId}]value" => array(

          'type' => 'text',

            'label' => $this->parameterName,

            'after' => $this->parameterUnits

        )

      )

    ), $this, $parent, $this->parameterId);

  }



Note the format for the element name - [index]attribute, and that the index is passed to the EForm constructor. parameterId, Name and Units are from the parameter models applicable to the product.

This model is also where isAtributeSafe/Required are overridden.

I think this makes the CForm concept applicable to many more situations, and I hope this is of interest.

Very interesting, I’m in a similar thing and I think “tabular input” is a MUST-HAVE for Form Builder, I’m going to try your approach and I’ll comment

Thanks Yeti!

Ok, it seems to be ok, just a couple of things:

at CFormInputElement:




public function getLabel() {

  if($this->_label!==null)

    return $this->_label;

  else

    return $this->getParent()->getModel()->getAttributeLabel(

                  preg_replace('/(\[\w+\])?(\w+)/', '$2', $this->name));

}



I had to add the same logic you put in EModel to get the correct label if there is not specified in the model

Other thing I got surprised is the parent-child relation with forms… with this code:




	$model=MockController::loadMock();

        $form_definition = array(

            'title'=>'Mock con CForm',

            'elements'=>array(

                'name'=>array('type'=>'text','maxlength'=>32,),

                'surname'=>array('type'=>'text','maxlength'=>64,),

                'age'=>array('type'=>'text','maxlength'=>10,'hint'=>'edad del fulano',),

                'description'=>array('type'=>'textarea','rows'=>5,'cols'=>60,'maxlength'=>128,),

                'files'=>array('type'=>'form','title'=>'Files','elements'=>array(),),

            ),

            'buttons'=>array(

                'submit'=>array('type'=>'submit','label'=>'Pa dentro',),

            ),

        );

        $form = new EForm($form_definition, $model);


        foreach($model->files as $i=>$file) {

            $form_file = new EForm(array(

                'title'=>'fichero '.$i,

                'elements'=>array(

                    "[$i]filename"=>array('type'=>'text','maxlength'=>32,),

                    "[$i]filemime"=>array('type'=>'text','maxlength'=>32,),

                ),

            ), $file, $form['files'], $i);

            $form['files'][$i] = $form_file;

        }


        if($form->submitted('submit') && $form->validate()) {

            if ($form->model->save(false)) {

                foreach($form['files']->elements as $form_file) {

                    $form_file->model->save(false);

                }

                $this->redirect(array('list'));

            }

        }



the intention is to edit a Mock with its Files objects in one form, with buttons for adding File and remove each File.

as you can see I declared the main form to hold Mock object, and a subform to hold all the forms for File objects… as a comment I realized that at CForm->renderBody checks if the parent is a CForm, so it does not print the fieldset correctly




public function renderBody() {

  $output='';

  if($this->title!==null) {

    if($this->getParent() instanceof self) { //// 

      $output=CHtml::openTag('fieldset', $this->attributes);

    } else {

      $output.="<fieldset>\n<legend>".$this->title."</legend>\n";

    }

  }

  ...



should it be "if ($this->getParent() === $this)" to check if the parent is itself??

Well, I hope to head the opinion from qiang about this, as in my opinion CForm is the most important addition to 1.1

Thank you, guys!!

Hwangar

Spain

Ok, more corrections… there is a duplication of code declaring in CForm the public field $attributes when it’s beeing inherited from CFormElement.

Currently I’ve duplicated the form api and I’m adding modifications, that I’ll publish soon, one of them I have taken from Drupal Forms is the possibility to set a view (theming) for any subform/element in a complex structure as in this example (notation is invented but trivial to follow :P):




form (view=>"postView")

  title => textfield

  body => textfield

  files => form (view=>"fileView")

    [1]file => form

       filename => textfield

       filesize => textfield

    [2]file => form

       filename => textfield

       filesize => textfield



Greetings!!!

Other bug of the solution:

in CFormInputElement:




	public function renderError()

	{

		//return CHtml::error($this->getParent()->getModel(), $this->name);

		return CHtml::error($this->getParent()->getModel(), preg_replace('/(\[\w+\])?(\w+)/', '$2', $this->name));

	}



as I commented I’ll try to refactor the whole form api to correct all of this and not require the hack in the “models”

Greetings

Hwangar