Skip to content

Anonymous TwigComponent #802

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

Merged
merged 20 commits into from
Aug 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/TwigComponent/src/AnonymousComponent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\TwigComponent;

use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;

/**
* @author Matheo Daninos <matheo.daninos@gmail.com>
*
* @internal
*/
final class AnonymousComponent
{
private array $props;

public function mount($props = []): void
{
$this->props = $props;
}

#[ExposeInTemplate(destruct: true)]
public function getProps(): array
{
return $this->props;
}
}
12 changes: 7 additions & 5 deletions src/TwigComponent/src/Attribute/ExposeInTemplate.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@
final class ExposeInTemplate
{
/**
* @param string|null $name The variable name to expose. Leave as null
* to default to property name.
* @param string|null $getter The getter method to use. Leave as null
* to default to PropertyAccessor logic.
* @param string|null $name The variable name to expose. Leave as null
* to default to property name.
* @param string|null $getter The getter method to use. Leave as null
* to default to PropertyAccessor logic.
* @param bool $destruct The content should be used as array of variable
* names
*/
public function __construct(public ?string $name = null, public ?string $getter = null)
public function __construct(public ?string $name = null, public ?string $getter = null, public bool $destruct = false)
{
}
}
27 changes: 25 additions & 2 deletions src/TwigComponent/src/ComponentFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@ final class ComponentFactory
* @param array<class-string, string> $classMap
*/
public function __construct(
private ComponentTemplateFinderInterface $componentTemplateFinder,
private ServiceLocator $components,
private PropertyAccessorInterface $propertyAccessor,
private EventDispatcherInterface $eventDispatcher,
private array $config,
private array $classMap,
private array $classMap
) {
}

Expand All @@ -43,6 +44,13 @@ public function metadataFor(string $name): ComponentMetadata
$name = $this->classMap[$name] ?? $name;

if (!$config = $this->config[$name] ?? null) {
if (($template = $this->componentTemplateFinder->findAnonymousComponentTemplate($name)) !== null) {
return new ComponentMetadata([
'key' => $name,
'template' => $template,
]);
}

$this->throwUnknownComponentException($name);
}

Expand Down Expand Up @@ -124,6 +132,12 @@ private function mount(object $component, array &$data): void
return;
}

if ($component instanceof AnonymousComponent) {
$component->mount($data);

return;
}

$parameters = [];

foreach ($method->getParameters() as $refParameter) {
Expand All @@ -149,6 +163,10 @@ private function getComponent(string $name): object
$name = $this->classMap[$name] ?? $name;

if (!$this->components->has($name)) {
if ($this->isAnonymousComponent($name)) {
return new AnonymousComponent();
}

$this->throwUnknownComponentException($name);
}

Expand Down Expand Up @@ -189,11 +207,16 @@ private function postMount(object $component, array $data): array
return $data;
}

private function isAnonymousComponent(string $name): bool
{
return null !== $this->componentTemplateFinder->findAnonymousComponentTemplate($name);
}

/**
* @return never
*/
private function throwUnknownComponentException(string $name): void
{
throw new \InvalidArgumentException(sprintf('Unknown component "%s". The registered components are: %s', $name, implode(', ', array_keys($this->config))));
throw new \InvalidArgumentException(sprintf('Unknown component "%s". The registered components are: %s. And no matching anonymous component template was found', $name, implode(', ', array_keys($this->config))));
}
}
14 changes: 14 additions & 0 deletions src/TwigComponent/src/ComponentRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@ private function exposedVariables(object $component, bool $exposePublicProps): \
/** @var ExposeInTemplate $attribute */
$value = $attribute->getter ? $component->{rtrim($attribute->getter, '()')}() : $this->propertyAccessor->getValue($component, $property->name);

if ($attribute->destruct) {
foreach ($value as $key => $destructedValue) {
yield $key => $destructedValue;
}
}

yield $attribute->name ?? $property->name => $value;
}

Expand All @@ -148,6 +154,14 @@ private function exposedVariables(object $component, bool $exposePublicProps): \
throw new \LogicException(sprintf('Cannot use %s on methods with required parameters (%s::%s).', ExposeInTemplate::class, $component::class, $method->name));
}

if ($attribute->destruct) {
foreach ($component->{$method->name}() as $prop => $value) {
yield $prop => $value;
}

return;
}

yield $name => $component->{$method->name}();
}
}
Expand Down
49 changes: 49 additions & 0 deletions src/TwigComponent/src/ComponentTemplateFinder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\TwigComponent;

use Twig\Environment;

/**
* @author Matheo Daninos <matheo.daninos@gmail.com>
*/
final class ComponentTemplateFinder implements ComponentTemplateFinderInterface
Copy link
Member

Choose a reason for hiding this comment

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

This feels a bit like a premature abstraction, what about in-lining in ComponentFactory for now?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@weaverryan ask me to do it in this comment: #802 (comment).
To not have to autowire the Environment service

Copy link
Member

Choose a reason for hiding this comment

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

Yea, it felt odd to inject ALL of Environment into ComponentFactory, which is otherwise unaware of Twig iirc

{
public function __construct(
private Environment $environment
) {
}

public function findAnonymousComponentTemplate(string $name): ?string
{
$loader = $this->environment->getLoader();
$componentPath = rtrim(str_replace(':', '/', $name));

if ($loader->exists('components/'.$componentPath.'.html.twig')) {
return 'components/'.$componentPath.'.html.twig';
}

if ($loader->exists($componentPath.'.html.twig')) {
return $componentPath.'.html.twig';
}

if ($loader->exists('components/'.$componentPath)) {
return 'components/'.$componentPath;
}

if ($loader->exists($componentPath)) {
return $componentPath;
}

return null;
}
}
20 changes: 20 additions & 0 deletions src/TwigComponent/src/ComponentTemplateFinderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\TwigComponent;

/**
* @author Matheo Daninos <matheo.daninos@gmail.com>
*/
interface ComponentTemplateFinderInterface
{
public function findAnonymousComponentTemplate(string $name): ?string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ public function process(ContainerBuilder $container): void
}

$factoryDefinition = $container->findDefinition('ux.twig_component.component_factory');
$factoryDefinition->setArgument(0, ServiceLocatorTagPass::register($container, $componentReferences));
$factoryDefinition->setArgument(3, $componentConfig);
$factoryDefinition->setArgument(4, $componentClassMap);
$factoryDefinition->setArgument(1, ServiceLocatorTagPass::register($container, $componentReferences));
$factoryDefinition->setArgument(4, $componentConfig);
$factoryDefinition->setArgument(5, $componentClassMap);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use Symfony\UX\TwigComponent\ComponentRenderer;
use Symfony\UX\TwigComponent\ComponentRendererInterface;
use Symfony\UX\TwigComponent\ComponentStack;
use Symfony\UX\TwigComponent\ComponentTemplateFinder;
use Symfony\UX\TwigComponent\DependencyInjection\Compiler\TwigComponentPass;
use Symfony\UX\TwigComponent\Twig\ComponentExtension;
use Symfony\UX\TwigComponent\Twig\ComponentLexer;
Expand All @@ -40,6 +41,14 @@ public function load(array $configs, ContainerBuilder $container): void
throw new LogicException('The TwigBundle is not registered in your application. Try running "composer require symfony/twig-bundle".');
}

$container->register('ux.twig_component.component_template_finder', ComponentTemplateFinder::class)
->setArguments([
new Reference('twig'),
])
;

$container->setAlias(ComponentRendererInterface::class, 'ux.twig_component.component_renderer');

$container->registerAttributeForAutoconfiguration(
AsTwigComponent::class,
static function (ChildDefinition $definition, AsTwigComponent $attribute) {
Expand All @@ -49,6 +58,7 @@ static function (ChildDefinition $definition, AsTwigComponent $attribute) {

$container->register('ux.twig_component.component_factory', ComponentFactory::class)
->setArguments([
new Reference('ux.twig_component.component_template_finder'),
class_exists(AbstractArgument::class) ? new AbstractArgument(sprintf('Added in %s.', TwigComponentPass::class)) : null,
new Reference('property_accessor'),
new Reference('event_dispatcher'),
Expand All @@ -68,7 +78,7 @@ class_exists(AbstractArgument::class) ? new AbstractArgument(sprintf('Added in %
])
;

$container->setAlias(ComponentRendererInterface::class, 'ux.twig_component.component_renderer');
$container->register(ComponentTemplateFinder::class, 'ux.twig_component.component_template_finder');

$container->register('ux.twig_component.twig.component_extension', ComponentExtension::class)
->addTag('twig.extension')
Expand Down
1 change: 1 addition & 0 deletions src/TwigComponent/src/Twig/ComponentExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public function getTokenParsers(): array
{
return [
new ComponentTokenParser(fn () => $this->container->get(ComponentFactory::class)),
new PropsTokenParser(),
];
}

Expand Down
54 changes: 54 additions & 0 deletions src/TwigComponent/src/Twig/PropsNode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\TwigComponent\Twig;

use Twig\Compiler;
use Twig\Node\Node;

/**
* @author Matheo Daninos <matheo.daninos@gmail.com>
*
* @internal
*/
class PropsNode extends Node
{
public function __construct(array $propsNames, array $values, $lineno = 0, string $tag = null)
{
parent::__construct($values, ['names' => $propsNames], $lineno, $tag);
}

public function compile(Compiler $compiler): void
{
foreach ($this->getAttribute('names') as $name) {
$compiler
->addDebugInfo($this)
->write('if (!isset($context[\''.$name.'\'])) {')
;

if (!$this->hasNode($name)) {
$compiler
->write('throw new \Twig\Error\RuntimeError("'.$name.' should be defined for component '.$this->getTemplateName().'");')
->write('}')
;

continue;
}

$compiler
->write('$context[\''.$name.'\'] = ')
->subcompile($this->getNode($name))
->raw(";\n")
->write('}')
;
}
}
}
55 changes: 55 additions & 0 deletions src/TwigComponent/src/Twig/PropsTokenParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\TwigComponent\Twig;

use Twig\Node\Node;
use Twig\Token;
use Twig\TokenParser\AbstractTokenParser;

/**
* @author Matheo Daninos <matheo.daninos@gmail.com>
*
* @internal
*/
class PropsTokenParser extends AbstractTokenParser
{
public function parse(Token $token): Node
{
$parser = $this->parser;
$stream = $parser->getStream();

$names = [];
$values = [];
while (!$stream->nextIf(Token::BLOCK_END_TYPE)) {
$name = $stream->expect(\Twig\Token::NAME_TYPE)->getValue();

if ($stream->nextIf(Token::OPERATOR_TYPE, '=')) {
$values[$name] = $parser->getExpressionParser()->parseExpression();
}

$names[] = $name;

if (!$stream->nextIf(Token::PUNCTUATION_TYPE)) {
break;
}
}

$stream->expect(\Twig\Token::BLOCK_END_TYPE);

return new PropsNode($names, $values, $token->getLine(), $token->getValue());
}

public function getTag(): string
{
return 'props';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<twig:Button label='Click me'/>
Loading