friendship ended with social-app. php is my new best friend
1<?php
2
3/*
4 * This file is part of the Symfony package.
5 *
6 * (c) Fabien Potencier <fabien@symfony.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Symfony\Component\DomCrawler;
13
14use Symfony\Component\DomCrawler\Field\ChoiceFormField;
15use Symfony\Component\DomCrawler\Field\FormField;
16
17/**
18 * Form represents an HTML form.
19 *
20 * @author Fabien Potencier <fabien@symfony.com>
21 */
22class Form extends Link implements \ArrayAccess
23{
24 private \DOMElement $button;
25 private FormFieldRegistry $fields;
26
27 /**
28 * @param \DOMElement $node A \DOMElement instance
29 * @param string|null $currentUri The URI of the page where the form is embedded
30 * @param string|null $method The method to use for the link (if null, it defaults to the method defined by the form)
31 * @param string|null $baseHref The URI of the <base> used for relative links, but not for empty action
32 *
33 * @throws \LogicException if the node is not a button inside a form tag
34 */
35 public function __construct(
36 \DOMElement $node,
37 ?string $currentUri = null,
38 ?string $method = null,
39 private ?string $baseHref = null,
40 ) {
41 parent::__construct($node, $currentUri, $method);
42
43 $this->initialize();
44 }
45
46 /**
47 * Gets the form node associated with this form.
48 */
49 public function getFormNode(): \DOMElement
50 {
51 return $this->node;
52 }
53
54 /**
55 * Sets the value of the fields.
56 *
57 * @param array $values An array of field values
58 *
59 * @return $this
60 */
61 public function setValues(array $values): static
62 {
63 foreach ($values as $name => $value) {
64 $this->fields->set($name, $value);
65 }
66
67 return $this;
68 }
69
70 /**
71 * Gets the field values.
72 *
73 * The returned array does not include file fields (@see getFiles).
74 */
75 public function getValues(): array
76 {
77 $values = [];
78 foreach ($this->fields->all() as $name => $field) {
79 if ($field->isDisabled()) {
80 continue;
81 }
82
83 if (!$field instanceof Field\FileFormField && $field->hasValue()) {
84 $values[$name] = $field->getValue();
85 }
86 }
87
88 return $values;
89 }
90
91 /**
92 * Gets the file field values.
93 */
94 public function getFiles(): array
95 {
96 if (!\in_array($this->getMethod(), ['POST', 'PUT', 'DELETE', 'PATCH'])) {
97 return [];
98 }
99
100 $files = [];
101
102 foreach ($this->fields->all() as $name => $field) {
103 if ($field->isDisabled()) {
104 continue;
105 }
106
107 if ($field instanceof Field\FileFormField) {
108 $files[$name] = $field->getValue();
109 }
110 }
111
112 return $files;
113 }
114
115 /**
116 * Gets the field values as PHP.
117 *
118 * This method converts fields with the array notation
119 * (like foo[bar] to arrays) like PHP does.
120 */
121 public function getPhpValues(): array
122 {
123 $values = [];
124 foreach ($this->getValues() as $name => $value) {
125 $qs = http_build_query([$name => $value], '', '&');
126 if ($qs) {
127 parse_str($qs, $expandedValue);
128 $varName = substr($name, 0, \strlen(key($expandedValue)));
129 $values[] = [$varName => current($expandedValue)];
130 }
131 }
132
133 return array_replace_recursive([], ...$values);
134 }
135
136 /**
137 * Gets the file field values as PHP.
138 *
139 * This method converts fields with the array notation
140 * (like foo[bar] to arrays) like PHP does.
141 * The returned array is consistent with the array for field values
142 * (@see getPhpValues), rather than uploaded files found in $_FILES.
143 * For a compound file field foo[bar] it will create foo[bar][name],
144 * instead of foo[name][bar] which would be found in $_FILES.
145 */
146 public function getPhpFiles(): array
147 {
148 $values = [];
149 foreach ($this->getFiles() as $name => $value) {
150 $qs = http_build_query([$name => $value], '', '&');
151 if ($qs) {
152 parse_str($qs, $expandedValue);
153 $varName = substr($name, 0, \strlen(key($expandedValue)));
154
155 array_walk_recursive(
156 $expandedValue,
157 function (&$value, $key) {
158 if (ctype_digit($value) && ('size' === $key || 'error' === $key)) {
159 $value = (int) $value;
160 }
161 }
162 );
163
164 reset($expandedValue);
165
166 $values[] = [$varName => current($expandedValue)];
167 }
168 }
169
170 return array_replace_recursive([], ...$values);
171 }
172
173 /**
174 * Gets the URI of the form.
175 *
176 * The returned URI is not the same as the form "action" attribute.
177 * This method merges the value if the method is GET to mimics
178 * browser behavior.
179 */
180 public function getUri(): string
181 {
182 $uri = parent::getUri();
183
184 if (!\in_array($this->getMethod(), ['POST', 'PUT', 'DELETE', 'PATCH'])) {
185 $currentParameters = [];
186 if ($query = parse_url($uri, \PHP_URL_QUERY)) {
187 parse_str($query, $currentParameters);
188 }
189
190 $queryString = http_build_query(array_merge($currentParameters, $this->getValues()), '', '&');
191
192 $pos = strpos($uri, '?');
193 $base = false === $pos ? $uri : substr($uri, 0, $pos);
194 $uri = rtrim($base.'?'.$queryString, '?');
195 }
196
197 return $uri;
198 }
199
200 protected function getRawUri(): string
201 {
202 // If the form was created from a button rather than the form node, check for HTML5 action overrides
203 if ($this->button !== $this->node && $this->button->getAttribute('formaction')) {
204 return $this->button->getAttribute('formaction');
205 }
206
207 return $this->node->getAttribute('action');
208 }
209
210 /**
211 * Gets the form method.
212 *
213 * If no method is defined in the form, GET is returned.
214 */
215 public function getMethod(): string
216 {
217 if (null !== $this->method) {
218 return $this->method;
219 }
220
221 // If the form was created from a button rather than the form node, check for HTML5 method override
222 if ($this->button !== $this->node && $this->button->getAttribute('formmethod')) {
223 return strtoupper($this->button->getAttribute('formmethod'));
224 }
225
226 return $this->node->getAttribute('method') ? strtoupper($this->node->getAttribute('method')) : 'GET';
227 }
228
229 /**
230 * Gets the form name.
231 *
232 * If no name is defined on the form, an empty string is returned.
233 */
234 public function getName(): string
235 {
236 return $this->node->getAttribute('name');
237 }
238
239 /**
240 * Returns true if the named field exists.
241 */
242 public function has(string $name): bool
243 {
244 return $this->fields->has($name);
245 }
246
247 /**
248 * Removes a field from the form.
249 */
250 public function remove(string $name): void
251 {
252 $this->fields->remove($name);
253 }
254
255 /**
256 * Gets a named field.
257 *
258 * @return FormField|FormField[]|FormField[][]
259 *
260 * @throws \InvalidArgumentException When field is not present in this form
261 */
262 public function get(string $name): FormField|array
263 {
264 return $this->fields->get($name);
265 }
266
267 /**
268 * Sets a named field.
269 */
270 public function set(FormField $field): void
271 {
272 $this->fields->add($field);
273 }
274
275 /**
276 * Gets all fields.
277 *
278 * @return FormField[]
279 */
280 public function all(): array
281 {
282 return $this->fields->all();
283 }
284
285 /**
286 * Returns true if the named field exists.
287 *
288 * @param string $name The field name
289 */
290 public function offsetExists(mixed $name): bool
291 {
292 return $this->has($name);
293 }
294
295 /**
296 * Gets the value of a field.
297 *
298 * @param string $name The field name
299 *
300 * @return FormField|FormField[]|FormField[][]
301 *
302 * @throws \InvalidArgumentException if the field does not exist
303 */
304 public function offsetGet(mixed $name): FormField|array
305 {
306 return $this->fields->get($name);
307 }
308
309 /**
310 * Sets the value of a field.
311 *
312 * @param string $name The field name
313 * @param string|array $value The value of the field
314 *
315 * @throws \InvalidArgumentException if the field does not exist
316 */
317 public function offsetSet(mixed $name, mixed $value): void
318 {
319 $this->fields->set($name, $value);
320 }
321
322 /**
323 * Removes a field from the form.
324 *
325 * @param string $name The field name
326 */
327 public function offsetUnset(mixed $name): void
328 {
329 $this->fields->remove($name);
330 }
331
332 /**
333 * Disables validation.
334 *
335 * @return $this
336 */
337 public function disableValidation(): static
338 {
339 foreach ($this->fields->all() as $field) {
340 if ($field instanceof ChoiceFormField) {
341 $field->disableValidation();
342 }
343 }
344
345 return $this;
346 }
347
348 /**
349 * Sets the node for the form.
350 *
351 * Expects a 'submit' button \DOMElement and finds the corresponding form element, or the form element itself.
352 *
353 * @throws \LogicException If given node is not a button or input or does not have a form ancestor
354 */
355 protected function setNode(\DOMElement $node): void
356 {
357 $this->button = $node;
358 if ('button' === $node->nodeName || ('input' === $node->nodeName && \in_array(strtolower($node->getAttribute('type')), ['submit', 'button', 'image']))) {
359 if ($node->hasAttribute('form')) {
360 // if the node has the HTML5-compliant 'form' attribute, use it
361 $formId = $node->getAttribute('form');
362 $form = $node->ownerDocument->getElementById($formId);
363 if (null === $form) {
364 throw new \LogicException(\sprintf('The selected node has an invalid form attribute (%s).', $formId));
365 }
366 $this->node = $form;
367
368 return;
369 }
370 // we loop until we find a form ancestor
371 do {
372 if (null === $node = $node->parentNode) {
373 throw new \LogicException('The selected node does not have a form ancestor.');
374 }
375 } while ('form' !== $node->nodeName);
376 } elseif ('form' !== $node->nodeName) {
377 throw new \LogicException(\sprintf('Unable to submit on a "%s" tag.', $node->nodeName));
378 }
379
380 $this->node = $node;
381 }
382
383 /**
384 * Adds form elements related to this form.
385 *
386 * Creates an internal copy of the submitted 'button' element and
387 * the form node or the entire document depending on whether we need
388 * to find non-descendant elements through HTML5 'form' attribute.
389 */
390 private function initialize(): void
391 {
392 $this->fields = new FormFieldRegistry();
393
394 $xpath = new \DOMXPath($this->node->ownerDocument);
395
396 // add submitted button if it has a valid name
397 if ('form' !== $this->button->nodeName && $this->button->hasAttribute('name') && $this->button->getAttribute('name')) {
398 if ('input' == $this->button->nodeName && 'image' == strtolower($this->button->getAttribute('type'))) {
399 $name = $this->button->getAttribute('name');
400 $this->button->setAttribute('value', '0');
401
402 // temporarily change the name of the input node for the x coordinate
403 $this->button->setAttribute('name', $name.'.x');
404 $this->set(new Field\InputFormField($this->button));
405
406 // temporarily change the name of the input node for the y coordinate
407 $this->button->setAttribute('name', $name.'.y');
408 $this->set(new Field\InputFormField($this->button));
409
410 // restore the original name of the input node
411 $this->button->setAttribute('name', $name);
412 } else {
413 $this->set(new Field\InputFormField($this->button));
414 }
415 }
416
417 // find form elements corresponding to the current form
418 if ($this->node->hasAttribute('id')) {
419 // corresponding elements are either descendants or have a matching HTML5 form attribute
420 $formId = Crawler::xpathLiteral($this->node->getAttribute('id'));
421
422 $fieldNodes = $xpath->query(\sprintf('( descendant::input[@form=%s] | descendant::button[@form=%1$s] | descendant::textarea[@form=%1$s] | descendant::select[@form=%1$s] | //form[@id=%1$s]//input[not(@form)] | //form[@id=%1$s]//button[not(@form)] | //form[@id=%1$s]//textarea[not(@form)] | //form[@id=%1$s]//select[not(@form)] )[( not(ancestor::template) or ancestor::turbo-stream )]', $formId));
423 } else {
424 // do the xpath query with $this->node as the context node, to only find descendant elements
425 // however, descendant elements with form attribute are not part of this form
426 $fieldNodes = $xpath->query('( descendant::input[not(@form)] | descendant::button[not(@form)] | descendant::textarea[not(@form)] | descendant::select[not(@form)] )[( not(ancestor::template) or ancestor::turbo-stream )]', $this->node);
427 }
428
429 foreach ($fieldNodes as $node) {
430 $this->addField($node);
431 }
432
433 if ($this->baseHref && '' !== $this->node->getAttribute('action')) {
434 $this->currentUri = $this->baseHref;
435 }
436 }
437
438 private function addField(\DOMElement $node): void
439 {
440 if (!$node->hasAttribute('name') || !$node->getAttribute('name')) {
441 return;
442 }
443
444 $nodeName = $node->nodeName;
445 if ('select' == $nodeName || 'input' == $nodeName && 'checkbox' == strtolower($node->getAttribute('type'))) {
446 $this->set(new ChoiceFormField($node));
447 } elseif ('input' == $nodeName && 'radio' == strtolower($node->getAttribute('type'))) {
448 // there may be other fields with the same name that are no choice
449 // fields already registered (see https://github.com/symfony/symfony/issues/11689)
450 if ($this->has($node->getAttribute('name')) && $this->get($node->getAttribute('name')) instanceof ChoiceFormField) {
451 $this->get($node->getAttribute('name'))->addChoice($node);
452 } else {
453 $this->set(new ChoiceFormField($node));
454 }
455 } elseif ('input' == $nodeName && 'file' == strtolower($node->getAttribute('type'))) {
456 $this->set(new Field\FileFormField($node));
457 } elseif ('input' == $nodeName && !\in_array(strtolower($node->getAttribute('type')), ['submit', 'button', 'image'])) {
458 $this->set(new Field\InputFormField($node));
459 } elseif ('textarea' == $nodeName) {
460 $this->set(new Field\TextareaFormField($node));
461 }
462 }
463}