vendor/symfony/ux-twig-component/src/ComponentFactory.php line 42

  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\UX\TwigComponent;
  11. use Psr\EventDispatcher\EventDispatcherInterface;
  12. use Symfony\Component\DependencyInjection\ServiceLocator;
  13. use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
  14. use Symfony\Contracts\Service\ResetInterface;
  15. use Symfony\UX\TwigComponent\Event\PostMountEvent;
  16. use Symfony\UX\TwigComponent\Event\PreMountEvent;
  17. use Twig\Environment;
  18. use Twig\Runtime\EscaperRuntime;
  19. /**
  20. * @author Kevin Bond <kevinbond@gmail.com>
  21. *
  22. * @internal
  23. */
  24. final class ComponentFactory implements ResetInterface
  25. {
  26. private array $mountMethods = [];
  27. /**
  28. * @param array<string, array> $config
  29. * @param array<class-string, string> $classMap
  30. */
  31. public function __construct(
  32. private ComponentTemplateFinderInterface $componentTemplateFinder,
  33. private ServiceLocator $components,
  34. private PropertyAccessorInterface $propertyAccessor,
  35. private EventDispatcherInterface $eventDispatcher,
  36. private array $config,
  37. private readonly array $classMap,
  38. private readonly Environment $twig,
  39. ) {
  40. }
  41. public function metadataFor(string $name): ComponentMetadata
  42. {
  43. if ($config = $this->config[$name] ?? null) {
  44. return new ComponentMetadata($config);
  45. }
  46. if ($template = $this->componentTemplateFinder->findAnonymousComponentTemplate($name)) {
  47. $this->config[$name] = [
  48. 'key' => $name,
  49. 'template' => $template,
  50. ];
  51. return new ComponentMetadata($this->config[$name]);
  52. }
  53. if ($mappedName = $this->classMap[$name] ?? null) {
  54. if ($config = $this->config[$mappedName] ?? null) {
  55. return new ComponentMetadata($config);
  56. }
  57. throw new \InvalidArgumentException(\sprintf('Unknown component "%s".', $name));
  58. }
  59. $this->throwUnknownComponentException($name);
  60. }
  61. /**
  62. * Creates the component and "mounts" it with the passed data.
  63. */
  64. public function create(string $name, array $data = []): MountedComponent
  65. {
  66. $metadata = $this->metadataFor($name);
  67. if ($metadata->isAnonymous()) {
  68. return $this->mountFromObject(new AnonymousComponent(), $data, $metadata);
  69. }
  70. return $this->mountFromObject($this->components->get($metadata->getName()), $data, $metadata);
  71. }
  72. /**
  73. * @internal
  74. */
  75. public function mountFromObject(object $component, array $data, ComponentMetadata $componentMetadata): MountedComponent
  76. {
  77. $originalData = $data;
  78. $event = $this->preMount($component, $data, $componentMetadata);
  79. $data = $event->getData();
  80. $this->mount($component, $data, $componentMetadata);
  81. if (!$componentMetadata->isAnonymous()) {
  82. // set data that wasn't set in mount on the component directly
  83. foreach ($data as $property => $value) {
  84. if ($this->propertyAccessor->isWritable($component, $property)) {
  85. $this->propertyAccessor->setValue($component, $property, $value);
  86. unset($data[$property]);
  87. }
  88. }
  89. }
  90. $postMount = $this->postMount($component, $data, $componentMetadata);
  91. $data = $postMount->getData();
  92. // create attributes from "attributes" key if exists
  93. $attributesVar = $componentMetadata->getAttributesVar();
  94. $attributes = $data[$attributesVar] ?? [];
  95. unset($data[$attributesVar]);
  96. foreach ($data as $key => $value) {
  97. if ($value instanceof \Stringable) {
  98. $data[$key] = (string) $value;
  99. }
  100. }
  101. return new MountedComponent(
  102. $componentMetadata->getName(),
  103. $component,
  104. new ComponentAttributes([...$attributes, ...$data], $this->twig->getRuntime(EscaperRuntime::class)),
  105. $originalData,
  106. $postMount->getExtraMetadata(),
  107. );
  108. }
  109. /**
  110. * Returns the "unmounted" component.
  111. *
  112. * @internal
  113. */
  114. public function get(string $name): object
  115. {
  116. $metadata = $this->metadataFor($name);
  117. if ($metadata->isAnonymous()) {
  118. return new AnonymousComponent();
  119. }
  120. return $this->components->get($metadata->getName());
  121. }
  122. private function mount(object $component, array &$data, ComponentMetadata $componentMetadata): void
  123. {
  124. if ($component instanceof AnonymousComponent) {
  125. $component->mount($data);
  126. return;
  127. }
  128. if (!$componentMetadata->getMounts()) {
  129. return;
  130. }
  131. $mount = $this->mountMethods[$component::class] ??= (new \ReflectionClass($component))->getMethod('mount');
  132. $parameters = [];
  133. foreach ($mount->getParameters() as $refParameter) {
  134. if (\array_key_exists($name = $refParameter->getName(), $data)) {
  135. $parameters[] = $data[$name];
  136. // remove the data element so it isn't used to set the property directly.
  137. unset($data[$name]);
  138. } elseif ($refParameter->isDefaultValueAvailable()) {
  139. $parameters[] = $refParameter->getDefaultValue();
  140. } else {
  141. throw new \LogicException(\sprintf('"%s" has a required $%s parameter. Make sure to pass it or give it a default value.', $component::class.'::mount()', $name));
  142. }
  143. }
  144. $mount->invoke($component, ...$parameters);
  145. }
  146. private function preMount(object $component, array $data, ComponentMetadata $componentMetadata): PreMountEvent
  147. {
  148. $event = new PreMountEvent($component, $data, $componentMetadata);
  149. $this->eventDispatcher->dispatch($event);
  150. $data = $event->getData();
  151. foreach ($componentMetadata->getPreMounts() as $preMount) {
  152. if (null !== $newData = $component->$preMount($data)) {
  153. $event->setData($data = $newData);
  154. }
  155. }
  156. return $event;
  157. }
  158. private function postMount(object $component, array $data, ComponentMetadata $componentMetadata): PostMountEvent
  159. {
  160. $event = new PostMountEvent($component, $data, $componentMetadata);
  161. $this->eventDispatcher->dispatch($event);
  162. $data = $event->getData();
  163. foreach ($componentMetadata->getPostMounts() as $postMount) {
  164. if (null !== $newData = $component->$postMount($data)) {
  165. $event->setData($data = $newData);
  166. }
  167. }
  168. return $event;
  169. }
  170. /**
  171. * @return never
  172. */
  173. private function throwUnknownComponentException(string $name): void
  174. {
  175. $message = \sprintf('Unknown component "%s".', $name);
  176. $lowerName = strtolower($name);
  177. $nameLength = \strlen($lowerName);
  178. $alternatives = [];
  179. foreach (array_keys($this->config) as $type) {
  180. $lowerType = strtolower($type);
  181. $lev = levenshtein($lowerName, $lowerType);
  182. if ($lev <= $nameLength / 3 || str_contains($lowerType, $lowerName)) {
  183. $alternatives[] = $type;
  184. }
  185. }
  186. if ($alternatives) {
  187. if (1 === \count($alternatives)) {
  188. $message .= ' Did you mean this: "';
  189. } else {
  190. $message .= ' Did you mean one of these: "';
  191. }
  192. $message .= implode('", "', $alternatives).'"?';
  193. } else {
  194. $message .= ' And no matching anonymous component template was found.';
  195. }
  196. throw new \InvalidArgumentException($message);
  197. }
  198. public function reset(): void
  199. {
  200. $this->mountMethods = [];
  201. }
  202. }