From 2533f294209c7aa476f68c03216783a9e58e6390 Mon Sep 17 00:00:00 2001 From: Philipp Rieber <p.rieber@gmail.com> Date: Fri, 3 Jan 2014 12:21:50 +0100 Subject: [PATCH 1/4] [Cookbook][Dynamic Form Modification] Add AJAX sample --- cookbook/form/dynamic_form_modification.rst | 151 ++++++++++++++++++-- 1 file changed, 136 insertions(+), 15 deletions(-) diff --git a/cookbook/form/dynamic_form_modification.rst b/cookbook/form/dynamic_form_modification.rst index 14ad9f02756..38004fb7145 100644 --- a/cookbook/form/dynamic_form_modification.rst +++ b/cookbook/form/dynamic_form_modification.rst @@ -486,7 +486,10 @@ sport like this:: public function buildForm(FormBuilderInterface $builder, array $options) { $builder - ->add('sport', 'entity', array(...)) + ->add('sport', 'entity', array( + 'class' => 'AcmeDemoBundle:Sport', + 'empty_value' => '', + )); ; $builder->addEventListener( @@ -497,12 +500,18 @@ sport like this:: // this would be your entity, i.e. SportMeetup $data = $event->getData(); - $positions = $data->getSport()->getAvailablePositions(); + $sport = $data->getSport(); + $positions = (null === $sport) ? array() : $sport->getAvailablePositions(); - $form->add('position', 'entity', array('choices' => $positions)); + $form->add('position', 'entity', array( + 'class' => 'AcmeDemoBundle:Position', + 'empty_value' => '', + 'choices' => $positions, + )); } ); } + // ... } When you're building this form to display to the user for the first time, @@ -547,13 +556,20 @@ The type would now look like:: public function buildForm(FormBuilderInterface $builder, array $options) { $builder - ->add('sport', 'entity', array(...)) + ->add('sport', 'entity', array( + 'class' => 'AcmeDemoBundle:Sport', + 'empty_value' => '', + )); ; - $formModifier = function(FormInterface $form, Sport $sport) { - $positions = $sport->getAvailablePositions(); + $formModifier = function(FormInterface $form, Sport $sport = null) { + $positions = (null === $sport) ? array() : $sport->getAvailablePositions(); - $form->add('position', 'entity', array('choices' => $positions)); + $form->add('position', 'entity', array( + 'class' => 'AcmeDemoBundle:Position', + 'empty_value' => '', + 'choices' => $positions, + )); }; $builder->addEventListener( @@ -579,17 +595,119 @@ The type would now look like:: } ); } + // ... } -You can see that you need to listen on these two events and have different callbacks -only because in two different scenarios, the data that you can use is available in different events. -Other than that, the listeners always perform exactly the same things on a given form. +You can see that you need to listen on these two events and have different +callbacks only because in two different scenarios, the data that you can use is +available in different events. Other than that, the listeners always perform +exactly the same things on a given form. + +One piece that is still missing is the client-side updating of your form after +the sport is selected. This should be handled by making an AJAX call back to +your application. Assume that you have a sport meetup creation controller:: + + // src/Acme/DemoBundle/Controller/MeetupController.php + // ... + + /** + * @Route("/meetup") + */ + class MeetupController extends Controller + { + /** + * @Route("/create", name="meetup_create") + * @Template + */ + public function createAction(Request $request) + + { + $meetup = new SportMeetup(); + $form = $this->createForm(new SportMeetupType(), $meetup); + $form->handleRequest($request); + if ($form->isValid()) { + // ... save the meetup, redirect etc. + } + + return array('form' => $form->createView()); + } + // ... + } -One piece that may still be missing is the client-side updating of your form -after the sport is selected. This should be handled by making an AJAX call -back to your application. In that controller, you can submit your form, but -instead of processing it, simply use the submitted form to render the updated -fields. The response from the AJAX call can then be used to update the view. +The associated template uses some JavaScript to update the ``position`` form +field according to the current selection in the ``sport`` field. To ease things +it makes use of `jQuery`_ library and the `FOSJsRoutingBundle`_: + +.. code-block:: html+jinja + + {# src/Acme/DemoBundle/Resources/views/Meetup/create.html.twig #} + {{ form_start(form) }} + {{ form_row(form.sport) }} {# <select id="meetup_sport" ... #} + {{ form_row(form.position) }} {# <select id="meetup_position" ... #} + {# ... #} + {{ form_end(form) }} + + {# ... Include jQuery and scripts from FOSJsRoutingBundle ... #} + <script> + $(function(){ + // When sport gets selected ... + $('#meetup_sport').change(function(){ + var $position = $('#meetup_position'); + // Remove current position options except first "empty_value" option + $position.find('option:not(:first)').remove(); + var sportId = $(this).val(); + if (sportId) { + // Issue AJAX call fetching positions for selected sport as JSON + $.getJSON( + // FOSJsRoutingBundle generates route including selected sport ID + Routing.generate('meetup_positions_by_sport', {id: sportId}), + function(positions) { + // Append fetched positions associated with selected sport + $.each(positions, function(key, position){ + $position.append(new Option(position[1], position[0])); + }); + } + ); + } + }); + }); + </script> + +The last piece is implementing a controller for the +``meetup_positions_by_sport`` route returning the positions as JSON according +to the currently selected sport. To ease things again the controller makes use +of the :doc:`@ParamConverter </bundles/SensioFrameworkExtraBundle/annotations/converters>` +listener to convert the submitted sport ID into a ``Sport`` object:: + + // src/Acme/DemoBundle/Controller/MeetupController.php + // ... + use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter; + + /** + * @Route("/meetup") + */ + class MeetupController extends Controller + { + // ... + /** + * @Route("/{id}/positions.json", name="meetup_positions_by_sport", options={"expose"=true}) + */ + public function positionsBySportAction(Sport $sport) + { + $result = array(); + foreach ($sport->getAvailablePositions() as $position) { + $result[] = array($position->getId(), $position->getName()); + } + + return new JsonResponse($result); + } + } + +.. note:: + + The returned JSON should not be created from an associative array + (``$result[$position->getId()] = $position->getName())``) as the iterating + order in JavaScript is undefined and may vary in different browsers. .. _cookbook-dynamic-form-modification-suppressing-form-validation: @@ -622,3 +740,6 @@ all of this, use a listener:: By doing this, you may accidentally disable something more than just form validation, since the ``POST_SUBMIT`` event may have other listeners. + +.. _`jQuery`: http://jquery.com +.. _`FOSJsRoutingBundle`: https://github.com/FriendsOfSymfony/FOSJsRoutingBundle From f47a7c3c126e3caf9df49b809c3915ee0f270c71 Mon Sep 17 00:00:00 2001 From: Philipp Rieber <p.rieber@gmail.com> Date: Sat, 4 Jan 2014 05:54:06 +0100 Subject: [PATCH 2/4] Updates & Fixes after public review --- cookbook/form/dynamic_form_modification.rst | 147 ++++++++++++++------ 1 file changed, 103 insertions(+), 44 deletions(-) diff --git a/cookbook/form/dynamic_form_modification.rst b/cookbook/form/dynamic_form_modification.rst index 38004fb7145..01e31228000 100644 --- a/cookbook/form/dynamic_form_modification.rst +++ b/cookbook/form/dynamic_form_modification.rst @@ -474,8 +474,10 @@ The meetup is passed as an entity field to the form. So we can access each sport like this:: // src/Acme/DemoBundle/Form/Type/SportMeetupType.php + namespace Acme\DemoBundle\Form\Type; + use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; @@ -487,9 +489,9 @@ sport like this:: { $builder ->add('sport', 'entity', array( - 'class' => 'AcmeDemoBundle:Sport', + 'class' => 'AcmeDemoBundle:Sport', 'empty_value' => '', - )); + )) ; $builder->addEventListener( @@ -501,16 +503,17 @@ sport like this:: $data = $event->getData(); $sport = $data->getSport(); - $positions = (null === $sport) ? array() : $sport->getAvailablePositions(); + $positions = null === $sport ? array() : $sport->getAvailablePositions(); $form->add('position', 'entity', array( - 'class' => 'AcmeDemoBundle:Position', + 'class' => 'AcmeDemoBundle:Position', 'empty_value' => '', - 'choices' => $positions, + 'choices' => $positions, )); } ); } + // ... } @@ -545,11 +548,12 @@ new field automatically and map it to the submitted client data. The type would now look like:: // src/Acme/DemoBundle/Form/Type/SportMeetupType.php + namespace Acme\DemoBundle\Form\Type; // ... - use Acme\DemoBundle\Entity\Sport; use Symfony\Component\Form\FormInterface; + use Acme\DemoBundle\Entity\Sport; class SportMeetupType extends AbstractType { @@ -557,18 +561,18 @@ The type would now look like:: { $builder ->add('sport', 'entity', array( - 'class' => 'AcmeDemoBundle:Sport', + 'class' => 'AcmeDemoBundle:Sport', 'empty_value' => '', )); ; $formModifier = function(FormInterface $form, Sport $sport = null) { - $positions = (null === $sport) ? array() : $sport->getAvailablePositions(); + $positions = null === $sport ? array() : $sport->getAvailablePositions(); $form->add('position', 'entity', array( - 'class' => 'AcmeDemoBundle:Position', + 'class' => 'AcmeDemoBundle:Position', 'empty_value' => '', - 'choices' => $positions, + 'choices' => $positions, )); }; @@ -595,6 +599,7 @@ The type would now look like:: } ); } + // ... } @@ -608,6 +613,15 @@ the sport is selected. This should be handled by making an AJAX call back to your application. Assume that you have a sport meetup creation controller:: // src/Acme/DemoBundle/Controller/MeetupController.php + + namespace Acme\DemoBundle\Controller; + + use Symfony\Bundle\FrameworkBundle\Controller\Controller; + use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; + use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template; + use Symfony\Component\HttpFoundation\Request; + use Acme\DemoBundle\Entity\SportMeetup; + use Acme\DemoBundle\Form\Type\SportMeetupType; // ... /** @@ -620,7 +634,6 @@ your application. Assume that you have a sport meetup creation controller:: * @Template */ public function createAction(Request $request) - { $meetup = new SportMeetup(); $form = $this->createForm(new SportMeetupType(), $meetup); @@ -631,6 +644,7 @@ your application. Assume that you have a sport meetup creation controller:: return array('form' => $form->createView()); } + // ... } @@ -638,40 +652,79 @@ The associated template uses some JavaScript to update the ``position`` form field according to the current selection in the ``sport`` field. To ease things it makes use of `jQuery`_ library and the `FOSJsRoutingBundle`_: -.. code-block:: html+jinja - - {# src/Acme/DemoBundle/Resources/views/Meetup/create.html.twig #} - {{ form_start(form) }} - {{ form_row(form.sport) }} {# <select id="meetup_sport" ... #} - {{ form_row(form.position) }} {# <select id="meetup_position" ... #} - {# ... #} - {{ form_end(form) }} - - {# ... Include jQuery and scripts from FOSJsRoutingBundle ... #} - <script> - $(function(){ - // When sport gets selected ... - $('#meetup_sport').change(function(){ - var $position = $('#meetup_position'); - // Remove current position options except first "empty_value" option - $position.find('option:not(:first)').remove(); - var sportId = $(this).val(); - if (sportId) { - // Issue AJAX call fetching positions for selected sport as JSON - $.getJSON( - // FOSJsRoutingBundle generates route including selected sport ID - Routing.generate('meetup_positions_by_sport', {id: sportId}), - function(positions) { - // Append fetched positions associated with selected sport - $.each(positions, function(key, position){ - $position.append(new Option(position[1], position[0])); - }); - } - ); - } +.. configuration-block:: + + .. code-block:: html+jinja + + {# src/Acme/DemoBundle/Resources/views/Meetup/create.html.twig #} + + {{ form_start(form) }} + {{ form_row(form.sport) }} {# <select id="meetup_sport" ... #} + {{ form_row(form.position) }} {# <select id="meetup_position" ... #} + {# ... #} + {{ form_end(form) }} + + {# ... Include jQuery and scripts from FOSJsRoutingBundle ... #} + <script> + $(function(){ + // When sport gets selected ... + $('#meetup_sport').change(function(){ + var $position = $('#meetup_position'); + // Remove current position options except first "empty_value" option + $position.find('option:not(:first)').remove(); + var sportId = $(this).val(); + if (sportId) { + // Issue AJAX call fetching positions for selected sport as JSON + $.getJSON( + // FOSJsRoutingBundle generates route including selected sport ID + Routing.generate('meetup_positions_by_sport', {id: sportId}), + function(positions) { + // Append fetched positions associated with selected sport + $.each(positions, function(key, position){ + $position.append(new Option(position[1], position[0])); + }); + } + ); + } + }); }); - }); - </script> + </script> + + .. code-block:: html+php + + <!-- src/Acme/DemoBundle/Resources/views/Meetup/create.html.php --> + + <?php echo $view['form']->start($form) ?> + <?php echo $view['form']->row($form['sport']) ?> <!-- <select id="meetup_sport" ... --> + <?php echo $view['form']->row($form['position']) ?> <!-- <select id="meetup_position" ... --> + <!-- ... --> + <?php echo $view['form']->end($form) ?> + + <!-- ... Include jQuery and scripts from FOSJsRoutingBundle ... --> + <script> + $(function(){ + // When sport gets selected ... + $('#meetup_sport').change(function(){ + var $position = $('#meetup_position'); + // Remove current position options except first "empty_value" option + $position.find('option:not(:first)').remove(); + var sportId = $(this).val(); + if (sportId) { + // Issue AJAX call fetching positions for selected sport as JSON + $.getJSON( + // FOSJsRoutingBundle generates route including selected sport ID + Routing.generate('meetup_positions_by_sport', {id: sportId}), + function(positions) { + // Append fetched positions associated with selected sport + $.each(positions, function(key, position){ + $position.append(new Option(position[1], position[0])); + }); + } + ); + } + }); + }); + </script> The last piece is implementing a controller for the ``meetup_positions_by_sport`` route returning the positions as JSON according @@ -680,8 +733,13 @@ of the :doc:`@ParamConverter </bundles/SensioFrameworkExtraBundle/annotations/co listener to convert the submitted sport ID into a ``Sport`` object:: // src/Acme/DemoBundle/Controller/MeetupController.php + + namespace Acme\DemoBundle\Controller; + // ... use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter; + use Symfony\Component\HttpFoundation\JsonResponse; + use Acme\DemoBundle\Entity\Sport; /** * @Route("/meetup") @@ -689,6 +747,7 @@ listener to convert the submitted sport ID into a ``Sport`` object:: class MeetupController extends Controller { // ... + /** * @Route("/{id}/positions.json", name="meetup_positions_by_sport", options={"expose"=true}) */ From ee33dcdd07c7a9c3cac3bb889be725b818228e83 Mon Sep 17 00:00:00 2001 From: Philipp Rieber <p.rieber@gmail.com> Date: Sat, 4 Jan 2014 06:00:58 +0100 Subject: [PATCH 3/4] Remove some empty lines from code samples --- cookbook/form/dynamic_form_modification.rst | 6 ------ 1 file changed, 6 deletions(-) diff --git a/cookbook/form/dynamic_form_modification.rst b/cookbook/form/dynamic_form_modification.rst index 01e31228000..8e1fc9cf865 100644 --- a/cookbook/form/dynamic_form_modification.rst +++ b/cookbook/form/dynamic_form_modification.rst @@ -474,7 +474,6 @@ The meetup is passed as an entity field to the form. So we can access each sport like this:: // src/Acme/DemoBundle/Form/Type/SportMeetupType.php - namespace Acme\DemoBundle\Form\Type; use Symfony\Component\Form\AbstractType; @@ -548,7 +547,6 @@ new field automatically and map it to the submitted client data. The type would now look like:: // src/Acme/DemoBundle/Form/Type/SportMeetupType.php - namespace Acme\DemoBundle\Form\Type; // ... @@ -613,7 +611,6 @@ the sport is selected. This should be handled by making an AJAX call back to your application. Assume that you have a sport meetup creation controller:: // src/Acme/DemoBundle/Controller/MeetupController.php - namespace Acme\DemoBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; @@ -657,7 +654,6 @@ it makes use of `jQuery`_ library and the `FOSJsRoutingBundle`_: .. code-block:: html+jinja {# src/Acme/DemoBundle/Resources/views/Meetup/create.html.twig #} - {{ form_start(form) }} {{ form_row(form.sport) }} {# <select id="meetup_sport" ... #} {{ form_row(form.position) }} {# <select id="meetup_position" ... #} @@ -693,7 +689,6 @@ it makes use of `jQuery`_ library and the `FOSJsRoutingBundle`_: .. code-block:: html+php <!-- src/Acme/DemoBundle/Resources/views/Meetup/create.html.php --> - <?php echo $view['form']->start($form) ?> <?php echo $view['form']->row($form['sport']) ?> <!-- <select id="meetup_sport" ... --> <?php echo $view['form']->row($form['position']) ?> <!-- <select id="meetup_position" ... --> @@ -733,7 +728,6 @@ of the :doc:`@ParamConverter </bundles/SensioFrameworkExtraBundle/annotations/co listener to convert the submitted sport ID into a ``Sport`` object:: // src/Acme/DemoBundle/Controller/MeetupController.php - namespace Acme\DemoBundle\Controller; // ... From a75ad9c2aea6b0e5a106c843f9f81c4ef16cae07 Mon Sep 17 00:00:00 2001 From: Philipp Rieber <p.rieber@gmail.com> Date: Thu, 23 Jan 2014 06:43:05 +0100 Subject: [PATCH 4/4] Shorten AJAX example --- cookbook/form/dynamic_form_modification.rst | 115 ++---------------- .../dynamic_form_modification_ajax_js.rst.inc | 25 ++++ 2 files changed, 35 insertions(+), 105 deletions(-) create mode 100644 cookbook/form/dynamic_form_modification_ajax_js.rst.inc diff --git a/cookbook/form/dynamic_form_modification.rst b/cookbook/form/dynamic_form_modification.rst index 8e1fc9cf865..b963bab1e5d 100644 --- a/cookbook/form/dynamic_form_modification.rst +++ b/cookbook/form/dynamic_form_modification.rst @@ -614,22 +614,13 @@ your application. Assume that you have a sport meetup creation controller:: namespace Acme\DemoBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; - use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; - use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template; use Symfony\Component\HttpFoundation\Request; use Acme\DemoBundle\Entity\SportMeetup; use Acme\DemoBundle\Form\Type\SportMeetupType; // ... - /** - * @Route("/meetup") - */ class MeetupController extends Controller { - /** - * @Route("/create", name="meetup_create") - * @Template - */ public function createAction(Request $request) { $meetup = new SportMeetup(); @@ -639,15 +630,17 @@ your application. Assume that you have a sport meetup creation controller:: // ... save the meetup, redirect etc. } - return array('form' => $form->createView()); + return $this->render( + 'AcmeDemoBundle:Meetup:create.html.twig', + array('form' => $form->createView()) + ); } // ... } The associated template uses some JavaScript to update the ``position`` form -field according to the current selection in the ``sport`` field. To ease things -it makes use of `jQuery`_ library and the `FOSJsRoutingBundle`_: +field according to the current selection in the ``sport`` field: .. configuration-block:: @@ -660,31 +653,7 @@ it makes use of `jQuery`_ library and the `FOSJsRoutingBundle`_: {# ... #} {{ form_end(form) }} - {# ... Include jQuery and scripts from FOSJsRoutingBundle ... #} - <script> - $(function(){ - // When sport gets selected ... - $('#meetup_sport').change(function(){ - var $position = $('#meetup_position'); - // Remove current position options except first "empty_value" option - $position.find('option:not(:first)').remove(); - var sportId = $(this).val(); - if (sportId) { - // Issue AJAX call fetching positions for selected sport as JSON - $.getJSON( - // FOSJsRoutingBundle generates route including selected sport ID - Routing.generate('meetup_positions_by_sport', {id: sportId}), - function(positions) { - // Append fetched positions associated with selected sport - $.each(positions, function(key, position){ - $position.append(new Option(position[1], position[0])); - }); - } - ); - } - }); - }); - </script> + .. include:: /cookbook/form/dynamic_form_modification_ajax_js.rst.inc .. code-block:: html+php @@ -695,72 +664,11 @@ it makes use of `jQuery`_ library and the `FOSJsRoutingBundle`_: <!-- ... --> <?php echo $view['form']->end($form) ?> - <!-- ... Include jQuery and scripts from FOSJsRoutingBundle ... --> - <script> - $(function(){ - // When sport gets selected ... - $('#meetup_sport').change(function(){ - var $position = $('#meetup_position'); - // Remove current position options except first "empty_value" option - $position.find('option:not(:first)').remove(); - var sportId = $(this).val(); - if (sportId) { - // Issue AJAX call fetching positions for selected sport as JSON - $.getJSON( - // FOSJsRoutingBundle generates route including selected sport ID - Routing.generate('meetup_positions_by_sport', {id: sportId}), - function(positions) { - // Append fetched positions associated with selected sport - $.each(positions, function(key, position){ - $position.append(new Option(position[1], position[0])); - }); - } - ); - } - }); - }); - </script> - -The last piece is implementing a controller for the -``meetup_positions_by_sport`` route returning the positions as JSON according -to the currently selected sport. To ease things again the controller makes use -of the :doc:`@ParamConverter </bundles/SensioFrameworkExtraBundle/annotations/converters>` -listener to convert the submitted sport ID into a ``Sport`` object:: + .. include:: /cookbook/form/dynamic_form_modification_ajax_js.rst.inc - // src/Acme/DemoBundle/Controller/MeetupController.php - namespace Acme\DemoBundle\Controller; - - // ... - use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter; - use Symfony\Component\HttpFoundation\JsonResponse; - use Acme\DemoBundle\Entity\Sport; - - /** - * @Route("/meetup") - */ - class MeetupController extends Controller - { - // ... - - /** - * @Route("/{id}/positions.json", name="meetup_positions_by_sport", options={"expose"=true}) - */ - public function positionsBySportAction(Sport $sport) - { - $result = array(); - foreach ($sport->getAvailablePositions() as $position) { - $result[] = array($position->getId(), $position->getName()); - } - - return new JsonResponse($result); - } - } - -.. note:: - - The returned JSON should not be created from an associative array - (``$result[$position->getId()] = $position->getName())``) as the iterating - order in JavaScript is undefined and may vary in different browsers. +The major benefit of submitting the whole form to just extract the updated +``position`` field is that no additional server-side code is needed; all the +code from above to generate the submitted form can be reused. .. _cookbook-dynamic-form-modification-suppressing-form-validation: @@ -793,6 +701,3 @@ all of this, use a listener:: By doing this, you may accidentally disable something more than just form validation, since the ``POST_SUBMIT`` event may have other listeners. - -.. _`jQuery`: http://jquery.com -.. _`FOSJsRoutingBundle`: https://github.com/FriendsOfSymfony/FOSJsRoutingBundle diff --git a/cookbook/form/dynamic_form_modification_ajax_js.rst.inc b/cookbook/form/dynamic_form_modification_ajax_js.rst.inc new file mode 100644 index 00000000000..09e255808db --- /dev/null +++ b/cookbook/form/dynamic_form_modification_ajax_js.rst.inc @@ -0,0 +1,25 @@ +<script> +var $sport = $('#meetup_sport'); +// When sport gets selected ... +$sport.change(function(){ + // ... retrieve the corresponding form. + var $form = $(this).closest('form'); + // Simulate form data, but only include the selected sport value. + var data = {}; + data[$sport.attr('name')] = $sport.val(); + // Submit data via AJAX to the form's action path. + $.ajax({ + url : $form.attr('action'), + type: $form.attr('method'), + data : data, + success: function(html) { + // Replace current position field ... + $('#meetup_position').replaceWith( + // ... with the returned one from the AJAX response. + $(html).find('#meetup_position') + ); + // Position field now displays the appropriate positions. + } + }); +}); +</script> \ No newline at end of file