LogoPhly, boy, phly
the weblog and site of Matthew Weier O'Phinney

Monday, April 13. 2009

Creating composite elements

In my last post on decorators, I had an example that showed rendering a "date of birth" element:


<div class="element">
    <?php echo $form->dateOfBirth->renderLabel() ?>
    <?php echo $this->formText('dateOfBirth[day]', '', array(
        'size' => 2, 'maxlength' => 2)) ?>
    /
    <?php echo $this->formText('dateOfBirth[month]', '', array(
        'size' => 2, 'maxlength' => 2)) ?>
    /
    <?php echo $this->formText('dateOfBirth[year]', '', array(
        'size' => 4, 'maxlength' => 4)) ?>
</div>
 

This has prompted some questions about how this element might be represented as a Zend_Form_Element, as well as how a decorator might be written to encapsulate this logic. Fortunately, I'd already planned to tackle those very subjects for this post!

The Element

The questions about how the element would work include:

  • How would you set and retrieve the value?
  • How would you validate the value?
  • Regardless, how would you then allow for discrete form inputs for the three segments (day, month, year)?

The first two questions center around the form element itself: how would setValue() and getValue() work? There's actually another question implied by the question about the decorator: how would you retrieve the discrete date segments from the element and/or set them?

The solution is to override the setValue() method of your element to provide some custom logic. In this particular case, our element should have three discrete behaviors:

  • If an integer timestamp is provided, it should be used to determine and store the day, month, and year
  • If a textual string is provided, it should be cast to a timestamp, and then that value used to determine and store the day, month, and year
  • If an array containing keys for date, month, and year is provided, those values should be stored

Internally, the day, month, and year will be stored discretely. When the value of the element is retrieved, it will be done so in a normalized string format. We'll override getValue() as well to assemble the discrete date segments into a final string.

Here's what the class would look like:



<?php
class My_Form_Element_Date extends Zend_Form_Element_Xhtml
{
    protected $_dateFormat = '%year%-%month%-%day%';
    protected $_day;
    protected $_month;
    protected $_year;

    public function setDay($value)
    {
        $this->_day = (int) $value;
        return $this;
    }

    public function getDay()
    {
        return $this->_day;
    }

    public function setMonth($value)
    {
        $this->_month = (int) $value;
        return $this;
    }

    public function getMonth()
    {
        return $this->_month;
    }

    public function setYear($value)
    {
        $this->_year = (int) $value;
        return $this;
    }

    public function getYear()
    {
        return $this->_year;
    }

    public function setValue($value)
    {
        if (is_int($value)) {
            $this->setDay(date('d', $value))
                 ->setMonth(date('m', $value))
                 ->setYear(date('Y', $value));
        } elseif (is_string($value)) {
            $date = strtotime($value);
            $this->setDay(date('d', $date))
                 ->setMonth(date('m', $date))
                 ->setYear(date('Y', $date));
        } elseif (is_array($value)
            && (isset($value['day'])
                && isset($value['month'])
                && isset($value['year'])
            )
        ) {
            $this->setDay($value['day'])
                 ->setMonth($value['month'])
                 ->setYear($value['year']);
        } else {
            throw new Exception('Invalid date value provided');
        }

        return $this;
    }

    public function getValue()
    {
        return str_replace(
            array('%year%', '%month%', '%day%'),
            array($this->getYear(), $this->getMonth(), $this->getDay()),
            $this->_dateFormat
        );
    }
}
 

This class gives some nice flexibility -- we can set default values from our database, and be certain that the value will be stored and represented correctly. Additionally, we can allow for the value to be set from an array passed via form input. Finally, we have discrete accessors for each date segment, which we can now use in a decorator to create a composite element.

The Decorator

Revisiting the example from the last post, let's assume that we want users to input each of the year, month, and day separately. PHP fortunately allows us to use array notation when creating elements, so it's still possible to capture these three entities into a single value -- and we've now created a Zend_Form element that can handle such an array value.

The decorator is relatively simple: it will grab the day, month, and year from the element, and pass each to a discrete view helper to render individual form inputs; these will then be aggregated to form the final markup.


class My_Form_Decorator_Date extends Zend_Form_Decorator_Abstract
{
    public function render($content)
    {
        $element = $this->getElement();
        if (!$element instanceof My_Form_Element_Date) {
            // only want to render Date elements
            return $content;
        }

        $view = $element->getView();
        if (!$view instanceof Zend_View_Interface) {
            // using view helpers, so do nothing if no view present
            return $content;
        }

        $day   = $element->getDay();
        $month = $element->getMonth();
        $year  = $element->getYear();
        $name  = $element->getFullyQualifiedName();

        $params = array(
            'size'      => 2,
            'maxlength' => 2,
        );
        $yearParams = array(
            'size'      => 4,
            'maxlength' => 4,
        );

        $markup = $view->formText($name . '[day]', $day, $params)
                . ' / ' . $view->formText($name . '[month]', $month, $params)
                . ' / ' . $view->formText($name . '[year]', $year, $yearParams);

        switch ($this->getPlacement()) {
            case self::PREPEND:
                return $markup . $this->getSeparator() . $content;
            case self::APPEND:
            default:
                return $content . $this->getSeparator() . $markup;
        }
    }
}
 

We now have to do a minor tweak to our form element, and tell it that we want to use the above decorator as a default. That takes two steps. First, we need to inform the element of the decorator path. We can do that in the constructor:


class My_Form_Element_Date extends Zend_Form_Element_Xhtml
{
    // ...

    public function __construct($spec, $options = null)
    {
        $this->addPrefixPath(
            'My_Form_Decorator',
            'My/Form/Decorator',
            'decorator'
        );
        parent::__construct($spec, $options);
    }

    // ...
}
 

Note that I'm doing this in the constructor and not in init(). This is for two reasons. First, it allows me to extend the element later to add logic in init without needing to worry about calling parent::init(). Second, it allows me to pass additional plugin paths via configuration or within an init method that will then allow me to override the default Date decorator with my own replacement.

Next, we need to override the loadDefaultDecorators() method to use our new Date decorator:


class My_Form_Element_Date extends Zend_Form_Element_Xhtml
{
    // ...

    public function loadDefaultDecorators()
    {
        if ($this->loadDefaultDecoratorsIsDisabled()) {
            return;
        }

        $decorators = $this->getDecorators();
        if (empty($decorators)) {
            $this->addDecorator('Date')
                 ->addDecorator('Errors')
                 ->addDecorator('Description', array(
                    'tag' => 'p',
                    'class' => 'description')
                 )
                 ->addDecorator('HtmlTag', array(
                    'tag' => 'dd',
                    'id'  => $this->getName() . '-element')
                 )
                 ->addDecorator('Label', array('tag' => 'dt'));
        }
    }

    // ...
}
 

What does the final output look like? Let's consider the following element:


$d = new My_Form_Element_Date('dateOfBirth');
$d->setLabel('Date of Birth: ')
  ->setView(new Zend_View());

// These are equivalent:
$d->setValue('20 April 2009');
$d->setValue(array('year' => '2009', 'month' => '04', 'day' => '20'));
 

If you then echo this element, you get the following markup (with some slight whitespace modifications for readability):


<dt id="dateOfBirth-label"><label for="dateOfBirth" class="optional">
    Date of Birth:
</label></dt>
<dd id="dateOfBirth-element">
    <input type="text" name="dateOfBirth[day]" id="dateOfBirth-day" value="20"
        size="2" maxlength="2"> /
    <input type="text" name="dateOfBirth[month]" id="dateOfBirth-month"
        value="4" size="2" maxlength="2"> /
    <input type="text" name="dateOfBirth[year]" id="dateOfBirth-year"
        value="2009" size="4" maxlength="4">
</dd>
 

Conclusion

We now have an element that can render multiple related form input fields, and then handle the aggregated fields as a single entity -- the dateOfBirth element will be passed as an array to the element, and the element will then, as we noted earlier, create the appropriate date segments and return a value we can use for most backends.

Additionally, we can use different decorators with the element. If we wanted to use a Dojo DateTextBox dijit decorator -- which accepts and returns string values -- we can, with no modifications to the element itself.

In the end, you get a uniform element API you can use to describe an element representing a composite value.

Other posts in this series:

  • The simplest Zend_Form decorator
  • From the inside out: How to layer decorators
  • Rendering Zend_Form decorators individually
Posted by Matthew Weier O'Phinney in PHP at 08:30 | Comments (20) | Trackbacks (0)
Defined tags for this entry: decorators, php, zend framework
Related entries by tags:
Module Bootstraps in Zend Framework: Do's and Don'ts
Responding to Different Content Types in RESTful ZF Apps
Symfony Live 2010
Creating Re-Usable Zend_Application Resource Plugins
Quick Start to Zend_Application_Bootstrap

Trackbacks
Trackback specific URI for this entry

No Trackbacks

Comments
Display comments as (Linear | Threaded)

Nice post Matthew - this post will become very popular! :-)

Cheers,

DM
#1 DangerMouse (Link) on 2009-04-13 09:22 (Reply)
Nice.. but again the issue that the label for should reference an element's id.
Which can be a bit of an issue here :S
#2 Harro on 2009-04-14 06:05 (Reply)
That's going to be an issue regardless when you do a composite element such as this one, and is not unique to the ZF decorators. In order to be 100% valid markup, the label's "for" attribute has to be specified and reference a unique input ID unless the label wraps the input(s). Clearly, neither situation will work when using a definition list -- but hopefully at this point you can figure out how to mix and match the various techniques in this series to accomplish some markup that *will* work.
#2.1 Matthew Weier O'Phinney (Link) on 2009-04-14 06:29 (Reply)
You are mistaken. The "for" attribute is not required (a label element needn't be attached to a form control in the first place), but when it is used "the value of the for attribute must be the same as the value of the id attribute of the associated control element." [http://www.w3.org/TR/html401/interact/forms.html#h-17.9.1]

Using an invalid "for" attribute value negates the whole point of the attribute, and the whole tag for that matter. Screen readers won't be able to associate the label with the form control, and browsers won't shift focus to the form element when the label is clicked or the label's access key is used.

What you should do instead is associate the label with the first of that group of control elements. That's exactly what happens as well if you were to include the form controls inside the label tag. [also: http://dev.w3.org/html5/spec/Overview.html#the-label-element]

For optimal accessibility, each individual form control should be given a (potentially hidden) label or a title attribute. [http://www.webaim.org/techniques/css/invisiblecontent/]

(Sorry I'm being pedantic. I've enjoyed the series so far, and wouldn't mind some more Zend_Form posts.)
#2.1.1 Anonymous on 2009-05-28 06:04 (Reply)
I'd have to disagree with the notion that a label need not be attached to a control element. According to the spec you just quoted, that's the whole point of the label element. "The LABEL element is used to specify labels for controls that do not have implicit labels" [http://www.w3.org/TR/REC-html40/interact/forms.html#h-17.9]

I do agree however that the the label should probably reference the first element though. At least then it would actually do something useful.

Another option would be to provide a label for each part of the composite, but only show them to screen-readers. Or use progressive enhancement with a date picker tool... but that kind of defeats the purpose of the article. :-)
#2.1.1.1 David (Link) on 2009-05-28 08:25 (Reply)
This is probably one of the best articles I've read so far on custom Zend Form elements. Am using it now to create my own time-selector :-)

You can simplify your setValue method a bit:
...
if (is_string($value)) {
$value = strtotime($value);
}

if (is_int($value)) {
...

And isset can do the isset checks in a single call if you want:
isset($value['day'], $value['month'], $value['year'])
#3 David (Link) on 2009-04-14 09:58 (Reply)
Thank you Mat! Waiting for next nice article about Zend_Form :-)
#4 Yaroslav Shatkevich on 2009-04-16 04:20 (Reply)
Great series Mat! Thanks a heap! Hate to nit pick, but your input tags aren't being closed properly (ie '/>')
#5 Rick Baril on 2009-04-16 13:15 (Reply)
That's because I didn't specify an XHTML doctype to the view when when I created the output. :-) If you do, then it will render them as self-closing tags.
#5.1 Matthew Weier O'Phinney (Link) on 2009-04-16 13:56 (Reply)
Great post!

What about validating? I'm using this kind of Element for a "Street & Housenumber" combination, I also set "setRequired" for this field, how can I validate both fields now?

I experimented with "isValid" in the Zend_Form_Element_Xhtml, without success.

Has anyone of you made a simple validation for such kind of Elements?
#6 Ben (Link) on 2009-04-17 03:08 (Reply)
In that particular case, you'd want to create your own validator. To do that, extend Zend_Validate_Abstract, and create an isValid() method that then takes the array and validates it. You can even have your validator proxy to other validators in order to validate each segment individually.
#6.1 Matthew Weier O'Phinney (Link) on 2009-04-17 06:54 (Reply)
Thanks Mat!

I just made a "StreetHouseNumber" Validator extending Zend_Validate_Abstract and set this validator with $this->setValidators(array('StreetHouseNumber')); in the init() function of the StreetHouseNumber Element.

The Validator only contains an isValid function that always returns false, but if I validate the form nothing is validatet, i tried to make a Zend_Debug::dump($value); in the isValid function or doing some other "echo", nothing is printed.

It looks like that setting the Validator has no effect to the real validation process?

...maybe, I' making something wrong. ;-)
#6.1.1 Ben (Link) on 2009-04-17 07:21 (Reply)
ah, found out that my getValue() function was wrong and does not return any values, now everything works! ;-)
#6.1.1.1 Ben (Link) on 2009-04-17 07:31 (Reply)
It would help a lot if there was a link to an actual example, so we could see it in action.
#7 Sny on 2009-04-17 05:04 (Reply)
When I get a chance to work on my own website sometime, I plan to build a "demos" area. Don't hold your breath for it, though.
#7.1 Matthew Weier O'Phinney (Link) on 2009-04-17 06:55 (Reply)
Hello, i had to comment the "return $content";
on the decorator class to show the birthdate text fields, otherwise i only can see the decorators tag. Why?


$element = $this->getElement();
if (!$element instanceof My_Form_Element_Date) {
// only want to render Date elements
//return $content;
}

Thanks,

Ed
#8 Ed on 2009-05-02 02:33 (Reply)
Working with composite elements, I noticed then when you use the same composite element that it writes over the first value...

both values are left blank. Is there anyway to prevent this?
#9 Brad on 2010-03-08 04:20 (Reply)
Make sure you're giving the elements different names. The logic provided ensures that the element name is used as a prefix, so there should be no overwriting unless two elements have the same name (which actually should never happen).
#9.1 Matthew Weier O'Phinney (Link) on 2010-03-08 07:03 (Reply)
Thank you for your response,

I should have mentioned that the elements are different names, and are associated under different subforms. Using a similar design structure like the Date example on http://www.framework.zend.com/manual/en/learning.form.decorators.composite.html I built a full name composite element (acquiring their first name, middle initial, and last name) from what I could tell everything was working fine right up until it hits the getValue function, it returns null, but if you do a post back on the form, the form is properly populated.

In order to trouble shoot this further, I just decided to echo out my variables $this->_first, $this->_initial, and $this->_last and see if data was being stored into them. The result was as expected, however not the same result was achieved when I had getValue function return the string consisting of the aforementioned variables, it keeps coming back null.
#9.1.1 Brad on 2010-03-08 09:01 (Reply)
Can you email me the full class so I can look at it and assist in debugging? You can find my email via the ZF mailing lists.
#9.1.1.1 Matthew Weier O'Phinney (Link) on 2010-03-09 07:41 (Reply)

Add Comment

Standard emoticons like :-) and ;-) are converted to images.
E-Mail addresses will not be displayed and will only be used for E-Mail notifications

To prevent automated Bots from commentspamming, please enter the string you see in the image below in the appropriate input box. Your comment will only be submitted if the strings match. Please ensure that your browser supports and accepts cookies, or your comment cannot be verified correctly.
CAPTCHA

 
 
  • Home
  • Resume
  • Blog
  • Phly PEAR Channel
  • Contact Me
  • About this site

ZCE

Zend Education Advisory Board Member

Add to Technorati Favorites

Calendar

Back March '10 Forward
Mon Tue Wed Thu Fri Sat Sun
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31        

Quicksearch

Links

  • PHLY - PHp LibrarY
  • Paul M. Jones
  • Mike Naberezny
  • Shahar Evron
  • Planet PHP
  • Zend Where I now work
  • Garden.org Where I once worked

Archives

March 2010
February 2010
January 2010
Recent...
Older...

Categories

XML Linux
XML Personal
XML Aikido
XML Family
XML Programming
XML Dojo
XML Perl
XML PHP

All categories

Syndicate This Blog

XML RSS 0.91 feed
XML RSS 1.0 feed
XML RSS 2.0 feed
ATOM/XML ATOM 0.3 feed
ATOM/XML ATOM 1.0 feed
XML RSS 2.0 Comments

Show tagged entries

xml best practices
xml books
xml conferences
xml cw09
xml decorators
xml dojo
xml dpc08
xml file_fortune
xml git
xml linux
xml mvc
xml oop
xml pear
xml perl
xml personal
xml php
xml phpworks08
xml programming
xml rest
xml ubuntu
xml vim
xml webinar
xml zendcon
xml zendcon08
xml zendcon09
xml zend framework
© 2004 - present, Matthew Weier O'Phinney
matthew-web <at> weierophinney.net