From 43e0db5f75ea7ada6321db3e36c4f7af4e1570dd Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Wed, 4 Jan 2012 16:20:28 +0100 Subject: [PATCH] [DomCrawler] Add support for multivalued form fields (fix #1579, #3012) --- CHANGELOG-2.1.md | 3 +- src/Symfony/Component/DomCrawler/Form.php | 295 ++++++++++++++---- .../Tests/Component/DomCrawler/FormTest.php | 261 ++++++++++++++-- 3 files changed, 476 insertions(+), 83 deletions(-) diff --git a/CHANGELOG-2.1.md b/CHANGELOG-2.1.md index 9dd55557960a..b54e3498e6dc 100644 --- a/CHANGELOG-2.1.md +++ b/CHANGELOG-2.1.md @@ -139,6 +139,7 @@ To get the diff between two versions, go to https://github.com/symfony/symfony/c ### DomCrawler + * refactor the Form class internals to support multi-dimensional fields (the public API is backward compatible) * added a way to get parsing errors for Crawler::addHtmlContent() and Crawler::addXmlContent() via libxml functions * added support for submitting a form without a submit button @@ -212,7 +213,7 @@ To get the diff between two versions, go to https://github.com/symfony/symfony/c ### Serializer * [BC BREAK] changed `GetSetMethodNormalizer`'s key names from all lowercased to camelCased (e.g. `mypropertyvalue` to `myPropertyValue`) - * [BC BREAK] convert the `item` XML tag to an array + * [BC BREAK] convert the `item` XML tag to an array ``` xml diff --git a/src/Symfony/Component/DomCrawler/Form.php b/src/Symfony/Component/DomCrawler/Form.php index 3ef7299d72b2..a235f953cca5 100644 --- a/src/Symfony/Component/DomCrawler/Form.php +++ b/src/Symfony/Component/DomCrawler/Form.php @@ -63,7 +63,7 @@ public function getFormNode() public function setValues(array $values) { foreach ($values as $name => $value) { - $this[$name] = $value; + $this->fields->set($name, $value); } return $this; @@ -81,7 +81,7 @@ public function setValues(array $values) public function getValues() { $values = array(); - foreach ($this->fields as $name => $field) { + foreach ($this->fields->all() as $name => $field) { if ($field->isDisabled()) { continue; } @@ -108,7 +108,8 @@ public function getFiles() } $files = array(); - foreach ($this->fields as $name => $field) { + + foreach ($this->fields->all() as $name => $field) { if ($field->isDisabled()) { continue; } @@ -124,7 +125,7 @@ public function getFiles() /** * Gets the field values as PHP. * - * This method converts fields with th array notation + * This method converts fields with the array notation * (like foo[bar] to arrays) like PHP does. * * @return array An array of field values. @@ -142,7 +143,7 @@ public function getPhpValues() /** * Gets the file field values as PHP. * - * This method converts fields with th array notation + * This method converts fields with the array notation * (like foo[bar] to arrays) like PHP does. * * @return array An array of field values. @@ -214,7 +215,7 @@ public function getMethod() */ public function has($name) { - return isset($this->fields[$name]); + return $this->fields->has($name); } /** @@ -222,11 +223,13 @@ public function has($name) * * @param string $name The field name * + * @throws \InvalidArgumentException when the name is malformed + * * @api */ public function remove($name) { - unset($this->fields[$name]); + $this->fields->remove($name); } /** @@ -242,25 +245,21 @@ public function remove($name) */ public function get($name) { - if (!$this->has($name)) { - throw new \InvalidArgumentException(sprintf('The form has no "%s" field', $name)); - } - - return $this->fields[$name]; + return $this->fields->get($name); } /** * Sets a named field. * - * @param Field\FormField $field The field + * @param FormField $field The field * * @return FormField The field instance * * @api */ - public function set(Field\FormField $field) + public function set(FormField $field) { - $this->fields[$field->getName()] = $field; + $this->fields->add($field); } /** @@ -272,12 +271,78 @@ public function set(Field\FormField $field) */ public function all() { - return $this->fields; + return $this->fields->all(); + } + + /** + * Returns true if the named field exists. + * + * @param string $name The field name + * + * @return Boolean true if the field exists, false otherwise + */ + public function offsetExists($name) + { + return $this->has($name); + } + + /** + * Gets the value of a field. + * + * @param string $name The field name + * + * @return FormField The associated Field instance + * + * @throws \InvalidArgumentException if the field does not exist + */ + public function offsetGet($name) + { + return $this->fields->get($name); + } + + /** + * Sets the value of a field. + * + * @param string $name The field name + * @param string|array $value The value of the field + * + * @throws \InvalidArgumentException if the field does not exist + */ + public function offsetSet($name, $value) + { + $this->fields->set($name, $value); + } + + /** + * Removes a field from the form. + * + * @param string $name The field name + */ + public function offsetUnset($name) + { + $this->fields->remove($name); + } + + protected function setNode(\DOMNode $node) + { + $this->button = $node; + if ('button' == $node->nodeName || ('input' == $node->nodeName && in_array($node->getAttribute('type'), array('submit', 'button', 'image')))) { + do { + // use the ancestor form element + if (null === $node = $node->parentNode) { + throw new \LogicException('The selected node does not have a form ancestor.'); + } + } while ('form' != $node->nodeName); + } elseif('form' != $node->nodeName) { + throw new \LogicException(sprintf('Unable to submit on a "%s" tag.', $node->nodeName)); + } + + $this->node = $node; } private function initialize() { - $this->fields = array(); + $this->fields = new FormFieldRegistry(); $document = new \DOMDocument('1.0', 'UTF-8'); $node = $document->importNode($this->node, true); @@ -313,78 +378,202 @@ private function initialize() } } } +} + +class FormFieldRegistry +{ + private $fields = array(); + + private $base; /** - * Returns true if the named field exists. + * Adds a field to the registry. * - * @param string $name The field name + * @param FormField $field The field * - * @return Boolean true if the field exists, false otherwise + * @throws \InvalidArgumentException when the name is malformed */ - public function offsetExists($name) + public function add(FormField $field) { - return $this->has($name); + $segments = $this->getSegments($field->getName()); + + $target =& $this->fields; + while ($segments) { + if (!is_array($target)) { + $target = array(); + } + $path = array_shift($segments); + if ('' === $path) { + $target =& $target[]; + } else { + $target =& $target[$path]; + } + } + $target = $field; } /** - * Gets the value of a field. + * Removes a field and its children from the registry. * - * @param string $name The field name + * @param string $name The fully qualified name of the base field * - * @return FormField The associated Field instance + * @throws \InvalidArgumentException when the name is malformed + */ + public function remove($name) + { + $segments = $this->getSegments($name); + $target =& $this->fields; + while (count($segments) > 1) { + $path = array_shift($segments); + if (!array_key_exists($path, $target)) { + return; + } + $target =& $target[$path]; + } + unset($target[array_shift($segments)]); + } + + /** + * Returns the value of the field and its children. * + * @param string $name The fully qualified name of the field + * + * @return mixed The value of the field + * + * @throws \InvalidArgumentException when the name is malformed * @throws \InvalidArgumentException if the field does not exist */ - public function offsetGet($name) + public function &get($name) { - if (!$this->has($name)) { - throw new \InvalidArgumentException(sprintf('The form field "%s" does not exist', $name)); + $segments = $this->getSegments($name); + $target =& $this->fields; + while ($segments) { + $path = array_shift($segments); + if (!array_key_exists($path, $target)) { + throw new \InvalidArgumentException(sprintf('Unreachable field "%s"', $path)); + } + $target =& $target[$path]; } - return $this->fields[$name]; + return $target; } /** - * Sets the value of a field. + * Tests whether the form has the given field. * - * @param string $name The field name - * @param string|array $value The value of the field + * @param string $name The fully qualified name of the field * + * @return Boolean Whether the form has the given field + */ + public function has($name) + { + try { + $this->get($name); + return true; + } catch (\InvalidArgumentException $e) { + return false; + } + } + + /** + * Set the value of a field and its children. + * + * @param string $name The fully qualified name of the field + * @param mixed $value The value + * + * @throws \InvalidArgumentException when the name is malformed * @throws \InvalidArgumentException if the field does not exist */ - public function offsetSet($name, $value) + public function set($name, $value) { - if (!$this->has($name)) { - throw new \InvalidArgumentException(sprintf('The form field "%s" does not exist', $name)); + $target =& $this->get($name); + if (is_array($value)) { + $fields = self::create($name, $value); + foreach ($fields->all() as $k => $v) { + $this->set($k, $v); + } + } else { + $target->setValue($value); } + } + + /** + * Returns the list of field with their value. + * + * @return array The list of fields as array((string) Fully qualified name => (mixed) value) + */ + public function all() + { + return $this->walk($this->fields, $this->base); + } + + /** + * Creates an instance of the class. + * + * This function is made private because it allows overriding the $base and + * the $values properties without any type checking. + * + * @param string $base The fully qualified name of the base field + * @param array $values The values of the fields + * + * @return FormFieldRegistry + */ + static private function create($base, array $values) + { + $registry = new static(); + $registry->base = $base; + $registry->fields = $values; - $this->fields[$name]->setValue($value); + return $registry; } /** - * Removes a field from the form. + * Transforms a PHP array in a list of fully qualified name / value. * - * @param string $name The field name + * @param array $array The PHP array + * @param string $base The name of the base field + * @param array $output The initial values + * + * @return array The list of fields as array((string) Fully qualified name => (mixed) value) */ - public function offsetUnset($name) + private function walk(array $array, $base = '', array &$output = array()) { - $this->remove($name); + foreach ($array as $k => $v) { + $path = empty($base) ? $k : sprintf("%s[%s]", $base, $k); + if (is_array($v)) { + $this->walk($v, $path, $output); + } else { + $output[$path] = $v; + } + } + + return $output; } - protected function setNode(\DOMNode $node) + /** + * Splits a field name into segments as a web browser would do. + * + * + * getSegments('base[foo][3][]') = array('base', 'foo, '3', ''); + * + * + * @param string $name The name of the field + * + * @return array The list of segments + * + * @throws \InvalidArgumentException when the name is malformed + */ + private function getSegments($name) { - $this->button = $node; - if ('button' == $node->nodeName || ('input' == $node->nodeName && in_array($node->getAttribute('type'), array('submit', 'button', 'image')))) { - do { - // use the ancestor form element - if (null === $node = $node->parentNode) { - throw new \LogicException('The selected node does not have a form ancestor.'); - } - } while ('form' != $node->nodeName); - } elseif('form' != $node->nodeName) { - throw new \LogicException(sprintf('Unable to submit on a "%s" tag.', $node->nodeName)); + if (preg_match('/^(?P[^[]+)(?P(\[.*)|$)/', $name, $m)) { + $segments = array($m['base']); + while (preg_match('/^\[(?P.*?)\](?P.*)$/', $m['extra'], $m)) { + $segments[] = $m['segment']; + } + + return $segments; } - $this->node = $node; + throw new \InvalidArgumentException(sprintf('Malformed field path "%s"', $name)); } -} +} \ No newline at end of file diff --git a/tests/Symfony/Tests/Component/DomCrawler/FormTest.php b/tests/Symfony/Tests/Component/DomCrawler/FormTest.php index a1ae49a0904a..01df4444b5ed 100644 --- a/tests/Symfony/Tests/Component/DomCrawler/FormTest.php +++ b/tests/Symfony/Tests/Component/DomCrawler/FormTest.php @@ -12,9 +12,17 @@ namespace Symfony\Tests\Component\DomCrawler; use Symfony\Component\DomCrawler\Form; +use Symfony\Component\DomCrawler\FormFieldRegistry; +use Symfony\Component\DomCrawler\Field; class FormTest extends \PHPUnit_Framework_TestCase { + public static function setUpBeforeClass() + { + // Ensure that the private helper class FormFieldRegistry is loaded + class_exists('Symfony\\Component\\DomCrawler\\Form'); + } + public function testConstructorThrowsExceptionIfTheNodeHasNoFormAncestor() { $dom = new \DOMDocument(); @@ -48,19 +56,64 @@ public function testConstructorThrowsExceptionIfTheNodeHasNoFormAncestor() try { $form = new Form($nodes->item(0), 'http://example.com'); - $this->fail('__construct() throws a \\LogicException if the input type is not submit, button, or image'); + $this->fail('__construct() throws a \\LogicException if the node has no form ancestor'); } catch (\LogicException $e) { - $this->assertTrue(true, '__construct() throws a \\LogicException if the input type is not submit, button, or image'); + $this->assertTrue(true, '__construct() throws a \\LogicException if the node has no form ancestor'); } } + public function testMultiValuedFields() + { + $form = $this->createForm('
+ + + + + + + +
+ '); + + $this->assertEquals( + array_keys($form->all()), + array('foo[2]', 'foo[3]', 'bar[foo][0]', 'bar[foo][foobar]') + ); + + $this->assertEquals($form->get('foo[2]')->getValue(), 'foo'); + $this->assertEquals($form->get('foo[3]')->getValue(), 'foo'); + $this->assertEquals($form->get('bar[foo][0]')->getValue(), 'foo'); + $this->assertEquals($form->get('bar[foo][foobar]')->getValue(), 'foo'); + + $form['foo[2]'] = 'bar'; + $form['foo[3]'] = 'bar'; + + $this->assertEquals($form->get('foo[2]')->getValue(), 'bar'); + $this->assertEquals($form->get('foo[3]')->getValue(), 'bar'); + + $form['bar'] = array('foo' => array('0' => 'bar', 'foobar' => 'foobar')); + + $this->assertEquals($form->get('bar[foo][0]')->getValue(), 'bar'); + $this->assertEquals($form->get('bar[foo][foobar]')->getValue(), 'foobar'); + + } + /** * @dataProvider provideInitializeValues */ public function testConstructor($message, $form, $values) { $form = $this->createForm('
'.$form.'
'); - $this->assertEquals($values, array_map(function ($field) { return array(get_class($field), $field->getValue()); }, $form->all()), '->getDefaultValues() '.$message); + $this->assertEquals( + $values, + array_map(function ($field) { + $class = get_class($field); + return array(substr($class, strrpos($class, '\\') + 1), $field->getValue()); + }, + $form->all() + ), + '->getDefaultValues() '.$message + ); } public function provideInitializeValues() @@ -76,55 +129,55 @@ public function provideInitializeValues() 'takes into account disabled input fields', ' ', - array('foo' => array('Symfony\\Component\\DomCrawler\\Field\\InputFormField', 'foo')), + array('foo' => array('InputFormField', 'foo')), ), array( 'appends the submitted button value', '', - array('bar' => array('Symfony\\Component\\DomCrawler\\Field\\InputFormField', 'bar')), + array('bar' => array('InputFormField', 'bar')), ), array( 'appends the submitted button value but not other submit buttons', ' ', - array('foobar' => array('Symfony\\Component\\DomCrawler\\Field\\InputFormField', 'foobar')), + array('foobar' => array('InputFormField', 'foobar')), ), array( 'returns textareas', ' ', - array('foo' => array('Symfony\\Component\\DomCrawler\\Field\\TextareaFormField', 'foo')), + array('foo' => array('TextareaFormField', 'foo')), ), array( 'returns inputs', ' ', - array('foo' => array('Symfony\\Component\\DomCrawler\\Field\\InputFormField', 'foo')), + array('foo' => array('InputFormField', 'foo')), ), array( 'returns checkboxes', ' ', - array('foo' => array('Symfony\\Component\\DomCrawler\\Field\\ChoiceFormField', 'foo')), + array('foo' => array('ChoiceFormField', 'foo')), ), array( 'returns not-checked checkboxes', ' ', - array('foo' => array('Symfony\\Component\\DomCrawler\\Field\\ChoiceFormField', false)), + array('foo' => array('ChoiceFormField', false)), ), array( 'returns radio buttons', ' ', - array('foo' => array('Symfony\\Component\\DomCrawler\\Field\\ChoiceFormField', 'bar')), + array('foo' => array('ChoiceFormField', 'bar')), ), array( 'returns file inputs', ' ', - array('foo' => array('Symfony\\Component\\DomCrawler\\Field\\FileFormField', array('name' => '', 'type' => '', 'tmp_name' => '', 'error' => 4, 'size' => 0))), + array('foo' => array('FileFormField', array('name' => '', 'type' => '', 'tmp_name' => '', 'error' => 4, 'size' => 0))), ), ); } @@ -248,6 +301,23 @@ public function testGetUri($message, $form, $values, $uri) $this->assertEquals('http://example.com'.$uri, $form->getUri(), '->getUri() '.$message); } + public function testGetBaseUri() + { + $dom = new \DOMDocument(); + $dom->loadHTML('
'); + + $nodes = $dom->getElementsByTagName('input'); + $form = new Form($nodes->item($nodes->length - 1), 'http://www.foo.com/'); + $this->assertEquals('http://www.foo.com/foo.php', $form->getUri()); + } + + public function testGetUriWithAnchor() + { + $form = $this->createForm('
', null, 'http://example.com/id/123'); + + $this->assertEquals('http://example.com/id/123#foo', $form->getUri()); + } + public function testGetUriActionAbsolute() { $formHtml='
'; @@ -382,23 +452,6 @@ public function testAll() $this->assertEquals('Symfony\\Component\\DomCrawler\\Field\\InputFormField', get_class($fields['bar']), '->all() return an array of form field objects'); } - public function testBase() - { - $dom = new \DOMDocument(); - $dom->loadHTML('
'); - - $nodes = $dom->getElementsByTagName('input'); - $form = new Form($nodes->item($nodes->length - 1), 'http://www.foo.com/'); - $this->assertEquals('http://www.foo.com/foo.php', $form->getUri()); - } - - public function testUriWithAnchor() - { - $form = $this->createForm('
', null, 'http://example.com/id/123'); - - $this->assertEquals('http://example.com/id/123#foo', $form->getUri()); - } - public function testSubmitWithoutAFormButton() { $dom = new \DOMDocument(); @@ -415,6 +468,156 @@ public function testSubmitWithoutAFormButton() $this->assertSame($nodes->item(0), $form->getFormNode(), '->getFormNode() returns the form node associated with this form'); } + /** + * @expectedException \InvalidArgumentException + */ + public function testFormFieldRegistryAddThrowAnExceptionWhenTheNameIsMalformed() + { + $registry = new FormFieldRegistry(); + $registry->add($this->getFormFieldMock('[foo]')); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testFormFieldRegistryRemoveThrowAnExceptionWhenTheNameIsMalformed() + { + $registry = new FormFieldRegistry(); + $registry->remove('[foo]'); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testFormFieldRegistryGetThrowAnExceptionWhenTheNameIsMalformed() + { + $registry = new FormFieldRegistry(); + $registry->get('[foo]'); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testFormFieldRegistryGetThrowAnExceptionWhenTheFieldDoesNotExist() + { + $registry = new FormFieldRegistry(); + $registry->get('foo'); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testFormFieldRegistrySetThrowAnExceptionWhenTheNameIsMalformed() + { + $registry = new FormFieldRegistry(); + $registry->set('[foo]', null); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testFormFieldRegistrySetThrowAnExceptionWhenTheFieldDoesNotExist() + { + $registry = new FormFieldRegistry(); + $registry->set('foo', null); + } + + public function testFormFieldRegistryHasReturnsTrueWhenTheFQNExists() + { + $registry = new FormFieldRegistry(); + $registry->add($this->getFormFieldMock('foo[bar]')); + + $this->assertTrue($registry->has('foo')); + $this->assertTrue($registry->has('foo[bar]')); + $this->assertFalse($registry->has('bar')); + $this->assertFalse($registry->has('foo[foo]')); + } + + public function testFormRegistryFieldsCanBeRemoved() + { + $registry = new FormFieldRegistry(); + $registry->add($this->getFormFieldMock('foo')); + $registry->remove('foo'); + $this->assertFalse($registry->has('foo')); + } + + public function testFormRegistrySupportsMultivaluedFields() + { + $registry = new FormFieldRegistry(); + $registry->add($this->getFormFieldMock('foo[]')); + $registry->add($this->getFormFieldMock('foo[]')); + $registry->add($this->getFormFieldMock('bar[5]')); + $registry->add($this->getFormFieldMock('bar[]')); + $registry->add($this->getFormFieldMock('bar[baz]')); + + $this->assertEquals( + array('foo[0]', 'foo[1]', 'bar[5]', 'bar[6]', 'bar[baz]'), + array_keys($registry->all()) + ); + } + + public function testFormRegistrySetValues() + { + $registry = new FormFieldRegistry(); + $registry->add($f2 = $this->getFormFieldMock('foo[2]')); + $registry->add($f3 = $this->getFormFieldMock('foo[3]')); + $registry->add($fbb = $this->getFormFieldMock('foo[bar][baz]')); + + $f2 + ->expects($this->exactly(2)) + ->method('setValue') + ->with(2) + ; + + $f3 + ->expects($this->exactly(2)) + ->method('setValue') + ->with(3) + ; + + $fbb + ->expects($this->exactly(2)) + ->method('setValue') + ->with('fbb') + ; + + $registry->set('foo[2]', 2); + $registry->set('foo[3]', 3); + $registry->set('foo[bar][baz]', 'fbb'); + + $registry->set('foo', array( + 2 => 2, + 3 => 3, + 'bar' => array( + 'baz' => 'fbb' + ) + )); + } + + protected function getFormFieldMock($name, $value = null) + { + $field = $this + ->getMockBuilder('Symfony\\Component\\DomCrawler\\Field\\FormField') + ->setMethods(array('getName', 'getValue', 'setValue', 'initialize')) + ->disableOriginalConstructor() + ->getMock() + ; + + $field + ->expects($this->any()) + ->method('getName') + ->will($this->returnValue($name)) + ; + + $field + ->expects($this->any()) + ->method('getValue') + ->will($this->returnValue($value)) + ; + + return $field; + } + protected function createForm($form, $method = null, $currentUri = null) { $dom = new \DOMDocument();