Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[2.7][Form] Refactored choice lists to support dynamic labels, values and attributes #12148

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from

Conversation

webmozart
Copy link
Contributor

Q A
Bug fix? no
New feature? yes
BC breaks? yes
Deprecations? yes
Tests pass? yes
Fixed tickets #4067, #5494, #3836, #12148
License MIT
Doc PR TODO

This PR didn't make it in time anymore for the 2.6 feature freeze. It will be merged as soon as development for 2.7 officially starts.

I implemented the additional options "choice_label", "choice_name", "choice_value", "choice_attr", "group_by" and "flip_choices" for ChoiceType. Additionally the "preferred_choices" option was updated to accept callables and property paths.

The "flip_choices" option will be removed in Symfony 3.0, where the "choices" will be flipped by default. The reason for that is that, right now, choices are limited to strings and integers (i.e. valid array keys). When we flip the array, we remove that limitation. Since choice labels are always strings, we can also always use them as array keys:

// Not possible currently, but possible with "flip_choices"
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => true,
        'No' => false,
        'Maybe' => null,
    ),
    'flip_choices' => true,
));

All the features described here obviously also apply to subtypes of "choice", such as "entity".

choice_label

Returns the label for each choice. Can be a callable (which receives the choice as first and the key of the "choices" array as second argument) or a property path.

If null, the keys of the "choices" array are used as labels.

// callable
$builder->add('attending', 'choice', array(
    'choices' => array(
        'yes' => true,
        'no' => false,
        'maybe' => null,
    ),
    'flip_choices' => true,
    'choice_label' => function ($choice, $key) {
        return 'form.choice.'.$key;
    },
));

// property path
$builder->add('attending', 'choice', array(
    'choices' => array(
        Status::getInstance(Status::YES),
        Status::getInstance(Status::NO),
        Status::getInstance(Status::MAYBE),
    ),
    'flip_choices' => true,
    'choice_label' => 'displayName',
));

choice_name

Returns the form name for each choice. That name is used as name of the checkbox/radio form for this choice. It is also used as index of the choice views in the template. Can be a callable (like for "choice_label") or a property path.

The generated names must be valid form names, i.e. contain alpha-numeric symbols, underscores, hyphens and colons only. They must start with an alpha-numeric symbol or an underscore.

If null, an incrementing integer is used as name.

// callable
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => true,
        'No' => false,
        'Maybe' => null,
    ),
    'flip_choices' => true,
    'choice_name' => function ($choice, $key) {
        // use the labels as names
        return strtolower($key);
    },
));

// property path
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => Status::getInstance(Status::YES),
        'No' => Status::getInstance(Status::NO),
        'Maybe' => Status::getInstance(Status::MAYBE),
    ),
    'flip_choices' => true,
    'choice_name' => 'value',
));

choice_value

Returns the string value for each choice. This value is displayed in the "value" attributes and submitted in the POST/PUT requests. Can be a callable (like for "choice_label") or a property path.

If null, an incrementing integer is used as value.

// callable
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => true,
        'No' => false,
        'Maybe' => null,
    ),
    'flip_choices' => true,
    'choice_value' => function ($choice, $key) {
        if (null === $choice) {
            return 'null';
        }

        if (true === $choice) {
            return 'true';
        }

        return 'false';
    },
));

// property path
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => Status::getInstance(Status::YES),
        'No' => Status::getInstance(Status::NO),
        'Maybe' => Status::getInstance(Status::MAYBE),
    ),
    'flip_choices' => true,
    'choice_value' => 'value',
));

choice_attr

Returns the additional HTML attributes for choices. Can be an array, a callable (like for "choice_label") or a property path.
If an array, the key of the "choices" array must be used as keys.

// array
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => true,
        'No' => false,
        'Maybe' => null,
    ),
    'flip_choices' => true,
    'choice_attr' => array(
        'Maybe' => array('class' => 'greyed-out'),
    ),
));

// callable
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => true,
        'No' => false,
        'Maybe' => null,
    ),
    'flip_choices' => true,
    'choice_attr' => function ($choice, $key) {
        if (null === $choice) {
            return array('class' => 'greyed-out');
        }
    },
));

// property path
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => Status::getInstance(Status::YES),
        'No' => Status::getInstance(Status::NO),
        'Maybe' => Status::getInstance(Status::MAYBE),
    ),
    'flip_choices' => true,
    'choice_value' => 'htmlAttributes',
));

group_by

Returns the grouping used for the choices. Can be an array/Traversable, a callable (like for "choice_label") or a property path.

The return values of the callable/property path are used as group labels. If null is returned, a choice is not grouped.

If null, the structure of the "choices" array is used to construct the groups.

// default
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Decided' => array(
            'Yes' => true,
            'No' => false,
        ),
        'Undecided' => array(
            'Maybe' => null,
        ),
    ),
    'flip_choices' => true,
));

// callable
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => true,
        'No' => false,
        'Maybe' => null,
    ),
    'flip_choices' => true,
    'group_by' => function ($choice, $key) {
        if (null === $choice) {
            return 'Undecided';
        }

        return 'Decided';
    },
));

// property path
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => Status::getInstance(Status::YES),
        'No' => Status::getInstance(Status::NO),
        'Maybe' => Status::getInstance(Status::MAYBE),
    ),
    'flip_choices' => true,
    'group_by' => 'type',
));

preferred_choices

Returns the preferred choices. Can be an array/Traversable, a callable (like for "choice_label") or a property path.

// array
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => true,
        'No' => false,
        'Maybe' => null,
    ),
    'flip_choices' => true,
    'preferred_choices' => array(true),
));

// callable
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => true,
        'No' => false,
        'Maybe' => null,
    ),
    'flip_choices' => true,
    'preferred_choices' => function ($choice, $key) {
        return true === $choice;
    },
));

// property path
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => Status::getInstance(Status::YES),
        'No' => Status::getInstance(Status::NO),
        'Maybe' => Status::getInstance(Status::MAYBE),
    ),
    'flip_choices' => true,
    'preferred_choices' => 'preferred',
));

Technical Changes

To properly implement all this, the old ChoiceListInterface class was deprecated and replaced by a new, slimmer one. The creation of choice views is now separated from choice lists. Hence a lot of logic is not executed anymore when processing (but not displaying) a form.

Internally, a ChoiceListFactoryInterface implementation is used to construct choice lists and choice views. Two decorators exist for this class:

  • CachingFactoryDecorator: caches choice lists/views so that multiple fields displaying the same choices (e.g. in collection fields) use the same choice list/view
  • PropertyAccessDecorator: adds support for property paths to a factory

BC Breaks

The option "choice_list" of ChoiceType now contains a Symfony\Component\Form\ChoiceList\ChoiceListInterface instance, which is a super-type of the deprecated ChoiceListInterface.

Todos

To be completed after the release of 2.6:

  • Adapt CHANGELOGs
  • Adapt UPGRADE files
  • symfony/symfony-docs issue/PR

@@ -355,3 +356,16 @@
{%- endif -%}
{%- endfor -%}
{%- endblock button_attributes %}

{% block attributes -%}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about this first, but this adds one additional function call for every single field, which is noticeable for big forms. I think the amount of duplicated code is manageable.

@webda2l
Copy link

webda2l commented Oct 6, 2014

Lots of possibilities, great!

/**
* @author Bernhard Schussek <[email protected]>
*/
class StringCastable
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

class seems unused

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks, removed

@Tobion
Copy link
Contributor

Tobion commented Oct 6, 2014

I fear the flip_choices option will cause a lot of confusing. Also it's marked as deprecated which is wrong I think. The option is added to be forward compatible. So actually people are encouraged to use that option, I guess. But deprecation means the opposite: It should no be used (and using it will raise deprecation warnings when we add that later).

/**
* {@inheritdoc}
*/
public function loadValuesForChoices(array $entities, $value = null)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you use inheritdoc I guess the parameter names must stay the same. So $choices instead of $entities.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added explicit doc blocks

@stof stof added the Form label Oct 6, 2014
@stof
Copy link
Member

stof commented Oct 6, 2014

I haven't looked at the code yet (I probably won't have time for that today, but the PR is waiting for the end of the 2.6 stabilization phase anyway so it is not an issue), but the description looks great.

Btw, the flipping of choices makes the grouping much more logical than previously: labels are always given in keys, instead of being the keys for groups and the values for leafs.
I also like the possibility to use callables to build labels. Much easier in many cases

@@ -33,19 +36,19 @@
protected $registry;

/**
* @var array
* @var DefaultChoiceListFactory
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChoiceListFactoryInterface

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks

@Tobion
Copy link
Contributor

Tobion commented Oct 6, 2014

The feature and code is beautiful 👍

@webmozart
Copy link
Contributor Author

@Tobion thanks :)

@webmozart
Copy link
Contributor Author

I removed the deprecation note from "flip_choices" now. People are indeed encouraged to use it.

@webmozart
Copy link
Contributor Author

#6456 should probably be fixed here (and #8658).

@webmozart
Copy link
Contributor Author

ref #9738

@webmozart webmozart closed this Oct 17, 2014
@webmozart
Copy link
Contributor Author

Why do I always click the wrong button..

@webmozart webmozart reopened this Oct 17, 2014
@webmozart
Copy link
Contributor Author

This should be fixed here: #10551

@liverbool
Copy link
Contributor

👍

@EmmanuelVella
Copy link
Contributor

If you add a choice_translation_domain, it would be nice to be able to set it to false to disable the choices translations.

Currently the Country, Timezone, etc. choices are being re-translated in twig, which is slow (at least in dev environment).

@raziel057
Copy link
Contributor

Definitively one of my most wanted feature

@Tom32i
Copy link

Tom32i commented Feb 2, 2015

👍

1 similar comment
@garak
Copy link
Contributor

garak commented Feb 11, 2015

👍

@Tobion
Copy link
Contributor

Tobion commented Feb 11, 2015

I think the flip_choices option should be named flipped_choices because when true they are already flipped and will not get flipped. The example

    'choices' => array(
        'Yes' => true,
        'No' => false,
        'Maybe' => null,
    ),
    'flip_choices' => true,

makes people think they will get flipped, but then it would remove the actualy values as they cannot be represented as array key.

@rvanlaak
Copy link
Contributor

👍 for what @EmmanuelVella mentioned. Callables for labels also would be great!

// Due to a bug in OptionsResolver, the choices haven't been
// validated yet at this point. Remove the if statement once that
// bug is resolved
if (!$options['choice_loader'] instanceof ChoiceLoaderInterface) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This problem in OptionsResolver is fixed with the rewritten implementation since 2.6. So when we set the correct dependency, we can also remove this condition.

@soullivaneuh
Copy link
Contributor

Nice feature @webmozart!

How about entity type? If I want to add an attribute to each object?

Concrete case: I have a Person object with an id, a name and a surname. I want to have a select option like this:

<option value="{{ id }}" data-surname="{{ surname }}">{{ name }}</option>

Could be possible with your PR?

Thanks.

@blaugueux
Copy link
Contributor

Any chance to see this feature on 2.7?

@dudemelo
Copy link

That's exactly what i need!! 👍

@webmozart
Copy link
Contributor Author

Replaced by #12148.

@webmozart webmozart closed this Mar 25, 2015
@stloyd
Copy link
Contributor

stloyd commented Mar 25, 2015

@webmozart You mean #14050 I guess ;)

@webmozart
Copy link
Contributor Author

Oops, thanks @stloyd :)

webmozart added a commit that referenced this pull request Apr 1, 2015
…l, value, index and attribute generation (webmozart)

This PR was merged into the 2.7 branch.

Discussion
----------

[Form] Refactored choice lists to support dynamic label, value, index and attribute generation

This is a rebase of #12148 on the 2.7 branch.

| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | yes
| Deprecations? | yes
| Tests pass?   | yes
| Fixed tickets | #4067, #5494, #3836, #8658, #12148
| License       | MIT
| Doc PR        | TODO

I implemented the additional options "choice_label", "choice_name", "choice_value", "choice_attr", "group_by" and "choices_as_values" for ChoiceType. Additionally the "preferred_choices" option was updated to accept callables and property paths.

The "choices_as_values" option will be removed in Symfony 3.0, where the choices will be passed in the values of the "choices" option by default. The reason for that is that, right now, choices are limited to strings and integers (i.e. valid array keys). When we flip the array, we remove that limitation. Since choice labels are always strings, we can also always use them as array keys:

```php
// Not possible currently, but possible with "flip_choices"
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => true,
        'No' => false,
        'Maybe' => null,
    ),
    'choices_as_values' => true,
));
```

All the features described here obviously also apply to subtypes of "choice", such as "entity".

**choice_label**

Returns the label for each choice. Can be a callable (which receives the choice as first and the key of the "choices" array as second argument) or a property path.

If `null`, the keys of the "choices" array are used as labels.

```php
// callable
$builder->add('attending', 'choice', array(
    'choices' => array(
        'yes' => true,
        'no' => false,
        'maybe' => null,
    ),
    'choices_as_values' => true,
    'choice_label' => function ($choice, $key) {
        return 'form.choice.'.$key;
    },
));

// property path
$builder->add('attending', 'choice', array(
    'choices' => array(
        Status::getInstance(Status::YES),
        Status::getInstance(Status::NO),
        Status::getInstance(Status::MAYBE),
    ),
    'choices_as_values' => true,
    'choice_label' => 'displayName',
));
```

**choice_name**

Returns the form name for each choice. That name is used as name of the checkbox/radio form for this choice. It is also used as index of the choice views in the template. Can be a callable (like for "choice_label") or a property path.

The generated names must be valid form names, i.e. contain alpha-numeric symbols, underscores, hyphens and colons only. They must start with an alpha-numeric symbol or an underscore.

If `null`, an incrementing integer is used as name.

```php
// callable
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => true,
        'No' => false,
        'Maybe' => null,
    ),
    'choices_as_values' => true,
    'choice_name' => function ($choice, $key) {
        // use the labels as names
        return strtolower($key);
    },
));

// property path
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => Status::getInstance(Status::YES),
        'No' => Status::getInstance(Status::NO),
        'Maybe' => Status::getInstance(Status::MAYBE),
    ),
    'choices_as_values' => true,
    'choice_name' => 'value',
));
```

**choice_value**

Returns the string value for each choice. This value is displayed in the "value" attributes and submitted in the POST/PUT requests. Can be a callable (like for "choice_label") or a property path.

If `null`, an incrementing integer is used as value.

```php
// callable
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => true,
        'No' => false,
        'Maybe' => null,
    ),
    'choices_as_values' => true,
    'choice_value' => function ($choice, $key) {
        if (null === $choice) {
            return 'null';
        }

        if (true === $choice) {
            return 'true';
        }

        return 'false';
    },
));

// property path
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => Status::getInstance(Status::YES),
        'No' => Status::getInstance(Status::NO),
        'Maybe' => Status::getInstance(Status::MAYBE),
    ),
    'choices_as_values' => true,
    'choice_value' => 'value',
));
```

**choice_attr**

Returns the additional HTML attributes for choices. Can be an array, a callable (like for "choice_label") or a property path.
If an array, the key of the "choices" array must be used as keys.

```php
// array
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => true,
        'No' => false,
        'Maybe' => null,
    ),
    'choices_as_values' => true,
    'choice_attr' => array(
        'Maybe' => array('class' => 'greyed-out'),
    ),
));

// callable
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => true,
        'No' => false,
        'Maybe' => null,
    ),
    'choices_as_values' => true,
    'choice_attr' => function ($choice, $key) {
        if (null === $choice) {
            return array('class' => 'greyed-out');
        }
    },
));

// property path
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => Status::getInstance(Status::YES),
        'No' => Status::getInstance(Status::NO),
        'Maybe' => Status::getInstance(Status::MAYBE),
    ),
    'choices_as_values' => true,
    'choice_value' => 'htmlAttributes',
));
```

**group_by**

Returns the grouping used for the choices. Can be an array/Traversable, a callable (like for "choice_label") or a property path.

The return values of the callable/property path are used as group labels. If `null` is returned, a choice is not grouped.

If `null`, the structure of the "choices" array is used to construct the groups.

```php
// default
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Decided' => array(
            'Yes' => true,
            'No' => false,
        ),
        'Undecided' => array(
            'Maybe' => null,
        ),
    ),
    'choices_as_values' => true,
));

// callable
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => true,
        'No' => false,
        'Maybe' => null,
    ),
    'choices_as_values' => true,
    'group_by' => function ($choice, $key) {
        if (null === $choice) {
            return 'Undecided';
        }

        return 'Decided';
    },
));

// property path
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => Status::getInstance(Status::YES),
        'No' => Status::getInstance(Status::NO),
        'Maybe' => Status::getInstance(Status::MAYBE),
    ),
    'choices_as_values' => true,
    'group_by' => 'type',
));
```

**preferred_choices**

Returns the preferred choices. Can be an array/Traversable, a callable (like for "choice_label") or a property path.

```php
// array
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => true,
        'No' => false,
        'Maybe' => null,
    ),
    'choices_as_values' => true,
    'preferred_choices' => array(true),
));

// callable
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => true,
        'No' => false,
        'Maybe' => null,
    ),
    'choices_as_values' => true,
    'preferred_choices' => function ($choice, $key) {
        return true === $choice;
    },
));

// property path
$builder->add('attending', 'choice', array(
    'choices' => array(
        'Yes' => Status::getInstance(Status::YES),
        'No' => Status::getInstance(Status::NO),
        'Maybe' => Status::getInstance(Status::MAYBE),
    ),
    'choices_as_values' => true,
    'preferred_choices' => 'preferred',
));
```

**Technical Changes**

To properly implement all this, the old `ChoiceListInterface` class was deprecated and replaced by a new, slimmer one. The creation of choice views is now separated from choice lists. Hence a lot of logic is not executed anymore when processing (but not displaying) a form.

Internally, a `ChoiceListFactoryInterface` implementation is used to construct choice lists and choice views. Two decorators exist for this class:

* `CachingFactoryDecorator`: caches choice lists/views so that multiple fields displaying the same choices (e.g. in collection fields) use the same choice list/view
* `PropertyAccessDecorator`: adds support for property paths to a factory

**BC Breaks**

The option "choice_list" of ChoiceType now contains a `Symfony\Component\Form\ChoiceList\ChoiceListInterface` instance, which is a super-type of the deprecated `ChoiceListInterface`.

**Todos**

- [ ] Adapt CHANGELOGs
- [ ] Adapt UPGRADE files
- [ ] symfony/symfony-docs issue/PR

Commits
-------

94d18e9 [Form] Fixed CS
7e0960d [Form] Fixed failing layout tests
1d89922 [Form] Fixed tests using legacy functionality
d6179c8 [Form] Fixed PR comments
26eba76 [Form] Fixed regression: Choices are compared by their values if a value callback is given
a289deb [Form] Fixed new ArrayChoiceList to compare choices by their values, if enabled
e6739bf [DoctrineBridge] DoctrineType now respects the "query_builder" option when caching the choice loader
3846b37 [DoctrineBridge] Fixed: don't cache choice lists if query builders are constructed dynamically
03efce1 [Form] Refactored choice lists to support dynamic label, value, index and attribute generation
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.